diff --git a/lib/app/window_lifecycle.dart b/lib/app/window_lifecycle.dart index df1f830..5c3e2b3 100644 --- a/lib/app/window_lifecycle.dart +++ b/lib/app/window_lifecycle.dart @@ -67,6 +67,12 @@ class _WindowLifecycleState extends ConsumerState } catch (_) {} }).timeout(const Duration(seconds: 4), onTimeout: () {}); } catch (_) {} + try { + trayManager.removeListener(this); + } catch (_) {} + try { + await trayManager.destroy(); + } catch (_) {} try { windowManager.removeListener(this); } catch (_) {} @@ -88,7 +94,7 @@ class _WindowLifecycleState extends ConsumerState try { return ref.read(prefsProvider).closeToTray; } catch (_) { - return true; + return false; } }(); if (toTray && !_quitting) { diff --git a/lib/core/storage/prefs.dart b/lib/core/storage/prefs.dart index 179be49..62ab05c 100644 --- a/lib/core/storage/prefs.dart +++ b/lib/core/storage/prefs.dart @@ -95,9 +95,9 @@ class Prefs { // -- Window behaviour --------------------------------------------------- /// Close button / Alt+F4 hides to the tray instead of quitting. /// Real quit stays in tray → Quit (ordered player teardown first). - /// Default on. + /// Default off (clicking X closes the application and stops audio). bool get closeToTray => - _sp.getBool('lw_close_to_tray') ?? true; + _sp.getBool('lw_close_to_tray') ?? false; Future setCloseToTray(bool v) => _sp.setBool('lw_close_to_tray', v); diff --git a/lib/features/equalizer/equalizer_controller.dart b/lib/features/equalizer/equalizer_controller.dart index b1bc957..58649af 100644 --- a/lib/features/equalizer/equalizer_controller.dart +++ b/lib/features/equalizer/equalizer_controller.dart @@ -147,6 +147,6 @@ class EqualizerNotifier extends StateNotifier { final gain = gains[i]; filters.add('equalizer=f=$freq:t=o:w=1:g=${gain.toStringAsFixed(1)}'); } - return 'lavfi=[${filters.join(",")}]'; + return 'format=floatp,lavfi=[${filters.join(",")}]'; } } diff --git a/lib/features/equalizer/equalizer_panel.dart b/lib/features/equalizer/equalizer_panel.dart index 3125aa4..e71e8d7 100644 --- a/lib/features/equalizer/equalizer_panel.dart +++ b/lib/features/equalizer/equalizer_panel.dart @@ -18,6 +18,8 @@ class WaveEqualizerPanel extends ConsumerStatefulWidget { } class _WaveEqualizerPanelState extends ConsumerState { + int _selectedTab = 0; // 0 = 10-Band EQ, 1 = Studio Master Clarity + @override Widget build(BuildContext context) { final dark = waveIsDark(context); @@ -42,31 +44,14 @@ class _WaveEqualizerPanelState extends ConsumerState { // Header _buildHeader(context, dark, eq, notifier), + // Tab Selector: 10-Band EQ vs Studio Master Clarity + _buildTabSelector(context, dark, eq), + // 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), - ], - ), + child: _selectedTab == 0 + ? _build10BandView(context, dark, eq, notifier) + : _buildStudioClarityView(context, dark, eq, notifier), ), ], ), @@ -79,6 +64,17 @@ class _WaveEqualizerPanelState extends ConsumerState { EqualizerState eq, EqualizerNotifier notifier, ) { + String statusSubtitle; + if (!eq.isProcessingActive) { + statusSubtitle = 'Bypassed (Bit-Perfect output)'; + } else if (eq.studioClarityEnabled && eq.enabled) { + statusSubtitle = 'Studio Clarity + ${eq.presetName}'; + } else if (eq.studioClarityEnabled) { + statusSubtitle = 'Studio Master Clarity (${eq.clarityPreset.displayName})'; + } else { + statusSubtitle = '${eq.presetName} preset active'; + } + return Container( height: WaveDensity.titleBar + 12, padding: const EdgeInsets.symmetric(horizontal: 16), @@ -105,26 +101,26 @@ class _WaveEqualizerPanelState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Equalizer', + 'Equalizer & Sound FX', style: WaveType.sectionTitle.copyWith(fontSize: 14), ), Text( - eq.isProcessingActive - ? (eq.studioClarityEnabled - ? 'Studio Master Clarity active' - : '${eq.presetName} preset') - : 'Bypassed (Bit-Perfect output)', + statusSubtitle, style: WaveType.meta.copyWith( fontSize: 11, color: eq.isProcessingActive - ? WaveColors.defaultAccent + ? (eq.studioClarityEnabled + ? const Color(0xFFF5A623) + : WaveColors.defaultAccent) : waveTextSecondary(context), ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), ], ), ), - // Master switch + // Master EQ switch ToggleSwitch( checked: eq.enabled, onChanged: (val) => notifier.setEnabled(val), @@ -132,7 +128,7 @@ class _WaveEqualizerPanelState extends ConsumerState { const SizedBox(width: 8), // Reset button LWTooltip( - message: 'Reset to flat', + message: 'Reset all bands to flat (0 dB)', child: IconButton( icon: Icon( FluentIcons.refresh, @@ -145,7 +141,7 @@ class _WaveEqualizerPanelState extends ConsumerState { const SizedBox(width: 4), // Close button LWTooltip( - message: 'Close equalizer', + message: 'Close panel', child: IconButton( icon: Icon( FluentIcons.chrome_close, @@ -160,139 +156,289 @@ class _WaveEqualizerPanelState extends ConsumerState { ); } - Widget _buildStudioClarityCard( + Widget _buildTabSelector(BuildContext context, bool dark, EqualizerState eq) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: waveDivider(context).withValues(alpha: dark ? 0.2 : 0.35), + ), + ), + ), + child: Row( + children: [ + Expanded( + child: _TabButton( + title: '10-Band EQ', + icon: FluentIcons.equalizer, + selected: _selectedTab == 0, + badgeActive: eq.enabled, + dark: dark, + onTap: () => setState(() => _selectedTab = 0), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _TabButton( + title: 'Studio Clarity', + icon: FluentIcons.favorite_star_fill, + selected: _selectedTab == 1, + badgeActive: eq.studioClarityEnabled, + dark: dark, + onTap: () => setState(() => _selectedTab = 1), + ), + ), + ], + ), + ); + } + + Widget _build10BandView( + BuildContext context, + bool dark, + EqualizerState eq, + EqualizerNotifier notifier, + ) { + return ListView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + children: [ + // Frequency response visualizer + _buildVisualizer(context, dark, eq), + const SizedBox(height: 12), + + // Presets chip selector + _buildPresetSelector(context, dark, eq, notifier), + const SizedBox(height: 14), + + // 10-band custom vertical bar rack + _buildSliderRack(context, dark, eq, notifier), + const SizedBox(height: 12), + + // Quick toolbar + _buildQuickToolbar(context, dark, eq, notifier), + const SizedBox(height: 12), + + // Footer status note + _buildFooterNote(context, dark, eq), + ], + ); + } + + Widget _buildStudioClarityView( 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( + return ListView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + children: [ + // Studio Master Clarity hero card + 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.8) + : const Color(0xFFE5B760)) + : waveDivider(context).withValues(alpha: dark ? 0.35 : 0.6), + width: active ? 1.5 : 1, + ), + ), + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, 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), - ), + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: active + ? const Color(0xFFF5A623).withValues(alpha: 0.18) + : (dark + ? Colors.white.withValues(alpha: 0.05) + : Colors.black.withValues(alpha: 0.05)), ), - Text( - 'Audiophile reference curve with targeted acoustic compensation', - style: WaveType.meta.copyWith( - fontSize: 10.5, - color: waveTextSecondary(context), - ), + child: Icon( + FluentIcons.favorite_star_fill, + size: 18, + color: active + ? const Color(0xFFF5A623) + : waveTextSecondary(context), ), - ], - ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Studio Master Clarity', + style: WaveType.body.copyWith( + fontWeight: FontWeight.w700, + fontSize: 14, + color: active + ? (dark ? Colors.white : const Color(0xFF5A3E00)) + : waveTextPrimary(context), + ), + ), + Text( + 'Audiophile reference curve ported from LastWave Native', + style: WaveType.meta.copyWith( + fontSize: 11, + color: waveTextSecondary(context), + ), + ), + ], + ), + ), + ToggleSwitch( + checked: active, + onChanged: (val) => notifier.setStudioClarityEnabled(val), + ), + ], ), - ToggleSwitch( - checked: active, - onChanged: (val) => notifier.setStudioClarityEnabled(val), + const SizedBox(height: 12), + Text( + 'Engineered to eliminate low-mid boxiness, tighten sub-bass control, preserve natural vocal body, and introduce a transparent, silky high-frequency air shelf.', + style: WaveType.meta.copyWith( + fontSize: 11, + color: waveTextSecondary(context), + height: 1.4, + ), ), ], ), - if (active) ...[ - const SizedBox(height: 10), + ), + const SizedBox(height: 14), + + // Acoustic Target Profile Selector + Text( + 'ACOUSTIC TARGET PROFILE', + style: WaveType.overline.copyWith( + color: waveTextSecondary(context), + ), + ), + const SizedBox(height: 8), + + for (final target in ClarityPresets.all) ...[ + _buildTargetOptionCard(context, dark, target, eq, notifier), + const SizedBox(height: 8), + ], + const SizedBox(height: 12), + + // Target Frequency Curve Preview + Container( + height: 80, + 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.studioClarityEnabled, + dark: dark, + ), + child: const SizedBox.expand(), + ), + ), + ), + ], + ); + } + + Widget _buildTargetOptionCard( + BuildContext context, + bool dark, + ClarityPreset target, + EqualizerState eq, + EqualizerNotifier notifier, + ) { + final isSelected = eq.clarityPresetKey == target.key; + final active = eq.studioClarityEnabled; + + return GestureDetector( + onTap: () => notifier.setClarityPreset(target.key), + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(WaveRadius.menu), + color: isSelected + ? (dark + ? const Color(0xFF2B2418) + : const Color(0xFFFFF3D6)) + : (dark + ? waveSurfaceRaised(context) + : WaveColors.lightOverlay), + border: Border.all( + color: isSelected + ? (dark ? const Color(0xFFF5A623) : const Color(0xFFE5B760)) + : waveDivider(context).withValues(alpha: dark ? 0.25 : 0.4), + width: isSelected ? 1.5 : 1.0, + ), + ), + child: Row( + children: [ Container( + width: 16, + height: 16, decoration: BoxDecoration( - borderRadius: BorderRadius.circular(6), - color: dark - ? Colors.black.withValues(alpha: 0.3) - : Colors.white.withValues(alpha: 0.8), + shape: BoxShape.circle, border: Border.all( - color: dark - ? Colors.white.withValues(alpha: 0.08) - : Colors.black.withValues(alpha: 0.06), + color: isSelected + ? const Color(0xFFF5A623) + : waveTextSecondary(context).withValues(alpha: 0.5), + width: 2, ), + color: isSelected && active + ? const Color(0xFFF5A623) + : Colors.transparent, ), - padding: const EdgeInsets.all(3), - child: Row( + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, 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), - ), - ), - ), - ), - ), + Text( + target.displayName, + style: WaveType.body.copyWith( + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w600, + fontSize: 12.5, + color: isSelected + ? (dark ? Colors.white : const Color(0xFF5A3E00)) + : waveTextPrimary(context), + ), + ), + const SizedBox(height: 2), + Text( + target.description, + style: WaveType.meta.copyWith( + fontSize: 10.5, + color: 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), - ), - ), ], - ], + ), ), ); } @@ -303,7 +449,7 @@ class _WaveEqualizerPanelState extends ConsumerState { EqualizerState eq, ) { return Container( - height: 72, + height: 76, decoration: BoxDecoration( color: dark ? const Color(0xFF101010) : const Color(0xFFF0F0F0), borderRadius: BorderRadius.circular(WaveRadius.menu), @@ -333,15 +479,17 @@ class _WaveEqualizerPanelState extends ConsumerState { EqualizerNotifier notifier, ) { return SizedBox( - height: 28, + height: 30, 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; + final isSelected = + eq.presetName.toLowerCase() == preset.name.toLowerCase(); + final isStudioMaster = + preset.name == EqualizerPresets.studioMaster.name; return GestureDetector( onTap: () => notifier.applyPreset(preset), @@ -349,10 +497,12 @@ class _WaveEqualizerPanelState extends ConsumerState { duration: const Duration(milliseconds: 140), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( - borderRadius: BorderRadius.circular(14), + borderRadius: BorderRadius.circular(15), color: isSelected ? (isStudioMaster - ? (dark ? const Color(0xFF8A6D3B) : const Color(0xFFE5B760)) + ? (dark + ? const Color(0xFF8A6D3B) + : const Color(0xFFE5B760)) : (dark ? Colors.white : Colors.black)) : (dark ? waveSurfaceRaised(context) @@ -381,7 +531,8 @@ class _WaveEqualizerPanelState extends ConsumerState { preset.name, style: WaveType.label.copyWith( fontSize: 11, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + fontWeight: + isSelected ? FontWeight.w600 : FontWeight.normal, color: isSelected ? (dark ? Colors.black : Colors.white) : waveTextSecondary(context), @@ -405,7 +556,7 @@ class _WaveEqualizerPanelState extends ConsumerState { ) { final gains = eq.gainsDb; return Container( - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 8), decoration: BoxDecoration( color: dark ? waveSurfaceRaised(context) : WaveColors.lightOverlay, borderRadius: BorderRadius.circular(WaveRadius.menu), @@ -417,13 +568,12 @@ class _WaveEqualizerPanelState extends ConsumerState { mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ for (int i = 0; i < eqBandFreqsHz.length; i++) - _buildBandColumn( - context, - dark, + _VerticalEqBand( index: i, freqHz: eqBandFreqsHz[i], gainDb: gains[i], enabled: eq.enabled, + dark: dark, onChanged: (val) => notifier.setBandGain(i, val), onReset: () => notifier.setBandGain(i, 0.0), ), @@ -432,71 +582,47 @@ class _WaveEqualizerPanelState extends ConsumerState { ); } - Widget _buildBandColumn( + Widget _buildQuickToolbar( 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, + bool dark, + EqualizerState eq, + EqualizerNotifier notifier, + ) { + return Row( 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), - ), - ), + _QuickActionPill( + label: 'Flat (0 dB)', + onTap: notifier.reset, + dark: dark, ), - 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(width: 6), + _QuickActionPill( + label: '+2 dB Bass', + onTap: () async { + for (int i = 0; i <= 2; i++) { + await notifier.setBandGain( + i, (eq.gainsDb[i] + 2.0).clamp(-eqMaxGainDb, eqMaxGainDb)); + } + }, + dark: dark, ), - 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), - ), - ), + const SizedBox(width: 6), + _QuickActionPill( + label: '+2 dB Treble', + onTap: () async { + for (int i = 7; i <= 9; i++) { + await notifier.setBandGain( + i, (eq.gainsDb[i] + 2.0).clamp(-eqMaxGainDb, eqMaxGainDb)); + } + }, + dark: dark, + ), + const Spacer(), + Text( + 'Double-click band to zero', + style: WaveType.meta.copyWith( + fontSize: 10, + color: waveTextSecondary(context).withValues(alpha: 0.7), ), ), ], @@ -527,8 +653,8 @@ class _WaveEqualizerPanelState extends ConsumerState { 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.', + ? 'Hardware-accelerated 32-bit float biquad filter applied via libmpv.' + : 'Bypassed: Audio stream flows directly to output with bit-perfect integrity.', style: WaveType.meta.copyWith( fontSize: 10.5, color: waveTextSecondary(context), @@ -541,6 +667,364 @@ class _WaveEqualizerPanelState extends ConsumerState { } } +/// Custom interactive vertical mixing console equalizer band. +class _VerticalEqBand extends StatelessWidget { + final int index; + final int freqHz; + final double gainDb; + final bool enabled; + final bool dark; + final ValueChanged onChanged; + final VoidCallback onReset; + + static const double kTrackHeight = 150.0; + + const _VerticalEqBand({ + required this.index, + required this.freqHz, + required this.gainDb, + required this.enabled, + required this.dark, + required this.onChanged, + required this.onReset, + }); + + void _handleDrag(Offset localPos) { + if (!enabled) return; + // localPos.dy ranges from 0 (top = +eqMaxGainDb) to kTrackHeight (bottom = -eqMaxGainDb) + final clampedY = localPos.dy.clamp(0.0, kTrackHeight); + final norm = 1.0 - (clampedY / kTrackHeight); // 0.0 at bottom, 1.0 at top + final gain = (norm * 2.0 - 1.0) * eqMaxGainDb; // -eqMaxGainDb .. +eqMaxGainDb + final rounded = (gain * 10).round() / 10.0; + onChanged(rounded); + } + + @override + Widget build(BuildContext context) { + final isNonZero = gainDb.abs() >= 0.05; + final gainText = gainDb > 0 + ? '+${gainDb.toStringAsFixed(1)}' + : (gainDb < 0 ? gainDb.toStringAsFixed(1) : '0.0'); + + // Calculate thumb position along track + // normGain ranges from -1.0 (-8dB) to +1.0 (+8dB) + final normGain = (gainDb / eqMaxGainDb).clamp(-1.0, 1.0); + // 0 dB is at half height + final zeroY = kTrackHeight / 2; + final thumbY = zeroY - (normGain * zeroY); + + Color activeColor; + if (gainDb > 0) { + activeColor = WaveColors.defaultAccent; + } else if (gainDb < 0) { + activeColor = const Color(0xFF42A5F5); + } else { + activeColor = waveTextSecondary(context); + } + + return Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // dB Readout (double tap to reset) + GestureDetector( + onDoubleTap: onReset, + child: SizedBox( + height: 18, + child: Center( + child: Text( + gainText, + style: WaveType.meta.copyWith( + fontSize: 9.5, + fontWeight: isNonZero ? FontWeight.w700 : FontWeight.normal, + color: isNonZero && enabled + ? activeColor + : waveTextSecondary(context).withValues(alpha: 0.6), + ), + ), + ), + ), + ), + const SizedBox(height: 6), + + // Interactive Track and Thumb + MouseRegion( + cursor: enabled + ? SystemMouseCursors.click + : SystemMouseCursors.basic, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onVerticalDragStart: (d) => _handleDrag(d.localPosition), + onVerticalDragUpdate: (d) => _handleDrag(d.localPosition), + onTapDown: (d) => _handleDrag(d.localPosition), + onDoubleTap: onReset, + child: SizedBox( + width: 32, + height: kTrackHeight, + child: Stack( + alignment: Alignment.center, + clipBehavior: Clip.none, + children: [ + // Background track rail + Container( + width: 5, + height: kTrackHeight, + decoration: BoxDecoration( + color: dark + ? const Color(0xFF1B1B1B) + : const Color(0xFFE2E2E2), + borderRadius: BorderRadius.circular(2.5), + border: Border.all( + color: dark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.06), + width: 0.5, + ), + ), + ), + + // 0 dB center reference line notch + Positioned( + top: zeroY - 0.75, + child: Container( + width: 14, + height: 1.5, + decoration: BoxDecoration( + color: dark + ? Colors.white.withValues(alpha: 0.22) + : Colors.black.withValues(alpha: 0.22), + borderRadius: BorderRadius.circular(1), + ), + ), + ), + + // Bipolar active fill bar + if (isNonZero && enabled) + Positioned( + top: gainDb > 0 ? thumbY : zeroY, + height: gainDb > 0 ? (zeroY - thumbY) : (thumbY - zeroY), + child: Container( + width: 5, + decoration: BoxDecoration( + color: activeColor, + borderRadius: BorderRadius.circular(2.5), + boxShadow: [ + BoxShadow( + color: activeColor.withValues(alpha: 0.4), + blurRadius: 4, + ), + ], + ), + ), + ), + + // Draggable knob / thumb + Positioned( + top: thumbY - 8, + child: Container( + width: 16, + height: 16, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: dark + ? const Color(0xFF181818) + : Colors.white, + border: Border.all( + color: enabled + ? (isNonZero + ? activeColor + : (dark + ? Colors.white.withValues(alpha: 0.7) + : Colors.black.withValues(alpha: 0.87))) + : waveTextSecondary(context).withValues(alpha: 0.4), + width: 2, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.35), + blurRadius: 4, + offset: const Offset(0, 1), + ), + ], + ), + child: Center( + child: Container( + width: 4, + height: 4, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: enabled && isNonZero + ? activeColor + : (dark + ? Colors.white.withValues(alpha: 0.38) + : Colors.black.withValues(alpha: 0.38)), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + const SizedBox(height: 6), + + // Frequency label badge (double-click to reset) + 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: isNonZero && enabled + ? waveTextPrimary(context) + : waveTextSecondary(context), + fontWeight: isNonZero && enabled + ? FontWeight.w600 + : FontWeight.normal, + ), + ), + ), + ), + ], + ), + ); + } +} + +class _TabButton extends StatelessWidget { + final String title; + final IconData icon; + final bool selected; + final bool badgeActive; + final bool dark; + final VoidCallback onTap; + + const _TabButton({ + required this.title, + required this.icon, + required this.selected, + required this.badgeActive, + required this.dark, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + padding: const EdgeInsets.symmetric(vertical: 7), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(WaveRadius.menu), + color: selected + ? (dark + ? waveSurfaceRaised(context) + : Colors.white) + : Colors.transparent, + border: Border.all( + color: selected + ? waveDivider(context).withValues(alpha: dark ? 0.35 : 0.6) + : Colors.transparent, + ), + boxShadow: selected + ? [ + BoxShadow( + color: Colors.black.withValues(alpha: dark ? 0.2 : 0.06), + blurRadius: 4, + offset: const Offset(0, 1), + ), + ] + : null, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 13, + color: selected + ? (badgeActive ? const Color(0xFFF5A623) : waveTextPrimary(context)) + : waveTextSecondary(context), + ), + const SizedBox(width: 6), + Text( + title, + style: WaveType.body.copyWith( + fontSize: 11.5, + fontWeight: selected ? FontWeight.w600 : FontWeight.normal, + color: selected + ? waveTextPrimary(context) + : waveTextSecondary(context), + ), + ), + if (badgeActive) ...[ + const SizedBox(width: 6), + Container( + width: 6, + height: 6, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Color(0xFFF5A623), + ), + ), + ], + ], + ), + ), + ); + } +} + +class _QuickActionPill extends StatelessWidget { + final String label; + final VoidCallback onTap; + final bool dark; + + const _QuickActionPill({ + required this.label, + required this.onTap, + required this.dark, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: dark + ? Colors.white.withValues(alpha: 0.05) + : Colors.black.withValues(alpha: 0.04), + border: Border.all( + color: waveDivider(context).withValues(alpha: dark ? 0.25 : 0.4), + ), + ), + child: Text( + label, + style: WaveType.meta.copyWith( + fontSize: 10, + fontWeight: FontWeight.w500, + color: waveTextSecondary(context), + ), + ), + ), + ); + } +} + /// Custom painter for smooth frequency response curve with subtle glow fill. class _EqCurvePainter extends CustomPainter { final List gainsDb; diff --git a/lib/features/lyrics/karaoke_lyrics_view.dart b/lib/features/lyrics/karaoke_lyrics_view.dart index d8750df..47a3fbb 100644 --- a/lib/features/lyrics/karaoke_lyrics_view.dart +++ b/lib/features/lyrics/karaoke_lyrics_view.dart @@ -612,6 +612,7 @@ class WaveKaraokeLyricLine extends StatefulWidget { final double? fontSize; final bool karaoke; final bool softenIdle; + final double lineOpacity; const WaveKaraokeLyricLine({ super.key, @@ -625,6 +626,7 @@ class WaveKaraokeLyricLine extends StatefulWidget { this.fontSize, this.karaoke = true, this.softenIdle = true, + this.lineOpacity = 1.0, }); @override @@ -819,29 +821,37 @@ class _WaveKaraokeLyricLineState extends State { ), ); - return RepaintBoundary( - child: MouseRegion( - onEnter: (_) => setState(() => _isHovered = true), - onExit: (_) => setState(() => _isHovered = false), - child: AnimatedContainer( - duration: WaveMotion.normal, - curve: Curves.easeOutCubic, - transform: Matrix4.diagonal3Values( - widget.isActive ? 1.025 : 1.0, - widget.isActive ? 1.025 : 1.0, - 1.0, - ), - padding: EdgeInsets.symmetric( - horizontal: widget.compact ? 8 : 12, - vertical: widget.karaoke ? 4 : 8, - ), - decoration: BoxDecoration( - color: _isHovered && !widget.isActive - ? (dark ? Colors.white : Colors.black).withValues(alpha: 0.06) - : Colors.transparent, - borderRadius: BorderRadius.circular(10), + return IgnorePointer( + ignoring: widget.lineOpacity < 0.05, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + opacity: widget.lineOpacity.clamp(0.0, 1.0), + child: RepaintBoundary( + child: MouseRegion( + onEnter: (_) => setState(() => _isHovered = true), + onExit: (_) => setState(() => _isHovered = false), + child: AnimatedContainer( + duration: WaveMotion.normal, + curve: Curves.easeOutCubic, + transform: Matrix4.diagonal3Values( + widget.isActive ? 1.025 : 1.0, + widget.isActive ? 1.025 : 1.0, + 1.0, + ), + padding: EdgeInsets.symmetric( + horizontal: widget.compact ? 8 : 12, + vertical: widget.karaoke ? 4 : 8, + ), + decoration: BoxDecoration( + color: _isHovered && !widget.isActive + ? (dark ? Colors.white : Colors.black).withValues(alpha: 0.06) + : Colors.transparent, + borderRadius: BorderRadius.circular(10), + ), + child: body, + ), ), - child: body, ), ), ); @@ -1646,42 +1656,58 @@ class _AppleLineLyricsViewState extends State<_AppleLineLyricsView> { final untimed = !widget.result.isSynced || lyricsAreUntimed(widget.result.lines); final padding = EdgeInsets.fromLTRB( widget.compact ? 12 : 28, - 24, + widget.compact ? 36 : 64, widget.compact ? 12 : 28, - 80, + widget.compact ? 80 : 120, ); if (untimed) { - return ListView.builder( - physics: const ClampingScrollPhysics(), - padding: padding, - 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: canSeek - ? SystemMouseCursors.click - : SystemMouseCursors.basic, - child: GestureDetector( - onTap: canSeek ? () => widget.onSeekLineMs(line.timeMs) : null, - child: WaveKaraokeLyricLine( - line: line, - positionMs: 0, - isActive: false, - isPast: false, - compact: widget.compact, - fontSize: widget.fontSize, - showTransliteration: widget.showTransliteration, - karaoke: false, - softenIdle: false, + return ShaderMask( + shaderCallback: (Rect bounds) { + return const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black, + Colors.black, + Colors.transparent, + ], + stops: [0.0, 0.14, 0.86, 1.0], + ).createShader(bounds); + }, + blendMode: BlendMode.dstIn, + child: ListView.builder( + physics: const ClampingScrollPhysics(), + padding: padding, + 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: canSeek + ? SystemMouseCursors.click + : SystemMouseCursors.basic, + child: GestureDetector( + onTap: canSeek ? () => widget.onSeekLineMs(line.timeMs) : null, + child: WaveKaraokeLyricLine( + line: line, + positionMs: 0, + isActive: false, + isPast: false, + compact: widget.compact, + fontSize: widget.fontSize, + showTransliteration: widget.showTransliteration, + karaoke: false, + softenIdle: false, + ), ), ), - ), - ); - }, + ); + }, + ), ); } @@ -1709,6 +1735,24 @@ class _AppleLineLyricsViewState extends State<_AppleLineLyricsView> { itemBuilder: (context, i) { final line = widget.result.lines[i]; final isActive = i == active; + + // Distance-based attenuation when following: + // Only lines that safely fit without being edged are shown. + // Lines that would touch or get clipped at the edges fade out. + double lineOpacity = 1.0; + if (widget.following && active >= 0) { + final dist = (i - active).abs(); + if (dist == 0) { + lineOpacity = 1.0; + } else if (dist == 1) { + lineOpacity = 0.45; + } else if (dist == 2) { + lineOpacity = 0.16; + } else { + lineOpacity = 0.0; + } + } + return Padding( padding: const EdgeInsets.only(bottom: 4), child: MouseRegion( @@ -1727,6 +1771,7 @@ class _AppleLineLyricsViewState extends State<_AppleLineLyricsView> { showTransliteration: widget.showTransliteration, karaoke: widget.result.isWordSynced, softenIdle: true, + lineOpacity: lineOpacity, ), ), ), @@ -1737,9 +1782,27 @@ class _AppleLineLyricsViewState extends State<_AppleLineLyricsView> { ), ); + final maskedList = ShaderMask( + shaderCallback: (Rect bounds) { + return const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black, + Colors.black, + Colors.transparent, + ], + stops: [0.0, 0.18, 0.82, 1.0], + ).createShader(bounds); + }, + blendMode: BlendMode.dstIn, + child: list, + ); + return Stack( children: [ - list, + maskedList, if (!widget.following && widget.result.isSynced) Positioned( right: 16, diff --git a/lib/ui/now_playing/now_playing_page.dart b/lib/ui/now_playing/now_playing_page.dart index e2eff5d..eadefa5 100644 --- a/lib/ui/now_playing/now_playing_page.dart +++ b/lib/ui/now_playing/now_playing_page.dart @@ -261,8 +261,6 @@ class _WaveNowPlayingPageState extends ConsumerState { ? _DesktopDualPane( track: current, lyricsVisible: _lyricsVisible, - maxHeight: constraints.maxHeight - 64, - maxWidth: constraints.maxWidth, ) : _NarrowCenteredPane( track: current, @@ -491,121 +489,133 @@ class _VisibleTicker extends ConsumerWidget { class _DesktopDualPane extends StatelessWidget { final PlayableTrack track; final bool lyricsVisible; - final double maxHeight; - final double maxWidth; const _DesktopDualPane({ required this.track, required this.lyricsVisible, - required this.maxHeight, - required this.maxWidth, }); @override Widget build(BuildContext context) { - // Symmetrical desktop dual pane layout: - // When lyrics are visible, media column and lyrics column have harmonious, balanced - // widths, leaving identical horizontal margins (emptiness) on both sides. - final colWidth = lyricsVisible - ? math.min(maxWidth * 0.36, maxHeight * 0.48).clamp(340.0, 420.0) - : math.min(maxWidth * 0.55, maxHeight * 0.52).clamp(360.0, 480.0); - - final gap = lyricsVisible ? (maxWidth * 0.04).clamp(40.0, 64.0) : 0.0; - - // Harmonized lyrics column width, balancing the visual weight of the left column - final targetLyricsWidth = (colWidth * 1.35).clamp(460.0, 580.0); - final maxAllowedLyricsWidth = math.max(320.0, maxWidth - colWidth - gap - 48.0); - final lyricsWidth = lyricsVisible - ? math.min(targetLyricsWidth, maxAllowedLyricsWidth) - : 0.0; - - return Center( - child: Padding( - padding: const EdgeInsets.only(bottom: 24), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - physics: const ClampingScrollPhysics(), - child: ConstrainedBox( - constraints: BoxConstraints(minWidth: maxWidth), - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - // Left Column (Media Player) - AnimatedContainer( - duration: const Duration(milliseconds: 500), - curve: Curves.easeInOutCubic, - width: colWidth, - child: RepaintBoundary( - child: SingleChildScrollView( - physics: const ClampingScrollPhysics(), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: lyricsVisible - ? CrossAxisAlignment.start - : CrossAxisAlignment.center, - children: [ - // Artwork Card - _HeroArtworkCard( - track: track, - size: colWidth, - ), - const SizedBox(height: 20), - - // Track Info — crossfades on track change. - _TrackSwap( - child: _TrackMetadataSection( - key: ValueKey(track.queueKey), - track: track, - alignCenter: !lyricsVisible, + return LayoutBuilder( + builder: (context, constraints) { + final maxWidth = constraints.maxWidth; + final maxHeight = math.max(0.0, constraints.maxHeight - 24.0); + + // Symmetrical desktop dual pane layout: + // When lyrics are visible, media column and lyrics column have harmonious, balanced + // widths, leaving identical horizontal margins (emptiness) on both sides. + final colWidth = lyricsVisible + ? math.min(maxWidth * 0.36, maxHeight * 0.48).clamp(340.0, 420.0) + : math.min(maxWidth * 0.55, maxHeight * 0.52).clamp(360.0, 480.0); + + final gap = lyricsVisible ? (maxWidth * 0.04).clamp(40.0, 64.0) : 0.0; + + // Harmonized lyrics column width, balancing the visual weight of the left column + final targetLyricsWidth = (colWidth * 1.35).clamp(460.0, 580.0); + final maxAllowedLyricsWidth = math.max(320.0, maxWidth - colWidth - gap - 48.0); + final lyricsWidth = lyricsVisible + ? math.min(targetLyricsWidth, maxAllowedLyricsWidth) + : 0.0; + + return Center( + child: Padding( + padding: const EdgeInsets.only(bottom: 24), + child: SizedBox( + height: maxHeight, + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Left Column (Media Player) — the metadata keeps its + // intrinsic height; artwork takes whatever remains. + AnimatedContainer( + duration: const Duration(milliseconds: 500), + curve: Curves.easeInOutCubic, + width: colWidth, + height: maxHeight, + child: RepaintBoundary( + child: Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: lyricsVisible + ? CrossAxisAlignment.start + : CrossAxisAlignment.center, + children: [ + Expanded( + child: LayoutBuilder( + builder: (context, artConstraints) { + final artSize = math.max( + 0.0, + math.min( + artConstraints.maxWidth, + artConstraints.maxHeight, + ), + ); + if (artSize <= 0) { + return const SizedBox.shrink(); + } + return Center( + child: _HeroArtworkCard( + track: track, + size: artSize, + ), + ); + }, + ), ), - ), - const SizedBox(height: 10), - - // Up Next Pill - _UpNextPill(alignCenter: !lyricsVisible), - ], + const SizedBox(height: 16), + + // Track Info & Badges — crossfades on track change. + _TrackSwap( + child: _TrackMetadataSection( + key: ValueKey(track.queueKey), + track: track, + alignCenter: !lyricsVisible, + ), + ), + ], + ), ), ), - ), - ), - // Gap - AnimatedContainer( - duration: const Duration(milliseconds: 500), - curve: Curves.easeInOutCubic, - width: gap, - ), + // Gap + AnimatedContainer( + duration: const Duration(milliseconds: 500), + curve: Curves.easeInOutCubic, + width: gap, + ), - // Right Column (Apple Music Karaoke Lyrics Pane) - AnimatedContainer( - duration: const Duration(milliseconds: 500), - curve: Curves.easeInOutCubic, - width: lyricsWidth, - child: ClipRect( - child: AnimatedOpacity( - duration: const Duration(milliseconds: 350), - curve: Curves.easeInOutCubic, - opacity: lyricsVisible ? 1.0 : 0.0, - child: ExcludeFocus( - excluding: !lyricsVisible, - child: IgnorePointer( - ignoring: !lyricsVisible, - child: SizedBox( - height: maxHeight, - width: lyricsWidth, - child: Offstage( - offstage: !lyricsVisible, - child: _VisibleTicker( - enabled: lyricsVisible, - child: RepaintBoundary( - child: WaveKaraokeLyricsView( - track: track, - compact: false, - showHeaderControls: true, - fontSize: 36.0, - isActive: lyricsVisible, + // Right Column (Apple Music Karaoke Lyrics Pane) + AnimatedContainer( + duration: const Duration(milliseconds: 500), + curve: Curves.easeInOutCubic, + width: lyricsWidth, + height: maxHeight, + child: ClipRect( + child: AnimatedOpacity( + duration: const Duration(milliseconds: 350), + curve: Curves.easeInOutCubic, + opacity: lyricsVisible ? 1.0 : 0.0, + child: ExcludeFocus( + excluding: !lyricsVisible, + child: IgnorePointer( + ignoring: !lyricsVisible, + child: SizedBox( + height: maxHeight, + width: lyricsWidth, + child: Offstage( + offstage: !lyricsVisible, + child: _VisibleTicker( + enabled: lyricsVisible, + child: RepaintBoundary( + child: WaveKaraokeLyricsView( + track: track, + compact: false, + showHeaderControls: true, + fontSize: 36.0, + isActive: lyricsVisible, + ), ), ), ), @@ -615,12 +625,12 @@ class _DesktopDualPane extends StatelessWidget { ), ), ), - ), - ], + ], + ), ), ), - ), - ), + ); + }, ); } } @@ -637,21 +647,38 @@ class _NarrowCenteredPane extends StatelessWidget { @override Widget build(BuildContext context) { - final coverArtSubtree = SingleChildScrollView( - physics: const ClampingScrollPhysics(), + final coverArtSubtree = Padding( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 440), child: Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: MainAxisSize.max, crossAxisAlignment: CrossAxisAlignment.center, children: [ - _HeroArtworkCard( - track: track, - size: 260, + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + final artSize = math.max( + 0.0, + math.min( + math.min(constraints.maxWidth, constraints.maxHeight), + 360.0, + ), + ); + if (artSize <= 0) { + return const SizedBox.shrink(); + } + return Center( + child: _HeroArtworkCard( + track: track, + size: artSize, + ), + ); + }, + ), ), - const SizedBox(height: 20), + const SizedBox(height: 16), _TrackSwap( child: _TrackMetadataSection( key: ValueKey(track.queueKey), @@ -659,8 +686,6 @@ class _NarrowCenteredPane extends StatelessWidget { alignCenter: true, ), ), - const SizedBox(height: 12), - const _UpNextPill(alignCenter: true), ], ), ), @@ -861,6 +886,44 @@ class _TrackSwap extends StatelessWidget { } } +const _metadataShadow = [Shadow(color: Color(0x59000000), blurRadius: 10)]; + +/// Split the stream spec into a tier ("HI-RES") and a quieter detail +/// ("FLAC · 4608 kbps") without repeating the tier inside the codec. +({String tier, String detail})? _streamQualityParts(ResolvedStream stream) { + final badge = stream.qualityBadge.trim().toUpperCase(); + final codec = stream.audioCodec.trim().toUpperCase(); + + String tier; + String? codecDetail; + if (badge.isEmpty) { + tier = codec; + } else { + tier = badge; + if (codec.isNotEmpty && codec != badge) { + if (codec.contains(badge)) { + final remainder = codec + .replaceFirst(badge, '') + .replaceFirst(RegExp(r'^[\s·\-]+'), '') + .trim(); + if (remainder.isNotEmpty) codecDetail = remainder; + } else if (!badge.contains(codec)) { + codecDetail = codec; + } + } + } + + final detailParts = [ + ?codecDetail, + if (stream.bitrateKbps > 0) '${stream.bitrateKbps} kbps', + ]; + if (tier.isEmpty && detailParts.isEmpty) return null; + return ( + tier: tier.isEmpty ? 'AUDIO' : tier, + detail: detailParts.join(' · '), + ); +} + class _TrackMetadataSection extends ConsumerWidget { final PlayableTrack track; final bool alignCenter; @@ -873,12 +936,24 @@ class _TrackMetadataSection extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final dark = waveIsDark(context); final stream = ref.watch( playbackServiceProvider.select((s) => s.stream), ); + final quality = stream == null ? null : _streamQualityParts(stream); + + final showDistinctAlbum = track.album.isNotEmpty && + track.album.toLowerCase().trim() != track.title.toLowerCase().trim(); + + final artistStyle = WaveType.meta.copyWith( + fontSize: 14, + height: 1.45, + fontWeight: FontWeight.w500, + letterSpacing: 0.1, + shadows: _metadataShadow, + ); return Column( + mainAxisSize: MainAxisSize.min, crossAxisAlignment: alignCenter ? CrossAxisAlignment.center : CrossAxisAlignment.start, children: [ @@ -888,138 +963,249 @@ class _TrackMetadataSection extends ConsumerWidget { overflow: TextOverflow.ellipsis, textAlign: alignCenter ? TextAlign.center : TextAlign.start, style: WaveType.pageTitle.copyWith( - fontSize: 26, + fontSize: 25, + height: 1.12, fontWeight: FontWeight.w700, - letterSpacing: -0.4, + letterSpacing: -0.5, color: Colors.white, - shadows: const [ - Shadow(color: Color(0x66000000), blurRadius: 10), - ], + shadows: _metadataShadow, ), ), - const SizedBox(height: 4), - GestureDetector( - onTap: () => context.go( - '/search?q=${Uri.encodeComponent(track.artist)}', - ), - child: Text( - track.artist, - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: alignCenter ? TextAlign.center : TextAlign.start, - style: WaveType.body.copyWith( - fontSize: 16, - fontWeight: FontWeight.w500, - color: Colors.white.withValues(alpha: 0.94), - shadows: const [ - Shadow(color: Color(0x66000000), blurRadius: 10), - ], + const SizedBox(height: 5), + MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => context.go( + '/search?q=${Uri.encodeComponent(track.artist)}', + ), + child: Text.rich( + TextSpan( + children: [ + TextSpan( + text: track.artist, + style: artistStyle.copyWith( + color: Colors.white.withValues(alpha: 0.86), + ), + ), + if (showDistinctAlbum) ...[ + TextSpan( + text: ' · ', + style: artistStyle.copyWith( + color: Colors.white.withValues(alpha: 0.30), + fontWeight: FontWeight.w400, + ), + ), + TextSpan( + text: track.album, + style: artistStyle.copyWith( + color: Colors.white.withValues(alpha: 0.56), + fontWeight: FontWeight.w400, + ), + ), + ], + ], + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: alignCenter ? TextAlign.center : TextAlign.start, ), ), ), - if (track.album.isNotEmpty) ...[ - const SizedBox(height: 2), + if (quality != null) ...[ + const SizedBox(height: 10), + _QualitySpecLine(quality: quality, centered: alignCenter), + ], + _UpNextRow(centered: alignCenter), + ], + ); + } +} + +class _QualitySpecLine extends StatelessWidget { + final ({String tier, String detail}) quality; + final bool centered; + + const _QualitySpecLine({required this.quality, required this.centered}); + + @override + Widget build(BuildContext context) { + return Align( + alignment: centered ? Alignment.center : Alignment.centerLeft, + child: Row( + key: const ValueKey('now-playing-quality'), + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + WaveIcons.equalizer, + size: 11, + color: waveAccent(context).withValues(alpha: 0.62), + ), + const SizedBox(width: 6), Text( - track.album, - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: alignCenter ? TextAlign.center : TextAlign.start, - style: WaveType.meta.copyWith( - fontSize: 13, - color: Colors.white.withValues(alpha: 0.78), - shadows: const [ - Shadow(color: Color(0x66000000), blurRadius: 10), - ], + quality.tier, + style: WaveType.overline.copyWith( + fontSize: 9.5, + letterSpacing: 1.05, + fontWeight: FontWeight.w700, + color: Colors.white.withValues(alpha: 0.94), + shadows: _metadataShadow, ), ), - ], - if (stream != null) ...[ - const SizedBox(height: 8), - WaveGlass( - blur: false, - borderRadius: WaveRadius.controlsRadius, - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - child: Text( - '${stream.qualityBadge} · ${stream.audioCodec}${stream.bitrateKbps > 0 ? ' · ${stream.bitrateKbps} kbps' : ''}', - style: WaveType.overline.copyWith( - fontSize: 9.5, - fontWeight: FontWeight.w700, - color: dark - ? WaveColors.textPrimary - : WaveColors.lightTextPrimary, + if (quality.detail.isNotEmpty) ...[ + const SizedBox(width: 7), + Text( + '·', + style: WaveType.label.copyWith( + fontSize: 10.5, + color: Colors.white.withValues(alpha: 0.32), + shadows: _metadataShadow, ), ), - ), + const SizedBox(width: 7), + Text( + quality.detail, + style: WaveType.label.copyWith( + fontSize: 10.5, + fontWeight: FontWeight.w500, + letterSpacing: 0.1, + color: Colors.white.withValues(alpha: 0.68), + shadows: _metadataShadow, + ), + ), + ], ], - ], + ), ); } } -class _UpNextPill extends ConsumerWidget { - final bool alignCenter; +class _UpNextRow extends ConsumerStatefulWidget { + final bool centered; - const _UpNextPill({this.alignCenter = false}); + const _UpNextRow({required this.centered}); @override - Widget build(BuildContext context, WidgetRef ref) { - final dark = waveIsDark(context); + ConsumerState<_UpNextRow> createState() => _UpNextRowState(); +} + +class _UpNextRowState extends ConsumerState<_UpNextRow> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { final queue = ref.watch(playbackServiceProvider.select((s) => s.queue)); - final index = ref.watch(playbackServiceProvider.select((s) => s.currentIndex)); + final index = ref.watch( + playbackServiceProvider.select((s) => s.currentIndex), + ); final hasNext = index >= 0 && index + 1 < queue.length; if (!hasNext) return const SizedBox.shrink(); final nextTrack = queue[index + 1]; + final nextStyle = WaveType.meta.copyWith( + fontSize: 11.5, + color: Colors.white.withValues(alpha: 0.62), + shadows: _metadataShadow, + ); - return Align( - alignment: alignCenter ? Alignment.center : Alignment.centerLeft, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () => ref.read(playbackServiceProvider.notifier).next(), - child: WaveGlass( - blur: false, - borderRadius: BorderRadius.circular(20), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'UP NEXT', - style: WaveType.overline.copyWith( - fontSize: 9, - fontWeight: FontWeight.w700, - color: waveAccent(context), - ), - ), - const SizedBox(width: 8), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 180), - child: Text( - '${nextTrack.title} · ${nextTrack.artist}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: WaveType.meta.copyWith( - fontSize: 11, - color: dark - ? WaveColors.textPrimary - : WaveColors.lightTextPrimary, - ), - ), - ), - const SizedBox(width: 4), - Icon( - FluentIcons.chevron_right, - size: 10, - color: dark - ? WaveColors.textSecondary - : WaveColors.lightTextSecondary, - ), - ], + final nextLabel = Text.rich( + TextSpan( + children: [ + TextSpan( + text: nextTrack.title, + style: WaveType.trackTitle.copyWith( + fontSize: 12.5, + fontWeight: FontWeight.w600, + color: Colors.white.withValues(alpha: 0.92), + shadows: _metadataShadow, ), ), + TextSpan( + text: ' · ', + style: nextStyle.copyWith( + color: Colors.white.withValues(alpha: 0.30), + ), + ), + TextSpan( + text: nextTrack.artist, + style: nextStyle, + ), + ], + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + + final content = Row( + mainAxisSize: widget.centered ? MainAxisSize.min : MainAxisSize.max, + children: [ + Text( + 'UP NEXT', + style: WaveType.overline.copyWith( + fontSize: 8.5, + letterSpacing: 1.2, + fontWeight: FontWeight.w700, + color: waveAccent(context).withValues(alpha: 0.82), + shadows: _metadataShadow, + ), + ), + const SizedBox(width: 10), + if (widget.centered) + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 270), + child: nextLabel, + ) + else + Expanded(child: nextLabel), + const SizedBox(width: 8), + AnimatedSlide( + duration: WaveMotion.fast, + curve: WaveMotion.standard, + offset: _hovered ? const Offset(0.18, 0) : Offset.zero, + child: Icon( + FluentIcons.chevron_right, + size: 11, + color: _hovered + ? waveAccent(context) + : Colors.white.withValues(alpha: 0.42), + ), ), + ], + ); + + return Padding( + padding: const EdgeInsets.only(top: 10), + child: Column( + crossAxisAlignment: + widget.centered ? CrossAxisAlignment.center : CrossAxisAlignment.start, + children: [ + Container( + width: 28, + height: 1, + color: Colors.white.withValues(alpha: 0.16), + ), + const SizedBox(height: 4), + MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => ref.read(playbackServiceProvider.notifier).next(), + child: AnimatedContainer( + key: const ValueKey('now-playing-up-next'), + duration: WaveMotion.fast, + curve: WaveMotion.standard, + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 5), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: _hovered ? 0.06 : 0.0), + borderRadius: BorderRadius.circular(6), + ), + child: content, + ), + ), + ), + ], ), ); } diff --git a/test/now_playing_layout_test.dart b/test/now_playing_layout_test.dart new file mode 100644 index 0000000..5a77b17 --- /dev/null +++ b/test/now_playing_layout_test.dart @@ -0,0 +1,126 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:lastwave_desktop/core/artwork/animated_artwork_service.dart'; +import 'package:lastwave_desktop/core/audio/stream_models.dart'; +import 'package:lastwave_desktop/core/storage/app_database.dart'; +import 'package:lastwave_desktop/core/storage/prefs.dart'; +import 'package:lastwave_desktop/features/lyrics/lyrics_models.dart'; +import 'package:lastwave_desktop/features/player/playback_service.dart'; +import 'package:lastwave_desktop/features/player/player_state.dart'; +import 'package:lastwave_desktop/features/search/shared_providers.dart'; +import 'package:lastwave_desktop/ui/components/ambient.dart'; +import 'package:lastwave_desktop/ui/lyrics/lyrics_panel.dart'; +import 'package:lastwave_desktop/ui/now_playing/now_playing_page.dart'; +import 'package:lastwave_desktop/ui/theme/haze.dart'; + +class _FakePlaybackService extends StateNotifier + implements PlaybackService { + _FakePlaybackService(super.state); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +const _current = PlayableTrack( + title: 'Do Numbari (From "Mirzapur The Movie")', + artist: 'Dhanda Nyoliwala', + album: 'Mirzapur', + artworkUrl: 'https://is1-ssl.mzstatic.com/image/thumb/test.jpg', +); +const _next = PlayableTrack(title: 'Hola Amigo', artist: 'KR\$NA'); + +PlayerSnapshot _snapshot() => const PlayerSnapshot( + current: _current, + queue: [_current, _next], + currentIndex: 0, + stream: ResolvedStream( + url: 'https://example.invalid/a.flac', + bitrateKbps: 4608, + audioCodec: 'HI-RES FLAC', + isLossless: true, + bitDepth: 24, + samplingRateKhz: 96, + ), +); + +Future _pumpNowPlaying(WidgetTester tester, {required Size size}) async { + SharedPreferences.setMockInitialValues({'lw_visualizer_enabled': false}); + final prefs = await Prefs.load(); + final db = AppDatabase.inMemory(); + addTearDown(db.close); + final fake = _FakePlaybackService(_snapshot()); + + await tester.binding.setSurfaceSize(size); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + prefsProvider.overrideWithValue(prefs), + databaseProvider.overrideWithValue(db), + playbackServiceProvider.overrideWith((ref) => fake), + animatedArtworkProvider.overrideWith((ref, query) async => null), + artworkPaletteProvider.overrideWith( + (ref, url) async => ArtworkPalette.fallback, + ), + waveLyricsProvider.overrideWith( + (ref, track) => Stream.value(const LyricsResult.empty()), + ), + ], + child: const FluentApp( + themeMode: ThemeMode.dark, + home: WaveNowPlayingPage(), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); +} + +Rect _upNextRowRect(WidgetTester tester) { + final row = find.byKey(const ValueKey('now-playing-up-next')); + expect(row, findsOneWidget); + return tester.getRect(row); +} + +void main() { + testWidgets('desktop metadata and UP NEXT stay inside the page', ( + tester, + ) async { + const size = Size(1024, 516); + await _pumpNowPlaying(tester, size: size); + + expect(find.text('Do Numbari (From "Mirzapur The Movie")'), findsOneWidget); + expect(find.text('HI-RES'), findsOneWidget); + expect(find.text('FLAC · 4608 kbps'), findsOneWidget); + expect( + find.ancestor(of: find.text('HI-RES'), matching: find.byType(WaveGlass)), + findsNothing, + ); + expect( + find.ancestor(of: find.text('UP NEXT'), matching: find.byType(WaveGlass)), + findsNothing, + ); + expect(_upNextRowRect(tester).bottom, lessThanOrEqualTo(size.height)); + }); + + testWidgets('narrow metadata and UP NEXT fit without scrolling', ( + tester, + ) async { + const size = Size(800, 516); + await _pumpNowPlaying(tester, size: size); + + await tester.tap(find.byTooltip('Hide lyrics')); + await tester.pumpAndSettle(); + + expect(find.text('Do Numbari (From "Mirzapur The Movie")'), findsOneWidget); + expect( + find.ancestor(of: find.text('UP NEXT'), matching: find.byType(WaveGlass)), + findsNothing, + ); + expect(_upNextRowRect(tester).bottom, lessThanOrEqualTo(size.height)); + }); +} diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp index c9ab6df..7436436 100644 --- a/windows/runner/main.cpp +++ b/windows/runner/main.cpp @@ -47,6 +47,23 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, // plugins. ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + // Single-instance enforcement: prevent multiple LastWave processes from + // launching simultaneously, which would create duplicate tray icons and + // audio playback conflicts. If an instance is already running, activate it. + HANDLE hMutex = ::CreateMutexW(nullptr, TRUE, L"Local\\LastWaveDesktopSingleInstanceMutex"); + if (GetLastError() == ERROR_ALREADY_EXISTS) { + HWND existingWnd = ::FindWindowW(L"FLUTTER_RUNNER_WIN32_WINDOW", nullptr); + if (existingWnd != nullptr) { + ::ShowWindow(existingWnd, SW_RESTORE); + ::SetForegroundWindow(existingWnd); + } + if (hMutex != nullptr) { + ::CloseHandle(hMutex); + } + ::CoUninitialize(); + return EXIT_SUCCESS; + } + // 1ms system timer resolution for the life of the app. Dart cannot set // this (no FFI binding, and it is process-global by design). Windows // defaults to ~15.6ms granularity, which coarsens every Sleep-based @@ -88,6 +105,10 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, } ::timeEndPeriod(1); + if (hMutex != nullptr) { + ::ReleaseMutex(hMutex); + ::CloseHandle(hMutex); + } ::CoUninitialize(); return EXIT_SUCCESS; }