From 80e99584e1cbf7674db92bc50f9272529808e0a5 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Wed, 4 Nov 2026 17:07:00 +0800 Subject: [PATCH 01/14] Add PCM callback telemetry to diagnostics logging Surface managed-output callback stats (counts, durations, overruns, xruns, buffer sizing) and PCM callback counters (lock misses, starved samples, skipped DSP stages, peak/clipping, non-finite samples) in the diagnostics report. --- lib/services/player_service.dart | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/lib/services/player_service.dart b/lib/services/player_service.dart index 0612b8f1..7fee7aba 100644 --- a/lib/services/player_service.dart +++ b/lib/services/player_service.dart @@ -2896,6 +2896,7 @@ class PlayerService { final directUsbState = _mapValue(rustAudioState?['direct_usb']); final dsdCallbackState = _mapValue(rustAudioState?['dsd_callback']); final managedOutputState = _mapValue(rustAudioState?['managed_output']); + final pcmCallbackState = _mapValue(managedOutputState?['pcm_callback']); final deviceProfile = _mapValue(rustAudioState?['device_profile']); final detectedDap = _isDetectedDapProfile(deviceProfile); final detectedDapBrand = _detectedDapBrand(deviceProfile); @@ -3313,6 +3314,14 @@ class PlayerService { reportedOutputSampleRate == 48000 ? 'app-stream-may-match-link' : 'app-stream-resampled-by-bt-mixer'; + final pcmSkippedStages = pcmCallbackState == null + ? -1 + : (_intValue(pcmCallbackState['skipped_pitch']) ?? 0) + + (_intValue(pcmCallbackState['skipped_eq']) ?? 0) + + (_intValue(pcmCallbackState['skipped_crossfeed']) ?? 0) + + (_intValue(pcmCallbackState['skipped_fx']) ?? 0) + + (_intValue(pcmCallbackState['skipped_convolver']) ?? 0) + + (_intValue(pcmCallbackState['skipped_dynamics']) ?? 0); _debugLog( '[Diagnostics] $reason: mode=${mode.logLabel}, ' @@ -3338,6 +3347,24 @@ class PlayerService { 'callbackFramesMax=${_intValue(managedOutputState?['burst_frames_max']) ?? -1}, ' 'callbackChunks=${_intValue(managedOutputState?['burst_chunked_count']) ?? -1}, ' 'callbackScratchFrames=${_intValue(managedOutputState?['scratch_frames']) ?? -1}, ' + 'callbackCount=${_intValue(managedOutputState?['callback_count']) ?? -1}, ' + 'callbackFramesTotal=${_intValue(managedOutputState?['callback_frames_total']) ?? -1}, ' + 'callbackDurationUsMax=${_intValue(managedOutputState?['callback_duration_us_max']) ?? -1}, ' + 'callbackOverruns=${_intValue(managedOutputState?['callback_overruns']) ?? -1}, ' + 'xrunSupported=${managedOutputState?['xrun_count_supported'] ?? 'unknown'}, ' + 'xrunCount=${_intValue(managedOutputState?['xrun_count']) ?? -1}, ' + 'framesPerBurst=${_intValue(managedOutputState?['frames_per_burst']) ?? -1}, ' + 'bufferFrames=${_intValue(managedOutputState?['buffer_size_frames']) ?? -1}/' + '${_intValue(managedOutputState?['buffer_capacity_frames']) ?? -1}, ' + 'pcmSourceLockMisses=${_intValue(pcmCallbackState?['source_lock_misses']) ?? -1}, ' + 'pcmCrossfadeLockMisses=${_intValue(pcmCallbackState?['crossfader_lock_misses']) ?? -1}, ' + 'pcmMixLockMisses=${_intValue(pcmCallbackState?['mix_buffer_lock_misses']) ?? -1}, ' + 'pcmSpeedLockMisses=${_intValue(pcmCallbackState?['speed_lock_misses']) ?? -1}, ' + 'pcmStarvedSamples=${_intValue(pcmCallbackState?['starved_samples']) ?? -1}, ' + 'pcmSkippedDspStages=$pcmSkippedStages, ' + 'pcmPeak=${pcmCallbackState?['peak'] ?? -1}, ' + 'pcmClippedSamples=${_intValue(pcmCallbackState?['clipped_samples']) ?? -1}, ' + 'pcmNonFiniteSamples=${_intValue(pcmCallbackState?['non_finite_samples']) ?? -1}, ' 'producerFrames=${producerFrames ?? -1}, consumerFrames=${consumerFrames ?? -1}, ' 'driftMs=${driftMsFromTarget ?? -999}, ' 'resampler=$resamplerActive, passthrough=$passthroughAllowed, ' From e8847af4dbeadfc9cacde91a6adb07dfa9977db6 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Wed, 4 Nov 2026 17:07:07 +0800 Subject: [PATCH 02/14] Add real-time DSP and output telemetry diagnostics Extend PcmCallbackStats and ManagedOutputStats with lock contention, DSP skips, peak/clipping, callback timing, and xrun counters, all published through atomics so the audio callback never logs or blocks. Replace the EQ mutex path with a coalesced mailbox of precomputed EqParams applied at block boundaries, and add lock-free enabled mirrors for fx, convolver, dynamics, and crossfade to skip inactive stages without contention. Publish playback progress via a seqlock snapshot instead of locking. --- rust/src/api/audio_api.rs | 32 +- rust/src/audio/engine.rs | 1035 +++++++++++++++++++++++++++++++---- rust/src/audio/equalizer.rs | 11 +- 3 files changed, 968 insertions(+), 110 deletions(-) diff --git a/rust/src/api/audio_api.rs b/rust/src/api/audio_api.rs index d593e8e9..b9523b60 100644 --- a/rust/src/api/audio_api.rs +++ b/rust/src/api/audio_api.rs @@ -1186,11 +1186,37 @@ pub(crate) fn managed_output_debug_json() -> serde_json::Value { #[cfg(not(target_os = "android"))] let scratch_frames = 0usize; - let stats = read_audio_engine(|handle| handle.managed_output_stats()); + let managed = read_audio_engine(|handle| handle.managed_output_stats()); + let pcm = read_audio_engine(|handle| handle.pcm_callback_stats()); serde_json::json!({ - "burst_frames_max": stats.map(|(frames, _)| frames), - "burst_chunked_count": stats.map(|(_, chunks)| chunks), + "burst_frames_max": managed.map(|stats| stats.burst_frames_max), + "burst_chunked_count": managed.map(|stats| stats.burst_chunked_count), + "callback_count": managed.map(|stats| stats.callback_count), + "callback_frames_total": managed.map(|stats| stats.callback_frames_total), + "callback_duration_us_max": managed.map(|stats| stats.callback_duration_us_max), + "callback_overruns": managed.map(|stats| stats.callback_overruns), + "xrun_count_supported": managed.map(|stats| stats.xrun_count_supported), + "xrun_count": managed.and_then(|stats| stats.xrun_count), + "frames_per_burst": managed.map(|stats| stats.frames_per_burst), + "buffer_size_frames": managed.map(|stats| stats.buffer_size_frames), + "buffer_capacity_frames": managed.map(|stats| stats.buffer_capacity_frames), "scratch_frames": scratch_frames, + "pcm_callback": { + "source_lock_misses": pcm.map(|stats| stats.source_lock_misses), + "crossfader_lock_misses": pcm.map(|stats| stats.crossfader_lock_misses), + "mix_buffer_lock_misses": pcm.map(|stats| stats.mix_buffer_lock_misses), + "speed_lock_misses": pcm.map(|stats| stats.speed_lock_misses), + "starved_samples": pcm.map(|stats| stats.starved_samples), + "skipped_pitch": pcm.map(|stats| stats.skipped_pitch), + "skipped_eq": pcm.map(|stats| stats.skipped_eq), + "skipped_crossfeed": pcm.map(|stats| stats.skipped_crossfeed), + "skipped_fx": pcm.map(|stats| stats.skipped_fx), + "skipped_convolver": pcm.map(|stats| stats.skipped_convolver), + "skipped_dynamics": pcm.map(|stats| stats.skipped_dynamics), + "peak": pcm.map(|stats| stats.peak), + "clipped_samples": pcm.map(|stats| stats.clipped_samples), + "non_finite_samples": pcm.map(|stats| stats.non_finite_samples), + }, }) } diff --git a/rust/src/audio/engine.rs b/rust/src/audio/engine.rs index b3c54264..41a7b783 100644 --- a/rust/src/audio/engine.rs +++ b/rust/src/audio/engine.rs @@ -15,7 +15,7 @@ use crate::audio::device::current_device_profile; use crate::audio::dsd_engine::DsdDecoderThread; use crate::audio::dynamics::DynamicsChain; use crate::audio::crossfeed::{Crossfeed, CrossfeedLevel}; -use crate::audio::equalizer::{EqBandSpec, Equalizer}; +use crate::audio::equalizer::{EqBandSpec, EqParams, Equalizer}; use crate::audio::fx::SpatialFx; use crate::audio::pitch_shifter::PitchShifter; use crate::audio::source::{ @@ -152,10 +152,50 @@ impl From for EngineDsdMode { } } +/// Cumulative counters from the real-time PCM/DSP callback. Published through +/// atomics so diagnostics can inspect the live path without logging from (or +/// taking locks in) the audio callback. +#[allow(dead_code)] // fields are consumed by Android diagnostics and tests +#[derive(Debug, Clone, Copy, Default)] +pub(crate) struct PcmCallbackStats { + pub source_lock_misses: u64, + pub crossfader_lock_misses: u64, + pub mix_buffer_lock_misses: u64, + pub speed_lock_misses: u64, + pub starved_samples: u64, + pub skipped_pitch: u64, + pub skipped_eq: u64, + pub skipped_crossfeed: u64, + pub skipped_fx: u64, + pub skipped_convolver: u64, + pub skipped_dynamics: u64, + pub peak: f32, + pub clipped_samples: u64, + pub non_finite_samples: u64, +} + +/// Managed Oboe stream configuration and xrun counters, sampled from the +/// control thread (never from the audio callback). +#[allow(dead_code)] // fields are consumed by Android diagnostics and tests +#[derive(Debug, Clone, Copy, Default)] +pub(crate) struct ManagedOutputStats { + pub callback_count: u64, + pub callback_frames_total: u64, + pub callback_duration_us_max: u64, + pub callback_overruns: u64, + pub burst_frames_max: u32, + pub burst_chunked_count: u64, + pub xrun_count_supported: bool, + pub xrun_count: Option, + pub frames_per_burst: u32, + pub buffer_size_frames: u32, + pub buffer_capacity_frames: u32, +} + /// Audio callback data shared between engine and audio thread. /// -/// This struct contains only lock-free or atomic data to ensure -/// the audio callback never blocks. +/// Progress and diagnostics use atomics. Mutable render state is protected by +/// non-blocking callback locks; control-side contention must be kept out of it. pub struct AudioCallbackData { /// Volume level (0.0 to 1.0) volume: std::sync::atomic::AtomicU32, // Using AtomicU32 for f32 bit pattern @@ -181,8 +221,12 @@ pub struct AudioCallbackData { speed_buffer: Mutex>, /// Fractional sample position for speed interpolation speed_frac_pos: Mutex, - /// Graphic EQ (10 bands). try_lock in callback to avoid blocking. + /// Live EQ filter state, mutated only on the render side. equalizer: Mutex, + /// Latest precomputed EQ update. A busy mailbox defers the update, not the + /// active EQ processing. Coalescing avoids unbounded slider-update queues. + pending_equalizer: Mutex>, + equalizer_update_pending: AtomicBool, /// Creative spatial/time FX. fx: Mutex, /// BS2B (Bauer stereophonic-to-binaural) crossfeed. Reduces headphone ear @@ -210,6 +254,14 @@ pub struct AudioCallbackData { /// passthrough so the BS2B filters run even on a verified bit-perfect USB /// output (same trick as EQ). crossfeed_forces_dsp: AtomicBool, + /// Lock-free mirrors of effects that are enabled but have no dedicated + /// passthrough override. The callback uses these to avoid contending on + /// inactive effect mutexes and to distinguish an active stage skipped by + /// lock contention from an effect that is simply bypassed. + fx_enabled: AtomicBool, + convolver_enabled: AtomicBool, + compressor_enabled: AtomicBool, + limiter_enabled: AtomicBool, /// Effective ReplayGain for the current track (dB, f32 bits). New sources /// are stamped with this value at spawn; [AudioCommand::SetReplayGain] /// updates it live. ReplayGain != 0 dB needs the DSP path, so this also @@ -234,6 +286,10 @@ pub struct AudioCallbackData { /// `try_lock` never collides mid-fade (a collision drops the fade gains and /// emits the outgoing track at full volume = audible chopping). crossfade_active: AtomicBool, + /// Lock-free mirror of `crossfader.is_enabled()`. Lets the command loop + /// skip disabled-crossfade checks without taking the source/crossfader + /// locks that the real-time callback needs. + crossfade_configured: AtomicBool, /// Latches a reported [AudioEvent::NextTrackReady] for the queued /// successor. Starts `true` ("nothing to report"); queueing arms it, and /// the command loop reports only once the successor actually holds audio. @@ -261,6 +317,49 @@ pub struct AudioCallbackData { dsd_starved_samples: AtomicU64, /// Telemetry: callbacks silenced by `sources` lock contention. dsd_lock_misses: AtomicU64, + /// Telemetry: PCM callbacks whose entire block was silenced because the + /// source provider lock was busy. This is the DSP-path counterpart of + /// `dsd_lock_misses`. + pcm_source_lock_misses: AtomicU64, + /// Telemetry: PCM callbacks that could not acquire crossfader state. + pcm_crossfader_lock_misses: AtomicU64, + /// Telemetry: PCM callbacks that could not acquire the crossfade mix or + /// speed scratch buffers. + pcm_mix_buffer_lock_misses: AtomicU64, + pcm_speed_lock_misses: AtomicU64, + /// Telemetry: samples zero-filled because a live PCM source ran short. + pcm_starved_samples: AtomicU64, + /// Telemetry: DSP blocks where an active effect was bypassed because its + /// control lock was busy. + dsp_skipped_pitch: AtomicU64, + dsp_skipped_eq: AtomicU64, + dsp_skipped_crossfeed: AtomicU64, + dsp_skipped_fx: AtomicU64, + dsp_skipped_convolver: AtomicU64, + dsp_skipped_dynamics: AtomicU64, + /// Telemetry: highest absolute output sample after the final volume stage + /// (f32 bits), and the number of samples outside [-1, 1] or non-finite. + output_peak_bits: AtomicU32, + output_clipped_samples: AtomicU64, + output_nonfinite_samples: AtomicU64, + /// Telemetry sampled from the managed Oboe stream control path. + managed_callback_count: AtomicU64, + managed_callback_frames_total: AtomicU64, + managed_callback_duration_us_max: AtomicU64, + managed_callback_overruns: AtomicU64, + managed_xrun_count_supported: AtomicBool, + managed_xrun_count_available: AtomicBool, + managed_xrun_count: AtomicU32, + managed_frames_per_burst: AtomicU32, + managed_buffer_size_frames: AtomicU32, + managed_buffer_capacity_frames: AtomicU32, + /// Lock-free playback progress snapshot. `progress_seq` is a seqlock + /// generation: odd while the callback publishes, even when stable. + progress_seq: AtomicU64, + progress_position_secs_bits: AtomicU64, + progress_duration_secs_bits: AtomicU64, + progress_buffer_level_bits: AtomicU32, + progress_has_source: AtomicBool, /// Latches the first persistent-silence report (zero channels, undersized /// mix buffers) so the RT callback emits a single "output lost" event /// instead of spamming the channel every callback. @@ -314,6 +413,8 @@ impl AudioCallbackData { speed_buffer: Mutex::new(vec![0.0; speed_buffer_size]), speed_frac_pos: Mutex::new(0.0), equalizer: Mutex::new(Equalizer::new()), + pending_equalizer: Mutex::new(None), + equalizer_update_pending: AtomicBool::new(false), fx: Mutex::new(SpatialFx::new(sample_rate)), crossfeed: Mutex::new(Crossfeed::new(sample_rate)), convolver: Mutex::new(Convolver::new(sample_rate)), @@ -322,12 +423,17 @@ impl AudioCallbackData { pitch_shift_forces_dsp: AtomicBool::new(false), eq_forces_dsp: AtomicBool::new(false), crossfeed_forces_dsp: AtomicBool::new(false), + fx_enabled: AtomicBool::new(false), + convolver_enabled: AtomicBool::new(false), + compressor_enabled: AtomicBool::new(false), + limiter_enabled: AtomicBool::new(false), replaygain_db: AtomicU32::new(0.0f32.to_bits()), replaygain_forces_dsp: AtomicBool::new(false), finished_tracks, tuning_432hz_enabled: AtomicBool::new(tuning_enabled), crossfade_forces_dsp: AtomicBool::new(false), crossfade_active: AtomicBool::new(false), + crossfade_configured: AtomicBool::new(false), next_ready_reported: AtomicBool::new(true), stream_needs_restart: AtomicBool::new(false), engine_dsd_mode: AtomicU8::new(engine_dsd_mode as u8), @@ -336,6 +442,35 @@ impl AudioCallbackData { dop_marker_phase: AtomicBool::new(false), dsd_starved_samples: AtomicU64::new(0), dsd_lock_misses: AtomicU64::new(0), + pcm_source_lock_misses: AtomicU64::new(0), + pcm_crossfader_lock_misses: AtomicU64::new(0), + pcm_mix_buffer_lock_misses: AtomicU64::new(0), + pcm_speed_lock_misses: AtomicU64::new(0), + pcm_starved_samples: AtomicU64::new(0), + dsp_skipped_pitch: AtomicU64::new(0), + dsp_skipped_eq: AtomicU64::new(0), + dsp_skipped_crossfeed: AtomicU64::new(0), + dsp_skipped_fx: AtomicU64::new(0), + dsp_skipped_convolver: AtomicU64::new(0), + dsp_skipped_dynamics: AtomicU64::new(0), + output_peak_bits: AtomicU32::new(0.0f32.to_bits()), + output_clipped_samples: AtomicU64::new(0), + output_nonfinite_samples: AtomicU64::new(0), + managed_callback_count: AtomicU64::new(0), + managed_callback_frames_total: AtomicU64::new(0), + managed_callback_duration_us_max: AtomicU64::new(0), + managed_callback_overruns: AtomicU64::new(0), + managed_xrun_count_supported: AtomicBool::new(false), + managed_xrun_count_available: AtomicBool::new(false), + managed_xrun_count: AtomicU32::new(0), + managed_frames_per_burst: AtomicU32::new(0), + managed_buffer_size_frames: AtomicU32::new(0), + managed_buffer_capacity_frames: AtomicU32::new(0), + progress_seq: AtomicU64::new(0), + progress_position_secs_bits: AtomicU64::new(0.0f64.to_bits()), + progress_duration_secs_bits: AtomicU64::new(0.0f64.to_bits()), + progress_buffer_level_bits: AtomicU32::new(0.0f32.to_bits()), + progress_has_source: AtomicBool::new(false), output_loss_reported: AtomicBool::new(false), oboe_burst_frames_max: AtomicU32::new(0), oboe_burst_chunked_count: AtomicU64::new(0), @@ -424,6 +559,200 @@ impl AudioCallbackData { ) } + /// Publish the current source's UI progress through the lock-free snapshot. + /// Callers must hold the `sources` lock so the source cannot be replaced + /// between selection and publication. + pub(crate) fn publish_progress_snapshot(&self, sources: &SourceProvider) { + let source = progress_source( + self.crossfade_active.load(Ordering::Relaxed), + sources, + ); + + self.progress_seq.fetch_add(1, Ordering::Relaxed); + // Publish the odd generation before any payload stores. The reader's + // acquire fence before rechecking the generation pairs with this fence. + std::sync::atomic::fence(Ordering::Release); + match source { + Some(source) => { + self.progress_position_secs_bits + .store(source.position_secs().to_bits(), Ordering::Relaxed); + self.progress_duration_secs_bits + .store(source.info.duration_secs.to_bits(), Ordering::Relaxed); + self.progress_buffer_level_bits + .store(source.buffer_level().to_bits(), Ordering::Relaxed); + self.progress_has_source.store(true, Ordering::Relaxed); + } + None => { + self.progress_position_secs_bits + .store(0.0f64.to_bits(), Ordering::Relaxed); + self.progress_duration_secs_bits + .store(0.0f64.to_bits(), Ordering::Relaxed); + self.progress_buffer_level_bits + .store(0.0f32.to_bits(), Ordering::Relaxed); + self.progress_has_source.store(false, Ordering::Relaxed); + } + } + self.progress_seq.fetch_add(1, Ordering::Release); + } + + /// Clear the progress snapshot when playback is explicitly stopped or the + /// engine is shutting down. Callers must hold the `sources` lock so this + /// cannot interleave with a callback publishing a newer snapshot. + pub(crate) fn clear_progress_snapshot(&self) { + self.progress_seq.fetch_add(1, Ordering::Relaxed); + std::sync::atomic::fence(Ordering::Release); + self.progress_position_secs_bits + .store(0.0f64.to_bits(), Ordering::Relaxed); + self.progress_duration_secs_bits + .store(0.0f64.to_bits(), Ordering::Relaxed); + self.progress_buffer_level_bits + .store(0.0f32.to_bits(), Ordering::Relaxed); + self.progress_has_source.store(false, Ordering::Relaxed); + self.progress_seq.fetch_add(1, Ordering::Release); + } + + /// Read the lock-free progress snapshot. Never takes the sources lock. + pub(crate) fn progress_snapshot(&self) -> Option { + for _ in 0..4 { + let seq_before = self.progress_seq.load(Ordering::Acquire); + if seq_before & 1 != 0 { + continue; + } + let has_source = self.progress_has_source.load(Ordering::Relaxed); + let position_bits = self.progress_position_secs_bits.load(Ordering::Relaxed); + let duration_bits = self.progress_duration_secs_bits.load(Ordering::Relaxed); + let buffer_level_bits = self.progress_buffer_level_bits.load(Ordering::Relaxed); + std::sync::atomic::fence(Ordering::Acquire); + let seq_after = self.progress_seq.load(Ordering::Relaxed); + if seq_before != seq_after { + continue; + } + if !has_source { + return None; + } + return Some(PlaybackProgress { + position_secs: f64::from_bits(position_bits), + duration_secs: Some(f64::from_bits(duration_bits)), + buffer_level: f32::from_bits(buffer_level_bits), + }); + } + None + } + + /// Cumulative PCM/DSP callback path counters. + pub(crate) fn pcm_callback_stats(&self) -> PcmCallbackStats { + PcmCallbackStats { + source_lock_misses: self.pcm_source_lock_misses.load(Ordering::Relaxed), + crossfader_lock_misses: self.pcm_crossfader_lock_misses.load(Ordering::Relaxed), + mix_buffer_lock_misses: self.pcm_mix_buffer_lock_misses.load(Ordering::Relaxed), + speed_lock_misses: self.pcm_speed_lock_misses.load(Ordering::Relaxed), + starved_samples: self.pcm_starved_samples.load(Ordering::Relaxed), + skipped_pitch: self.dsp_skipped_pitch.load(Ordering::Relaxed), + skipped_eq: self.dsp_skipped_eq.load(Ordering::Relaxed), + skipped_crossfeed: self.dsp_skipped_crossfeed.load(Ordering::Relaxed), + skipped_fx: self.dsp_skipped_fx.load(Ordering::Relaxed), + skipped_convolver: self.dsp_skipped_convolver.load(Ordering::Relaxed), + skipped_dynamics: self.dsp_skipped_dynamics.load(Ordering::Relaxed), + peak: f32::from_bits(self.output_peak_bits.load(Ordering::Relaxed)), + clipped_samples: self.output_clipped_samples.load(Ordering::Relaxed), + non_finite_samples: self.output_nonfinite_samples.load(Ordering::Relaxed), + } + } + + /// Record the output peak and clipping/non-finite sample counts after the + /// final volume stage. RT-safe: only positive-float bit comparisons and + /// atomic updates. + #[inline] + pub(crate) fn note_output_peak(&self, peak: f32, clipped: u64, non_finite: u64) { + if peak > 0.0 { + self.output_peak_bits + .fetch_max(peak.to_bits(), Ordering::Relaxed); + } + if clipped > 0 { + self.output_clipped_samples + .fetch_add(clipped, Ordering::Relaxed); + } + if non_finite > 0 { + self.output_nonfinite_samples + .fetch_add(non_finite, Ordering::Relaxed); + } + } + + /// Telemetry: execution time for one whole device burst (including bounded + /// rendering and output conversion). An overrun is not an OS xrun count. + #[inline] + pub(crate) fn note_managed_callback( + &self, + frames: usize, + elapsed: std::time::Duration, + sample_rate: u32, + ) { + self.managed_callback_count.fetch_add(1, Ordering::Relaxed); + self.managed_callback_frames_total + .fetch_add(frames as u64, Ordering::Relaxed); + self.managed_callback_duration_us_max.fetch_max( + elapsed.as_micros().min(u64::MAX as u128) as u64, + Ordering::Relaxed, + ); + if sample_rate > 0 + && frames > 0 + && elapsed.as_nanos() > frames as u128 * 1_000_000_000 / sample_rate as u128 + { + self.managed_callback_overruns.fetch_add(1, Ordering::Relaxed); + } + } + + /// Publish managed-stream configuration and xrun counters sampled from the + /// control thread. An unavailable reading must not look like zero underruns + /// or a stale reading from a previously opened stream. + #[allow(clippy::too_many_arguments)] + pub(crate) fn note_managed_telemetry( + &self, + xrun_supported: bool, + xrun_count: Option, + frames_per_burst: u32, + buffer_size_frames: u32, + buffer_capacity_frames: u32, + ) { + self.managed_xrun_count_supported + .store(xrun_supported, Ordering::Relaxed); + if let Some(count) = xrun_count { + self.managed_xrun_count.store(count, Ordering::Relaxed); + } + self.managed_xrun_count_available + .store(xrun_supported && xrun_count.is_some(), Ordering::Relaxed); + self.managed_frames_per_burst + .store(frames_per_burst, Ordering::Relaxed); + self.managed_buffer_size_frames + .store(buffer_size_frames, Ordering::Relaxed); + self.managed_buffer_capacity_frames + .store(buffer_capacity_frames, Ordering::Relaxed); + } + + /// Snapshot of managed-stream and burst telemetry. + pub(crate) fn managed_output_stats(&self) -> ManagedOutputStats { + ManagedOutputStats { + callback_count: self.managed_callback_count.load(Ordering::Relaxed), + callback_frames_total: self.managed_callback_frames_total.load(Ordering::Relaxed), + callback_duration_us_max: self + .managed_callback_duration_us_max + .load(Ordering::Relaxed), + callback_overruns: self.managed_callback_overruns.load(Ordering::Relaxed), + burst_frames_max: self.oboe_burst_frames_max.load(Ordering::Relaxed), + burst_chunked_count: self.oboe_burst_chunked_count.load(Ordering::Relaxed), + xrun_count_supported: self.managed_xrun_count_supported.load(Ordering::Relaxed), + xrun_count: self + .managed_xrun_count_available + .load(Ordering::Relaxed) + .then(|| self.managed_xrun_count.load(Ordering::Relaxed)), + frames_per_burst: self.managed_frames_per_burst.load(Ordering::Relaxed), + buffer_size_frames: self.managed_buffer_size_frames.load(Ordering::Relaxed), + buffer_capacity_frames: self + .managed_buffer_capacity_frames + .load(Ordering::Relaxed), + } + } + /// Telemetry: record a managed-output device burst and the number of /// bounded chunks it was rendered in (see `render_managed_burst`). #[allow(dead_code)] // consumed by the Android-only managed output callbacks @@ -450,6 +779,39 @@ impl AudioCallbackData { self.dsp_burst_frames_cap.load(Ordering::Relaxed) } + /// Publish already prepared coefficients without touching the live EQ. + /// Replacing an unconsumed update is intentional: the latest slider value wins. + fn queue_equalizer_params(&self, params: EqParams) { + let mut pending = self.pending_equalizer.lock(); + *pending = Some(params); + self.equalizer_update_pending.store(true, Ordering::Release); + } + + /// Apply the latest EQ settings at a render block boundary. If the producer + /// is publishing, leave the old settings active and retry next block. Neither + /// a mailbox collision nor coefficient computation can bypass an active EQ. + fn apply_pending_equalizer(&self) { + if !self.equalizer_update_pending.load(Ordering::Acquire) { + return; + } + let Some(mut pending) = self.pending_equalizer.try_lock() else { + return; + }; + let Some(mut eq) = self.equalizer.try_lock() else { + return; + }; + if let Some(params) = pending.take() { + eq.apply_params(params); + self.eq_forces_dsp.store( + params.enabled + && (params.active_bands > 0 + || (params.preamp_linear - 1.0).abs() > f32::EPSILON), + Ordering::Relaxed, + ); + } + self.equalizer_update_pending.store(false, Ordering::Release); + } + #[inline] pub fn channels(&self) -> usize { self.channels @@ -592,7 +954,11 @@ impl AudioCallbackData { .store((buffer_size / self.channels.max(1)).max(1), Ordering::Relaxed); self.crossfader.lock().rebind_sample_rate(sample_rate); self.crossfade_active.store(false, Ordering::Relaxed); - *self.sources.lock() = SourceProvider::new(sample_rate, self.channels); + { + let mut sources = self.sources.lock(); + *sources = SourceProvider::new(sample_rate, self.channels); + self.clear_progress_snapshot(); + } *self.mix_buffer_a.lock() = vec![0.0; buffer_size]; *self.mix_buffer_b.lock() = vec![0.0; buffer_size]; *self.speed_buffer.lock() = vec![0.0; speed_buffer_size]; @@ -601,6 +967,8 @@ impl AudioCallbackData { self.crossfeed.lock().rebind_sample_rate(sample_rate); self.convolver.lock().reconfigure_sample_rate(sample_rate); *self.dynamics.lock() = DynamicsChain::new(sample_rate); + self.compressor_enabled.store(false, Ordering::Relaxed); + self.limiter_enabled.store(false, Ordering::Relaxed); let semitones = self.pitch_shifter.lock().semitones(); let mut shifter = PitchShifter::new(sample_rate, self.channels); shifter.set_semitones(semitones); @@ -931,18 +1299,11 @@ impl AudioEngineHandle { } } - /// Get current progress. + /// Get current progress from the callback-published snapshot. Progress + /// polling runs every 50 ms; taking the source lock here raced the audio + /// callback and could silence a block on a `try_lock` miss. pub fn get_progress(&self) -> Option { - let sources = self.callback_data.sources.lock(); - progress_source( - self.callback_data.crossfade_active.load(Ordering::Relaxed), - &sources, - ) - .map(|source| PlaybackProgress { - position_secs: source.position_secs(), - duration_secs: Some(source.info.duration_secs), - buffer_level: source.buffer_level(), - }) + self.callback_data.progress_snapshot() } /// Get the current track path. @@ -988,8 +1349,12 @@ impl AudioEngineHandle { self.callback_data.dsd_starve_stats() } - pub(crate) fn managed_output_stats(&self) -> (u32, u64) { - self.callback_data.oboe_burst_stats() + pub(crate) fn managed_output_stats(&self) -> ManagedOutputStats { + self.callback_data.managed_output_stats() + } + + pub(crate) fn pcm_callback_stats(&self) -> PcmCallbackStats { + self.callback_data.pcm_callback_stats() } pub fn audio_api_pref(&self) -> AudioApiPreference { @@ -1404,8 +1769,50 @@ struct AndroidManagedStream { exclusive_error: Option, } +#[cfg(target_os = "android")] +fn read_managed_stream_telemetry( + stream: &mut T, +) -> (bool, Option, u32, u32, u32) { + let xrun_supported = stream.is_xrun_count_supported(); + let xrun_count = if xrun_supported { + stream + .get_xrun_count() + .ok() + .filter(|count| *count >= 0) + .map(|count| count as u32) + } else { + None + }; + ( + xrun_supported, + xrun_count, + stream.get_frames_per_burst().max(0) as u32, + stream.get_buffer_size_in_frames().max(0) as u32, + stream.get_buffer_capacity_in_frames().max(0) as u32, + ) +} + #[cfg(target_os = "android")] impl AndroidManagedStream { + /// Sample control-thread telemetry from the live stream. Oboe's xrun count + /// is only meaningful while the stream is running, so the supervisor + /// refreshes it alongside its restart polling. + fn note_telemetry(&mut self, callback_data: &AudioCallbackData) { + let (supported, xruns, frames_per_burst, buffer_size, capacity) = match &mut self.kind { + AndroidManagedStreamKind::F32(s) => read_managed_stream_telemetry(s), + AndroidManagedStreamKind::I32(s) => read_managed_stream_telemetry(s), + AndroidManagedStreamKind::I32Pcm(s) => read_managed_stream_telemetry(s), + AndroidManagedStreamKind::I16(s) => read_managed_stream_telemetry(s), + }; + callback_data.note_managed_telemetry( + supported, + xruns, + frames_per_burst, + buffer_size, + capacity, + ); + } + fn start(&mut self) -> Result<(), oboe::Error> { match &mut self.kind { AndroidManagedStreamKind::F32(s) => s.start(), @@ -1484,6 +1891,9 @@ impl ManagedStreamSupervisor { /// parameters. The source position is untouched, so the new stream /// continues from the interrupted sample. fn poll_restart(&mut self, shutdown: &AtomicBool) { + if let Some(stream) = self.stream.as_mut() { + stream.note_telemetry(&self.callback_data); + } if !self.callback_data.stream_restart_requested() { return; } @@ -2655,6 +3065,7 @@ impl AudioOutputCallback for AndroidOutputCallbackF32 { _audio_stream: &mut dyn AudioOutputStreamSafe, audio_data: &mut [(f32, f32)], ) -> DataCallbackResult { + let callback_started = std::time::Instant::now(); let total_frames = audio_data.len(); let callback_data = &self.callback_data; let event_tx = &self.event_tx; @@ -2672,6 +3083,11 @@ impl AudioOutputCallback for AndroidOutputCallbackF32 { } }, ); + callback_data.note_managed_callback( + total_frames, + callback_started.elapsed(), + _audio_stream.get_sample_rate().max(0) as u32, + ); callback_data.note_oboe_burst(total_frames, chunks); if panicked { @@ -2754,6 +3170,7 @@ impl AudioOutputCallback for AndroidOutputCallbackI32 { _audio_stream: &mut dyn AudioOutputStreamSafe, audio_data: &mut [(i32, i32)], ) -> DataCallbackResult { + let callback_started = std::time::Instant::now(); let total_frames = audio_data.len(); let callback_data = &self.callback_data; let event_tx = &self.event_tx; @@ -2774,6 +3191,11 @@ impl AudioOutputCallback for AndroidOutputCallbackI32 { } }, ); + callback_data.note_managed_callback( + total_frames, + callback_started.elapsed(), + _audio_stream.get_sample_rate().max(0) as u32, + ); callback_data.note_oboe_burst(total_frames, chunks); if panicked { @@ -2864,6 +3286,7 @@ impl AudioOutputCallback for AndroidOutputCallbackI32Pcm { _audio_stream: &mut dyn AudioOutputStreamSafe, audio_data: &mut [(i32, i32)], ) -> DataCallbackResult { + let callback_started = std::time::Instant::now(); let total_frames = audio_data.len(); let callback_data = &self.callback_data; let event_tx = &self.event_tx; @@ -2884,6 +3307,11 @@ impl AudioOutputCallback for AndroidOutputCallbackI32Pcm { } }, ); + callback_data.note_managed_callback( + total_frames, + callback_started.elapsed(), + _audio_stream.get_sample_rate().max(0) as u32, + ); callback_data.note_oboe_burst(total_frames, chunks); if panicked { @@ -2968,6 +3396,7 @@ impl AudioOutputCallback for AndroidOutputCallbackI16 { _audio_stream: &mut dyn AudioOutputStreamSafe, audio_data: &mut [(i16, i16)], ) -> DataCallbackResult { + let callback_started = std::time::Instant::now(); let total_frames = audio_data.len(); let callback_data = &self.callback_data; let event_tx = &self.event_tx; @@ -2988,6 +3417,11 @@ impl AudioOutputCallback for AndroidOutputCallbackI16 { } }, ); + callback_data.note_managed_callback( + total_frames, + callback_started.elapsed(), + _audio_stream.get_sample_rate().max(0) as u32, + ); callback_data.note_oboe_burst(total_frames, chunks); if panicked { @@ -3360,14 +3794,16 @@ fn open_android_output_stream( } else { (!exclusive_notes.is_empty()).then(|| exclusive_notes.join(" | ")) }; - return Ok(AndroidManagedStream { + let mut stream = AndroidManagedStream { kind, actual_sample_rate: actual_rate.max(1) as u32, active_audio_api: audio_api_label(actual_api), active_sharing: sharing_label(actual_sharing), sample_format, exclusive_error, - }); + }; + stream.note_telemetry(&callback_data); + return Ok(stream); } Err(error) => { if sharing_mode == SharingMode::Exclusive { @@ -3382,14 +3818,16 @@ fn open_android_output_stream( } if let Some(kind) = fallback_kind { - return Ok(AndroidManagedStream { + let mut stream = AndroidManagedStream { kind, actual_sample_rate: fallback_rate.max(1), active_audio_api: fallback_api.unwrap_or("Unspecified"), active_sharing: fallback_sharing.unwrap_or("Unspecified"), sample_format: fallback_format.unwrap_or("f32"), exclusive_error: (!exclusive_notes.is_empty()).then(|| exclusive_notes.join(" | ")), - }); + }; + stream.note_telemetry(&callback_data); + return Ok(stream); } Err(last_error.unwrap_or_else(|| { @@ -3543,6 +3981,86 @@ fn volume_to_gain(volume: f32) -> f32 { } } +/// Run the DSP effects that are actually active and finish with the master +/// volume stage. Inactive effects are never locked, which keeps their control +/// mutexes out of the real-time callback entirely. An active effect whose lock +/// is busy is counted as skipped instead of being silently bypassed. +#[inline] +fn process_dsp_effects(data: &AudioCallbackData, output: &mut [f32], channels: usize, volume: f32) { + if data.pitch_shift_forces_dsp.load(Ordering::Relaxed) { + match data.pitch_shifter.try_lock() { + Some(mut pitch) => pitch.process(output, channels), + None => { + data.dsp_skipped_pitch.fetch_add(1, Ordering::Relaxed); + } + } + } + if data.eq_forces_dsp.load(Ordering::Relaxed) { + match data.equalizer.try_lock() { + Some(mut eq) => eq.process(output, channels), + None => { + data.dsp_skipped_eq.fetch_add(1, Ordering::Relaxed); + } + } + } + if data.crossfeed_forces_dsp.load(Ordering::Relaxed) { + match data.crossfeed.try_lock() { + Some(mut crossfeed) => crossfeed.process(output, channels), + None => { + data.dsp_skipped_crossfeed.fetch_add(1, Ordering::Relaxed); + } + } + } + if data.fx_enabled.load(Ordering::Relaxed) { + match data.fx.try_lock() { + Some(mut fx) => fx.process(output, channels), + None => { + data.dsp_skipped_fx.fetch_add(1, Ordering::Relaxed); + } + } + } + if data.convolver_enabled.load(Ordering::Relaxed) { + match data.convolver.try_lock() { + Some(mut convolver) => convolver.process(output, channels), + None => { + data.dsp_skipped_convolver.fetch_add(1, Ordering::Relaxed); + } + } + } + if data.compressor_enabled.load(Ordering::Relaxed) + || data.limiter_enabled.load(Ordering::Relaxed) + { + match data.dynamics.try_lock() { + Some(mut dynamics) => dynamics.process(output, channels), + None => { + data.dsp_skipped_dynamics.fetch_add(1, Ordering::Relaxed); + } + } + } + + // Volume is always applied last, after all DSP processing. This is also the + // single point where the callback measures the final signal level without + // logging or allocating on the audio thread. + let mut peak = 0.0f32; + let mut clipped = 0u64; + let mut non_finite = 0u64; + for sample in output.iter_mut() { + *sample *= volume; + if sample.is_finite() { + let magnitude = sample.abs(); + if magnitude > peak { + peak = magnitude; + } + if magnitude > 1.0 { + clipped += 1; + } + } else { + non_finite += 1; + } + } + data.note_output_peak(peak, clipped, non_finite); +} + /// The real-time audio callback. /// /// This function MUST NOT: @@ -3560,6 +4078,8 @@ pub(crate) fn audio_callback( return; } + data.apply_pending_equalizer(); + // Passthrough path: raw samples from decoder straight to output. if data.pipeline_mode.load(Ordering::Relaxed) == PipelineMode::Dop as u8 { let mut marker_is_fa = data.dop_marker_phase.load(Ordering::Relaxed); @@ -3584,6 +4104,7 @@ pub(crate) fn audio_callback( crate::audio::dsd_engine::dsd::dop::frame_dop_f32(output, data.channels, &mut marker_is_fa); data.dop_marker_phase.store(marker_is_fa, Ordering::Relaxed); } + data.publish_progress_snapshot(&sources); return; } @@ -3610,6 +4131,7 @@ pub(crate) fn audio_callback( .fetch_add((output.len() - read) as u64, Ordering::Relaxed); data.fill_silence(&mut output[read..]); } + data.publish_progress_snapshot(&sources); return; } @@ -3631,61 +4153,48 @@ pub(crate) fn audio_callback( let mut sources = match data.sources.try_lock() { Some(s) => s, None => { + data.pcm_source_lock_misses.fetch_add(1, Ordering::Relaxed); output.fill(0.0); return; } }; - let mut crossfader = match data.crossfader.try_lock() { - Some(c) => c, - None => { - // Never take the ordinary gapless read path during a fade: it - // drops both fade gains and can advance at EOF behind the - // crossfader's back. Stay non-blocking and preserve both sources - // until the next callback can acquire the fade state. - if data.crossfade_active.load(Ordering::Relaxed) { - output.fill(0.0); - return; - } - let (read, old_source) = sources.read(output); - - if let Some(source) = old_source { - let _ = data.finished_tracks.try_send(source); - } - - if read < output.len() { - output[read..].fill(0.0); - } - if let Some(mut pitch) = data.pitch_shifter.try_lock() { - pitch.process(output, channels); - } - if let Some(mut eq) = data.equalizer.try_lock() { - eq.process(output, channels); - } - if let Some(mut crossfeed) = data.crossfeed.try_lock() { - crossfeed.process(output, channels); - } - if let Some(mut fx) = data.fx.try_lock() { - fx.process(output, channels); - } - if let Some(mut convolver) = data.convolver.try_lock() { - convolver.process(output, channels); - } - if let Some(mut dynamics) = data.dynamics.try_lock() { - dynamics.process(output, channels); - } - // Volume is always applied last, after all DSP processing. - for sample in output.iter_mut() { - *sample *= volume; + // Only touch crossfader state when a fade can exist. A disabled crossfade + // must not contend with the command loop's auto-fade check on every block. + let mut crossfader = None; + if data.crossfade_configured.load(Ordering::Relaxed) + || data.crossfade_active.load(Ordering::Relaxed) + { + match data.crossfader.try_lock() { + Some(c) => crossfader = Some(c), + None => { + data.pcm_crossfader_lock_misses + .fetch_add(1, Ordering::Relaxed); + // Never take the ordinary gapless read path during a fade: it + // drops both fade gains and can advance at EOF behind the + // crossfader's back. Stay non-blocking and preserve both sources + // until the next callback can acquire the fade state. + if data.crossfade_active.load(Ordering::Relaxed) { + output.fill(0.0); + return; + } } - return; } - }; + } + + let fading = crossfader + .as_ref() + .map(|c| c.is_active()) + .unwrap_or(false) + && sources.next().is_some(); - if crossfader.is_active() && sources.next_mut().is_some() { + if fading { + let crossfader = crossfader.as_mut().expect("fade requires crossfader state"); let mut buf_a = match data.mix_buffer_a.try_lock() { Some(b) => b, None => { + data.pcm_mix_buffer_lock_misses + .fetch_add(1, Ordering::Relaxed); output.fill(0.0); return; } @@ -3693,6 +4202,8 @@ pub(crate) fn audio_callback( let mut buf_b = match data.mix_buffer_b.try_lock() { Some(b) => b, None => { + data.pcm_mix_buffer_lock_misses + .fetch_add(1, Ordering::Relaxed); output.fill(0.0); return; } @@ -3711,6 +4222,8 @@ pub(crate) fn audio_callback( return; } + let had_current = sources.current().is_some(); + let had_next = sources.next().is_some(); let read_a = sources .current_mut() .map(|s| s.read(&mut buf_a[..needed])) @@ -3722,22 +4235,31 @@ pub(crate) fn audio_callback( if read_a < needed { buf_a[read_a..needed].fill(0.0); + if had_current && sources.current().is_some_and(|s| !s.is_finished()) { + data.pcm_starved_samples + .fetch_add((needed - read_a) as u64, Ordering::Relaxed); + } } if read_b < needed { buf_b[read_b..needed].fill(0.0); + if had_next && sources.next().is_some_and(|s| !s.is_finished()) { + data.pcm_starved_samples + .fetch_add((needed - read_b) as u64, Ordering::Relaxed); + } } let _ = crossfader.mix(&buf_a[..needed], &buf_b[..needed], output, channels); if !crossfader.is_active() { data.crossfade_active.store(false, Ordering::Relaxed); - drop(crossfader); if let Some(source) = sources.advance_to_next() { let _ = data.finished_tracks.try_send(source); } } + data.publish_progress_snapshot(&sources); } else { if (speed - 1.0).abs() < 0.001 { + let had_current = sources.current().is_some(); let (read, old_source) = sources.read(output); if let Some(source) = old_source { @@ -3746,11 +4268,17 @@ pub(crate) fn audio_callback( if read < output.len() { output[read..].fill(0.0); + if had_current && sources.current().is_some_and(|s| !s.is_finished()) { + data.pcm_starved_samples + .fetch_add((output.len() - read) as u64, Ordering::Relaxed); + } } + data.publish_progress_snapshot(&sources); } else { let mut speed_buf = match data.speed_buffer.try_lock() { Some(b) => b, None => { + data.pcm_speed_lock_misses.fetch_add(1, Ordering::Relaxed); output.fill(0.0); return; } @@ -3758,6 +4286,7 @@ pub(crate) fn audio_callback( let mut frac_pos = match data.speed_frac_pos.try_lock() { Some(p) => p, None => { + data.pcm_speed_lock_misses.fetch_add(1, Ordering::Relaxed); output.fill(0.0); return; } @@ -3772,12 +4301,22 @@ pub(crate) fn audio_callback( return; } + let had_current = sources.current().is_some(); let (read, old_source) = sources.read(&mut speed_buf[..input_samples_needed]); if let Some(source) = old_source { let _ = data.finished_tracks.try_send(source); } + if had_current + && sources.current().is_some_and(|s| !s.is_finished()) + && read < input_samples_needed + { + data.pcm_starved_samples + .fetch_add((input_samples_needed - read) as u64, Ordering::Relaxed); + } + data.publish_progress_snapshot(&sources); + if read < channels { output.fill(0.0); return; @@ -3810,29 +4349,11 @@ pub(crate) fn audio_callback( } } - if let Some(mut pitch) = data.pitch_shifter.try_lock() { - pitch.process(output, channels); - } - if let Some(mut eq) = data.equalizer.try_lock() { - eq.process(output, channels); - } - if let Some(mut crossfeed) = data.crossfeed.try_lock() { - crossfeed.process(output, channels); - } - if let Some(mut fx) = data.fx.try_lock() { - fx.process(output, channels); - } - if let Some(mut convolver) = data.convolver.try_lock() { - convolver.process(output, channels); - } - if let Some(mut dynamics) = data.dynamics.try_lock() { - dynamics.process(output, channels); - } - - // Volume is always applied last, after all DSP processing. - for sample in output.iter_mut() { - *sample *= volume; - } + // Release playback state before the effect chain: command handling must not + // wait on the source/crossfade locks while the callback runs effects. + drop(crossfader); + drop(sources); + process_dsp_effects(data, output, channels, volume); } /// Command processing loop running in the audio thread. @@ -3966,7 +4487,11 @@ fn command_processing_loop( let _ = event_tx.try_send(AudioEvent::StateChanged(PlaybackState::Playing)); } AudioCommand::Stop => { - callback_data.sources.lock().stop(); + { + let mut sources = callback_data.sources.lock(); + sources.stop(); + callback_data.clear_progress_snapshot(); + } callback_data.crossfader.lock().reset(); callback_data.pitch_shifter.lock().reset(); callback_data.crossfade_active.store(false, Ordering::Relaxed); @@ -4027,6 +4552,9 @@ fn command_processing_loop( callback_data .crossfade_forces_dsp .store(enabled, Ordering::Relaxed); + callback_data + .crossfade_configured + .store(enabled, Ordering::Relaxed); log::info!( "[crossfade] set enabled={} duration={:.1}s forces_dsp={}", enabled, @@ -4046,17 +4574,14 @@ fn command_processing_loop( preamp_db, specs, } => { - if let Some(mut eq) = callback_data.equalizer.try_lock() { - eq.set(enabled, preamp_db, &specs, sample_rate); - } - // EQ needs the DSP path (see crossfade). Force out of - // passthrough when active so the biquads run even on a - // verified bit-perfect USB output. Preamp-only also - // needs the gain stage. - callback_data.eq_forces_dsp.store( - enabled && (!specs.is_empty() || preamp_db.abs() > f32::EPSILON), - Ordering::Relaxed, - ); + // Biquad computation belongs on the control thread, not + // behind the live filter-state lock or in the callback. + let params = if enabled { + EqParams::from_specs(&specs, preamp_db, sample_rate) + } else { + EqParams::disabled() + }; + callback_data.queue_equalizer_params(params); } AudioCommand::SetCrossfeed { level } => { callback_data.crossfeed.lock().set_level(level); @@ -4096,6 +4621,9 @@ fn command_processing_loop( release_ms, makeup_gain_db, ); + callback_data + .compressor_enabled + .store(enabled, Ordering::Relaxed); } AudioCommand::SetLimiter { enabled, @@ -4109,6 +4637,9 @@ fn command_processing_loop( ceiling_db, release_ms, ); + callback_data + .limiter_enabled + .store(enabled, Ordering::Relaxed); } AudioCommand::SetPipelineMode { passthrough } => { let mode = if passthrough { @@ -4155,22 +4686,33 @@ fn command_processing_loop( enabled, balance, tempo, damp, filter_hz, delay_ms, size, mix, feedback, width, ); + callback_data.fx_enabled.store(enabled, Ordering::Relaxed); } AudioCommand::SetConvolver { enabled, mix } => { callback_data.convolver.lock().set(enabled, mix); + callback_data + .convolver_enabled + .store(enabled, Ordering::Relaxed); } AudioCommand::SetConvolverIr { coeffs } => { callback_data.convolver.lock().load_ir(coeffs); } AudioCommand::ClearConvolverIr => { callback_data.convolver.lock().clear_ir(); + callback_data + .convolver_enabled + .store(false, Ordering::Relaxed); } AudioCommand::CrossfadeToNext | AudioCommand::SkipToNext => { handle_skip_to_next(&callback_data, &state, &event_tx); } AudioCommand::Shutdown => { // Stop everything and exit - callback_data.sources.lock().stop(); + { + let mut sources = callback_data.sources.lock(); + sources.stop(); + callback_data.clear_progress_snapshot(); + } for decoder in decoders.lock().drain(..) { decoder.stop(); } @@ -4442,7 +4984,11 @@ fn start_playback_source( source.set_ready(); source.set_playing(); - callback_data.sources.lock().set_current(source); + { + let mut sources = callback_data.sources.lock(); + sources.set_current(source); + callback_data.publish_progress_snapshot(&sources); + } callback_data.set_paused(false); decoders.lock().push(decoder_handle); @@ -4533,6 +5079,12 @@ fn maybe_start_auto_crossfade( return false; } + // Disabled crossfade is the common case. Check the lock-free mirror before + // taking the source/crossfader locks that the real-time callback needs. + if !callback_data.crossfade_configured.load(Ordering::Relaxed) { + return false; + } + let sources = callback_data.sources.lock(); let mut crossfader = callback_data.crossfader.lock(); let configured = crossfader.configured_duration_secs(); @@ -4887,7 +5439,11 @@ fn handle_seek( source.set_playing(); } - callback_data.sources.lock().set_current(source); + { + let mut sources = callback_data.sources.lock(); + sources.set_current(source); + callback_data.publish_progress_snapshot(&sources); + } callback_data.set_paused(was_paused); decoders.lock().push(handle); @@ -5333,6 +5889,7 @@ mod tests { fade.set_enabled(true); fade.set_duration(0.5); } + data.crossfade_configured.store(true, Ordering::Relaxed); let state = Arc::new(AtomicU8::new(PlaybackState::Playing as u8)); let mut near_end_logged = false; @@ -5376,6 +5933,7 @@ mod tests { fade.set_enabled(true); fade.set_duration(0.5); } + data.crossfade_configured.store(true, Ordering::Relaxed); let state = Arc::new(AtomicU8::new(PlaybackState::Playing as u8)); let mut near_end_logged = false; @@ -6233,4 +6791,271 @@ mod tests { assert_eq!(chunks, 1); assert_eq!(spans, vec![(0, 1_000)]); } + + #[test] + fn pcm_callback_counts_source_lock_miss_and_silences_block() { + let data = build_callback_data(48_000, 2); + let _sources = data.sources.lock(); + + let output = run_callback(&data, 16); + + assert!(output.iter().all(|sample| *sample == 0.0)); + assert_eq!(data.pcm_callback_stats().source_lock_misses, 1); + } + + #[test] + fn pcm_callback_counts_starved_samples_on_short_read() { + let data = build_callback_data(48_000, 2); + let (mut source, _producer) = + AudioSource::new(queued_source_info("empty-current.flac", 48_000, 2)); + source.set_ready(); + source.set_playing(); + data.sources.lock().set_current(source); + + let output = run_callback(&data, 16); + + assert!(output.iter().all(|sample| *sample == 0.0)); + let stats = data.pcm_callback_stats(); + assert_eq!(stats.starved_samples, 16); + assert_eq!(stats.source_lock_misses, 0); + } + + #[test] + fn pcm_callback_does_not_count_natural_eof_as_starvation() { + let data = build_callback_data(48_000, 2); + data.sources + .lock() + .set_current(build_source(&[0.5, 0.5, -0.5, -0.5], 48_000, 2)); + + let output = run_callback(&data, 16); + + assert_eq!(output[..4], [0.5, 0.5, -0.5, -0.5]); + assert!(output[4..].iter().all(|sample| *sample == 0.0)); + assert_eq!(data.pcm_callback_stats().starved_samples, 0); + } + + #[test] + fn pcm_callback_records_peak_and_clipping_after_volume() { + let data = build_callback_data(48_000, 2); + data.set_volume(2.0); + data.sources.lock().set_current(build_source( + &[0.75, -0.75, 0.125, -0.125], + 48_000, + 2, + )); + + let output = run_callback(&data, 4); + + assert_eq!(output, vec![1.5, -1.5, 0.25, -0.25]); + let stats = data.pcm_callback_stats(); + assert_eq!(stats.peak, 1.5); + assert_eq!(stats.clipped_samples, 2); + assert_eq!(stats.non_finite_samples, 0); + } + + #[test] + fn active_effect_lock_miss_is_counted_as_skipped() { + let data = build_callback_data(48_000, 2); + data.sources + .lock() + .set_current(build_source(&[0.1; 8], 48_000, 2)); + data.eq_forces_dsp.store(true, Ordering::Relaxed); + let _eq = data.equalizer.lock(); + + let output = run_callback(&data, 8); + + assert_eq!(output, vec![0.1; 8]); + assert_eq!(data.pcm_callback_stats().skipped_eq, 1); + } + + #[test] + fn progress_snapshot_is_readable_while_sources_are_locked() { + let data = build_callback_data(48_000, 2); + let source = build_source(&[0.25; 96_000], 48_000, 2); + source.set_position_secs(0.75); + + let mut sources = data.sources.lock(); + sources.set_current(source); + data.publish_progress_snapshot(&sources); + + let progress = data + .progress_snapshot() + .expect("published progress must survive a held source lock"); + assert_eq!(progress.position_secs, 0.75); + assert!(progress.duration_secs.is_some()); + } + + #[test] + fn auto_crossfade_does_not_start_when_configuration_mirror_is_disabled() { + let data = build_callback_data(48_000, 2); + let (event_tx, _event_rx) = bounded::(8); + { + let mut sources = data.sources.lock(); + let outgoing = build_source(&[0.25; 96_000], 48_000, 2); + outgoing.set_position_secs(0.75); + sources.set_current(outgoing); + sources.queue_next(build_source(&[0.5; 96_000], 48_000, 2)); + } + { + let mut fade = data.crossfader.lock(); + fade.set_enabled(true); + fade.set_duration(0.5); + } + let state = Arc::new(AtomicU8::new(PlaybackState::Playing as u8)); + let mut near_end_logged = false; + + assert!(!maybe_start_auto_crossfade( + &data, + &state, + &event_tx, + &mut near_end_logged + )); + assert!(!data.crossfade_active.load(Ordering::Relaxed)); + } + + #[test] + fn managed_output_telemetry_accumulates() { + let data = build_callback_data(48_000, 2); + data.note_managed_callback(1_922, std::time::Duration::from_micros(1_000), 96_000); + data.note_managed_callback(480, std::time::Duration::from_millis(11), 48_000); + data.note_managed_telemetry(true, Some(3), 192, 960, 9_600); + + let stats = data.managed_output_stats(); + assert_eq!(stats.callback_count, 2); + assert_eq!(stats.callback_frames_total, 2_402); + assert!(stats.xrun_count_supported); + assert_eq!(stats.xrun_count, Some(3)); + assert_eq!(stats.callback_duration_us_max, 11_000); + assert_eq!(stats.callback_overruns, 1); + assert_eq!(stats.frames_per_burst, 192); + assert_eq!(stats.buffer_size_frames, 960); + assert_eq!(stats.buffer_capacity_frames, 9_600); + } + + #[test] + fn managed_output_does_not_report_unavailable_xruns_as_zero_or_stale() { + let data = build_callback_data(48_000, 2); + assert_eq!(data.managed_output_stats().xrun_count, None); + data.note_managed_telemetry(true, Some(7), 192, 960, 9_600); + assert_eq!(data.managed_output_stats().xrun_count, Some(7)); + + data.note_managed_telemetry(true, None, 192, 960, 9_600); + assert!(data.managed_output_stats().xrun_count_supported); + assert_eq!(data.managed_output_stats().xrun_count, None); + + data.note_managed_telemetry(false, None, 0, 0, 0); + let stats = data.managed_output_stats(); + assert!(!stats.xrun_count_supported); + assert_eq!(stats.xrun_count, None); + assert_eq!(stats.frames_per_burst, 0); + assert_eq!(stats.buffer_size_frames, 0); + assert_eq!(stats.buffer_capacity_frames, 0); + } + + #[test] + fn eq_mailbox_contention_keeps_previous_processing_until_next_block() { + let data = build_callback_data(48_000, 2); + data.sources + .lock() + .set_current(build_source(&[0.5; 64], 48_000, 2)); + let previous = EqParams::from_specs(&[], -6.0, 48_000); + data.queue_equalizer_params(previous); + let expected = 0.5 * previous.preamp_linear; + assert!(run_callback(&data, 8) + .iter() + .all(|sample| (*sample - expected).abs() < 1e-6)); + + data.queue_equalizer_params(EqParams::disabled()); + let pending = data.pending_equalizer.lock(); + let during_update = run_callback(&data, 8); + assert!(during_update + .iter() + .all(|sample| (*sample - expected).abs() < 1e-6)); + assert_eq!(data.pcm_callback_stats().skipped_eq, 0); + assert!(data.equalizer_update_pending.load(Ordering::Acquire)); + drop(pending); + + assert_eq!(run_callback(&data, 8), vec![0.5; 8]); + assert!(!data.eq_forces_dsp.load(Ordering::Relaxed)); + assert!(!data.equalizer_update_pending.load(Ordering::Acquire)); + } + + #[test] + fn eq_updates_never_lock_live_state_and_latest_pending_value_wins() { + let data = build_callback_data(48_000, 2); + data.sources + .lock() + .set_current(build_source(&[0.5; 64], 48_000, 2)); + let live = data.equalizer.lock(); + data.queue_equalizer_params(EqParams::from_specs(&[], 6.0, 48_000)); + let latest = EqParams::from_specs(&[], -12.0, 48_000); + data.queue_equalizer_params(latest); + drop(live); + + let expected = 0.5 * latest.preamp_linear; + assert!(run_callback(&data, 8) + .iter() + .all(|sample| (*sample - expected).abs() < 1e-6)); + assert_eq!(data.pcm_callback_stats().skipped_eq, 0); + assert_eq!(data.pcm_callback_stats().source_lock_misses, 0); + } + + #[test] + fn eq_enable_and_disable_take_effect_at_passthrough_block_boundaries() { + let data = build_passthrough_callback_data(96_000, 2); + data.sources + .lock() + .set_current(build_source(&[0.5; 64], 96_000, 2)); + let params = EqParams::from_specs(&[], -6.0, 96_000); + data.queue_equalizer_params(params); + assert!(run_callback(&data, 8) + .iter() + .all(|sample| (*sample - 0.5 * params.preamp_linear).abs() < 1e-6)); + assert!(!data.is_passthrough()); + + data.queue_equalizer_params(EqParams::disabled()); + assert_eq!(run_callback(&data, 8), vec![0.5; 8]); + assert!(data.is_passthrough()); + } + + #[test] + fn progress_polling_during_pcm_render_does_not_contend_with_sources() { + for sample_rate in [44_100, 48_000, 96_000] { + let data = Arc::new(build_callback_data(sample_rate, 2)); + data.sources.lock().set_current(build_source( + &vec![0.25; 80_000], + sample_rate, + 2, + )); + let polling_data = Arc::clone(&data); + let polling = thread::spawn(move || { + for _ in 0..10_000 { + let _ = polling_data.progress_snapshot(); + } + }); + for _ in 0..20 { + // The reporter's largest device burst was 1,922 frames. + assert_eq!(run_callback(&data, 1_922 * 2), vec![0.25; 1_922 * 2]); + } + polling.join().unwrap(); + assert_eq!(data.pcm_callback_stats().source_lock_misses, 0); + assert_eq!(data.pcm_callback_stats().starved_samples, 0); + assert!(data.progress_snapshot().unwrap().position_secs > 0.0); + } + } + + #[test] + fn reconfigure_sample_rate_clears_progress_snapshot() { + let data = build_callback_data(48_000, 2); + { + let mut sources = data.sources.lock(); + sources.set_current(build_source(&[0.25; 64], 48_000, 2)); + data.publish_progress_snapshot(&sources); + } + assert!(data.progress_snapshot().is_some()); + + data.reconfigure_sample_rate(96_000); + + assert!(data.progress_snapshot().is_none()); + } } diff --git a/rust/src/audio/equalizer.rs b/rust/src/audio/equalizer.rs index 3060b2ee..737337f0 100644 --- a/rust/src/audio/equalizer.rs +++ b/rust/src/audio/equalizer.rs @@ -192,7 +192,8 @@ impl Equalizer { } } - /// Called from command thread. sample_rate must match engine. + /// Prepare parameters at the engine sample rate, then apply them. The + /// engine prepares these off-thread and uses `apply_params` at a block boundary. pub fn set( &mut self, enabled: bool, @@ -205,11 +206,17 @@ impl Equalizer { } else { EqParams::disabled() }; + self.apply_params(next); + } + + /// Apply precomputed coefficients without allocating or resetting active + /// filter history. Only the render side mutates the live filter state. + pub(crate) fn apply_params(&mut self, next: EqParams) { let idx = self.index.load(Ordering::Relaxed); self.params[1 - idx as usize] = next; self.index.store(1 - idx, Ordering::Release); // Reset state when disabling to avoid artifacts when re-enabling - if !enabled { + if !next.enabled { for ch_state in &mut self.state { for band_state in ch_state.iter_mut() { band_state.fill(0.0); From 9b2fd52d5ff405baf61f961a1580a46ccdd69121 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Thu, 5 Nov 2026 17:31:03 +0800 Subject: [PATCH 03/14] Add SAF document staging and write-back for metadata edits Stage content:// documents into a private cache copy so the Rust tagger can open them by path, and stream edited copies back over the original URI. "wt" truncation is retried with "w" for providers that reject it, and stale edit copies older than 24h are pruned. --- .../kotlin/com/mossapps/flick/MainActivity.kt | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) diff --git a/android/app/src/main/kotlin/com/mossapps/flick/MainActivity.kt b/android/app/src/main/kotlin/com/mossapps/flick/MainActivity.kt index dab2cdcb..ed4291ff 100644 --- a/android/app/src/main/kotlin/com/mossapps/flick/MainActivity.kt +++ b/android/app/src/main/kotlin/com/mossapps/flick/MainActivity.kt @@ -87,6 +87,7 @@ import java.io.ByteArrayOutputStream import java.io.File import java.io.FileInputStream import java.io.InputStream +import java.io.OutputStream import java.nio.ByteBuffer import java.nio.ByteOrder import java.security.MessageDigest @@ -605,6 +606,34 @@ class MainActivity: FlutterActivity() { result.error("INVALID_ARGUMENT", "folderTreeUri, filePath, and tempFilePath are required", null) } } + "stageContentUriForEdit" -> { + val uri = call.argument("uri") + val extensionHint = call.argument("extensionHint") + if (uri != null) { + mainScope.launch { + val staged = withContext(Dispatchers.IO) { + stageContentUriForEdit(uri, extensionHint) + } + result.success(staged) + } + } else { + result.error("INVALID_ARGUMENT", "uri is required", null) + } + } + "writeBytesToContentUri" -> { + val uri = call.argument("uri") + val tempFilePath = call.argument("tempFilePath") + if (uri != null && tempFilePath != null) { + mainScope.launch { + val written = withContext(Dispatchers.IO) { + writeBytesToContentUri(uri, tempFilePath) + } + result.success(written) + } + } else { + result.error("INVALID_ARGUMENT", "uri and tempFilePath are required", null) + } + } "queryMediaStoreAudio" -> { val folderPaths = call.argument>("folderPaths") val volumeName = call.argument("volumeName") @@ -1880,6 +1909,124 @@ class MainActivity: FlutterActivity() { } } + /** + * Copies a content:// document into a private, uniquely named cache file so + * the Rust tagger can open it by path. The shared playback staging cache is + * deliberately not reused: editing mutates this copy, and a cached playback + * file must never be modified in place. + */ + private fun stageContentUriForEdit(uriString: String, extensionHint: String?): Map { + val uri = Uri.parse(uriString) + val stagingDir = File(cacheDir, "metadata_edits") + val stagedFile = try { + stagingDir.mkdirs() + pruneMetadataEditStaging(stagingDir) + val extension = resolveStagedExtension(uri, extensionHint) + File(stagingDir, "${md5(uriString)}-${UUID.randomUUID()}.$extension") + } catch (e: Exception) { + Log.w("MainActivity", "stageContentUriForEdit: could not prepare staging: ${e.message}", e) + return mapOf("path" to null, "reason" to "failed") + } + + return try { + val input = contentResolver.openInputStream(uri) + if (input == null) { + mapOf("path" to null, "reason" to "not_found") + } else { + input.use { source -> + stagedFile.outputStream().use { target -> + copyStream(source, target) + } + } + if (stagedFile.length() <= 0L) { + stagedFile.delete() + mapOf("path" to null, "reason" to "failed") + } else { + mapOf("path" to stagedFile.absolutePath, "reason" to null) + } + } + } catch (e: java.io.FileNotFoundException) { + stagedFile.delete() + Log.w("MainActivity", "stageContentUriForEdit: not found: $uriString (${e.message})") + mapOf("path" to null, "reason" to "not_found") + } catch (e: SecurityException) { + stagedFile.delete() + Log.w("MainActivity", "stageContentUriForEdit: no access: $uriString (${e.message})") + mapOf("path" to null, "reason" to "no_access") + } catch (e: Exception) { + stagedFile.delete() + Log.w("MainActivity", "stageContentUriForEdit failed: ${e.message}", e) + mapOf("path" to null, "reason" to "failed") + } + } + + /** Streams an edited cache copy back over the original document URI. */ + private fun writeBytesToContentUri(uriString: String, tempFilePath: String): Map { + val tempFile = File(tempFilePath) + if (!tempFile.exists()) { + return mapOf("success" to false, "reason" to "failed") + } + val uri = Uri.parse(uriString) + + return try { + val output = openContentUriOutputStream(uri) + if (output == null) { + mapOf("success" to false, "reason" to "failed") + } else { + output.use { target -> + tempFile.inputStream().use { source -> + copyStream(source, target) + } + } + mapOf("success" to true, "reason" to "ok") + } + } catch (e: java.io.FileNotFoundException) { + Log.w("MainActivity", "writeBytesToContentUri: not found: $uriString (${e.message})") + mapOf("success" to false, "reason" to "not_found") + } catch (e: SecurityException) { + Log.w("MainActivity", "writeBytesToContentUri: no access: $uriString (${e.message})") + mapOf("success" to false, "reason" to "no_access") + } catch (e: Exception) { + Log.w("MainActivity", "writeBytesToContentUri failed: ${e.message}", e) + mapOf("success" to false, "reason" to "failed") + } + } + + /** + * "wt" truncates before writing, but a few document providers only accept + * "w". A genuinely missing document fails again on the retry and is + * reported as not_found by the caller. + */ + private fun openContentUriOutputStream(uri: Uri): OutputStream? { + return try { + contentResolver.openOutputStream(uri, "wt") + } catch (e: IllegalArgumentException) { + contentResolver.openOutputStream(uri, "w") + } catch (e: java.io.FileNotFoundException) { + contentResolver.openOutputStream(uri, "w") + } + } + + private fun copyStream(source: InputStream, target: OutputStream) { + val buffer = ByteArray(1024 * 1024) + while (true) { + val read = source.read(buffer) + if (read < 0) break + target.write(buffer, 0, read) + } + target.flush() + } + + /** Clear edit copies left behind by crashes; 24h is far beyond one save. */ + private fun pruneMetadataEditStaging(stagingDir: File) { + val cutoff = System.currentTimeMillis() - 24 * 60 * 60 * 1000L + stagingDir.listFiles()?.forEach { file -> + if (file.isFile && file.lastModified() < cutoff) { + file.delete() + } + } + } + private fun isIgnoringBatteryOptimizations(): Boolean { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { return true From a847480ea3651b94184b6460d2d40161f99f7d58 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Thu, 5 Nov 2026 17:31:17 +0800 Subject: [PATCH 04/14] Refactor metadata editor for testable content URI writes Inject the Rust tagger, DB persistence, and temp directory so the content:// staging and SAF fallback paths can be unit tested. Add coverage for content URI write-back, permission failures, path normalization, and read staging. --- lib/services/metadata_editor_service.dart | 394 ++++++++++++++++-- .../metadata_editor_service_test.dart | 381 +++++++++++++++++ 2 files changed, 733 insertions(+), 42 deletions(-) create mode 100644 test/services/metadata_editor_service_test.dart diff --git a/lib/services/metadata_editor_service.dart b/lib/services/metadata_editor_service.dart index 26b66ae6..275ca6d3 100644 --- a/lib/services/metadata_editor_service.dart +++ b/lib/services/metadata_editor_service.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:path_provider/path_provider.dart'; @@ -31,15 +32,136 @@ class MetadataWriteResult { bool get verified => outcome == MetadataWriteOutcome.verified; } +typedef MetadataTagReader = + Future Function(String path); +typedef MetadataTagWriter = + Future Function( + String path, + rust_metadata.TagEditFields fields, + ); +typedef MetadataTempTagWriter = + Future Function( + String path, + rust_metadata.TagEditFields fields, + String tempDir, + ); +typedef MetadataPersist = + Future Function(String path, rust_metadata.TagEditFields fields); +typedef MetadataTempDirectory = Future Function(); + +class _StagedContentUri { + const _StagedContentUri.ok(this.path) : reason = null; + const _StagedContentUri.failed(this.reason) : path = null; + + final String? path; + final String? reason; +} + +class _ContentUriWrite { + const _ContentUriWrite(this.success, this.reason); + + final bool success; + final String? reason; +} + class MetadataEditorService { - MetadataEditorService._(); + MetadataEditorService._({ + MetadataTagReader? readTagFile, + MetadataTagWriter? writeTagFile, + MetadataTempTagWriter? writeTagFileToTemp, + MetadataPersist? persistMetadata, + MetadataTempDirectory? temporaryDirectory, + }) : _readTagFile = readTagFile ?? _readTagsFromRust, + _writeTagFile = writeTagFile ?? _writeTagsWithRust, + _writeTagFileToTemp = writeTagFileToTemp ?? _writeTagsToTempWithRust, + _persistMetadata = persistMetadata ?? _persistToRepository, + _temporaryDirectory = temporaryDirectory ?? getTemporaryDirectory; + static final MetadataEditorService instance = MetadataEditorService._(); + /// Test seam: inject the Rust tagger, DB write, and temp directory so the + /// content:// orchestration can be exercised in `flutter test`. + @visibleForTesting + factory MetadataEditorService.forTesting({ + MetadataTagReader? readTagFile, + MetadataTagWriter? writeTagFile, + MetadataTempTagWriter? writeTagFileToTemp, + MetadataPersist? persistMetadata, + MetadataTempDirectory? temporaryDirectory, + }) { + return MetadataEditorService._( + readTagFile: readTagFile, + writeTagFile: writeTagFile, + writeTagFileToTemp: writeTagFileToTemp, + persistMetadata: persistMetadata, + temporaryDirectory: temporaryDirectory, + ); + } + + final MetadataTagReader _readTagFile; + final MetadataTagWriter _writeTagFile; + final MetadataTempTagWriter _writeTagFileToTemp; + final MetadataPersist _persistMetadata; + final MetadataTempDirectory _temporaryDirectory; + static const _channel = MethodChannel('com.mossapps.flick/storage'); + static Future _readTagsFromRust(String path) => + rust_metadata.readTags(path: path); + + static Future _writeTagsWithRust( + String path, + rust_metadata.TagEditFields fields, + ) => rust_metadata.writeTags(path: path, fields: fields); + + static Future _writeTagsToTempWithRust( + String path, + rust_metadata.TagEditFields fields, + String tempDir, + ) => rust_metadata.writeTagsToTemp( + path: path, + fields: fields, + tempDir: tempDir, + ); + + static Future _persistToRepository( + String path, + rust_metadata.TagEditFields fields, + ) { + return SongRepository().updateSongMetadata( + path, + title: fields.title, + artist: fields.artist, + album: fields.album, + albumArtist: fields.albumArtist, + trackNumber: fields.trackNumber, + discNumber: fields.discNumber, + year: fields.year, + genre: fields.genre, + ); + } + + /// Reads tags from a local path or an Android `content://` document. + /// + /// The Rust tagger can only open filesystem paths, so content URIs are first + /// copied to a private cache file and that copy is removed afterwards. Future readTags(String filePath) async { + final path = _plainPath(filePath); + if (path == null) { + final staged = await _stageContentUri(filePath); + final stagedPath = staged.path; + if (stagedPath == null) return null; + try { + return await _readTagFile(stagedPath); + } catch (_) { + return null; + } finally { + await _deleteQuietly(stagedPath); + } + } + try { - return await rust_metadata.readTags(path: filePath); + return await _readTagFile(path); } catch (_) { return null; } @@ -47,11 +169,13 @@ class MetadataEditorService { /// Persists [fields] to the actual audio file and verifies them. /// - /// Android Scoped Storage blocks direct writes to most user files, so this - /// tries the fast in-place write first and falls back to the SAF pipeline - /// (write tags to an app-cache temp copy, then copy the bytes back through - /// the folder's document tree grant) when that fails. Either way, the result - /// is confirmed by re-reading the tags before the local DB is touched. + /// Android Scoped Storage blocks direct writes to most user files. Plain + /// filesystem paths try the fast in-place write first and fall back to the + /// SAF pipeline (write tags to an app-cache temp copy, then copy the bytes + /// back through the folder's document tree grant). Content URIs are staged, + /// edited in cache, then streamed back to the original document. Either way, + /// the result is confirmed by re-reading the tags before the local DB is + /// touched. Future writeTags( Song song, rust_metadata.TagEditFields fields, @@ -75,32 +199,97 @@ class MetadataEditorService { ); } - final path = song.filePath!; + final source = song.filePath!; + final path = _plainPath(source); + + // SAF-scanned songs keep a content:// document URI as their filePath. The + // Rust tagger cannot open that URI, so route it through Android staging. + if (path == null) { + return _writeViaContentUri(source, fields); + } + final rust_metadata.TagWriteResult direct; try { // Fast path: direct in-place write. Works for app-owned files and any // location the OS lets us write to directly. - final direct = await rust_metadata.writeTags(path: path, fields: fields); - if (direct.success) { - return await _verifyAndPersist(path, fields); + direct = await _writeTagFile(path, fields); + } catch (e) { + // A thrown direct write must not skip the SAF fallback. + final safResult = await _tryWriteViaSaf(song, source, path, fields); + if (safResult != null) return safResult; + + return MetadataWriteResult( + MetadataWriteOutcome.failed, + message: _unexpectedWriteFailure(e), + ); + } + + if (direct.success) { + try { + return await _verifyAndPersist(source, fields); + } catch (e) { + return MetadataWriteResult( + MetadataWriteOutcome.failed, + message: _unexpectedWriteFailure(e), + ); } + } - // Direct write failed (typically Scoped Storage EACCES). Try the SAF - // copy-back pipeline if we have a folder tree grant to write through. - final safResult = await _writeViaSaf(song, path, fields); - if (safResult != null) return safResult; + // Direct write failed (typically Scoped Storage EACCES). Try the SAF + // copy-back pipeline if we have a folder tree grant to write through. + final safResult = await _tryWriteViaSaf(song, source, path, fields); + if (safResult != null) return safResult; + + // No SAF route available โ€” surface a message that tells the user what + // to do instead of the raw "os error 2" from the tagger. + return MetadataWriteResult( + MetadataWriteOutcome.failed, + message: _writeFailureMessage(direct.error), + ); + } - // No SAF route available โ€” surface the original failure. + /// Stages the document, edits the cache copy with Rust, streams it back to + /// the original URI, then verifies by re-staging the original. + Future _writeViaContentUri( + String uri, + rust_metadata.TagEditFields fields, + ) async { + final staging = await _stageContentUri(uri); + final stagedPath = staging.path; + if (stagedPath == null) { return MetadataWriteResult( MetadataWriteOutcome.failed, - message: - direct.error ?? l10n.englishForFilipino.failedToWriteTagsToTheFile, + message: _stageFailureMessage(staging.reason), ); + } + + try { + final edited = await _writeTagFile(stagedPath, fields); + if (!edited.success) { + return MetadataWriteResult( + MetadataWriteOutcome.failed, + message: l10n.englishForFilipino.couldNotPrepareTheEditedFile( + edited.error ?? l10n.englishForFilipino.unknown, + ), + ); + } + + final written = await _writeBytesToContentUri(uri, stagedPath); + if (!written.success) { + return MetadataWriteResult( + MetadataWriteOutcome.failed, + message: _contentWriteFailureMessage(written.reason), + ); + } + + return await _verifyAndPersist(uri, fields); } catch (e) { return MetadataWriteResult( MetadataWriteOutcome.failed, - message: l10n.englishForFilipino.failedToSaveMetadata(e), + message: _unexpectedWriteFailure(e), ); + } finally { + await _deleteQuietly(stagedPath); } } @@ -108,23 +297,13 @@ class MetadataEditorService { /// result into the local DB. The read-back is the integrity guard: it proves /// the on-disk file actually holds what we asked for. Future _verifyAndPersist( - String path, + String source, rust_metadata.TagEditFields fields, ) async { - final readBack = await readTags(path); + final readBack = await readTags(source); final verified = _verifyReadback(readBack, fields); - await SongRepository().updateSongMetadata( - path, - title: fields.title, - artist: fields.artist, - album: fields.album, - albumArtist: fields.albumArtist, - trackNumber: fields.trackNumber, - discNumber: fields.discNumber, - year: fields.year, - genre: fields.genre, - ); + await _persistMetadata(source, fields); return verified ? const MetadataWriteResult(MetadataWriteOutcome.verified) @@ -134,22 +313,39 @@ class MetadataEditorService { ); } + Future _tryWriteViaSaf( + Song song, + String source, + String path, + rust_metadata.TagEditFields fields, + ) async { + try { + return await _writeViaSaf(song, source, path, fields); + } catch (e) { + return MetadataWriteResult( + MetadataWriteOutcome.failed, + message: _unexpectedWriteFailure(e), + ); + } + } + /// SAF copy-back pipeline. Returns `null` when SAF isn't applicable (no - /// folder tree grant, or a content:// URI the Rust layer can't open); a - /// non-null result (which may be a failure) when SAF was attempted. + /// folder tree grant); a non-null result (which may be a failure) when SAF + /// was attempted. Future _writeViaSaf( Song song, + String source, String path, rust_metadata.TagEditFields fields, ) async { final treeUri = song.folderUri; if (treeUri == null || treeUri.isEmpty) return null; - // The Rust tagger opens the file by path; it can't open a content:// URI. + // Defensive: content URIs are handled by _writeViaContentUri. if (path.startsWith('content://')) return null; final Directory cache; try { - cache = await getTemporaryDirectory(); + cache = await _temporaryDirectory(); } catch (_) { return null; } @@ -157,11 +353,7 @@ class MetadataEditorService { await tempDir.create(recursive: true); // Stage the edited file in app cache (always writable). - final temp = await rust_metadata.writeTagsToTemp( - path: path, - fields: fields, - tempDir: tempDir.path, - ); + final temp = await _writeTagFileToTemp(path, fields, tempDir.path); if (!temp.success || temp.tempPath == null) { return MetadataWriteResult( MetadataWriteOutcome.failed, @@ -186,7 +378,7 @@ class MetadataEditorService { ); } - return _verifyAndPersist(path, fields); + return _verifyAndPersist(source, fields); } Future _writeFileBytesViaSaf( @@ -206,6 +398,124 @@ class MetadataEditorService { } } + Future<_StagedContentUri> _stageContentUri(String uri) async { + try { + final res = await _channel.invokeMapMethod( + 'stageContentUriForEdit', + {'uri': uri, 'extensionHint': _extensionOf(uri)}, + ); + final path = res?['path']; + if (path is String && path.isNotEmpty) { + return _StagedContentUri.ok(path); + } + final reason = res?['reason']; + return _StagedContentUri.failed(reason is String ? reason : 'failed'); + } catch (_) { + return const _StagedContentUri.failed('failed'); + } + } + + Future<_ContentUriWrite> _writeBytesToContentUri( + String uri, + String tempFilePath, + ) async { + try { + final res = await _channel.invokeMapMethod( + 'writeBytesToContentUri', + {'uri': uri, 'tempFilePath': tempFilePath}, + ); + final success = res?['success'] == true; + final reason = res?['reason']; + return _ContentUriWrite(success, reason is String ? reason : null); + } catch (_) { + return const _ContentUriWrite(false, 'failed'); + } + } + + /// Maps a `file://` URI to a filesystem path, passes plain paths through, + /// and returns null for `content://` documents (which Rust cannot open). + String? _plainPath(String source) { + if (source.startsWith('content://')) return null; + if (source.startsWith('file://')) { + try { + return Uri.parse(source).toFilePath(); + } catch (_) { + return source; + } + } + return source; + } + + /// Best-effort extension hint for the staged cache file. Android resolves + /// the provider's display name when this is null (e.g. MediaStore URIs). + String? _extensionOf(String source) { + final path = Uri.tryParse(source)?.path ?? source; + final dot = path.lastIndexOf('.'); + if (dot < 0 || dot == path.length - 1) return null; + final extension = path.substring(dot + 1).toLowerCase(); + if (extension.length > 5 || extension.contains('/')) return null; + return extension; + } + + Future _deleteQuietly(String path) async { + try { + await File(path).delete(); + } catch (_) {} + } + + String _stageFailureMessage(String? reason) { + if (reason == 'no_access') { + return l10n.englishForFilipino.flickNoLongerHasPermissionToEditThisFile; + } + return l10n.englishForFilipino.fileIsNoLongerAvailable; + } + + String _contentWriteFailureMessage(String? reason) { + if (reason == 'no_access') { + return l10n.englishForFilipino.flickNoLongerHasPermissionToEditThisFile; + } + if (reason == 'not_found') { + return l10n.englishForFilipino.fileIsNoLongerAvailable; + } + return l10n.englishForFilipino.androidBlockedWritingToThisFile; + } + + String _writeFailureMessage(String? error) { + if (_isNoSuchFile(error)) { + return l10n.englishForFilipino.fileIsNoLongerAvailable; + } + if (_isPermissionDenied(error)) { + return l10n.englishForFilipino.flickNoLongerHasPermissionToEditThisFile; + } + return error ?? l10n.englishForFilipino.failedToWriteTagsToTheFile; + } + + String _unexpectedWriteFailure(Object error) { + final text = error.toString(); + if (_isNoSuchFile(text)) { + return l10n.englishForFilipino.fileIsNoLongerAvailable; + } + if (_isPermissionDenied(text)) { + return l10n.englishForFilipino.flickNoLongerHasPermissionToEditThisFile; + } + return l10n.englishForFilipino.failedToSaveMetadata(error); + } + + bool _isNoSuchFile(String? error) { + final text = error?.toLowerCase() ?? ''; + return text.contains('os error 2') || + text.contains('no such file or directory') || + text.contains('enoent'); + } + + bool _isPermissionDenied(String? error) { + final text = error?.toLowerCase() ?? ''; + return text.contains('os error 13') || + text.contains('permission denied') || + text.contains('operation not permitted') || + text.contains('eacces'); + } + /// Compares the freshly read tags against the fields we intended to set. /// Only fields with a concrete intent (non-null) are checked; null fields /// mean "leave untouched" and are skipped. String compares are normalized so diff --git a/test/services/metadata_editor_service_test.dart b/test/services/metadata_editor_service_test.dart new file mode 100644 index 00000000..ca98fffc --- /dev/null +++ b/test/services/metadata_editor_service_test.dart @@ -0,0 +1,381 @@ +import 'dart:io'; +import 'dart:ui' show Locale; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:flick/l10n/l10n.dart'; +import 'package:flick/models/song.dart'; +import 'package:flick/services/metadata_editor_service.dart'; +import 'package:flick/src/rust/api/metadata_editor.dart' as rust_metadata; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('com.mossapps.flick/storage'); + const contentUri = + 'content://com.android.externalstorage.documents/tree/primary%3AMusic/' + 'document/primary%3AMusic%2Ftrack.flac'; + const treeUri = + 'content://com.android.externalstorage.documents/tree/primary%3AMusic'; + + final en = lookupAppLocalizations(const Locale('en')); + + late Directory tempDir; + late List channelCalls; + late Future Function(MethodCall call) onChannelCall; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync('flick-metadata-editor-'); + channelCalls = []; + onChannelCall = (call) async { + fail('Unexpected storage channel call: ${call.method}'); + }; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + channelCalls.add(call); + return onChannelCall(call); + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + if (tempDir.existsSync()) { + tempDir.deleteSync(recursive: true); + } + }); + + File stagedFile(String name) { + final file = File('${tempDir.path}/$name'); + file.writeAsBytesSync([1, 2, 3]); + return file; + } + + Song songFor(String filePath, {String? folderUri}) { + return Song( + id: '1', + title: 'Blank Space', + artist: 'Taylor Swift', + duration: const Duration(minutes: 4), + fileType: 'FLAC', + filePath: filePath, + folderUri: folderUri, + ); + } + + const fields = rust_metadata.TagEditFields( + title: 'New Title', + artist: 'New Artist', + album: 'New Album', + albumArtist: 'New Album Artist', + genre: 'Rock', + year: 2026, + trackNumber: 1, + discNumber: 1, + ); + + const readBack = rust_metadata.TagReadResult( + title: 'New Title', + artist: 'New Artist', + album: 'New Album', + albumArtist: 'New Album Artist', + genre: 'Rock', + year: 2026, + trackNumber: 1, + discNumber: 1, + ); + + test( + 'edits a content URI through staging and writes the bytes back', + () async { + final editStage = stagedFile('edit.flac'); + final verifyStage = stagedFile('verify.flac'); + var stageCalls = 0; + + onChannelCall = (call) async { + switch (call.method) { + case 'stageContentUriForEdit': + final args = call.arguments as Map; + expect(args['uri'], contentUri); + expect(args['extensionHint'], 'flac'); + return stageCalls++ == 0 + ? {'path': editStage.path, 'reason': null} + : {'path': verifyStage.path, 'reason': null}; + case 'writeBytesToContentUri': + final args = call.arguments as Map; + expect(args['uri'], contentUri); + expect(args['tempFilePath'], editStage.path); + return {'success': true, 'reason': 'ok'}; + default: + fail('Unexpected storage channel call: ${call.method}'); + } + }; + + final writtenTo = []; + final persisted = []; + final service = MetadataEditorService.forTesting( + readTagFile: (path) async { + expect(path, verifyStage.path); + return readBack; + }, + writeTagFile: (path, editFields) async { + expect(editFields, fields); + writtenTo.add(path); + return const rust_metadata.TagWriteResult(success: true); + }, + persistMetadata: (path, editFields) async => persisted.add(path), + ); + + final result = await service.writeTags( + songFor(contentUri, folderUri: treeUri), + fields, + ); + + expect(result.outcome, MetadataWriteOutcome.verified); + expect(writtenTo, [editStage.path]); + expect(persisted, [contentUri]); + expect(stageCalls, 2); + expect(editStage.existsSync(), isFalse); + expect(verifyStage.existsSync(), isFalse); + expect(channelCalls.map((call) => call.method), [ + 'stageContentUriForEdit', + 'writeBytesToContentUri', + 'stageContentUriForEdit', + ]); + }, + ); + + test( + 'write-back without permission keeps the DB untouched and says why', + () async { + final editStage = stagedFile('edit.flac'); + var stageCalls = 0; + + onChannelCall = (call) async { + switch (call.method) { + case 'stageContentUriForEdit': + stageCalls++; + return {'path': editStage.path, 'reason': null}; + case 'writeBytesToContentUri': + return {'success': false, 'reason': 'no_access'}; + default: + fail('Unexpected storage channel call: ${call.method}'); + } + }; + + var readCalls = 0; + final persisted = []; + final service = MetadataEditorService.forTesting( + readTagFile: (path) async { + readCalls++; + return readBack; + }, + writeTagFile: (path, editFields) async => + const rust_metadata.TagWriteResult(success: true), + persistMetadata: (path, editFields) async => persisted.add(path), + ); + + final result = await service.writeTags( + songFor(contentUri, folderUri: treeUri), + fields, + ); + + expect(result.outcome, MetadataWriteOutcome.failed); + expect(result.message, en.flickNoLongerHasPermissionToEditThisFile); + expect(stageCalls, 1); + expect(readCalls, 0); + expect(persisted, isEmpty); + expect(editStage.existsSync(), isFalse); + }, + ); + + test('a missing content URI reports a moved-or-deleted file', () async { + onChannelCall = (call) async { + expect(call.method, 'stageContentUriForEdit'); + return {'path': null, 'reason': 'not_found'}; + }; + + var writeCalls = 0; + final service = MetadataEditorService.forTesting( + readTagFile: (path) async => readBack, + writeTagFile: (path, editFields) async { + writeCalls++; + return const rust_metadata.TagWriteResult(success: true); + }, + persistMetadata: (path, editFields) async {}, + ); + + final result = await service.writeTags( + songFor(contentUri, folderUri: treeUri), + fields, + ); + + expect(result.outcome, MetadataWriteOutcome.failed); + expect(result.message, en.fileIsNoLongerAvailable); + expect(writeCalls, 0); + }); + + test( + 'file:// paths are normalized before reaching the Rust tagger', + () async { + const fileUri = 'file:///storage/emulated/0/Music/track.flac'; + final writtenTo = []; + final persisted = []; + + final service = MetadataEditorService.forTesting( + readTagFile: (path) async => readBack, + writeTagFile: (path, editFields) async { + writtenTo.add(path); + return const rust_metadata.TagWriteResult(success: true); + }, + persistMetadata: (path, editFields) async => persisted.add(path), + ); + + final result = await service.writeTags(songFor(fileUri), fields); + + expect(result.outcome, MetadataWriteOutcome.verified); + expect(writtenTo, ['/storage/emulated/0/Music/track.flac']); + // The DB row is keyed by the original URI, not the normalized path. + expect(persisted, [fileUri]); + expect(channelCalls, isEmpty); + }, + ); + + test( + 'a direct ENOENT failure gets a clear message instead of os error 2', + () async { + final service = MetadataEditorService.forTesting( + readTagFile: (path) async => readBack, + writeTagFile: (path, editFields) async => + const rust_metadata.TagWriteResult( + success: false, + error: + 'Failed to open file: No such file or directory (os error 2)', + ), + persistMetadata: (path, editFields) async {}, + ); + + final result = await service.writeTags( + songFor('/storage/emulated/0/Music/missing.flac'), + fields, + ); + + expect(result.outcome, MetadataWriteOutcome.failed); + expect(result.message, en.fileIsNoLongerAvailable); + expect(result.message, isNot(contains('os error 2'))); + expect(channelCalls, isEmpty); + }, + ); + + test( + 'a thrown direct write still attempts the SAF fallback and keeps the URI key', + () async { + const path = '/storage/emulated/0/Music/track.flac'; + const fileUri = 'file://$path'; + final editStage = stagedFile('saf-edit.flac'); + final persisted = []; + + onChannelCall = (call) async { + expect(call.method, 'writeFileBytesViaSaf'); + final args = call.arguments as Map; + expect(args['folderTreeUri'], treeUri); + expect(args['filePath'], path); + expect(args['tempFilePath'], editStage.path); + return true; + }; + + final service = MetadataEditorService.forTesting( + readTagFile: (readPath) async { + expect(readPath, path); + return readBack; + }, + writeTagFile: (path, editFields) async { + throw Exception('direct write denied'); + }, + writeTagFileToTemp: (path, editFields, tempDirPath) async { + expect(path, '/storage/emulated/0/Music/track.flac'); + expect(tempDirPath, '${tempDir.path}/metadata_edits'); + return rust_metadata.TagWriteResult( + success: true, + tempPath: editStage.path, + ); + }, + persistMetadata: (path, editFields) async => persisted.add(path), + temporaryDirectory: () async => tempDir, + ); + + final result = await service.writeTags( + songFor(fileUri, folderUri: treeUri), + fields, + ); + + expect(result.outcome, MetadataWriteOutcome.verified); + expect(persisted, [fileUri]); + expect(editStage.existsSync(), isFalse); + expect(channelCalls.single.method, 'writeFileBytesViaSaf'); + }, + ); + + test('a direct permission failure falls back to SAF', () async { + const path = '/storage/emulated/0/Music/track.flac'; + final editStage = stagedFile('saf-denied.flac'); + final persisted = []; + + onChannelCall = (call) async { + expect(call.method, 'writeFileBytesViaSaf'); + final args = call.arguments as Map; + expect(args['filePath'], path); + expect(args['tempFilePath'], editStage.path); + return true; + }; + + final service = MetadataEditorService.forTesting( + readTagFile: (readPath) async => readBack, + writeTagFile: (path, editFields) async => + const rust_metadata.TagWriteResult( + success: false, + error: 'Failed to open file: Permission denied (os error 13)', + ), + writeTagFileToTemp: (path, editFields, tempDirPath) async => + rust_metadata.TagWriteResult(success: true, tempPath: editStage.path), + persistMetadata: (path, editFields) async => persisted.add(path), + temporaryDirectory: () async => tempDir, + ); + + final result = await service.writeTags( + songFor(path, folderUri: treeUri), + fields, + ); + + expect(result.outcome, MetadataWriteOutcome.verified); + expect(persisted, [path]); + expect(editStage.existsSync(), isFalse); + expect(channelCalls.single.method, 'writeFileBytesViaSaf'); + }); + + test( + 'readTags stages a content URI into cache and removes the copy', + () async { + final readStage = stagedFile('read.flac'); + + onChannelCall = (call) async { + expect(call.method, 'stageContentUriForEdit'); + return {'path': readStage.path, 'reason': null}; + }; + + final service = MetadataEditorService.forTesting( + readTagFile: (path) async { + expect(path, readStage.path); + return readBack; + }, + ); + + final result = await service.readTags(contentUri); + + expect(result, readBack); + expect(readStage.existsSync(), isFalse); + expect(channelCalls.single.method, 'stageContentUriForEdit'); + }, + ); +} From c84ed819d95dbeecc0b2307b011da0148b9a9299 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Thu, 5 Nov 2026 17:33:26 +0800 Subject: [PATCH 05/14] Add blur gradient immersive background option Make AmbientBackground opacity configurable so the full player can render un-scrimmed blurred artwork under a dstIn shader mask that fades into the foreground, with a softer gradient overlay. Add a background style selector to the player layout sheet and mirror the style in its immersive and full-screen previews. --- .../player/screens/full_player_screen.dart | 48 +++++ .../player/widgets/ambient_background.dart | 20 +- .../player/widgets/player_layout_sheet.dart | 184 ++++++++++++++++-- 3 files changed, 235 insertions(+), 17 deletions(-) diff --git a/lib/features/player/screens/full_player_screen.dart b/lib/features/player/screens/full_player_screen.dart index d2e12653..a612388e 100644 --- a/lib/features/player/screens/full_player_screen.dart +++ b/lib/features/player/screens/full_player_screen.dart @@ -20,6 +20,7 @@ import 'package:flick/features/player/widgets/song_stage_carousel.dart'; import 'package:flick/features/player/widgets/lyrics_mode_waveform_strip.dart'; import 'package:flick/features/player/widgets/album_color_helpers.dart'; import 'package:flick/models/album_color_mode.dart'; +import 'package:flick/models/immersive_background_style.dart'; import 'package:flick/models/player_screen_mode.dart'; import 'package:flick/models/player_action_button.dart'; import 'package:flick/models/song.dart'; @@ -333,6 +334,7 @@ class _FullPlayerScreenState extends ConsumerState String visFreq, String visMove, bool animatedArtwork, + ImmersiveBackgroundStyle backgroundStyle, ) { final bgBlend = albumColorMode.backgroundBlend; final hasAlbumTint = albumColor != null && bgBlend > 0; @@ -356,6 +358,7 @@ class _FullPlayerScreenState extends ConsumerState visFreq, visMove, animatedArtwork, + backgroundStyle, ), ), Positioned.fill( @@ -384,9 +387,12 @@ class _FullPlayerScreenState extends ConsumerState String visFreq, String visMove, bool animatedArtwork, + ImmersiveBackgroundStyle backgroundStyle, ) { final bgBlend = albumColorMode.backgroundBlend; final hasAlbumTint = albumColor != null && bgBlend > 0; + final useBlurGradient = + backgroundStyle == ImmersiveBackgroundStyle.blurGradient; if (visualizationMode && _playerScreenMode != PlayerScreenMode.artworkCard) { @@ -487,6 +493,15 @@ class _FullPlayerScreenState extends ConsumerState gradientBase.withValues(alpha: 0.08), Colors.transparent, ] + : useBlurGradient + ? [ + gradientBase.withValues(alpha: 0.72), + gradientBase.withValues(alpha: 0.58), + gradientBase.withValues(alpha: 0.42), + gradientBase.withValues(alpha: 0.24), + gradientBase.withValues(alpha: 0.1), + Colors.transparent, + ] : [ gradientBase, gradientBase.withValues(alpha: 0.95), @@ -530,6 +545,33 @@ class _FullPlayerScreenState extends ConsumerState return Stack( children: [ Positioned.fill(child: artLayer), + if (useBlurGradient) + Positioned.fill( + child: IgnorePointer( + child: AnimatedOpacity( + duration: AppConstants.animationNormal, + opacity: _isImmersiveFullView ? 0.0 : 1.0, + child: ShaderMask( + shaderCallback: (bounds) => const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.transparent, + Colors.white, + ], + stops: [0.0, 0.42, 0.82], + ).createShader(bounds), + blendMode: BlendMode.dstIn, + child: AmbientBackground( + song: song, + imageOpacity: 1.0, + scrimOpacity: 0.0, + ), + ), + ), + ), + ), Positioned.fill( child: AnimatedContainer( duration: AppConstants.animationNormal, @@ -860,6 +902,9 @@ class _FullPlayerScreenState extends ConsumerState final visStyle = appPrefs.visualizerAnimationStyle; final visFreq = appPrefs.visualizerFrequencyMode; final visMove = appPrefs.visualizerMovementMode; + final immersiveBackgroundStyle = ImmersiveBackgroundStyleX.fromStorageValue( + appPrefs.immersiveBackgroundStyle, + ); final colorMode = ref.watch(albumColorModeProvider); final dominantColor = ref.watch(albumDominantColorSyncProvider); @@ -954,6 +999,7 @@ class _FullPlayerScreenState extends ConsumerState visMove, showImmersiveFullView, showVisualizerOnly, + immersiveBackgroundStyle, ), ), ); @@ -974,6 +1020,7 @@ class _FullPlayerScreenState extends ConsumerState String visMove, bool showImmersiveFullView, bool showVisualizerOnly, + ImmersiveBackgroundStyle backgroundStyle, ) { final visualizationMode = _isVisualizationMode && appPrefs.visualizerEnabled; @@ -1033,6 +1080,7 @@ class _FullPlayerScreenState extends ConsumerState visFreq, visMove, appPrefs.animatedAlbumArt && appPrefs.animationsEnabled, + backgroundStyle, ); if (showVisualizerOnly) { diff --git a/lib/features/player/widgets/ambient_background.dart b/lib/features/player/widgets/ambient_background.dart index 90bb5cd7..54bdc84f 100644 --- a/lib/features/player/widgets/ambient_background.dart +++ b/lib/features/player/widgets/ambient_background.dart @@ -50,7 +50,18 @@ class AmbientBackgroundScope extends InheritedWidget { class AmbientBackground extends StatefulWidget { final Song? song; - const AmbientBackground({super.key, this.song}); + /// Opacity of the pre-blurred artwork itself. + final double imageOpacity; + + /// Black scrim drawn over the blurred artwork. + final double scrimOpacity; + + const AmbientBackground({ + super.key, + this.song, + this.imageOpacity = 0.6, + this.scrimOpacity = 0.3, + }); @override State createState() => _AmbientBackgroundState(); @@ -265,10 +276,13 @@ class _AmbientBackgroundState extends State { RawImage( image: image, fit: BoxFit.cover, - opacity: const AlwaysStoppedAnimation(0.6), + opacity: AlwaysStoppedAnimation(widget.imageOpacity), ), // Dark scrim for readability - ColoredBox(color: Colors.black.withValues(alpha: 0.3)), + if (widget.scrimOpacity > 0) + ColoredBox( + color: Colors.black.withValues(alpha: widget.scrimOpacity), + ), ], ), ), diff --git a/lib/features/player/widgets/player_layout_sheet.dart b/lib/features/player/widgets/player_layout_sheet.dart index f0f65704..aff5d3dd 100644 --- a/lib/features/player/widgets/player_layout_sheet.dart +++ b/lib/features/player/widgets/player_layout_sheet.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:math' as math; +import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flick/core/theme/app_colors.dart'; @@ -7,6 +8,7 @@ import 'package:flick/core/theme/adaptive_color_provider.dart'; import 'package:flick/core/constants/app_constants.dart'; import 'package:flick/services/player_service.dart'; import 'package:flick/models/song.dart'; +import 'package:flick/models/immersive_background_style.dart'; import 'package:flick/models/player_screen_mode.dart'; import 'package:flick/models/album_color_mode.dart'; import 'package:flick/models/player_action_button.dart'; @@ -63,6 +65,9 @@ class _PlayerLayoutSheetState extends ConsumerState { final colorMode = ref.watch(albumColorModeProvider); final appPrefs = ref.watch(appPreferencesProvider); final prefsNotifier = ref.read(appPreferencesProvider.notifier); + final immersiveBackgroundStyle = ImmersiveBackgroundStyleX.fromStorageValue( + appPrefs.immersiveBackgroundStyle, + ); return SafeArea( top: false, bottom: false, @@ -122,6 +127,7 @@ class _PlayerLayoutSheetState extends ConsumerState { immersiveFullViewScale: appPrefs.immersiveFullViewScale, immersiveShowTitle: appPrefs.immersiveShowTitle, immersiveShowArtist: appPrefs.immersiveShowArtist, + immersiveBackgroundStyle: immersiveBackgroundStyle, ), Positioned( top: 8, @@ -282,6 +288,14 @@ class _PlayerLayoutSheetState extends ConsumerState { title: l10n.immersive, icon: Icons.fit_screen_rounded, children: [ + _PlayerBackgroundStyleSelector( + currentValue: immersiveBackgroundStyle, + onChanged: (style) { + prefsNotifier.setImmersiveBackgroundStyle( + style.storageValue, + ); + }, + ), _PlayerCustomizationSlider( title: l10n.textSize, value: appPrefs.immersiveTextScale, @@ -501,6 +515,10 @@ class _PlayerLayoutSheetState extends ConsumerState { immersiveShowTitle: appPrefs.immersiveShowTitle, immersiveShowArtist: appPrefs.immersiveShowArtist, immersiveShowFileInfo: appPrefs.immersiveShowFileInfo, + immersiveBackgroundStyle: + ImmersiveBackgroundStyleX.fromStorageValue( + appPrefs.immersiveBackgroundStyle, + ), ), ), Positioned( @@ -815,6 +833,92 @@ class _PlayerCustomizationSlider extends StatelessWidget { } } +class _PlayerBackgroundStyleSelector extends StatelessWidget { + final ImmersiveBackgroundStyle currentValue; + final ValueChanged onChanged; + + const _PlayerBackgroundStyleSelector({ + required this.currentValue, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.background, + style: TextStyle( + fontFamily: 'GoogleSansFlex', + fontSize: 13, + fontWeight: FontWeight.w600, + color: context.adaptiveTextPrimary, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 6, + children: ImmersiveBackgroundStyle.values.map((style) { + final isSelected = style == currentValue; + return GestureDetector( + onTap: () => onChanged(style), + child: AnimatedContainer( + duration: AppConstants.animationFast, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: isSelected + ? AppColors.accent.withValues(alpha: 0.18) + : AppColors.glassBackground, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: isSelected + ? AppColors.accent.withValues(alpha: 0.6) + : AppColors.glassBorder, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + style == ImmersiveBackgroundStyle.colorGradient + ? Icons.gradient_rounded + : Icons.blur_on_rounded, + size: 14, + color: isSelected + ? AppColors.accent + : context.adaptiveTextSecondary, + ), + const SizedBox(width: 4), + Text( + style.labelFor(l10n), + style: TextStyle( + fontFamily: 'GoogleSansFlex', + fontSize: 12, + fontWeight: isSelected + ? FontWeight.bold + : FontWeight.normal, + color: isSelected + ? AppColors.textPrimary + : context.adaptiveTextSecondary, + ), + ), + ], + ), + ), + ); + }).toList(), + ), + const SizedBox(height: 6), + ], + ); + } +} + class _PlayerLayoutPreview extends StatelessWidget { final Song? song; final PlayerScreenMode mode; @@ -831,6 +935,7 @@ class _PlayerLayoutPreview extends StatelessWidget { final double immersiveFullViewScale; final bool immersiveShowTitle; final bool immersiveShowArtist; + final ImmersiveBackgroundStyle immersiveBackgroundStyle; const _PlayerLayoutPreview({ required this.song, @@ -848,6 +953,7 @@ class _PlayerLayoutPreview extends StatelessWidget { required this.immersiveFullViewScale, this.immersiveShowTitle = true, this.immersiveShowArtist = true, + this.immersiveBackgroundStyle = ImmersiveBackgroundStyle.colorGradient, }); @override @@ -951,13 +1057,11 @@ class _PlayerLayoutPreview extends StatelessWidget { return Stack( children: [ Positioned.fill( - child: Opacity( - opacity: 0.28, - child: _PreviewAlbumArt( - song: song, - size: double.infinity, - radius: 0, - ), + child: _ImmersivePreviewBackground( + song: song, + backgroundStyle: immersiveBackgroundStyle, + artOpacity: 0.28, + blurSigma: 6, ), ), Positioned( @@ -1028,6 +1132,58 @@ class _PlayerLayoutPreview extends StatelessWidget { } } +class _ImmersivePreviewBackground extends StatelessWidget { + final Song? song; + final ImmersiveBackgroundStyle backgroundStyle; + final double artOpacity; + final double blurSigma; + + const _ImmersivePreviewBackground({ + required this.song, + required this.backgroundStyle, + required this.artOpacity, + required this.blurSigma, + }); + + @override + Widget build(BuildContext context) { + final useBlur = backgroundStyle == ImmersiveBackgroundStyle.blurGradient; + return Stack( + fit: StackFit.expand, + children: [ + Opacity( + opacity: useBlur ? math.min(1.0, artOpacity + 0.18) : artOpacity, + child: _PreviewAlbumArt(song: song, size: double.infinity, radius: 0), + ), + if (useBlur) + ShaderMask( + shaderCallback: (bounds) => const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, Colors.transparent, Colors.white], + stops: [0.0, 0.42, 0.82], + ).createShader(bounds), + blendMode: BlendMode.dstIn, + child: ImageFiltered( + imageFilter: ui.ImageFilter.blur( + sigmaX: blurSigma, + sigmaY: blurSigma, + ), + child: Opacity( + opacity: 0.86, + child: _PreviewAlbumArt( + song: song, + size: double.infinity, + radius: 0, + ), + ), + ), + ), + ], + ); + } +} + class _PreviewAlbumArt extends StatelessWidget { final Song? song; final double size; @@ -1094,6 +1250,7 @@ class _FullScreenPreview extends StatelessWidget { final bool immersiveShowTitle; final bool immersiveShowArtist; final bool immersiveShowFileInfo; + final ImmersiveBackgroundStyle immersiveBackgroundStyle; const _FullScreenPreview({ required this.song, @@ -1113,6 +1270,7 @@ class _FullScreenPreview extends StatelessWidget { this.immersiveShowTitle = true, this.immersiveShowArtist = true, this.immersiveShowFileInfo = true, + this.immersiveBackgroundStyle = ImmersiveBackgroundStyle.colorGradient, }); @override @@ -1222,13 +1380,11 @@ class _FullScreenPreview extends StatelessWidget { return Stack( children: [ Positioned.fill( - child: Opacity( - opacity: 0.32, - child: _PreviewAlbumArt( - song: song, - size: double.infinity, - radius: 0, - ), + child: _ImmersivePreviewBackground( + song: song, + backgroundStyle: immersiveBackgroundStyle, + artOpacity: 0.32, + blurSigma: 18, ), ), Positioned( From 9d1edef3536d045375188c35b0c57ce96967c0b3 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Thu, 5 Nov 2026 17:33:46 +0800 Subject: [PATCH 06/14] Add immersive background style preference Persist the choice in SharedPreferences, defaulting to the color gradient and falling back to it for unknown or missing values. --- lib/models/immersive_background_style.dart | 23 +++++++++++ lib/providers/app_preferences_provider.dart | 8 ++++ lib/services/app_preferences_service.dart | 18 +++++++++ .../immersive_background_style_test.dart | 25 ++++++++++++ .../app_preferences_provider_test.dart | 40 +++++++++++++++++++ 5 files changed, 114 insertions(+) create mode 100644 lib/models/immersive_background_style.dart create mode 100644 test/models/immersive_background_style_test.dart diff --git a/lib/models/immersive_background_style.dart b/lib/models/immersive_background_style.dart new file mode 100644 index 00000000..54124fbc --- /dev/null +++ b/lib/models/immersive_background_style.dart @@ -0,0 +1,23 @@ +/// Background treatments for the immersive full player. +enum ImmersiveBackgroundStyle { colorGradient, blurGradient } + +extension ImmersiveBackgroundStyleX on ImmersiveBackgroundStyle { + String get storageValue { + switch (this) { + case ImmersiveBackgroundStyle.colorGradient: + return 'color_gradient'; + case ImmersiveBackgroundStyle.blurGradient: + return 'blur_gradient'; + } + } + + static ImmersiveBackgroundStyle fromStorageValue(String? value) { + switch (value) { + case 'blur_gradient': + return ImmersiveBackgroundStyle.blurGradient; + case 'color_gradient': + default: + return ImmersiveBackgroundStyle.colorGradient; + } + } +} diff --git a/lib/providers/app_preferences_provider.dart b/lib/providers/app_preferences_provider.dart index baea1dd0..83d6fb5d 100644 --- a/lib/providers/app_preferences_provider.dart +++ b/lib/providers/app_preferences_provider.dart @@ -207,6 +207,14 @@ class AppPreferencesNotifier extends Notifier { .setImmersiveAutoFullViewSeconds(value); } + Future setImmersiveBackgroundStyle(String value) async { + if (state.immersiveBackgroundStyle == value) return; + state = state.copyWith(immersiveBackgroundStyle: value); + await ref + .read(appPreferencesServiceProvider) + .setImmersiveBackgroundStyle(value); + } + Future setVisualizerAnimationStyle(String value) async { if (state.visualizerAnimationStyle == value) return; state = state.copyWith(visualizerAnimationStyle: value); diff --git a/lib/services/app_preferences_service.dart b/lib/services/app_preferences_service.dart index ac916595..2dddb6b2 100644 --- a/lib/services/app_preferences_service.dart +++ b/lib/services/app_preferences_service.dart @@ -22,6 +22,7 @@ class AppPreferences { final bool fastIndexEnabled; final int fastIndexTimeoutSeconds; final int immersiveAutoFullViewSeconds; + final String immersiveBackgroundStyle; final String visualizerAnimationStyle; final String visualizerFrequencyMode; final String visualizerMovementMode; @@ -141,6 +142,7 @@ class AppPreferences { this.fastIndexEnabled = true, this.fastIndexTimeoutSeconds = 3, this.immersiveAutoFullViewSeconds = 0, + this.immersiveBackgroundStyle = 'color_gradient', this.visualizerAnimationStyle = 'bars', this.visualizerFrequencyMode = 'full', this.visualizerMovementMode = 'bouncy', @@ -261,6 +263,7 @@ class AppPreferences { bool? fastIndexEnabled, int? fastIndexTimeoutSeconds, int? immersiveAutoFullViewSeconds, + String? immersiveBackgroundStyle, String? visualizerAnimationStyle, String? visualizerFrequencyMode, String? visualizerMovementMode, @@ -386,6 +389,8 @@ class AppPreferences { fastIndexTimeoutSeconds ?? this.fastIndexTimeoutSeconds, immersiveAutoFullViewSeconds: immersiveAutoFullViewSeconds ?? this.immersiveAutoFullViewSeconds, + immersiveBackgroundStyle: + immersiveBackgroundStyle ?? this.immersiveBackgroundStyle, visualizerAnimationStyle: visualizerAnimationStyle ?? this.visualizerAnimationStyle, visualizerFrequencyMode: @@ -555,6 +560,7 @@ class AppPreferencesService { static const _fastIndexEnabledKey = 'fast_index_enabled'; static const _fastIndexTimeoutKey = 'fast_index_timeout_seconds'; static const _immersiveAutoFullViewKey = 'immersive_auto_full_view_seconds'; + static const _immersiveBackgroundStyleKey = 'immersive_background_style'; static const _visualizerAnimationStyleKey = 'visualizer_animation_style'; static const _visualizerFrequencyModeKey = 'visualizer_frequency_mode'; static const _visualizerMovementModeKey = 'visualizer_movement_mode'; @@ -696,6 +702,8 @@ class AppPreferencesService { fastIndexTimeoutSeconds: prefs.getInt(_fastIndexTimeoutKey) ?? 3, immersiveAutoFullViewSeconds: prefs.getInt(_immersiveAutoFullViewKey) ?? 0, + immersiveBackgroundStyle: + prefs.getString(_immersiveBackgroundStyleKey) ?? 'color_gradient', visualizerAnimationStyle: prefs.getString(_visualizerAnimationStyleKey) ?? 'bars', visualizerFrequencyMode: @@ -1108,6 +1116,16 @@ class AppPreferencesService { await prefs.setInt(_immersiveAutoFullViewKey, value); } + Future getImmersiveBackgroundStyle() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_immersiveBackgroundStyleKey) ?? 'color_gradient'; + } + + Future setImmersiveBackgroundStyle(String value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_immersiveBackgroundStyleKey, value); + } + Future getVisualizerAnimationStyle() async { final prefs = await SharedPreferences.getInstance(); return prefs.getString(_visualizerAnimationStyleKey) ?? 'bars'; diff --git a/test/models/immersive_background_style_test.dart b/test/models/immersive_background_style_test.dart new file mode 100644 index 00000000..b4feb03e --- /dev/null +++ b/test/models/immersive_background_style_test.dart @@ -0,0 +1,25 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:flick/models/immersive_background_style.dart'; + +void main() { + test('storage values round-trip', () { + for (final style in ImmersiveBackgroundStyle.values) { + expect( + ImmersiveBackgroundStyleX.fromStorageValue(style.storageValue), + style, + ); + } + }); + + test('unknown or missing values fall back to the color gradient', () { + expect( + ImmersiveBackgroundStyleX.fromStorageValue(null), + ImmersiveBackgroundStyle.colorGradient, + ); + expect( + ImmersiveBackgroundStyleX.fromStorageValue('unknown'), + ImmersiveBackgroundStyle.colorGradient, + ); + }); +} diff --git a/test/providers/app_preferences_provider_test.dart b/test/providers/app_preferences_provider_test.dart index 346270d0..198b2cf9 100644 --- a/test/providers/app_preferences_provider_test.dart +++ b/test/providers/app_preferences_provider_test.dart @@ -207,4 +207,44 @@ void main() { expect(prefs.getBool('onboarding_completed'), isTrue); }); } + + test('immersive background defaults to the color gradient', () async { + final container = ProviderContainer(); + addTearDown(container.dispose); + container.read(appPreferencesProvider); + await pumpEventQueue(); + expect( + container.read(appPreferencesProvider).immersiveBackgroundStyle, + 'color_gradient', + ); + }); + + test( + 'immersive blur gradient survives reopening the preferences', + () async { + final container = ProviderContainer(); + addTearDown(container.dispose); + container.read(appPreferencesProvider); + await pumpEventQueue(); + await container + .read(appPreferencesProvider.notifier) + .setImmersiveBackgroundStyle('blur_gradient'); + expect( + container.read(appPreferencesProvider).immersiveBackgroundStyle, + 'blur_gradient', + ); + + final restored = ProviderContainer(); + addTearDown(restored.dispose); + restored.read(appPreferencesProvider); + await pumpEventQueue(); + expect( + restored.read(appPreferencesProvider).immersiveBackgroundStyle, + 'blur_gradient', + ); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('immersive_background_style'), 'blur_gradient'); + }, + ); } From e126d00c153ef8739cc281928a27b6f468de1913 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Thu, 5 Nov 2026 17:34:46 +0800 Subject: [PATCH 07/14] Add immersive background style labels Add metadata editor strings for missing files and lost edit permission. --- lib/l10n/app_en.arb | 10 ++++++++ lib/l10n/app_tl.arb | 4 ++++ lib/l10n/app_zh.arb | 4 ++++ lib/l10n/generated/app_localizations.dart | 24 ++++++++++++++++++++ lib/l10n/generated/app_localizations_en.dart | 14 ++++++++++++ lib/l10n/generated/app_localizations_tl.dart | 14 ++++++++++++ lib/l10n/generated/app_localizations_zh.dart | 13 +++++++++++ lib/l10n/model_labels.dart | 11 +++++++++ 8 files changed, 94 insertions(+) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c43f4ccc..62e40df8 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1113,6 +1113,8 @@ "@background": { "description": "lib/features/settings/screens/widget_settings_screen.dart:155" }, + "blurGradient": "Blur gradient", + "colorGradient": "Color gradient", "backgroundOpacity": "Background Opacity", "@backgroundOpacity": { "description": "lib/features/settings/widgets/mini_player_customization.dart:197" @@ -4348,6 +4350,10 @@ "@fileInformation": { "description": "lib/features/songs/screens/metadata_editor_screen.dart:444" }, + "fileIsNoLongerAvailable": "This file is no longer available. It may have been moved or deleted. Rescan the library, then try again.", + "@fileIsNoLongerAvailable": { + "description": "lib/services/metadata_editor_service.dart: user-visible service error" + }, "filePath": "File Path", "@filePath": { "description": "lib/features/player/widgets/song_metadata_sheet.dart:82" @@ -4492,6 +4498,10 @@ "@flickLogs": { "description": "lib/features/settings/screens/logs_screen.dart:110" }, + "flickNoLongerHasPermissionToEditThisFile": "Flick no longer has permission to edit this file. Re-add its folder in Settings to grant edit access, then try again.", + "@flickNoLongerHasPermissionToEditThisFile": { + "description": "lib/services/metadata_editor_service.dart: user-visible service error" + }, "flickPlayer": "Flick Player", "@flickPlayer": { "description": "lib/features/settings/screens/app_info_settings_screen.dart:444" diff --git a/lib/l10n/app_tl.arb b/lib/l10n/app_tl.arb index 44c778ff..9b21917a 100644 --- a/lib/l10n/app_tl.arb +++ b/lib/l10n/app_tl.arb @@ -228,6 +228,8 @@ "backToReplay": "Bumalik sa Replay", "backend": "Backend", "background": "Likuran", + "blurGradient": "Gradient ng blur", + "colorGradient": "Gradient ng kulay", "backgroundOpacity": "Pagkalabo ng likuran", "backgroundPlaybackAnchor": "Pangpanatili ng pagtugtog sa background", "balance": "Balanse", @@ -795,6 +797,7 @@ "feedback": "Puna", "file": "File", "fileInformation": "Impormasyon ng file", + "fileIsNoLongerAvailable": "Hindi na available ang file na ito. Maaaring nailipat o natanggal ito. I-scan muli ang library at subukan ulit.", "filePath": "Path ng file", "files": "{done} / {total} file", "files2": "{count, plural, =1{{count} file} other{{count} file}}", @@ -824,6 +827,7 @@ "flickChecksFlickPlayerSiteFor": "Sinusuri ng Flick ang flick-player.site para sa update kapag may koneksiyon ka", "flickIsBuiltAndMaintainedBy": "Iisang tao ang gumagawa at nagpapanatili ng Flick nang walang badyet. Walang koponan, pondo, o patalastas.", "flickLogs": "Mga log ng Flick", + "flickNoLongerHasPermissionToEditThisFile": "Wala nang pahintulot ang Flick na i-edit ang file na ito. Alisin at idagdag muli ang folder nito sa Mga pagsasaayos upang payagang baguhin ito, saka subukan muli.", "flickPlayer": "Flick Player", "flickPlayerDoesNotCollectStore": "Hindi nangongolekta, nag-iimbak, o nagpapadala ng personal na datos ang Flick Player. Walang kinukuhang personal na impormasyon, analytics ng paggamit, ulat ng pag-crash, o advertising identifier. Walang datos na ibinabahagi sa mga ikatlong partido.", "flickPreview": "Paunang tingin ng Flick", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 8b2406b5..a1054942 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -237,6 +237,8 @@ "backToReplay": "่ฟ”ๅ›žๅ›ž้กพ", "backend": "ๅŽ็ซฏ", "background": "่ƒŒๆ™ฏ", + "blurGradient": "ๆจก็ณŠๆธๅ˜", + "colorGradient": "่‰ฒๅฝฉๆธๅ˜", "backgroundOpacity": "่ƒŒๆ™ฏไธ้€ๆ˜Žๅบฆ", "backgroundPlaybackAnchor": "ๅŽๅฐๆ’ญๆ”พ้”š็‚น", "balance": "ๅนณ่กก", @@ -948,6 +950,7 @@ "feedback": "ๅ้ฆˆ", "file": "ๆ–‡ไปถ", "fileInformation": "ๆ–‡ไปถไฟกๆฏ", + "fileIsNoLongerAvailable": "ๆญคๆ–‡ไปถๅทฒไธๅฏ็”จ๏ผŒๅฏ่ƒฝๅทฒ่ขซ็งปๅŠจๆˆ–ๅˆ ้™คใ€‚่ฏท้‡ๆ–ฐๆ‰ซๆๅช’ไฝ“ๅบ“ๅŽ้‡่ฏ•ใ€‚", "filePath": "ๆ–‡ไปถ่ทฏๅพ„", "files": "{done} / {total} ไธชๆ–‡ไปถ", "files2": "{count} ไธชๆ–‡ไปถ", @@ -977,6 +980,7 @@ "flickChecksFlickPlayerSiteFor": "่”็ฝ‘ๆ—ถ Flick ไผšๆฃ€ๆŸฅ flick-player.site ็š„ๆ›ดๆ–ฐ", "flickIsBuiltAndMaintainedBy": "Flick ็”ฑไธ€ไธชไบบ้›ถ้ข„็ฎ—ๅผ€ๅ‘ไธŽ็ปดๆŠคใ€‚ๆฒกๆœ‰ๅ›ข้˜Ÿ๏ผŒๆฒกๆœ‰่ต„้‡‘๏ผŒๆฒกๆœ‰ๅนฟๅ‘Šใ€‚", "flickLogs": "Flick ๆ—ฅๅฟ—", + "flickNoLongerHasPermissionToEditThisFile": "Flick ๅทฒๆฒกๆœ‰็ผ–่พ‘ๆญคๆ–‡ไปถ็š„ๆƒ้™ใ€‚่ฏทๅœจ่ฎพ็ฝฎไธญ็งป้™คๅนถ้‡ๆ–ฐๆทปๅŠ ่ฏฅๆ–‡ไปถๅคนไปฅๆŽˆไบˆ็ผ–่พ‘ๆƒ้™๏ผŒ็„ถๅŽ้‡่ฏ•ใ€‚", "flickPlayer": "Flick ้Ÿณไนๆ’ญๆ”พๅ™จ", "flickPlayerDoesNotCollectStore": "Flick Player ไธๆ”ถ้›†ใ€ไธๅญ˜ๅ‚จใ€ไธไผ ่พ“ไปปไฝ•ไธชไบบๆ•ฐๆฎใ€‚ไธไผš้‡‡้›†ไธชไบบไฟกๆฏใ€ไฝฟ็”จๅˆ†ๆžใ€ๅดฉๆบƒๆŠฅๅ‘Šๆˆ–ๅนฟๅ‘Šๆ ‡่ฏ†็ฌฆใ€‚ไธไผšไธŽ็ฌฌไธ‰ๆ–นๅ…ฑไบซไปปไฝ•ๆ•ฐๆฎใ€‚", "flickPreview": "Flick ้ข„่งˆ", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 86801d3d..93b252f3 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -1529,6 +1529,18 @@ abstract class AppLocalizations { /// **'Background'** String get background; + /// No description provided for @blurGradient. + /// + /// In en, this message translates to: + /// **'Blur gradient'** + String get blurGradient; + + /// No description provided for @colorGradient. + /// + /// In en, this message translates to: + /// **'Color gradient'** + String get colorGradient; + /// lib/features/settings/widgets/mini_player_customization.dart:197 /// /// In en, this message translates to: @@ -5741,6 +5753,12 @@ abstract class AppLocalizations { /// **'File Information'** String get fileInformation; + /// lib/services/metadata_editor_service.dart: user-visible service error + /// + /// In en, this message translates to: + /// **'This file is no longer available. It may have been moved or deleted. Rescan the library, then try again.'** + String get fileIsNoLongerAvailable; + /// lib/features/player/widgets/song_metadata_sheet.dart:82 /// /// In en, this message translates to: @@ -5915,6 +5933,12 @@ abstract class AppLocalizations { /// **'Flick logs'** String get flickLogs; + /// lib/services/metadata_editor_service.dart: user-visible service error + /// + /// In en, this message translates to: + /// **'Flick no longer has permission to edit this file. Re-add its folder in Settings to grant edit access, then try again.'** + String get flickNoLongerHasPermissionToEditThisFile; + /// lib/features/settings/screens/app_info_settings_screen.dart:444 /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 9bf4cd8f..41f581f6 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -860,6 +860,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get background => 'Background'; + @override + String get blurGradient => 'Blur gradient'; + + @override + String get colorGradient => 'Color gradient'; + @override String get backgroundOpacity => 'Background Opacity'; @@ -3345,6 +3351,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get fileInformation => 'File Information'; + @override + String get fileIsNoLongerAvailable => + 'This file is no longer available. It may have been moved or deleted. Rescan the library, then try again.'; + @override String get filePath => 'File Path'; @@ -3461,6 +3471,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get flickLogs => 'Flick logs'; + @override + String get flickNoLongerHasPermissionToEditThisFile => + 'Flick no longer has permission to edit this file. Re-add its folder in Settings to grant edit access, then try again.'; + @override String get flickPlayer => 'Flick Player'; diff --git a/lib/l10n/generated/app_localizations_tl.dart b/lib/l10n/generated/app_localizations_tl.dart index 60f0af8e..b1e7fd68 100644 --- a/lib/l10n/generated/app_localizations_tl.dart +++ b/lib/l10n/generated/app_localizations_tl.dart @@ -873,6 +873,12 @@ class AppLocalizationsTl extends AppLocalizations { @override String get background => 'Likuran'; + @override + String get blurGradient => 'Gradient ng blur'; + + @override + String get colorGradient => 'Gradient ng kulay'; + @override String get backgroundOpacity => 'Pagkalabo ng likuran'; @@ -3388,6 +3394,10 @@ class AppLocalizationsTl extends AppLocalizations { @override String get fileInformation => 'Impormasyon ng file'; + @override + String get fileIsNoLongerAvailable => + 'Hindi na available ang file na ito. Maaaring nailipat o natanggal ito. I-scan muli ang library at subukan ulit.'; + @override String get filePath => 'Path ng file'; @@ -3507,6 +3517,10 @@ class AppLocalizationsTl extends AppLocalizations { @override String get flickLogs => 'Mga log ng Flick'; + @override + String get flickNoLongerHasPermissionToEditThisFile => + 'Wala nang pahintulot ang Flick na i-edit ang file na ito. Alisin at idagdag muli ang folder nito sa Mga pagsasaayos upang payagang baguhin ito, saka subukan muli.'; + @override String get flickPlayer => 'Flick Player'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 5e63da33..43a3a6f7 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -792,6 +792,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get background => '่ƒŒๆ™ฏ'; + @override + String get blurGradient => 'ๆจก็ณŠๆธๅ˜'; + + @override + String get colorGradient => '่‰ฒๅฝฉๆธๅ˜'; + @override String get backgroundOpacity => '่ƒŒๆ™ฏไธ้€ๆ˜Žๅบฆ'; @@ -3116,6 +3122,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get fileInformation => 'ๆ–‡ไปถไฟกๆฏ'; + @override + String get fileIsNoLongerAvailable => 'ๆญคๆ–‡ไปถๅทฒไธๅฏ็”จ๏ผŒๅฏ่ƒฝๅทฒ่ขซ็งปๅŠจๆˆ–ๅˆ ้™คใ€‚่ฏท้‡ๆ–ฐๆ‰ซๆๅช’ไฝ“ๅบ“ๅŽ้‡่ฏ•ใ€‚'; + @override String get filePath => 'ๆ–‡ไปถ่ทฏๅพ„'; @@ -3217,6 +3226,10 @@ class AppLocalizationsZh extends AppLocalizations { @override String get flickLogs => 'Flick ๆ—ฅๅฟ—'; + @override + String get flickNoLongerHasPermissionToEditThisFile => + 'Flick ๅทฒๆฒกๆœ‰็ผ–่พ‘ๆญคๆ–‡ไปถ็š„ๆƒ้™ใ€‚่ฏทๅœจ่ฎพ็ฝฎไธญ็งป้™คๅนถ้‡ๆ–ฐๆทปๅŠ ่ฏฅๆ–‡ไปถๅคนไปฅๆŽˆไบˆ็ผ–่พ‘ๆƒ้™๏ผŒ็„ถๅŽ้‡่ฏ•ใ€‚'; + @override String get flickPlayer => 'Flick ้Ÿณไนๆ’ญๆ”พๅ™จ'; diff --git a/lib/l10n/model_labels.dart b/lib/l10n/model_labels.dart index d9628f45..8a312fb6 100644 --- a/lib/l10n/model_labels.dart +++ b/lib/l10n/model_labels.dart @@ -25,6 +25,7 @@ import 'package:flick/l10n/l10n.dart'; import 'package:flick/models/advance_list_order.dart'; import 'package:flick/models/album_color_mode.dart'; import 'package:flick/models/audio_engine_type.dart'; +import 'package:flick/models/immersive_background_style.dart'; import 'package:flick/models/nav_bar_config.dart'; import 'package:flick/models/playback_context.dart'; import 'package:flick/models/player_action_button.dart'; @@ -161,6 +162,16 @@ extension PlayerScreenModeL10n on PlayerScreenMode { }; } +// --------------------------------------------------------------------------- +// ๆฒ‰ๆตธๅผๆ’ญๆ”พๅ™จ่ƒŒๆ™ฏ +// --------------------------------------------------------------------------- +extension ImmersiveBackgroundStyleL10n on ImmersiveBackgroundStyle { + String labelFor(AppLocalizations l10n) => switch (this) { + ImmersiveBackgroundStyle.colorGradient => l10n.colorGradient, + ImmersiveBackgroundStyle.blurGradient => l10n.blurGradient, + }; +} + // --------------------------------------------------------------------------- // ่ฟ›ๅบฆๆกๆ ทๅผ // --------------------------------------------------------------------------- From cbb6a42a1cb2cbc4f44c43a2b778497be69ec16c Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Thu, 5 Nov 2026 18:03:57 +0800 Subject: [PATCH 08/14] Add configurable progress bar size settings Add a slider and preview in UI customization for waveform height and line thickness, with per-style reset to defaults. Seek bars scale their painted size while keeping a 48dp touch area. --- .../player/widgets/line_seek_bar.dart | 34 +++++--- .../player/widgets/waveform_layer.dart | 3 + .../player/widgets/waveform_seek_bar.dart | 14 +++- .../data/settings_search_catalog.dart | 5 +- .../ui_customization_settings_screen.dart | 79 ++++++++++++++++++- 5 files changed, 117 insertions(+), 18 deletions(-) diff --git a/lib/features/player/widgets/line_seek_bar.dart b/lib/features/player/widgets/line_seek_bar.dart index 18ded4d7..7f44360c 100644 --- a/lib/features/player/widgets/line_seek_bar.dart +++ b/lib/features/player/widgets/line_seek_bar.dart @@ -10,6 +10,10 @@ class LineSeekBar extends StatefulWidget { final ValueChanged? onChangeEnd; final double appearProgress; + /// Visible thickness of the progress line in logical pixels. The touch + /// area stays at least 48dp tall even when this is smaller. + final double thickness; + const LineSeekBar({ super.key, required this.position, @@ -17,6 +21,7 @@ class LineSeekBar extends StatefulWidget { required this.onChanged, this.onChangeEnd, this.appearProgress = 1.0, + this.thickness = 3, }); @override @@ -25,10 +30,12 @@ class LineSeekBar extends StatefulWidget { class _LineSeekBarState extends State with SingleTickerProviderStateMixin { - static const double _baseHeight = 40; - static const double _expandedHeight = 72; static const double _fineScrubZoom = 2.6; + double get _baseHeight => math.max(48.0, widget.thickness + 36.0); + double get _expandedHeight => math.max(72.0, widget.thickness + 36.0); + double get _finePaintHeight => math.max(10.0, widget.thickness); + bool _isDragging = false; bool _isFineScrubbing = false; Duration? _interactivePosition; @@ -184,15 +191,17 @@ class _LineSeekBarState extends State } double get _effectiveLineHeight { - if (_isFineScrubbing) return 10; - if (_isDragging) return 5; - return 3; + if (_isFineScrubbing) return math.max(10.0, widget.thickness); + if (_isDragging) return math.max(5.0, widget.thickness); + return widget.thickness; } + double get _restingDotRadius => math.max(3.5, widget.thickness / 2 + 2); + double get _effectiveDotRadius { - if (_isFineScrubbing) return 7; - if (_isDragging) return 5; - return 3.5; + if (_isFineScrubbing) return math.max(7.0, _restingDotRadius + 3.5); + if (_isDragging) return math.max(5.0, _restingDotRadius + 1.5); + return _restingDotRadius; } @override @@ -231,8 +240,13 @@ class _LineSeekBarState extends State : Colors.transparent, ), padding: _isFineScrubbing - ? EdgeInsets.symmetric(vertical: (_expandedHeight - 10) / 2) - : const EdgeInsets.only(top: 26, bottom: 10), + ? EdgeInsets.symmetric( + vertical: (_expandedHeight - _finePaintHeight) / 2, + ) + : EdgeInsets.only( + top: 26, + bottom: _baseHeight - 26 - _finePaintHeight, + ), child: RepaintBoundary( child: AnimatedBuilder( animation: _pulseAnimation, diff --git a/lib/features/player/widgets/waveform_layer.dart b/lib/features/player/widgets/waveform_layer.dart index 24ff080d..68d93328 100644 --- a/lib/features/player/widgets/waveform_layer.dart +++ b/lib/features/player/widgets/waveform_layer.dart @@ -86,6 +86,7 @@ class _WaveformLayerState extends State return Consumer( builder: (context, ref, _) { final style = ref.watch(progressBarStyleProvider); + final sizes = ref.watch(progressBarSizeProvider); return AnimatedBuilder( animation: _appearAnimation, builder: (context, _) { @@ -109,6 +110,7 @@ class _WaveformLayerState extends State position: position, duration: duration, appearProgress: t, + thickness: sizes.lineThickness, onChanged: (newPos) { widget.positionNotifier.value = newPos; unawaited(widget.playerService.seek(newPos)); @@ -119,6 +121,7 @@ class _WaveformLayerState extends State position: position, duration: duration, appearProgress: t, + height: sizes.waveformHeight, cachedPeaks: _cachedPeaks, onChanged: (newPos) { widget.positionNotifier.value = newPos; diff --git a/lib/features/player/widgets/waveform_seek_bar.dart b/lib/features/player/widgets/waveform_seek_bar.dart index f427c50e..7986c5fc 100644 --- a/lib/features/player/widgets/waveform_seek_bar.dart +++ b/lib/features/player/widgets/waveform_seek_bar.dart @@ -20,20 +20,26 @@ class WaveformSeekBar extends StatefulWidget { this.barCount = 60, this.appearProgress = 1.0, this.cachedPeaks, + this.height = 60, }); final int barCount; + /// Visible height of the waveform bars in logical pixels. The touch area + /// stays at least 48dp tall even when this is smaller. + final double height; + @override State createState() => _WaveformSeekBarState(); } class _WaveformSeekBarState extends State { - static const double _baseHeight = 60; - static const double _expandedHeight = 96; static const double _fineScrubZoom = 2.6; static const int _cachedSampleCount = 180; + double get _baseHeight => max(48.0, widget.height); + double get _expandedHeight => max(72.0, widget.height + 36.0); + // Cache the waveform data so it doesn't jitter on rebuilds late List _waveformData; bool _isFineScrubbing = false; @@ -245,7 +251,9 @@ class _WaveformSeekBarState extends State { ), padding: EdgeInsets.symmetric( horizontal: 2, - vertical: _isFineScrubbing ? 6 : 0, + vertical: _isFineScrubbing + ? 6 + : (_baseHeight - widget.height) / 2, ), child: RepaintBoundary( child: CustomPaint( diff --git a/lib/features/settings/data/settings_search_catalog.dart b/lib/features/settings/data/settings_search_catalog.dart index 5e1b15b1..0002b099 100644 --- a/lib/features/settings/data/settings_search_catalog.dart +++ b/lib/features/settings/data/settings_search_catalog.dart @@ -390,7 +390,10 @@ List buildSettingsSearchCatalog() { _section('progressBar', l10n.progressBar, [ ('waveform', l10n.waveform), ('line', l10n.line), - ], keywords: 'seekbar'), + ('waveformHeight', l10n.waveformHeight), + ('lineThickness', l10n.lineThickness), + ('resetToDefaults', l10n.resetToDefaults), + ], keywords: 'seekbar height thickness size'), _section('albumColors', l10n.albumColors, [ ('off', l10n.off), ('subtle', l10n.subtle), diff --git a/lib/features/settings/screens/ui_customization_settings_screen.dart b/lib/features/settings/screens/ui_customization_settings_screen.dart index 8e055fd9..35ca7a29 100644 --- a/lib/features/settings/screens/ui_customization_settings_screen.dart +++ b/lib/features/settings/screens/ui_customization_settings_screen.dart @@ -3,9 +3,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flick/core/constants/app_constants.dart'; import 'package:flick/models/album_color_mode.dart'; +import 'package:flick/models/progress_bar_size.dart'; import 'package:flick/models/progress_bar_style.dart'; import 'package:flick/models/song_tile_thumbnail_mode.dart'; import 'package:flick/providers/providers.dart'; +import 'package:flick/features/settings/widgets/progress_bar_preview.dart'; import 'package:flick/features/settings/widgets/settings_widgets.dart'; import 'package:flick/l10n/settings_l10n.dart'; @@ -17,6 +19,22 @@ class UiCustomizationSettingsScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final appPreferences = ref.watch(appPreferencesProvider); + final progressBarStyle = ref.watch(progressBarStyleProvider); + final progressBarSizes = ref.watch(progressBarSizeProvider); + final isWaveform = progressBarStyle == ProgressBarStyle.waveform; + final progressBarSize = isWaveform + ? progressBarSizes.waveformHeight + : progressBarSizes.lineThickness; + final progressBarMin = isWaveform + ? ProgressBarSizes.minWaveformHeight + : ProgressBarSizes.minLineThickness; + final progressBarMax = isWaveform + ? ProgressBarSizes.maxWaveformHeight + : ProgressBarSizes.maxLineThickness; + final progressBarDivisions = isWaveform + ? ((progressBarMax - progressBarMin) / ProgressBarSizes.waveformHeightStep) + .round() + : (progressBarMax - progressBarMin).round(); return SettingsScaffold( title: l10n.englishForFilipino.uiCustomization, @@ -241,8 +259,7 @@ class UiCustomizationSettingsScreen extends ConsumerWidget { icon: LucideIcons.audioWaveform, title: ProgressBarStyle.waveform.labelFor(l10n.englishForFilipino), subtitle: ProgressBarStyle.waveform.description, - selected: ref.watch(progressBarStyleProvider) == - ProgressBarStyle.waveform, + selected: progressBarStyle == ProgressBarStyle.waveform, onTap: () { ref .read(progressBarStyleProvider.notifier) @@ -254,8 +271,7 @@ class UiCustomizationSettingsScreen extends ConsumerWidget { icon: LucideIcons.minus, title: ProgressBarStyle.line.labelFor(l10n.englishForFilipino), subtitle: ProgressBarStyle.line.description, - selected: ref.watch(progressBarStyleProvider) == - ProgressBarStyle.line, + selected: progressBarStyle == ProgressBarStyle.line, onTap: () { ref .read(progressBarStyleProvider.notifier) @@ -264,6 +280,61 @@ class UiCustomizationSettingsScreen extends ConsumerWidget { ), ], ), + const SizedBox(height: AppConstants.spacingMd), + SettingsCard( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppConstants.spacingLg, + vertical: AppConstants.spacingMd, + ), + child: const ProgressBarPreview(), + ), + ], + ), + const SizedBox(height: AppConstants.spacingMd), + SettingsCard( + children: [ + SliderSetting( + icon: isWaveform ? LucideIcons.audioWaveform : LucideIcons.minus, + title: isWaveform + ? l10n.englishForFilipino.waveformHeight + : l10n.englishForFilipino.lineThickness, + subtitle: isWaveform + ? l10n.englishForFilipino.waveformHeightDescription + : l10n.englishForFilipino.lineThicknessDescription, + value: progressBarSize, + displayValue: l10n.englishForFilipino.dp( + progressBarSize.round(), + ), + min: progressBarMin, + max: progressBarMax, + divisions: progressBarDivisions, + onChanged: (value) { + final notifier = ref.read(progressBarSizeProvider.notifier); + if (isWaveform) { + notifier.setWaveformHeight(value); + } else { + notifier.setLineThickness(value); + } + }, + ), + const SettingsDivider(), + ActionButton( + icon: LucideIcons.rotateCcw, + title: l10n.englishForFilipino.resetToDefaults, + subtitle: l10n.englishForFilipino.restoreDefaultProgressBarSize, + onTap: () { + final notifier = ref.read(progressBarSizeProvider.notifier); + if (isWaveform) { + notifier.resetWaveformHeight(); + } else { + notifier.resetLineThickness(); + } + }, + ), + ], + ), const SizedBox(height: AppConstants.spacingLg), SettingsSectionHeader(l10n.englishForFilipino.albumColors, sectionId: 'albumColors'), SettingsCard( From 5954bce9c438e234167945782797cfd6823b00a7 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Thu, 5 Nov 2026 18:04:07 +0800 Subject: [PATCH 09/14] Add progress bar size preference and preview Persist waveform height and line thickness independently, clamping saved values to supported ranges. Queue rapid edits so the final state is written, and show a non-interactive preview at the current size. --- .../widgets/progress_bar_preview.dart | 41 ++++++++ lib/models/progress_bar_size.dart | 53 ++++++++++ lib/providers/progress_bar_size_provider.dart | 77 +++++++++++++++ .../progress_bar_size_preference_service.dart | 34 +++++++ .../progress_bar_size_provider_test.dart | 99 +++++++++++++++++++ test/widgets/progress_bar_size_test.dart | 80 +++++++++++++++ 6 files changed, 384 insertions(+) create mode 100644 lib/features/settings/widgets/progress_bar_preview.dart create mode 100644 lib/models/progress_bar_size.dart create mode 100644 lib/providers/progress_bar_size_provider.dart create mode 100644 lib/services/progress_bar_size_preference_service.dart create mode 100644 test/providers/progress_bar_size_provider_test.dart create mode 100644 test/widgets/progress_bar_size_test.dart diff --git a/lib/features/settings/widgets/progress_bar_preview.dart b/lib/features/settings/widgets/progress_bar_preview.dart new file mode 100644 index 00000000..ccb3a97c --- /dev/null +++ b/lib/features/settings/widgets/progress_bar_preview.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:flick/features/player/widgets/line_seek_bar.dart'; +import 'package:flick/features/player/widgets/waveform_seek_bar.dart'; +import 'package:flick/models/progress_bar_style.dart'; +import 'package:flick/providers/providers.dart'; + +/// Non-interactive preview of the player progress bar at its current size. +class ProgressBarPreview extends ConsumerWidget { + const ProgressBarPreview({super.key}); + + static const _position = Duration(minutes: 1, seconds: 23); + static const _duration = Duration(minutes: 3, seconds: 20); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final style = ref.watch(progressBarStyleProvider); + final sizes = ref.watch(progressBarSizeProvider); + + return ExcludeSemantics( + child: IgnorePointer( + child: switch (style) { + ProgressBarStyle.line => LineSeekBar( + position: _position, + duration: _duration, + onChanged: (_) {}, + thickness: sizes.lineThickness, + ), + ProgressBarStyle.waveform => WaveformSeekBar( + barCount: 60, + position: _position, + duration: _duration, + onChanged: (_) {}, + height: sizes.waveformHeight, + ), + }, + ), + ); + } +} diff --git a/lib/models/progress_bar_size.dart b/lib/models/progress_bar_size.dart new file mode 100644 index 00000000..27f820d1 --- /dev/null +++ b/lib/models/progress_bar_size.dart @@ -0,0 +1,53 @@ +/// User-selectable visible sizes for the player progress bar. +class ProgressBarSizes { + static const double defaultWaveformHeight = 60; + static const double minWaveformHeight = 24; + static const double maxWaveformHeight = 96; + static const double waveformHeightStep = 4; + + static const double defaultLineThickness = 3; + static const double minLineThickness = 1; + static const double maxLineThickness = 12; + + /// The seekbars keep this much vertical room for touch even when the + /// visible waveform bars or line stroke are smaller. + static const double minimumTouchTarget = 48; + + static const ProgressBarSizes defaults = ProgressBarSizes( + waveformHeight: defaultWaveformHeight, + lineThickness: defaultLineThickness, + ); + + const ProgressBarSizes({ + required this.waveformHeight, + required this.lineThickness, + }); + + final double waveformHeight; + final double lineThickness; + + static double clampWaveformHeight(double value) { + return value.clamp(minWaveformHeight, maxWaveformHeight).toDouble(); + } + + static double clampLineThickness(double value) { + return value.clamp(minLineThickness, maxLineThickness).toDouble(); + } + + ProgressBarSizes copyWith({double? waveformHeight, double? lineThickness}) { + return ProgressBarSizes( + waveformHeight: waveformHeight ?? this.waveformHeight, + lineThickness: lineThickness ?? this.lineThickness, + ); + } + + @override + bool operator ==(Object other) { + return other is ProgressBarSizes && + other.waveformHeight == waveformHeight && + other.lineThickness == lineThickness; + } + + @override + int get hashCode => Object.hash(waveformHeight, lineThickness); +} diff --git a/lib/providers/progress_bar_size_provider.dart b/lib/providers/progress_bar_size_provider.dart new file mode 100644 index 00000000..f839082f --- /dev/null +++ b/lib/providers/progress_bar_size_provider.dart @@ -0,0 +1,77 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:flick/models/progress_bar_size.dart'; +import 'package:flick/services/progress_bar_size_preference_service.dart'; + +final progressBarSizePreferenceServiceProvider = + Provider((ref) { + return ProgressBarSizePreferenceService(); + }); + +class ProgressBarSizeNotifier extends Notifier { + late Future _ready; + Future _writes = Future.value(); + bool _loaded = false; + final _pending = []; + + @override + ProgressBarSizes build() { + _loaded = false; + _pending.clear(); + _ready = Future.microtask(_loadFromPreferences); + return ProgressBarSizes.defaults; + } + + Future _loadFromPreferences() async { + var sizes = await ref + .read(progressBarSizePreferenceServiceProvider) + .getSizes(); + if (!ref.mounted) return; + for (final change in _pending) { + sizes = change(sizes); + } + _pending.clear(); + _loaded = true; + state = sizes; + } + + Future setWaveformHeight(double height) { + final clamped = ProgressBarSizes.clampWaveformHeight(height); + return _update((sizes) => sizes.copyWith(waveformHeight: clamped)); + } + + Future setLineThickness(double thickness) { + final clamped = ProgressBarSizes.clampLineThickness(thickness); + return _update((sizes) => sizes.copyWith(lineThickness: clamped)); + } + + Future resetWaveformHeight() { + return setWaveformHeight(ProgressBarSizes.defaultWaveformHeight); + } + + Future resetLineThickness() { + return setLineThickness(ProgressBarSizes.defaultLineThickness); + } + + Future _update( + ProgressBarSizes Function(ProgressBarSizes) change, + ) async { + if (!_loaded) _pending.add(change); + state = change(state); + await _ready; + if (!ref.mounted) return; + + final snapshot = state; + final write = _writes.then( + (_) => + ref.read(progressBarSizePreferenceServiceProvider).setSizes(snapshot), + ); + _writes = write.catchError((Object _) {}); + await write; + } +} + +final progressBarSizeProvider = + NotifierProvider( + ProgressBarSizeNotifier.new, + ); diff --git a/lib/services/progress_bar_size_preference_service.dart b/lib/services/progress_bar_size_preference_service.dart new file mode 100644 index 00000000..3e43a812 --- /dev/null +++ b/lib/services/progress_bar_size_preference_service.dart @@ -0,0 +1,34 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:flick/models/progress_bar_size.dart'; + +class ProgressBarSizePreferenceService { + static const waveformHeightKey = 'progress_bar_waveform_height'; + static const lineThicknessKey = 'progress_bar_line_thickness'; + + Future getSizes() async { + final prefs = await SharedPreferences.getInstance(); + return ProgressBarSizes( + waveformHeight: ProgressBarSizes.clampWaveformHeight( + prefs.getDouble(waveformHeightKey) ?? + ProgressBarSizes.defaultWaveformHeight, + ), + lineThickness: ProgressBarSizes.clampLineThickness( + prefs.getDouble(lineThicknessKey) ?? + ProgressBarSizes.defaultLineThickness, + ), + ); + } + + Future setSizes(ProgressBarSizes sizes) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble( + waveformHeightKey, + ProgressBarSizes.clampWaveformHeight(sizes.waveformHeight), + ); + await prefs.setDouble( + lineThicknessKey, + ProgressBarSizes.clampLineThickness(sizes.lineThickness), + ); + } +} diff --git a/test/providers/progress_bar_size_provider_test.dart b/test/providers/progress_bar_size_provider_test.dart new file mode 100644 index 00000000..ae9a304c --- /dev/null +++ b/test/providers/progress_bar_size_provider_test.dart @@ -0,0 +1,99 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:flick/models/progress_bar_size.dart'; +import 'package:flick/providers/progress_bar_size_provider.dart'; +import 'package:flick/services/progress_bar_size_preference_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('defaults are used when nothing is saved', () async { + SharedPreferences.setMockInitialValues({}); + final container = ProviderContainer(); + addTearDown(container.dispose); + + container.read(progressBarSizeProvider); + await pumpEventQueue(); + + expect(container.read(progressBarSizeProvider), ProgressBarSizes.defaults); + }); + + test('waveform height and line thickness persist independently', () async { + SharedPreferences.setMockInitialValues({}); + final container = ProviderContainer(); + final notifier = container.read(progressBarSizeProvider.notifier); + + await notifier.setWaveformHeight(80); + await notifier.setLineThickness(10); + container.dispose(); + + final reloaded = ProviderContainer(); + addTearDown(reloaded.dispose); + reloaded.read(progressBarSizeProvider); + await pumpEventQueue(); + + final sizes = reloaded.read(progressBarSizeProvider); + expect(sizes.waveformHeight, 80); + expect(sizes.lineThickness, 10); + }); + + test('reset only restores the selected style', () async { + SharedPreferences.setMockInitialValues({ + ProgressBarSizePreferenceService.waveformHeightKey: 80.0, + ProgressBarSizePreferenceService.lineThicknessKey: 10.0, + }); + final container = ProviderContainer(); + addTearDown(container.dispose); + + container.read(progressBarSizeProvider); + await pumpEventQueue(); + await container + .read(progressBarSizeProvider.notifier) + .resetWaveformHeight(); + + final sizes = container.read(progressBarSizeProvider); + expect(sizes.waveformHeight, ProgressBarSizes.defaultWaveformHeight); + expect(sizes.lineThickness, 10); + }); + + test('saved values outside the supported range are clamped', () async { + SharedPreferences.setMockInitialValues({ + ProgressBarSizePreferenceService.waveformHeightKey: 500.0, + ProgressBarSizePreferenceService.lineThicknessKey: -3.0, + }); + final container = ProviderContainer(); + addTearDown(container.dispose); + + container.read(progressBarSizeProvider); + await pumpEventQueue(); + + final sizes = container.read(progressBarSizeProvider); + expect(sizes.waveformHeight, ProgressBarSizes.maxWaveformHeight); + expect(sizes.lineThickness, ProgressBarSizes.minLineThickness); + }); + + test('rapid edits persist the final state', () async { + SharedPreferences.setMockInitialValues({}); + final container = ProviderContainer(); + addTearDown(container.dispose); + final notifier = container.read(progressBarSizeProvider.notifier); + + await Future.wait([ + notifier.setWaveformHeight(32), + notifier.setWaveformHeight(48), + notifier.setLineThickness(7), + notifier.setLineThickness(9), + ]); + + final prefs = await SharedPreferences.getInstance(); + expect( + prefs.getDouble(ProgressBarSizePreferenceService.waveformHeightKey), + 48, + ); + expect( + prefs.getDouble(ProgressBarSizePreferenceService.lineThicknessKey), + 9, + ); + }); +} diff --git a/test/widgets/progress_bar_size_test.dart b/test/widgets/progress_bar_size_test.dart new file mode 100644 index 00000000..21613d57 --- /dev/null +++ b/test/widgets/progress_bar_size_test.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flick/features/player/widgets/line_seek_bar.dart'; +import 'package:flick/features/player/widgets/waveform_seek_bar.dart'; +import 'package:flick/models/progress_bar_size.dart'; + +void main() { + const position = Duration(minutes: 1); + const duration = Duration(minutes: 3); + + Future pumpBar(WidgetTester tester, Widget bar) { + return tester.pumpWidget( + MaterialApp( + theme: ThemeData.dark(), + home: Scaffold( + body: Center(child: SizedBox(width: 320, child: bar)), + ), + ), + ); + } + + for (final thickness in [ + ProgressBarSizes.minLineThickness, + ProgressBarSizes.maxLineThickness, + ]) { + testWidgets('line style keeps a 48dp touch target at ${thickness}dp', ( + tester, + ) async { + await pumpBar( + tester, + LineSeekBar( + position: position, + duration: duration, + onChanged: (_) {}, + thickness: thickness, + ), + ); + + expect(tester.getSize(find.byType(LineSeekBar)).height, 48); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('waveform height below the touch target stays tappable', ( + tester, + ) async { + await pumpBar( + tester, + WaveformSeekBar( + position: position, + duration: duration, + onChanged: (_) {}, + height: ProgressBarSizes.minWaveformHeight, + ), + ); + + expect(tester.getSize(find.byType(WaveformSeekBar)).height, 48); + expect(tester.takeException(), isNull); + }); + + testWidgets('waveform uses the selected height above the touch target', ( + tester, + ) async { + await pumpBar( + tester, + WaveformSeekBar( + position: position, + duration: duration, + onChanged: (_) {}, + height: ProgressBarSizes.maxWaveformHeight, + ), + ); + + expect( + tester.getSize(find.byType(WaveformSeekBar)).height, + ProgressBarSizes.maxWaveformHeight, + ); + expect(tester.takeException(), isNull); + }); +} From 05de87c4f5585b9904ea1b4a43c7461307583079 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Thu, 5 Nov 2026 21:58:58 +0800 Subject: [PATCH 10/14] Allow landscape orientation Remove forced portrait lock and add progress bar size localization strings. --- android/app/src/main/AndroidManifest.xml | 1 - .../kotlin/com/mossapps/flick/MainActivity.kt | 2 -- lib/l10n/app_en.arb | 20 +++++++++++++ lib/l10n/app_tl.arb | 5 ++++ lib/l10n/app_zh.arb | 5 ++++ lib/l10n/generated/app_localizations.dart | 30 +++++++++++++++++++ lib/l10n/generated/app_localizations_en.dart | 18 +++++++++++ lib/l10n/generated/app_localizations_tl.dart | 18 +++++++++++ lib/l10n/generated/app_localizations_zh.dart | 15 ++++++++++ lib/providers/providers.dart | 1 + 10 files changed, 112 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d90da09e..abcad912 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -44,7 +44,6 @@ android:launchMode="singleTop" android:taskAffinity="" android:theme="@style/LaunchTheme" - android:screenOrientation="portrait" android:resizeableActivity="true" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" diff --git a/android/app/src/main/kotlin/com/mossapps/flick/MainActivity.kt b/android/app/src/main/kotlin/com/mossapps/flick/MainActivity.kt index ed4291ff..a8b69720 100644 --- a/android/app/src/main/kotlin/com/mossapps/flick/MainActivity.kt +++ b/android/app/src/main/kotlin/com/mossapps/flick/MainActivity.kt @@ -36,7 +36,6 @@ import android.content.ContentValues import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.content.pm.ActivityInfo import android.content.pm.PackageManager import android.hardware.usb.UsbConstants import android.hardware.usb.UsbDevice @@ -186,7 +185,6 @@ class MainActivity: FlutterActivity() { } override fun onCreate(savedInstanceState: Bundle?) { - requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT super.onCreate(savedInstanceState) if (!nativeInitRustAndroidContext(applicationContext)) { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 62e40df8..b2b580a1 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -5806,6 +5806,14 @@ } } }, + "lineThickness": "Line Thickness", + "@lineThickness": { + "description": "lib/features/settings/screens/ui_customization_settings_screen.dart" + }, + "lineThicknessDescription": "Adjust how thick the progress line appears.", + "@lineThicknessDescription": { + "description": "lib/features/settings/screens/ui_customization_settings_screen.dart" + }, "linear": "Linear", "@linear": { "description": "lib/features/player/widgets/bit_perfect_indicator.dart:919" @@ -8348,6 +8356,10 @@ "@restartingFlick": { "description": "lib/features/menu/screens/restarting_screen.dart:126" }, + "restoreDefaultProgressBarSize": "Restore the selected progress bar style to its default size.", + "@restoreDefaultProgressBarSize": { + "description": "lib/features/settings/screens/ui_customization_settings_screen.dart" + }, "restoreTheOriginalOrbitalLayout": "Restore the original orbital layout", "@restoreTheOriginalOrbitalLayout": { "description": "lib/features/settings/screens/orbit_settings_screen.dart:200" @@ -11489,6 +11501,14 @@ "@waveform": { "description": "lib/models/progress_bar_style.dart:16" }, + "waveformHeight": "Waveform Height", + "@waveformHeight": { + "description": "lib/features/settings/screens/ui_customization_settings_screen.dart" + }, + "waveformHeightDescription": "Adjust how tall the waveform bars appear.", + "@waveformHeightDescription": { + "description": "lib/features/settings/screens/ui_customization_settings_screen.dart" + }, "waveformSeekbar": "Waveform seekbar", "@waveformSeekbar": { "description": "lib/features/manual/data/manual_data.dart" diff --git a/lib/l10n/app_tl.arb b/lib/l10n/app_tl.arb index 9b21917a..cd17fdf5 100644 --- a/lib/l10n/app_tl.arb +++ b/lib/l10n/app_tl.arb @@ -1097,6 +1097,8 @@ "line": "Linya", "lineOf": "Linya {arg1} sa {arg2}", "lineOfWords": "Linya {arg1} sa {arg2} ยท {captured}/{segmentCount} salita", + "lineThickness": "Kapal ng Linya", + "lineThicknessDescription": "Ayusin ang kapal ng linya ng progreso.", "linear": "Linear", "lines": "Mga linya", "lines2": "{arg1} linya", @@ -1641,6 +1643,7 @@ "restartTheAppToApplyPlayback": "Simulan muli ang app upang mailapat ang mga pagbabago sa pagtugtog.", "restartYourDeviceToApplyOutput": "Simulan muli ang aparato upang mailapat ang mga pagbabago sa format ng output.", "restartingFlick": "Muling sinisimulan ang Flick", + "restoreDefaultProgressBarSize": "Ibalik sa default na laki ang napiling istilo ng progress bar.", "restoreTheOriginalOrbitalLayout": "Ibalik ang orihinal na ayos ng Orbital", "results": "Mga resulta ({arg1})", "resumeAFamiliarFavoriteOrShuffle": "Balikan ang pamilyar na paborito o ihalo ang isa sa mga halong binuo mula sa iyong pakikinig.", @@ -2318,6 +2321,8 @@ "was": "dati: {arg1}", "wave": "Alon", "waveform": "Waveform", + "waveformHeight": "Taas ng Waveform", + "waveformHeightDescription": "Ayusin ang taas ng mga bar ng waveform.", "weLlKeepTheBestQuality": "Ititira namin ang pinakamagandang bersiyon sa bawat pangkat (inuuna ang larawan ng album, saka ang pinakamataas na bitrate) at aalisin ang {count, plural, =1{{count} kopya} other{{count} kopya}}. Hindi mabubura ang mga file sa imbakan.", "weLlKeepYourCheckedVersion": "Ititira namin ang {kept, plural, =1{{kept} bersiyong nilagyan mo ng tsek} other{{kept} bersiyong nilagyan mo ng tsek}} at aalisin ang {removed, plural, =1{{removed} iba pang kopya} other{{removed} iba pang kopya}} sa iyong aklatan. Hindi mabubura ang mga audio file sa imbakan.", "webdav": "WebDAV", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index a1054942..706ffb76 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1249,6 +1249,8 @@ "line": "็บฟๆก", "lineOf": "็ฌฌ {arg1} ่กŒ / ๅ…ฑ {arg2} ่กŒ", "lineOfWords": "็ฌฌ {arg1} ่กŒ / ๅ…ฑ {arg2} ่กŒ ยท {captured}/{segmentCount} ไธช่ฏ", + "lineThickness": "็บฟๆก็ฒ—็ป†", + "lineThicknessDescription": "่ฐƒๆ•ด่ฟ›ๅบฆ็บฟๆก็š„็ฒ—็ป†ใ€‚", "linear": "็บฟๆ€ง", "lines": "ๆญŒ่ฏ่กŒ", "lines2": "{arg1} ่กŒ", @@ -1792,6 +1794,7 @@ "restartTheAppToApplyPlayback": "้‡ๅฏๅบ”็”จไปฅๅบ”็”จๆ’ญๆ”พ่ฎพ็ฝฎๆ›ดๆ”นใ€‚", "restartYourDeviceToApplyOutput": "้‡ๅฏ่ฎพๅค‡ไปฅๅบ”็”จ่พ“ๅ‡บๆ ผๅผๆ›ดๆ”นใ€‚", "restartingFlick": "ๆญฃๅœจ้‡ๅฏ Flick", + "restoreDefaultProgressBarSize": "ๅฐ†ๆ‰€้€‰่ฟ›ๅบฆๆกๆ ทๅผๆขๅคไธบ้ป˜่ฎคๅคงๅฐใ€‚", "restoreTheOriginalOrbitalLayout": "ๆขๅคๅŽŸๅง‹่ฝจ้“ๅธƒๅฑ€", "results": "็ป“ๆžœ๏ผˆ{arg1}๏ผ‰", "resumeAFamiliarFavoriteOrShuffle": "็ปง็ปญๆ’ญๆ”พ็†Ÿๆ‚‰็š„ๆ”ถ่—๏ผŒๆˆ–้šๆœบๆ’ญๆ”พ็”ฑไฝ ็š„ๆ”ถๅฌ็”Ÿๆˆ็š„ๆทท้Ÿณใ€‚", @@ -2467,6 +2470,8 @@ "was": "ๅŽŸไธบ๏ผš{arg1}", "wave": "ๆณขๅฝข", "waveform": "ๆณขๅฝข", + "waveformHeight": "ๆณขๅฝข้ซ˜ๅบฆ", + "waveformHeightDescription": "่ฐƒๆ•ดๆณขๅฝขๆŸฑ็š„้ซ˜ๅบฆใ€‚", "waveformSeekbar": "ๆณขๅฝข่ฟ›ๅบฆๆก", "weLlKeepTheBestQuality": "ๆฏ็ป„ไฟ็•™่ดจ้‡ๆœ€ๅฅฝ็š„็‰ˆๆœฌ๏ผˆไผ˜ๅ…ˆๆœ‰ไธ“่พ‘ๅฐ้ข๏ผŒๅ…ถๆฌก็ ็އๆœ€้ซ˜๏ผ‰๏ผŒๅนถ็งป้™ค {count} ไธชใ€‚็ฃ็›˜ไธŠ็š„ๆ–‡ไปถไธไผš่ขซๅˆ ้™คใ€‚", "weLlKeepYourCheckedVersion": "ๅฐ†ไฟ็•™ไฝ ๅ‹พ้€‰็š„ {kept} ไธช็‰ˆๆœฌ๏ผŒๅนถไปŽ้Ÿณไนๅบ“ไธญ็งป้™คๅฆๅค– {removed} ไธชใ€‚็ฃ็›˜ไธŠ็š„้Ÿณ้ข‘ๆ–‡ไปถไธไผš่ขซๅˆ ้™คใ€‚", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 93b252f3..98d1a9a2 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -7552,6 +7552,18 @@ abstract class AppLocalizations { Object segmentCount, ); + /// lib/features/settings/screens/ui_customization_settings_screen.dart + /// + /// In en, this message translates to: + /// **'Line Thickness'** + String get lineThickness; + + /// lib/features/settings/screens/ui_customization_settings_screen.dart + /// + /// In en, this message translates to: + /// **'Adjust how thick the progress line appears.'** + String get lineThicknessDescription; + /// lib/features/player/widgets/bit_perfect_indicator.dart:919 /// /// In en, this message translates to: @@ -10804,6 +10816,12 @@ abstract class AppLocalizations { /// **'Restarting Flick'** String get restartingFlick; + /// lib/features/settings/screens/ui_customization_settings_screen.dart + /// + /// In en, this message translates to: + /// **'Restore the selected progress bar style to its default size.'** + String get restoreDefaultProgressBarSize; + /// lib/features/settings/screens/orbit_settings_screen.dart:200 /// /// In en, this message translates to: @@ -14858,6 +14876,18 @@ abstract class AppLocalizations { /// **'Waveform'** String get waveform; + /// lib/features/settings/screens/ui_customization_settings_screen.dart + /// + /// In en, this message translates to: + /// **'Waveform Height'** + String get waveformHeight; + + /// lib/features/settings/screens/ui_customization_settings_screen.dart + /// + /// In en, this message translates to: + /// **'Adjust how tall the waveform bars appear.'** + String get waveformHeightDescription; + /// lib/features/manual/data/manual_data.dart /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 41f581f6..e38e5aeb 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -4433,6 +4433,13 @@ class AppLocalizationsEn extends AppLocalizations { return 'Line $arg1 of $arg2 ยท $captured/$segmentCount words'; } + @override + String get lineThickness => 'Line Thickness'; + + @override + String get lineThicknessDescription => + 'Adjust how thick the progress line appears.'; + @override String get linear => 'Linear'; @@ -6330,6 +6337,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get restartingFlick => 'Restarting Flick'; + @override + String get restoreDefaultProgressBarSize => + 'Restore the selected progress bar style to its default size.'; + @override String get restoreTheOriginalOrbitalLayout => 'Restore the original orbital layout'; @@ -8786,6 +8797,13 @@ class AppLocalizationsEn extends AppLocalizations { @override String get waveform => 'Waveform'; + @override + String get waveformHeight => 'Waveform Height'; + + @override + String get waveformHeightDescription => + 'Adjust how tall the waveform bars appear.'; + @override String get waveformSeekbar => 'Waveform seekbar'; diff --git a/lib/l10n/generated/app_localizations_tl.dart b/lib/l10n/generated/app_localizations_tl.dart index b1e7fd68..aa9bd60a 100644 --- a/lib/l10n/generated/app_localizations_tl.dart +++ b/lib/l10n/generated/app_localizations_tl.dart @@ -4485,6 +4485,13 @@ class AppLocalizationsTl extends AppLocalizations { return 'Linya $arg1 sa $arg2 ยท $captured/$segmentCount salita'; } + @override + String get lineThickness => 'Kapal ng Linya'; + + @override + String get lineThicknessDescription => + 'Ayusin ang kapal ng linya ng progreso.'; + @override String get linear => 'Linear'; @@ -6400,6 +6407,10 @@ class AppLocalizationsTl extends AppLocalizations { @override String get restartingFlick => 'Muling sinisimulan ang Flick'; + @override + String get restoreDefaultProgressBarSize => + 'Ibalik sa default na laki ang napiling istilo ng progress bar.'; + @override String get restoreTheOriginalOrbitalLayout => 'Ibalik ang orihinal na ayos ng Orbital'; @@ -8887,6 +8898,13 @@ class AppLocalizationsTl extends AppLocalizations { @override String get waveform => 'Waveform'; + @override + String get waveformHeight => 'Taas ng Waveform'; + + @override + String get waveformHeightDescription => + 'Ayusin ang taas ng mga bar ng waveform.'; + @override String get waveformSeekbar => 'Waveform seekbar'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 43a3a6f7..e2d85c92 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -4119,6 +4119,12 @@ class AppLocalizationsZh extends AppLocalizations { return '็ฌฌ $arg1 ่กŒ / ๅ…ฑ $arg2 ่กŒ ยท $captured/$segmentCount ไธช่ฏ'; } + @override + String get lineThickness => '็บฟๆก็ฒ—็ป†'; + + @override + String get lineThicknessDescription => '่ฐƒๆ•ด่ฟ›ๅบฆ็บฟๆก็š„็ฒ—็ป†ใ€‚'; + @override String get linear => '็บฟๆ€ง'; @@ -5901,6 +5907,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get restartingFlick => 'ๆญฃๅœจ้‡ๅฏ Flick'; + @override + String get restoreDefaultProgressBarSize => 'ๅฐ†ๆ‰€้€‰่ฟ›ๅบฆๆกๆ ทๅผๆขๅคไธบ้ป˜่ฎคๅคงๅฐใ€‚'; + @override String get restoreTheOriginalOrbitalLayout => 'ๆขๅคๅŽŸๅง‹่ฝจ้“ๅธƒๅฑ€'; @@ -8143,6 +8152,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get waveform => 'ๆณขๅฝข'; + @override + String get waveformHeight => 'ๆณขๅฝข้ซ˜ๅบฆ'; + + @override + String get waveformHeightDescription => '่ฐƒๆ•ดๆณขๅฝขๆŸฑ็š„้ซ˜ๅบฆใ€‚'; + @override String get waveformSeekbar => 'ๆณขๅฝข่ฟ›ๅบฆๆก'; diff --git a/lib/providers/providers.dart b/lib/providers/providers.dart index 146228f6..92a4b77c 100644 --- a/lib/providers/providers.dart +++ b/lib/providers/providers.dart @@ -20,6 +20,7 @@ export 'duplicate_cleaner_provider.dart'; export 'onboarding_provider.dart'; export 'app_preferences_provider.dart'; export 'progress_bar_style_provider.dart'; +export 'progress_bar_size_provider.dart'; export 'album_color_provider.dart'; export 'nav_bar_config_provider.dart'; export 'player_screen_mode_provider.dart'; From 3152ad0ccdd1885baf61eab66881cfe26c788d06 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Thu, 5 Nov 2026 21:59:12 +0800 Subject: [PATCH 11/14] Allow landscape orientation with adaptive layout Add a side navigation rail and compact side mini player for wide/landscape layouts. Unlock the bottom sheet width cap so sheets use the full window on tablets and landscape, and ignore horizontal scroll notifications when toggling the nav bar. --- lib/app/app.dart | 547 +++++++++++++++++++++------------- lib/core/theme/app_theme.dart | 6 + 2 files changed, 342 insertions(+), 211 deletions(-) diff --git a/lib/app/app.dart b/lib/app/app.dart index 49466d9f..74866a1a 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math' as math; import 'package:flick/widgets/common/flick_snackbar.dart'; import 'package:flick/widgets/common/flick_dialog.dart'; @@ -23,6 +24,7 @@ import 'package:flick/features/favorites/screens/favorites_screen.dart'; import 'package:flick/features/search/screens/search_screen.dart'; import 'package:flick/core/navigation/nav_bar_visibility_observer.dart'; import 'package:flick/core/navigation/root_navigator.dart'; +import 'package:flick/core/utils/adaptive_layout.dart'; import 'package:flick/core/utils/navigation_helper.dart'; import 'package:flick/core/utils/app_haptics.dart'; import 'package:flick/core/utils/app_log.dart'; @@ -35,6 +37,7 @@ import 'package:flick/features/onboarding/screens/onboarding_screen.dart'; import 'package:flick/features/onboarding/tutorial_targets.dart'; import 'package:flick/features/onboarding/widgets/tutorial_overlay.dart'; import 'package:flick/widgets/common/embedded_mini_player.dart'; +import 'package:flick/widgets/common/landscape_side_mini_player.dart'; import 'package:flick/widgets/navigation/bottom_bar_geometry.dart'; import 'package:flick/providers/mini_player_config_provider.dart'; import 'package:flick/widgets/common/floating_mini_player.dart'; @@ -50,6 +53,7 @@ import 'package:flick/services/milestone_service.dart'; import 'package:flick/services/widget_sync_service.dart'; import 'package:flick/services/widget_intent_handler.dart'; import 'package:flick/models/nav_bar_config.dart'; +import 'package:flick/models/song_view_mode.dart'; import 'package:flick/features/milestone/widgets/milestone_card.dart'; import 'package:flick/features/milestone/widgets/streak_popup.dart'; import 'package:flick/features/whats_new/widgets/whats_new_bottom_sheet.dart'; @@ -354,10 +358,7 @@ class _MainShellState extends ConsumerState _onPlaybackDesyncChanged, ); - ref.listenManual>( - libraryWarmupProvider, - _onWarmupChanged, - ); + ref.listenManual>(libraryWarmupProvider, _onWarmupChanged); ref.listenManual(appPreferencesProvider, (previous, next) { if (!mounted) return; @@ -413,9 +414,9 @@ class _MainShellState extends ConsumerState supportLabel: l10n.englishForFilipino.supportFlick, dismissLabel: l10n.englishForFilipino.dismiss, onSupportTap: () { - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const SupportFlickScreen()), - ); + Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const SupportFlickScreen())); }, ), ); @@ -516,9 +517,7 @@ class _MainShellState extends ConsumerState messenger.showFlickSnackBar( SnackBar( - content: Text( - l10n.englishForFilipino.finishingMetadataForSongs(count), - ), + content: Text(l10n.englishForFilipino.finishingMetadataForSongs(count)), duration: const Duration(days: 1), action: SnackBarAction( label: l10n.englishForFilipino.speedUp, @@ -667,7 +666,9 @@ class _MainShellState extends ConsumerState } void _onNavBarVisibilityChanged(bool isVisible) { - final separated = ref.read(appPreferencesProvider).separateMiniPlayerFromNavBar; + final separated = ref + .read(appPreferencesProvider) + .separateMiniPlayerFromNavBar; if (separated) { if (_navBarAnimationController.isAnimating || _navBarAnimationController.value != 0) { @@ -730,7 +731,8 @@ class _MainShellState extends ConsumerState return false; } - if (notification is UserScrollNotification) { + if (notification is UserScrollNotification && + notification.metrics.axis == Axis.vertical) { final direction = notification.direction; final currentVisibility = ref.read(navBarVisibleProvider); @@ -779,8 +781,41 @@ class _MainShellState extends ConsumerState ), ); final extraClearance = BottomBarGeometry.extraClearance( - context, miniConfig, separated, + context, + miniConfig, + separated, + ); + final screenSize = MediaQuery.sizeOf(context); + final useSideNavigation = AdaptiveLayout.useSideNavigation(screenSize); + final railWidth = useSideNavigation ? FlickNavRail.width(context) : 0.0; + final sideMiniClearance = + BottomBarGeometry.miniPlayerHeight(context, miniConfig) + 20; + final contentSize = Size( + math.max(0.0, screenSize.width - railWidth), + screenSize.height, ); + final currentIndex = ref.watch(navigationIndexProvider); + final songsViewMode = ref.watch(songsViewModeProvider); + final sideMiniPlayerCandidate = + useSideNavigation && + AdaptiveLayout.useCompactLandscapeMiniPlayer(contentSize) && + currentIndex == NavBarButton.songs.pageIndex && + songsViewMode == SongViewMode.orbit; + // Only listen to the auto-disposed library provider while the orbit is on + // screen; the shell must not keep the whole song list alive at startup. + final songsSortOption = sideMiniPlayerCandidate + ? ref.watch(songsProvider.select((state) => state.value?.sortOption)) + : null; + final useOrbitSideMiniPlayer = + sideMiniPlayerCandidate && songsSortOption != SongSortOption.album; + final nestedBottomInset = useOrbitSideMiniPlayer + ? 0.0 + : useSideNavigation + ? sideMiniClearance + : extraClearance; + final bottomClearance = useSideNavigation + ? (useOrbitSideMiniPlayer ? 0.0 : sideMiniClearance) + : _kNestedBarClearance + extraClearance; return PopScope( canPop: false, @@ -793,196 +828,287 @@ class _MainShellState extends ConsumerState child: SnackBarTheme( data: Theme.of(context).snackBarTheme.copyWith( insetPadding: EdgeInsets.fromLTRB( - 16, 8, 16, _kNestedBarClearance + extraClearance + 12, + (useSideNavigation ? railWidth : 0.0) + 16, + 8, + 16, + bottomClearance + 12, ), ), child: Scaffold( - backgroundColor: AppColors.background, - extendBody: true, - body: NotificationListener( - onNotification: _handleScrollNotification, - child: Listener( - behavior: HitTestBehavior.translucent, - onPointerDown: (_) => _onUserInteraction(), - onPointerMove: (_) => _onUserInteraction(), - child: Stack( - children: [ - // Base Gradient - Container( - decoration: const BoxDecoration( - gradient: AppColors.backgroundGradient, - ), - ), + backgroundColor: AppColors.background, + extendBody: true, + body: NotificationListener( + onNotification: _handleScrollNotification, + child: Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: (_) => _onUserInteraction(), + onPointerMove: (_) => _onUserInteraction(), + child: Stack( + children: [ + // Base Gradient + Container( + decoration: const BoxDecoration( + gradient: AppColors.backgroundGradient, + ), + ), - // Persistent Background - always on for all bottom-bar screens - Positioned.fill( - child: Consumer( - builder: (context, ref, _) { - final currentSong = ref.watch(currentSongProvider); - return AmbientBackground(song: currentSong); - }, - ), - ), + // Persistent Background - always on for all bottom-bar screens + Positioned.fill( + child: Consumer( + builder: (context, ref, _) { + final currentSong = ref.watch(currentSongProvider); + return AmbientBackground(song: currentSong); + }, + ), + ), - // Nested navigator: tabs + detail screens below the bar. - Positioned.fill( - child: Builder( - builder: (nestedContext) { - final mq = MediaQuery.of(nestedContext); - final inflated = mq.copyWith( - padding: mq.padding.copyWith( - bottom: mq.padding.bottom + _kNestedBarClearance + extraClearance, - ), - viewPadding: mq.viewPadding.copyWith( - bottom: - mq.viewPadding.bottom + _kNestedBarClearance + extraClearance, - ), - ); - return MediaQuery( - data: inflated, - child: Navigator( - key: nestedNavigatorKey, - initialRoute: '/', - observers: [_navBarVisibilityObserver], - onGenerateRoute: (settings) { - if (settings.name == '/') { - return PageRouteBuilder( - settings: settings, - pageBuilder: (routeContext, _, __) => - MediaQuery.removePadding( - context: routeContext, - removeBottom: true, - child: Consumer( - builder: (context, ref, _) { - final currentIdx = - ref.watch(navigationIndexProvider); - final cfg = - ref.watch(navBarConfigProvider); - final order = _getPageOrder(cfg); - return Padding( - padding: EdgeInsets.only(bottom: extraClearance), - child: PageView( - controller: _pageController, - physics: - const ClampingScrollPhysics(), - onPageChanged: (position) { - final currentConfig = ref.read( - navBarConfigProvider); - final currentOrder = - _getPageOrder(currentConfig); - if (position < 0 || - position >= - currentOrder.length) { - return; - } - if (_programmaticPageTarget == null && - position != _lastHapticPage) { - AppHaptics.selection(); - } - _lastHapticPage = position; - final enabledCount = currentConfig - .orderedButtons.length; - if (position >= enabledCount) { - if (_programmaticPageTarget == - position) { - _programmaticPageTarget = null; - final pageIndex = - currentOrder[position] - .pageIndex; - if (ref.read( - navigationIndexProvider) != - pageIndex) { - ref - .read( + // Nested navigator: tabs + detail screens below the bar. + // In rail mode the navigator is physically inset, so fixed + // elements inside screens (detail headers, FABs) cannot sit + // under the rail. The MediaQuery width is reduced too, so + // width-based grids measure the real content pane. + Positioned.fill( + left: useSideNavigation ? railWidth : 0.0, + child: Builder( + builder: (nestedContext) { + final mq = MediaQuery.of(nestedContext); + final inflated = mq.copyWith( + size: useSideNavigation + ? Size( + math.max(0.0, mq.size.width - railWidth), + mq.size.height, + ) + : mq.size, + padding: mq.padding.copyWith( + left: useSideNavigation ? 0.0 : mq.padding.left, + bottom: mq.padding.bottom + bottomClearance, + ), + viewPadding: mq.viewPadding.copyWith( + left: useSideNavigation + ? 0.0 + : mq.viewPadding.left, + bottom: mq.viewPadding.bottom + bottomClearance, + ), + ); + return MediaQuery( + data: inflated, + child: Navigator( + key: nestedNavigatorKey, + initialRoute: '/', + observers: [_navBarVisibilityObserver], + onGenerateRoute: (settings) { + if (settings.name == '/') { + return PageRouteBuilder( + settings: settings, + pageBuilder: (routeContext, _, __) => MediaQuery.removePadding( + context: routeContext, + removeBottom: true, + child: Consumer( + builder: (context, ref, _) { + final currentIdx = ref.watch( + navigationIndexProvider, + ); + final cfg = ref.watch( + navBarConfigProvider, + ); + final order = _getPageOrder(cfg); + return Padding( + padding: EdgeInsets.only( + bottom: nestedBottomInset, + ), + child: PageView( + controller: _pageController, + physics: + const ClampingScrollPhysics(), + onPageChanged: (position) { + final currentConfig = ref.read( + navBarConfigProvider, + ); + final currentOrder = + _getPageOrder( + currentConfig, + ); + if (position < 0 || + position >= + currentOrder.length) { + return; + } + if (_programmaticPageTarget == + null && + position != + _lastHapticPage) { + AppHaptics.selection(); + } + _lastHapticPage = position; + final enabledCount = + currentConfig + .orderedButtons + .length; + if (position >= enabledCount) { + if (_programmaticPageTarget == + position) { + _programmaticPageTarget = + null; + final pageIndex = + currentOrder[position] + .pageIndex; + if (ref.read( + navigationIndexProvider, + ) != + pageIndex) { + ref + .read( + navigationIndexProvider + .notifier, + ) + .setIndex(pageIndex); + } + return; + } + if (_programmaticPageTarget != + null) { + return; + } + final pageIndex = + currentOrder[position] + .pageIndex; + if (ref.read( + navigationIndexProvider, + ) != + pageIndex) { + ref + .read( + navigationIndexProvider + .notifier, + ) + .setIndex(pageIndex); + } + return; + } + if (_programmaticPageTarget == + position) { + _programmaticPageTarget = + null; + } + final pageIndex = + currentOrder[position] + .pageIndex; + if (ref.read( + navigationIndexProvider, + ) != + pageIndex) { + ref + .read( navigationIndexProvider - .notifier) - .setIndex(pageIndex); - } - return; - } - if (_programmaticPageTarget != - null) { - return; - } - final pageIndex = - currentOrder[position].pageIndex; - if (ref.read( - navigationIndexProvider) != - pageIndex) { - ref - .read(navigationIndexProvider - .notifier) - .setIndex(pageIndex); - } - return; - } - if (_programmaticPageTarget == - position) { - _programmaticPageTarget = null; - } - final pageIndex = - currentOrder[position].pageIndex; - if (ref.read( - navigationIndexProvider) != - pageIndex) { - ref - .read(navigationIndexProvider - .notifier) - .setIndex(pageIndex); - } - }, - children: order.map((button) { - return _buildTab( - tabIndex: button.pageIndex, - currentIndex: currentIdx, - child: _buildScreen(button), + .notifier, + ) + .setIndex(pageIndex); + } + }, + children: order.map((button) { + return _buildTab( + tabIndex: button.pageIndex, + currentIndex: currentIdx, + child: _buildScreen(button), + ); + }).toList(), + ), ); - }).toList(), - ), - ); - }, - ), - ), - transitionDuration: Duration.zero, - ); - } - return null; - }, + }, + ), + ), + transitionDuration: Duration.zero, + ); + } + return null; + }, + ), + ); + }, + ), + ), + + // Wide windows use a side rail; portrait keeps the bottom bar. + if (useSideNavigation) + Positioned( + left: 0, + top: 0, + bottom: 0, + child: TutorialTargetAnchor( + target: TutorialTarget.navBar, + child: FlickNavRail( + currentIndex: ref.watch(navigationIndexProvider), + config: ref.watch(navBarConfigProvider), + onTap: _handleNavigationTap, + onBottomBarSettings: _openBottomBarSettings, + ), ), - ); - }, - ), - ), + ) + else + Positioned( + left: 0, + right: 0, + bottom: 0, + child: RepaintBoundary( + child: SlideTransition( + position: _navBarSlideAnimation, + child: _buildUnifiedBottomBar(), + ), + ), + ), - // Unified Bottom Bar (Mini Player + Navigation) - Positioned( - left: 0, - right: 0, - bottom: 0, - child: RepaintBoundary( - child: SlideTransition( - position: _navBarSlideAnimation, - child: _buildUnifiedBottomBar(), - ), - ), - ), + // In rail mode the mini player owns the bottom of the content + // area, not the full window width. + if (useSideNavigation && !useOrbitSideMiniPlayer) + Positioned( + left: railWidth, + right: 0, + bottom: 0, + child: SafeArea( + top: false, + left: false, + child: TutorialTargetAnchor( + target: TutorialTarget.miniPlayer, + child: EmbeddedMiniPlayer( + collapsed: false, + separated: separated, + ), + ), + ), + ), + + // Compact landscape orbit moves the mini player to the right + // edge so the orbit stage keeps the full window height. + if (useOrbitSideMiniPlayer) + Positioned( + right: 6, + top: 0, + bottom: 0, + child: SafeArea( + left: false, + child: TutorialTargetAnchor( + target: TutorialTarget.miniPlayer, + child: const LandscapeSideMiniPlayer(), + ), + ), + ), - // Floating island mini-player (draggable pill) - const FloatingMiniPlayer(), + // Floating island mini-player (draggable pill) + const FloatingMiniPlayer(), - // Pinned scan/preload progress bubble (overlay minimized) - const Positioned.fill(child: FloatingScanProgress()), + // Pinned scan/preload progress bubble (overlay minimized) + const Positioned.fill(child: FloatingScanProgress()), - // Interactive tutorial overlay - const Positioned.fill(child: TutorialOverlay()), + // Interactive tutorial overlay + const Positioned.fill(child: TutorialOverlay()), - // USB DAC attach โ†’ auto bit-perfect switch (decline-aware) - const UsbBitPerfectPrompt(), - ], + // USB DAC attach โ†’ auto bit-perfect switch (decline-aware) + const UsbBitPerfectPrompt(), + ], + ), + ), ), ), - ), - ), ), ), ); @@ -1037,6 +1163,25 @@ class _MainShellState extends ConsumerState ); } + void _handleNavigationTap(int index) { + final nested = nestedNavigatorKey.currentState; + if (nested != null && nested.canPop()) { + nested.popUntil((route) => route.isFirst); + } + if (ref.read(navigationIndexProvider) != index) { + ref.read(navigationIndexProvider.notifier).setIndex(index); + } else if (nested != null && nested.canPop()) { + nested.popUntil((route) => route.isFirst); + } + } + + void _openBottomBarSettings() { + NavigationHelper.pushOnRoot( + context, + MaterialPageRoute(builder: (_) => const BottomBarSettingsScreen()), + ); + } + Widget _buildUnifiedBottomBar() { final currentIndex = ref.watch(navigationIndexProvider); final navBarConfig = ref.watch(navBarConfigProvider); @@ -1053,32 +1198,12 @@ class _MainShellState extends ConsumerState currentIndex: currentIndex, config: navBarConfig, collapsed: collapsed, - onTap: (index) { - final nested = nestedNavigatorKey.currentState; - if (nested != null && nested.canPop()) { - nested.popUntil((route) => route.isFirst); - } - if (ref.read(navigationIndexProvider) != index) { - ref.read(navigationIndexProvider.notifier).setIndex(index); - } else if (nested != null && nested.canPop()) { - nested.popUntil((route) => route.isFirst); - } - }, - onBottomBarSettings: () { - NavigationHelper.pushOnRoot( - context, - MaterialPageRoute( - builder: (_) => const BottomBarSettingsScreen(), - ), - ); - }, + onTap: _handleNavigationTap, + onBottomBarSettings: _openBottomBarSettings, showMiniPlayer: true, miniPlayerWidget: TutorialTargetAnchor( target: TutorialTarget.miniPlayer, - child: EmbeddedMiniPlayer( - collapsed: collapsed, - separated: separated, - ), + child: EmbeddedMiniPlayer(collapsed: collapsed, separated: separated), ), ), ); diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index 08420778..b43e6ce6 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -156,6 +156,12 @@ class AppTheme { // Text theme textTheme: _textTheme, + // Let the app's sheet surfaces use the available window, rather than + // Material 3's fixed 640dp modal-route cap on landscape/tablets. + bottomSheetTheme: const BottomSheetThemeData( + constraints: BoxConstraints(maxWidth: double.infinity), + ), + // Shared transient feedback: a quiet surface with native Material motion. snackBarTheme: SnackBarThemeData( behavior: SnackBarBehavior.floating, From 3357912e6f5a53ea9459381db178026236c03200 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Thu, 5 Nov 2026 21:59:20 +0800 Subject: [PATCH 12/14] Extend English-only Taglish copy to UI Customization Document that Track Thumbnails, Progress Bar and Album Colors sections use the English role catalog in Taglish, mirroring the Player Layout pattern. --- docs/BAYBAYIN_REVIEW.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/BAYBAYIN_REVIEW.md b/docs/BAYBAYIN_REVIEW.md index 080c0035..de44b166 100644 --- a/docs/BAYBAYIN_REVIEW.md +++ b/docs/BAYBAYIN_REVIEW.md @@ -63,6 +63,9 @@ subsequently turning them off survives a restart. - The standalone Player Layout settings screen is English in Taglish too, including both layout names and descriptions, layout controls, quick-action pickers and album-color options. English and Chinese keep their own copy. +- The UI Customization settings screen is English in Taglish for Track + Thumbnails, Progress Bar and Album Colors, including option names and + descriptions. English and Chinese keep their own copy. - The album, artist, playlist and smart-mix three-dot More sheets use English, including their **More** header. - Album completeness uses English in Taglish: header and grid badges, missing @@ -289,7 +292,9 @@ Player Layout has two entry points, and both use the English role in Taglish: `model_labels.dart` gained caller-catalog `descriptionFor` methods for player screen modes and album-color modes, so plain extension getters elsewhere still follow the active locale. The action picker uses the same English catalog as -the sheet. +the sheet. The same pattern covers `ProgressBarStyle` and +`SongTileThumbnailMode`; the UI Customization screen passes its English role +catalog so those option descriptions do not fall back to Taglish. `sort_filter_bottom_sheet.dart` also uses the English shim. Scoped copy roles in `songs_screen.dart` keep the album menu, album-options sheet and multiselect @@ -353,6 +358,8 @@ Before removing the experimental/draft notices, the Filipino reviewer should: the fully English full-player three-dot sheet, inline/online lyrics and its playlist/share/metadata/speed/pitch/sleep/layout sub-interfaces, the English Player Layout settings screen and action picker, + the English UI Customization Track Thumbnails, Progress Bar and Album + Colors sections, the fully English Lyrics Sync Studio, the English Milestones surface and playlist cover editor, the English playlist detail header information line/lossless indicator, all-English From 84719f8ea15dbe6405ba2ce4db31486e93292789 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Thu, 5 Nov 2026 21:59:46 +0800 Subject: [PATCH 13/14] Add adaptive landscape layouts across the app Introduce a shared AdaptiveLayout policy keyed off window size rather than MediaQuery.orientation, and use it to split the player into a stage/controls side-by-side composition, swap bottom navigation for a scrollable side rail, and render a vertical side mini player in short landscape windows. Rework bottom sheets onto AppBottomSheetSurface so they fill more of the window, scroll under enlarged text, and respect keyboard insets. Also localize UI Customization descriptions via the English copy role so Taglish uses English copy. --- lib/core/utils/adaptive_layout.dart | 49 ++ .../player/screens/full_player_screen.dart | 404 +++++++-- .../player/widgets/add_to_playlist_sheet.dart | 9 +- .../player/widgets/bit_perfect_indicator.dart | 5 +- .../widgets/landscape_player_layout.dart | 94 ++ .../player/widgets/pitch_bottom_sheet.dart | 9 +- .../widgets/player_action_button_row.dart | 410 +++++---- .../player/widgets/player_controls.dart | 109 ++- .../player/widgets/player_layout_sheet.dart | 825 +++++++++--------- .../player/widgets/rating_button.dart | 57 +- .../widgets/share/share_bottom_sheet.dart | 8 +- .../widgets/sleep_timer_bottom_sheet.dart | 9 +- .../player/widgets/song_actions_sheet.dart | 576 ++++++------ .../player/widgets/song_metadata_sheet.dart | 8 +- lib/features/player/widgets/song_stage.dart | 279 ++++-- .../player/widgets/song_stage_carousel.dart | 79 +- .../player/widgets/speed_bottom_sheet.dart | 9 +- .../player/widgets/volume_bottom_sheet.dart | 9 +- .../settings/screens/equalizer_screen.dart | 262 +++--- .../ui_customization_settings_screen.dart | 8 +- lib/features/songs/screens/songs_screen.dart | 375 ++++---- lib/features/songs/widgets/orbit_scroll.dart | 261 ++++-- lib/features/songs/widgets/song_card.dart | 138 ++- lib/main.dart | 5 - lib/widgets/common/detail_header.dart | 13 +- lib/widgets/common/flick_dialog.dart | 17 +- lib/widgets/common/floating_mini_player.dart | 23 +- lib/widgets/common/glass_bottom_sheet.dart | 38 +- .../common/landscape_side_mini_player.dart | 327 +++++++ lib/widgets/navigation/flick_nav_bar.dart | 269 ++++++ test/core/adaptive_layout_test.dart | 82 ++ test/features/songs/orbit_scroll_test.dart | 169 ++++ test/l10n/ui_customization_locale_test.dart | 166 ++++ test/widgets/adaptive_bottom_sheet_test.dart | 92 ++ test/widgets/flick_nav_rail_test.dart | 90 ++ .../widgets/landscape_player_layout_test.dart | 95 ++ .../landscape_side_mini_player_test.dart | 283 ++++++ .../widgets/player_landscape_sheets_test.dart | 132 +++ 38 files changed, 4255 insertions(+), 1538 deletions(-) create mode 100644 lib/core/utils/adaptive_layout.dart create mode 100644 lib/features/player/widgets/landscape_player_layout.dart create mode 100644 lib/widgets/common/landscape_side_mini_player.dart create mode 100644 test/core/adaptive_layout_test.dart create mode 100644 test/features/songs/orbit_scroll_test.dart create mode 100644 test/l10n/ui_customization_locale_test.dart create mode 100644 test/widgets/adaptive_bottom_sheet_test.dart create mode 100644 test/widgets/flick_nav_rail_test.dart create mode 100644 test/widgets/landscape_player_layout_test.dart create mode 100644 test/widgets/landscape_side_mini_player_test.dart create mode 100644 test/widgets/player_landscape_sheets_test.dart diff --git a/lib/core/utils/adaptive_layout.dart b/lib/core/utils/adaptive_layout.dart new file mode 100644 index 00000000..b668dc9d --- /dev/null +++ b/lib/core/utils/adaptive_layout.dart @@ -0,0 +1,49 @@ +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; + +/// Layout policy for the app window, not the physical device. +/// +/// Android windows can be resized, folded, and split while the device stays +/// in one orientation. Every decision here therefore comes from the current +/// constraints/size; nothing reads `MediaQuery.orientation`. +abstract final class AdaptiveLayout { + /// Below this width a two-pane composition starts to lose its right pane. + static const double sideBySideMinWidth = 520.0; + + /// Material's medium/expanded boundary, kept for wide library layouts. + static const double expandedMinWidth = 840.0; + + /// Short windows need vertically compact chrome even when they are wide. + static const double shortHeight = 480.0; + + /// Width of the compact navigation rail in wide windows. + static const double navigationRailWidth = 76.0; + + /// Narrow, vertical mini player used by the compact landscape song orbit. + static const double sideMiniPlayerWidth = 64.0; + static const double sideMiniPlayerContentInset = 78.0; + + static bool isWide(Size size) => size.width > size.height; + + /// Short landscape windows trade the bottom mini player for a side strip so + /// the orbit keeps its vertical stage. + static bool useCompactLandscapeMiniPlayer(Size size) => + size.width > size.height && size.height < shortHeight; + + /// True when the player should use its left-stage/right-controls layout. + static bool usePlayerSideLayout(Size size) => + size.width >= sideBySideMinWidth && size.width > size.height; + + /// Same decision using the space actually left for the player body. + static bool usePlayerSideLayoutFor(BoxConstraints constraints) => + constraints.maxWidth >= sideBySideMinWidth && + constraints.maxWidth > constraints.maxHeight; + + /// True when the shell should move navigation from the bottom to the side. + static bool useSideNavigation(Size size) => + size.width >= 520.0 && size.width > size.height; + + static double railWidth(BuildContext context) => + math.min(navigationRailWidth, MediaQuery.sizeOf(context).width * 0.16); +} diff --git a/lib/features/player/screens/full_player_screen.dart b/lib/features/player/screens/full_player_screen.dart index a612388e..91f8b934 100644 --- a/lib/features/player/screens/full_player_screen.dart +++ b/lib/features/player/screens/full_player_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flick/core/theme/app_colors.dart'; import 'package:flick/core/theme/app_theme.dart'; import 'package:flick/core/constants/app_constants.dart'; +import 'package:flick/core/utils/adaptive_layout.dart'; import 'package:flick/core/utils/duration_format.dart'; import 'package:flick/core/utils/responsive.dart'; import 'package:flick/data/repositories/song_repository.dart'; @@ -17,7 +18,9 @@ import 'package:flick/features/player/widgets/player_navigation.dart'; import 'package:flick/features/player/widgets/song_actions_sheet.dart'; import 'package:flick/features/player/widgets/song_stage.dart'; import 'package:flick/features/player/widgets/song_stage_carousel.dart'; +import 'package:flick/features/player/widgets/landscape_player_layout.dart'; import 'package:flick/features/player/widgets/lyrics_mode_waveform_strip.dart'; +import 'package:flick/features/player/widgets/inline_lyrics_panel.dart'; import 'package:flick/features/player/widgets/album_color_helpers.dart'; import 'package:flick/models/album_color_mode.dart'; import 'package:flick/models/immersive_background_style.dart'; @@ -234,8 +237,9 @@ class _FullPlayerScreenState extends ConsumerState void _setVisualizationMode(bool value) { final nextLyricsMode = value ? false : _isLyricsMode; - if (_isVisualizationMode == value && _isLyricsMode == nextLyricsMode) + if (_isVisualizationMode == value && _isLyricsMode == nextLyricsMode) { return; + } setState(() { _isVisualizationMode = value; _isLyricsMode = nextLyricsMode; @@ -393,6 +397,12 @@ class _FullPlayerScreenState extends ConsumerState final hasAlbumTint = albumColor != null && bgBlend > 0; final useBlurGradient = backgroundStyle == ImmersiveBackgroundStyle.blurGradient; + // In the side-by-side immersive layout the controls own the right pane, so + // the scrim/blur needs to deepen toward that edge instead of the bottom. + final sideImmersiveLayout = + _playerScreenMode == PlayerScreenMode.immersive && + !_isImmersiveFullView && + AdaptiveLayout.usePlayerSideLayout(MediaQuery.sizeOf(context)); if (visualizationMode && _playerScreenMode != PlayerScreenMode.artworkCard) { @@ -413,19 +423,31 @@ class _FullPlayerScreenState extends ConsumerState Positioned.fill( child: Container( decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - overlayColor.withValues(alpha: 0.7), - overlayColor.withValues(alpha: 0.35), - Colors.transparent, - Colors.transparent, - overlayColor.withValues(alpha: 0.3), - overlayColor.withValues(alpha: 0.75), - ], - stops: const [0.0, 0.12, 0.28, 0.62, 0.82, 1.0], - ), + gradient: sideImmersiveLayout + ? LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + Colors.transparent, + Colors.transparent, + overlayColor.withValues(alpha: 0.35), + overlayColor.withValues(alpha: 0.75), + ], + stops: const [0.0, 0.42, 0.68, 1.0], + ) + : LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + overlayColor.withValues(alpha: 0.7), + overlayColor.withValues(alpha: 0.35), + Colors.transparent, + Colors.transparent, + overlayColor.withValues(alpha: 0.3), + overlayColor.withValues(alpha: 0.75), + ], + stops: const [0.0, 0.12, 0.28, 0.62, 0.82, 1.0], + ), ), ), ), @@ -526,7 +548,8 @@ class _FullPlayerScreenState extends ConsumerState errorWidget: placeholder, albumName: song.album, artistName: - (song.albumArtist != null && song.albumArtist!.trim().isNotEmpty) + (song.albumArtist != null && + song.albumArtist!.trim().isNotEmpty) ? song.albumArtist : song.artist, representativeSongTitle: song.title, @@ -552,15 +575,19 @@ class _FullPlayerScreenState extends ConsumerState duration: AppConstants.animationNormal, opacity: _isImmersiveFullView ? 0.0 : 1.0, child: ShaderMask( - shaderCallback: (bounds) => const LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ + shaderCallback: (bounds) => LinearGradient( + begin: sideImmersiveLayout + ? Alignment.centerLeft + : Alignment.topCenter, + end: sideImmersiveLayout + ? Alignment.centerRight + : Alignment.bottomCenter, + colors: const [ Colors.transparent, Colors.transparent, Colors.white, ], - stops: [0.0, 0.42, 0.82], + stops: const [0.0, 0.42, 0.82], ).createShader(bounds), blendMode: BlendMode.dstIn, child: AmbientBackground( @@ -577,8 +604,12 @@ class _FullPlayerScreenState extends ConsumerState duration: AppConstants.animationNormal, decoration: BoxDecoration( gradient: LinearGradient( - begin: Alignment.bottomCenter, - end: Alignment.topCenter, + begin: sideImmersiveLayout + ? Alignment.centerRight + : Alignment.bottomCenter, + end: sideImmersiveLayout + ? Alignment.centerLeft + : Alignment.topCenter, colors: gradientColors, stops: const [0.0, 0.2, 0.4, 0.6, 0.8, 1.0], ), @@ -648,7 +679,8 @@ class _FullPlayerScreenState extends ConsumerState BaybayinText( label: BaybayinLabel.playing, fallback: context.l10n.isFilipino - ? context.l10n.playingLabel : l10n.nowPlaying, + ? context.l10n.playingLabel + : l10n.nowPlaying, style: TextStyle( fontFamily: 'GoogleSansFlex', fontSize: context.responsive(12.0, 13.0, 14.0), @@ -871,8 +903,9 @@ class _FullPlayerScreenState extends ConsumerState Widget _buildPinnedWaveform( BuildContext context, Song song, - PlayerScreenMode mode, - ) { + PlayerScreenMode mode, { + double? horizontalPadding, + }) { final isImmersive = mode == PlayerScreenMode.immersive; // Lyrics uses the strip for immersive (waveform + dismiss arrow) but the // time labels are now unified as a single pinned default for BOTH @@ -883,9 +916,11 @@ class _FullPlayerScreenState extends ConsumerState positionNotifier: _throttledPositionNotifier, currentSong: song, formatDuration: formatDuration, - horizontalPadding: isImmersive - ? context.responsive(18.0, 24.0, 30.0) - : context.responsive(16.0, 20.0, 24.0), + horizontalPadding: + horizontalPadding ?? + (isImmersive + ? context.responsive(18.0, 24.0, 30.0) + : context.responsive(16.0, 20.0, 24.0)), onSwipeUp: () => _setLyricsMode(false), showTimeLabels: false, showArrow: _isLyricsMode && isImmersive, @@ -931,11 +966,9 @@ class _FullPlayerScreenState extends ConsumerState return const SizedBox.shrink(); } - final isShortHeight = MediaQuery.sizeOf(context).height < 620.0; final showImmersiveFullView = _isImmersiveFullView && - _playerScreenMode == PlayerScreenMode.immersive && - !isShortHeight; + _playerScreenMode == PlayerScreenMode.immersive; final showVisualizerOnly = _isVisualizationMode && appPrefs.visualizerEnabled && @@ -1025,10 +1058,16 @@ class _FullPlayerScreenState extends ConsumerState final visualizationMode = _isVisualizationMode && appPrefs.visualizerEnabled; - // Shared file-info builder for stages; also drawn pinned (overlay) so the - // left/right buttons don't slide with the carousel. - Widget fileInfoBuilder(Song s, bool lm, PlayerScreenMode mode) { + // Shared action-row builder for stages and the pinned overlay. The + // landscape pane passes `compact: true` for a single short row. + Widget buildActionRow( + Song s, + bool lm, + PlayerScreenMode mode, { + bool compact = false, + }) { return PlayerActionButtonRow( + compact: compact, song: s, lyricsMode: lm, isVisualizationMode: _isVisualizationMode, @@ -1057,6 +1096,9 @@ class _FullPlayerScreenState extends ConsumerState ); } + Widget fileInfoBuilder(Song s, bool lm, PlayerScreenMode mode) => + buildActionRow(s, lm, mode); + // Overlay shows over the stage's reserved invisible row slot. final showPinnedActionRow = _playerScreenMode == PlayerScreenMode.immersive ? (appPrefs.immersiveShowFileInfo && !_isLyricsMode) @@ -1182,6 +1224,33 @@ class _FullPlayerScreenState extends ConsumerState ); } + if (AdaptiveLayout.usePlayerSideLayout(MediaQuery.sizeOf(context))) { + return Stack( + children: [ + Positioned.fill(child: background), + SafeArea( + child: _buildLandscapeLayout( + context, + song, + appPrefs, + colorMode, + albumColor, + visStyle, + visFreq, + visMove, + visualizationMode, + (s) => buildActionRow( + s, + _isLyricsMode, + _playerScreenMode, + compact: true, + ), + ), + ), + ], + ); + } + // Default: pinned top chrome + sliding stage + pinned controls return Stack( children: [ @@ -1360,11 +1429,7 @@ class _FullPlayerScreenState extends ConsumerState mainAxisSize: MainAxisSize.min, children: [ SizedBox( - height: context.responsive( - 8.0, - 10.0, - 12.0, - ), + height: context.responsive(8.0, 10.0, 12.0), ), Padding( padding: EdgeInsets.symmetric( @@ -1377,19 +1442,23 @@ class _FullPlayerScreenState extends ConsumerState return ValueListenableBuilder( valueListenable: _playerService.durationNotifier, - builder: (context, engineDuration, _) { - final duration = - engineDuration.inMilliseconds > 0 - ? engineDuration - : (song.duration); - return PlaybackTimeLabels( - position: position, - duration: duration, - formatDuration: formatDuration, - horizontalPadding: - timeLabelsPadding, - ); - }, + builder: + (context, engineDuration, _) { + final duration = + engineDuration + .inMilliseconds > + 0 + ? engineDuration + : (song.duration); + return PlaybackTimeLabels( + position: position, + duration: duration, + formatDuration: + formatDuration, + horizontalPadding: + timeLabelsPadding, + ); + }, ); }, ), @@ -1445,4 +1514,235 @@ class _FullPlayerScreenState extends ConsumerState ], ); } + + /// Wide, short window: square stage on the left, pinned playback on the + /// right. Both player styles keep their own identity instead of being + /// squeezed into the portrait column. + Widget _buildLandscapeLayout( + BuildContext context, + Song song, + dynamic appPrefs, + AlbumColorMode colorMode, + Color? albumColor, + String visStyle, + String visFreq, + String visMove, + bool visualizationMode, + Widget Function(Song song) actionRowBuilder, + ) { + final isArtwork = _playerScreenMode == PlayerScreenMode.artworkCard; + final showActions = isArtwork + ? appPrefs.artworkCardShowFileInfo + : appPrefs.immersiveShowFileInfo; + + final leftPane = _isLyricsMode + ? Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 4, 0), + child: InlineLyricsPanel( + song: song, + playerService: _playerService, + lyricsService: _lyricsService, + albumColor: albumColor, + positionNotifier: _throttledPositionNotifier, + active: true, + ), + ) + : ValueListenableBuilder>( + valueListenable: _playerService.upNextNotifier, + builder: (context, upNext, _) { + return ValueListenableBuilder( + valueListenable: _playerService.currentIndexNotifier, + builder: (context, idx, _) { + final prev = _playerService.peekPrevious; + final next = _playerService.peekNext; + return SongStageCarousel( + key: _carouselKey, + currentSong: song, + prevSong: prev, + nextSong: next, + enabled: !_isVinylRotationActive, + onDragStateChanged: (v) => _isCarouselDragging = v, + onNext: _handleCarouselNext, + onPrevious: _handleCarouselPrevious, + stageBuilder: (s) { + final isCurrent = s.id == song.id; + return SongStage( + song: s, + pane: isArtwork + ? SongStagePane.artworkOnly + : SongStagePane.backdropOnly, + lyricsMode: _isLyricsMode, + visualizationMode: visualizationMode, + immersiveFullView: false, + playerScreenMode: _playerScreenMode, + albumColorMode: colorMode, + albumColor: albumColor, + playerService: _playerService, + lyricsService: _lyricsService, + positionNotifier: isCurrent + ? _throttledPositionNotifier + : _zeroPosition, + formatDuration: formatDuration, + onNavigateToArtistDetail: (s) => + _navigation.openArtistFromSong(context, s), + onNavigateToAlbumDetail: (s) => + _navigation.openAlbumFromSong(context, s), + buildFileInfoRow: (s, _, _) => actionRowBuilder(s), + fileInfoRowVisible: false, + visualizerAnimationStyle: visStyle, + visualizerFrequencyMode: visFreq, + visualizerMovementMode: visMove, + artworkCardArtworkScale: + appPrefs.artworkCardArtworkScale, + artworkCardTextScale: appPrefs.artworkCardTextScale, + artworkCardVerticalOffset: + appPrefs.artworkCardVerticalOffset, + artworkCardArtworkOffset: + appPrefs.artworkCardArtworkOffset, + artworkCardShowTitle: appPrefs.artworkCardShowTitle, + artworkCardShowArtist: appPrefs.artworkCardShowArtist, + artworkCardShowAlbum: appPrefs.artworkCardShowAlbum, + artworkCardShowFileInfo: + appPrefs.artworkCardShowFileInfo, + artworkCardShowFrame: appPrefs.artworkCardShowFrame, + immersiveTextScale: appPrefs.immersiveTextScale, + immersiveVerticalOffset: + appPrefs.immersiveVerticalOffset, + immersiveFullViewScale: appPrefs.immersiveFullViewScale, + immersiveShowTitle: appPrefs.immersiveShowTitle, + immersiveShowArtist: appPrefs.immersiveShowArtist, + immersiveShowFileInfo: appPrefs.immersiveShowFileInfo, + onRotationEnabledChanged: (enabled) { + _isVinylRotationActive = enabled; + }, + vinylMode: _isVinylMode, + onVinylChanged: (v) => _isVinylMode = v, + onToggleLyrics: () => _setLyricsMode(!_isLyricsMode), + showWaveform: false, + motionArtEnabled: isCurrent, + active: isCurrent, + ); + }, + ); + }, + ); + }, + ); + + final detailsPane = isArtwork + ? SongStagePane.artworkCardDetails + : SongStagePane.immersiveIdentity; + + return Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), + child: Column( + children: [ + const Uac2ErrorNotification(), + _buildTopChrome(context, song, albumColor, colorMode, appPrefs), + const SizedBox(height: 4), + Expanded( + child: LandscapePlayerLayout( + stage: leftPane, + artworkCard: isArtwork, + lyricsMode: _isLyricsMode, + identity: SongStage( + song: song, + pane: detailsPane, + lyricsMode: _isLyricsMode, + visualizationMode: visualizationMode, + immersiveFullView: false, + playerScreenMode: _playerScreenMode, + albumColorMode: colorMode, + albumColor: albumColor, + playerService: _playerService, + lyricsService: _lyricsService, + positionNotifier: _throttledPositionNotifier, + formatDuration: formatDuration, + onNavigateToArtistDetail: (s) => + _navigation.openArtistFromSong(context, s), + onNavigateToAlbumDetail: (s) => + _navigation.openAlbumFromSong(context, s), + buildFileInfoRow: (s, _, _) => actionRowBuilder(s), + fileInfoRowVisible: false, + visualizerAnimationStyle: visStyle, + visualizerFrequencyMode: visFreq, + visualizerMovementMode: visMove, + artworkCardArtworkScale: appPrefs.artworkCardArtworkScale, + artworkCardTextScale: appPrefs.artworkCardTextScale, + artworkCardVerticalOffset: appPrefs.artworkCardVerticalOffset, + artworkCardArtworkOffset: appPrefs.artworkCardArtworkOffset, + artworkCardShowTitle: appPrefs.artworkCardShowTitle, + artworkCardShowArtist: appPrefs.artworkCardShowArtist, + artworkCardShowAlbum: appPrefs.artworkCardShowAlbum, + artworkCardShowFileInfo: appPrefs.artworkCardShowFileInfo, + artworkCardShowFrame: appPrefs.artworkCardShowFrame, + immersiveTextScale: appPrefs.immersiveTextScale, + immersiveVerticalOffset: appPrefs.immersiveVerticalOffset, + immersiveFullViewScale: appPrefs.immersiveFullViewScale, + immersiveShowTitle: appPrefs.immersiveShowTitle, + immersiveShowArtist: appPrefs.immersiveShowArtist, + immersiveShowFileInfo: appPrefs.immersiveShowFileInfo, + onRotationEnabledChanged: (enabled) { + _isVinylRotationActive = enabled; + }, + vinylMode: _isVinylMode, + onVinylChanged: (v) => _isVinylMode = v, + onToggleLyrics: () => _setLyricsMode(!_isLyricsMode), + showWaveform: false, + motionArtEnabled: true, + active: true, + ), + actions: showActions ? actionRowBuilder(song) : null, + playback: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildPinnedWaveform( + context, + song, + _playerScreenMode, + horizontalPadding: 4, + ), + if (_isLyricsMode) ...[ + const SizedBox(height: 6), + ValueListenableBuilder( + valueListenable: _playerService.positionNotifier, + builder: (context, position, _) { + return ValueListenableBuilder( + valueListenable: _playerService.durationNotifier, + builder: (context, engineDuration, _) { + final duration = engineDuration.inMilliseconds > 0 + ? engineDuration + : song.duration; + return PlaybackTimeLabels( + position: position, + duration: duration, + formatDuration: formatDuration, + horizontalPadding: 4, + ); + }, + ); + }, + ), + ], + const SizedBox(height: 4), + PlayerControls( + playerService: _playerService, + formatDuration: formatDuration, + currentSong: song, + onPrevious: _animateToPreviousSong, + onNext: _animateToNextSong, + timelineHorizontalPadding: 4, + albumColorMode: colorMode, + albumColor: albumColor, + showTimeLabels: !_isLyricsMode, + compact: MediaQuery.sizeOf(context).height < 500, + ), + ], + ), + ), + ), + ], + ), + ); + } } diff --git a/lib/features/player/widgets/add_to_playlist_sheet.dart b/lib/features/player/widgets/add_to_playlist_sheet.dart index 703fee81..f11b2ae8 100644 --- a/lib/features/player/widgets/add_to_playlist_sheet.dart +++ b/lib/features/player/widgets/add_to_playlist_sheet.dart @@ -25,19 +25,20 @@ class AddToPlaylistSheet extends ConsumerWidget { useRootNavigator: true, context: context, backgroundColor: Colors.transparent, + isScrollControlled: true, builder: (context) => AddToPlaylistSheet(songs: songs), ); } @override Widget build(BuildContext context, WidgetRef ref) { - return Container( - decoration: AppBottomSheetSurface.decoration, - padding: EdgeInsets.fromLTRB( + return AppBottomSheetSurface( + scrollable: true, + padding: const EdgeInsets.fromLTRB( + 24, 24, 24, 24, - 24 + MediaQuery.paddingOf(context).bottom, ), child: Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/features/player/widgets/bit_perfect_indicator.dart b/lib/features/player/widgets/bit_perfect_indicator.dart index 0495b976..31de10d0 100644 --- a/lib/features/player/widgets/bit_perfect_indicator.dart +++ b/lib/features/player/widgets/bit_perfect_indicator.dart @@ -265,8 +265,9 @@ class _AudioInfoBottomSheetState extends ConsumerState<_AudioInfoBottomSheet> { ? dominantColor : null; - return Container( - decoration: AppBottomSheetSurface.decoration, + return AppBottomSheetSurface( + scrollable: true, + padding: EdgeInsets.zero, child: SafeArea( child: Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/features/player/widgets/landscape_player_layout.dart b/lib/features/player/widgets/landscape_player_layout.dart new file mode 100644 index 00000000..835b0f82 --- /dev/null +++ b/lib/features/player/widgets/landscape_player_layout.dart @@ -0,0 +1,94 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +/// A bounded listening composition, not two columns stretched to the window. +/// The identity and playback stay together; only the identity/actions scroll +/// when enlarged text or a short window leaves too little vertical space. +class LandscapePlayerLayout extends StatelessWidget { + final Widget stage; + final Widget identity; + final Widget? actions; + final Widget playback; + final bool artworkCard; + final bool lyricsMode; + + const LandscapePlayerLayout({ + super.key, + required this.stage, + required this.identity, + required this.playback, + this.actions, + this.artworkCard = false, + this.lyricsMode = false, + }); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final width = math.min(1080.0, constraints.maxWidth); + final gap = width >= 800 ? 40.0 : 20.0; + final controlsWidth = math.min( + 500.0, + math.max(248.0, width * (lyricsMode ? 0.48 : 0.52)), + ); + final stageWidth = math.max(0.0, width - controlsWidth - gap); + final artExtent = math.min(400.0, stageWidth); + // The artwork card's file row sits directly below the identity block; + // give that pairing more air than the immersive header needs. + final identityToActionsGap = artworkCard ? 24.0 : 12.0; + + return Center( + child: SizedBox( + width: width, + child: Row( + children: [ + SizedBox( + width: stageWidth, + child: Center( + child: artworkCard && !lyricsMode + ? SizedBox( + width: artExtent, + height: math.min(artExtent, constraints.maxHeight), + child: stage, + ) + : stage, + ), + ), + SizedBox(width: gap), + SizedBox( + width: controlsWidth, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: SingleChildScrollView( + key: const ValueKey('landscape-player-details'), + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + identity, + if (actions != null) ...[ + SizedBox(height: identityToActionsGap), + actions!, + ], + ], + ), + ), + ), + const SizedBox(height: 12), + playback, + ], + ), + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/features/player/widgets/pitch_bottom_sheet.dart b/lib/features/player/widgets/pitch_bottom_sheet.dart index ff8f0036..0d2cb6f4 100644 --- a/lib/features/player/widgets/pitch_bottom_sheet.dart +++ b/lib/features/player/widgets/pitch_bottom_sheet.dart @@ -14,19 +14,20 @@ class PitchBottomSheet extends StatelessWidget { useRootNavigator: true, context: context, backgroundColor: Colors.transparent, + isScrollControlled: true, builder: (context) => PitchBottomSheet(playerService: playerService), ); } @override Widget build(BuildContext context) { - return Container( - decoration: AppBottomSheetSurface.decoration, - padding: EdgeInsets.fromLTRB( + return AppBottomSheetSurface( + scrollable: true, + padding: const EdgeInsets.fromLTRB( 20, 16, 20, - 24 + MediaQuery.paddingOf(context).bottom, + 24, ), child: Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/features/player/widgets/player_action_button_row.dart b/lib/features/player/widgets/player_action_button_row.dart index 871119d2..9c352d5e 100644 --- a/lib/features/player/widgets/player_action_button_row.dart +++ b/lib/features/player/widgets/player_action_button_row.dart @@ -43,6 +43,9 @@ class PlayerActionButtonRow extends ConsumerStatefulWidget { final GlobalKey usbVolumeButtonKey; final void Function(BuildContext) onShowUsbVolumePopup; + /// Horizontal, tighter arrangement used by the landscape player pane. + final bool compact; + const PlayerActionButtonRow({ super.key, required this.song, @@ -62,6 +65,7 @@ class PlayerActionButtonRow extends ConsumerStatefulWidget { required this.onOpenQueue, required this.usbVolumeButtonKey, required this.onShowUsbVolumePopup, + this.compact = false, }); @override @@ -82,120 +86,121 @@ class _PlayerActionButtonRowState extends ConsumerState { final rightTopAction = widget.rightTopAction; final rightAction = widget.rightAction; final immersiveActions = playerScreenMode == PlayerScreenMode.immersive; - final actionPadding = immersiveActions + final actionPadding = widget.compact + ? const EdgeInsets.all(15) + : immersiveActions ? EdgeInsets.all(context.responsive(8.0, 9.0, 10.0)) : EdgeInsets.all(context.responsive(6.0, 7.0, 8.0)); final actionRadius = immersiveActions ? 12.0 : 10.0; - final actionIconSize = context.responsive(18.0, 20.0, 22.0); + final actionIconSize = widget.compact + ? 18.0 + : context.responsive(18.0, 20.0, 22.0); final accentBlend = albumColorMode.accentBlend; final surfaceBlend = albumColorMode.surfaceBlend; final hasAlbumTint = albumColor != null && accentBlend > 0; final inactiveBg = hasAlbumTint - ? albumSurface( - albumColor, - surfaceBlend, - ).withValues(alpha: 0.15) + ? albumSurface(albumColor, surfaceBlend).withValues(alpha: 0.15) : Colors.white.withValues(alpha: 0.15); final inactiveBorder = hasAlbumTint - ? albumSurface( - albumColor, - surfaceBlend, - ).withValues(alpha: 0.08) + ? albumSurface(albumColor, surfaceBlend).withValues(alpha: 0.08) : Colors.white.withValues(alpha: 0.08); - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, + final leftButtons = _buildSideButtons( + topAction: leftTopAction, + bottomAction: leftAction, + context: context, + song: song, + actionPadding: actionPadding, + actionRadius: actionRadius, + actionIconSize: actionIconSize, + inactiveBg: inactiveBg, + inactiveBorder: inactiveBorder, + albumColor: albumColor, + accentBlend: accentBlend, + hasAlbumTint: hasAlbumTint, + immersiveActions: immersiveActions, + lyricsMode: lyricsMode, + compact: widget.compact, + ); + final fileInfo = Row( + mainAxisSize: MainAxisSize.min, children: [ - _buildSideButtons( - topAction: leftTopAction, - bottomAction: leftAction, - context: context, - song: song, - actionPadding: actionPadding, - actionRadius: actionRadius, - actionIconSize: actionIconSize, - inactiveBg: inactiveBg, - inactiveBorder: inactiveBorder, - albumColor: albumColor, - accentBlend: accentBlend, - hasAlbumTint: hasAlbumTint, - immersiveActions: immersiveActions, - lyricsMode: lyricsMode, - ), - Flexible( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - _buildPlayerBadge(context, song.fileType.toUpperCase()), - if (song.isDsd && song.dsdRateLabel.isNotEmpty) ...[ - SizedBox(width: context.responsive(5.0, 6.0, 7.0)), - Flexible( - child: Text( - song.dsdRateLabel, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontFamily: 'GoogleSansFlex', - fontSize: context.responsive(9.0, 10.0, 11.0), - color: Colors.white.withValues(alpha: 0.7), - ), - ), - ), - ] else if (song.resolution != null) ...[ - SizedBox(width: context.responsive(5.0, 6.0, 7.0)), - Flexible( - child: Text( - song.resolution!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontFamily: 'GoogleSansFlex', - fontSize: context.responsive(9.0, 10.0, 11.0), - color: Colors.white.withValues(alpha: 0.7), - ), - ), - ), - ], - SizedBox(width: context.responsive(5.0, 6.0, 7.0)), - BitPerfectIndicator( - song: song, - playerService: widget.playerService, - onTap: () { - final diagnostics = ref.read(audioOutputDiagnosticsProvider); - final deviceStatus = ref.read(uac2DeviceStatusProvider); - BitPerfectIndicator.showInfoSheet( - context, - song: song, - diagnostics: diagnostics, - deviceStatus: deviceStatus, - playerService: widget.playerService, - ); - }, + _buildPlayerBadge(context, song.fileType.toUpperCase()), + if (song.isDsd && song.dsdRateLabel.isNotEmpty) ...[ + SizedBox(width: context.responsive(5.0, 6.0, 7.0)), + Flexible( + child: Text( + song.dsdRateLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontFamily: 'GoogleSansFlex', + fontSize: context.responsive(9.0, 10.0, 11.0), + color: Colors.white.withValues(alpha: 0.7), ), - ], + ), ), - ), - _buildSideButtons( - topAction: rightTopAction, - bottomAction: rightAction, - context: context, + ] else if (song.resolution != null) ...[ + SizedBox(width: context.responsive(5.0, 6.0, 7.0)), + Flexible( + child: Text( + song.resolution!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontFamily: 'GoogleSansFlex', + fontSize: context.responsive(9.0, 10.0, 11.0), + color: Colors.white.withValues(alpha: 0.7), + ), + ), + ), + ], + SizedBox(width: context.responsive(5.0, 6.0, 7.0)), + BitPerfectIndicator( song: song, - actionPadding: actionPadding, - actionRadius: actionRadius, - actionIconSize: actionIconSize, - inactiveBg: inactiveBg, - inactiveBorder: inactiveBorder, - albumColor: albumColor, - accentBlend: accentBlend, - hasAlbumTint: hasAlbumTint, - immersiveActions: immersiveActions, - lyricsMode: lyricsMode, + playerService: widget.playerService, + onTap: () { + final diagnostics = ref.read(audioOutputDiagnosticsProvider); + final deviceStatus = ref.read(uac2DeviceStatusProvider); + BitPerfectIndicator.showInfoSheet( + context, + song: song, + diagnostics: diagnostics, + deviceStatus: deviceStatus, + playerService: widget.playerService, + ); + }, ), ], ); + final rightButtons = _buildSideButtons( + topAction: rightTopAction, + bottomAction: rightAction, + context: context, + song: song, + actionPadding: actionPadding, + actionRadius: actionRadius, + actionIconSize: actionIconSize, + inactiveBg: inactiveBg, + inactiveBorder: inactiveBorder, + albumColor: albumColor, + accentBlend: accentBlend, + hasAlbumTint: hasAlbumTint, + immersiveActions: immersiveActions, + lyricsMode: lyricsMode, + compact: widget.compact, + ); + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + leftButtons, + Flexible(child: fileInfo), + rightButtons, + ], + ); } Widget _buildPlayerBadge(BuildContext context, String label) { @@ -219,6 +224,7 @@ class _PlayerActionButtonRowState extends ConsumerState { ), ); } + Widget _buildSideButtons({ required PlayerActionButton topAction, required PlayerActionButton bottomAction, @@ -234,12 +240,17 @@ class _PlayerActionButtonRowState extends ConsumerState { required bool hasAlbumTint, required bool immersiveActions, required bool lyricsMode, + required bool compact, }) { final buttons = []; for (final action in [topAction, bottomAction]) { if (action == PlayerActionButton.none) continue; if (buttons.isNotEmpty) { - buttons.add(SizedBox(height: context.responsive(6.0, 7.0, 8.0))); + buttons.add( + compact + ? const SizedBox(width: 6) + : SizedBox(height: context.responsive(6.0, 7.0, 8.0)), + ); } buttons.add( _buildActionButton( @@ -260,6 +271,9 @@ class _PlayerActionButtonRowState extends ConsumerState { ); } if (buttons.isEmpty) return const SizedBox.shrink(); + if (compact) { + return Row(mainAxisSize: MainAxisSize.min, children: buttons); + } return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, @@ -426,17 +440,14 @@ class _PlayerActionButtonRowState extends ConsumerState { required bool lyricsMode, }) { final lyricsActiveBg = hasAlbumTint - ? albumAccent( - albumColor!, - accentBlend, - ).withValues(alpha: 0.28) + ? albumAccent(albumColor!, accentBlend).withValues(alpha: 0.28) : AppColors.accent.withValues(alpha: 0.28); final lyricsActiveBorder = hasAlbumTint - ? albumAccent( - albumColor!, - accentBlend, - ).withValues(alpha: 0.45) + ? albumAccent(albumColor!, accentBlend).withValues(alpha: 0.45) : AppColors.accent.withValues(alpha: 0.45); + final lyricsActiveColor = hasAlbumTint + ? albumAccent(albumColor!, accentBlend) + : AppColors.accent; return Tooltip( message: lyricsMode ? l10n.hideLyrics : l10n.showLyrics, @@ -444,18 +455,22 @@ class _PlayerActionButtonRowState extends ConsumerState { onTap: () => widget.onToggleLyrics(), child: Container( padding: actionPadding, - decoration: BoxDecoration( - color: lyricsMode ? lyricsActiveBg : inactiveBg, - borderRadius: BorderRadius.circular(actionRadius), - border: Border.all( - color: lyricsMode ? lyricsActiveBorder : inactiveBorder, - ), - ), + decoration: widget.compact + ? null + : BoxDecoration( + color: lyricsMode ? lyricsActiveBg : inactiveBg, + borderRadius: BorderRadius.circular(actionRadius), + border: Border.all( + color: lyricsMode ? lyricsActiveBorder : inactiveBorder, + ), + ), child: Icon( lyricsMode ? Icons.keyboard_arrow_down_rounded : LucideIcons.fileText, - color: Colors.white.withValues(alpha: 0.96), + color: widget.compact && lyricsMode + ? lyricsActiveColor + : Colors.white.withValues(alpha: 0.96), size: actionIconSize, ), ), @@ -479,14 +494,18 @@ class _PlayerActionButtonRowState extends ConsumerState { final isFavorite = snapshot.data ?? false; return GestureDetector( onTap: () async { - final newState = await widget.favoritesService.toggleFavorite(song.id); + final newState = await widget.favoritesService.toggleFavorite( + song.id, + ); setState(() {}); widget.playerService.refreshNotificationState(); if (context.mounted) { ScaffoldMessenger.of(context).showFlickSnackBar( SnackBar( content: Text( - newState ? l10n.addedToFavorites : l10n.removedFromFavorites3, + newState + ? l10n.addedToFavorites + : l10n.removedFromFavorites3, ), duration: const Duration(seconds: 1), ), @@ -495,25 +514,24 @@ class _PlayerActionButtonRowState extends ConsumerState { }, child: Container( padding: actionPadding, - decoration: BoxDecoration( - color: isFavorite - ? (hasAlbumTint - ? albumAccent( - albumColor!, - accentBlend, - ).withValues(alpha: 0.25) - : Colors.red.withValues(alpha: 0.25)) - : inactiveBg, - borderRadius: BorderRadius.circular(actionRadius), - ), + decoration: widget.compact + ? null + : BoxDecoration( + color: isFavorite + ? (hasAlbumTint + ? albumAccent( + albumColor!, + accentBlend, + ).withValues(alpha: 0.25) + : Colors.red.withValues(alpha: 0.25)) + : inactiveBg, + borderRadius: BorderRadius.circular(actionRadius), + ), child: Icon( isFavorite ? Icons.favorite : Icons.favorite_border, color: isFavorite ? (hasAlbumTint - ? albumAccent( - albumColor!, - accentBlend, - ) + ? albumAccent(albumColor!, accentBlend) : Colors.red) : Colors.white.withValues(alpha: 0.9), size: actionIconSize, @@ -537,17 +555,14 @@ class _PlayerActionButtonRowState extends ConsumerState { }) { final isVisMode = widget.isVisualizationMode; final visActiveBg = hasAlbumTint - ? albumAccent( - albumColor!, - accentBlend, - ).withValues(alpha: 0.28) + ? albumAccent(albumColor!, accentBlend).withValues(alpha: 0.28) : AppColors.accent.withValues(alpha: 0.28); final visActiveBorder = hasAlbumTint - ? albumAccent( - albumColor!, - accentBlend, - ).withValues(alpha: 0.45) + ? albumAccent(albumColor!, accentBlend).withValues(alpha: 0.45) : AppColors.accent.withValues(alpha: 0.45); + final visActiveColor = hasAlbumTint + ? albumAccent(albumColor!, accentBlend) + : AppColors.accent; return Tooltip( message: isVisMode ? l10n.hideVisualizer : l10n.showVisualizer, @@ -555,16 +570,20 @@ class _PlayerActionButtonRowState extends ConsumerState { onTap: () => widget.onToggleVisualization(!isVisMode), child: Container( padding: actionPadding, - decoration: BoxDecoration( - color: isVisMode ? visActiveBg : inactiveBg, - borderRadius: BorderRadius.circular(actionRadius), - border: Border.all( - color: isVisMode ? visActiveBorder : inactiveBorder, - ), - ), + decoration: widget.compact + ? null + : BoxDecoration( + color: isVisMode ? visActiveBg : inactiveBg, + borderRadius: BorderRadius.circular(actionRadius), + border: Border.all( + color: isVisMode ? visActiveBorder : inactiveBorder, + ), + ), child: Icon( Icons.graphic_eq_rounded, - color: Colors.white.withValues(alpha: 0.96), + color: widget.compact && isVisMode + ? visActiveColor + : Colors.white.withValues(alpha: 0.96), size: actionIconSize, ), ), @@ -602,6 +621,7 @@ class _PlayerActionButtonRowState extends ConsumerState { accentBlend: accentBlend, inactiveBg: inactiveBg, inactiveBorder: inactiveBorder, + flat: widget.compact, ); } @@ -622,11 +642,13 @@ class _PlayerActionButtonRowState extends ConsumerState { onTap: () => widget.onOpenQueue(context), child: Container( padding: actionPadding, - decoration: BoxDecoration( - color: inactiveBg, - borderRadius: BorderRadius.circular(actionRadius), - border: Border.all(color: inactiveBorder), - ), + decoration: widget.compact + ? null + : BoxDecoration( + color: inactiveBg, + borderRadius: BorderRadius.circular(actionRadius), + border: Border.all(color: inactiveBorder), + ), child: Icon( LucideIcons.listMusic, color: Colors.white.withValues(alpha: 0.96), @@ -651,11 +673,13 @@ class _PlayerActionButtonRowState extends ConsumerState { onTap: () => SleepTimerBottomSheet.show(context, widget.playerService), child: Container( padding: actionPadding, - decoration: BoxDecoration( - color: inactiveBg, - borderRadius: BorderRadius.circular(actionRadius), - border: Border.all(color: inactiveBorder), - ), + decoration: widget.compact + ? null + : BoxDecoration( + color: inactiveBg, + borderRadius: BorderRadius.circular(actionRadius), + border: Border.all(color: inactiveBorder), + ), child: Icon( LucideIcons.moonStar, color: Colors.white.withValues(alpha: 0.96), @@ -679,7 +703,7 @@ class _PlayerActionButtonRowState extends ConsumerState { child: GestureDetector( onTap: () { showModalBottomSheet( - useRootNavigator: true, + useRootNavigator: true, context: context, backgroundColor: Colors.transparent, isScrollControlled: true, @@ -688,11 +712,13 @@ class _PlayerActionButtonRowState extends ConsumerState { }, child: Container( padding: actionPadding, - decoration: BoxDecoration( - color: inactiveBg, - borderRadius: BorderRadius.circular(actionRadius), - border: Border.all(color: inactiveBorder), - ), + decoration: widget.compact + ? null + : BoxDecoration( + color: inactiveBg, + borderRadius: BorderRadius.circular(actionRadius), + border: Border.all(color: inactiveBorder), + ), child: Icon( LucideIcons.share2, color: Colors.white.withValues(alpha: 0.96), @@ -731,11 +757,13 @@ class _PlayerActionButtonRowState extends ConsumerState { onTap: () => widget.onShowUsbVolumePopup(context), child: Container( padding: actionPadding, - decoration: BoxDecoration( - color: inactiveBg, - borderRadius: BorderRadius.circular(actionRadius), - border: Border.all(color: inactiveBorder), - ), + decoration: widget.compact + ? null + : BoxDecoration( + color: inactiveBg, + borderRadius: BorderRadius.circular(actionRadius), + border: Border.all(color: inactiveBorder), + ), child: Icon( LucideIcons.volume2, color: Colors.white.withValues(alpha: 0.96), @@ -788,11 +816,13 @@ class _PlayerActionButtonRowState extends ConsumerState { }, child: Container( padding: actionPadding, - decoration: BoxDecoration( - color: inactiveBg, - borderRadius: BorderRadius.circular(actionRadius), - border: Border.all(color: inactiveBorder), - ), + decoration: widget.compact + ? null + : BoxDecoration( + color: inactiveBg, + borderRadius: BorderRadius.circular(actionRadius), + border: Border.all(color: inactiveBorder), + ), child: Icon( LucideIcons.slidersHorizontal, color: Colors.white.withValues(alpha: 0.96), @@ -817,11 +847,13 @@ class _PlayerActionButtonRowState extends ConsumerState { onTap: () => VolumeBottomSheet.show(context, widget.playerService), child: Container( padding: actionPadding, - decoration: BoxDecoration( - color: inactiveBg, - borderRadius: BorderRadius.circular(actionRadius), - border: Border.all(color: inactiveBorder), - ), + decoration: widget.compact + ? null + : BoxDecoration( + color: inactiveBg, + borderRadius: BorderRadius.circular(actionRadius), + border: Border.all(color: inactiveBorder), + ), child: Icon( LucideIcons.volume, color: Colors.white.withValues(alpha: 0.96), @@ -845,23 +877,23 @@ class _PlayerActionButtonRowState extends ConsumerState { message: isCasting ? l10n.castingTapToManage : l10n.cast, child: GestureDetector( onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const CastingSettingsScreen(), - ), + MaterialPageRoute(builder: (_) => const CastingSettingsScreen()), ), child: Container( padding: actionPadding, - decoration: BoxDecoration( - color: isCasting - ? AppColors.accent.withValues(alpha: 0.28) - : inactiveBg, - borderRadius: BorderRadius.circular(actionRadius), - border: Border.all( - color: isCasting - ? AppColors.accent.withValues(alpha: 0.45) - : inactiveBorder, - ), - ), + decoration: widget.compact + ? null + : BoxDecoration( + color: isCasting + ? AppColors.accent.withValues(alpha: 0.28) + : inactiveBg, + borderRadius: BorderRadius.circular(actionRadius), + border: Border.all( + color: isCasting + ? AppColors.accent.withValues(alpha: 0.45) + : inactiveBorder, + ), + ), child: Icon( LucideIcons.cast, color: isCasting diff --git a/lib/features/player/widgets/player_controls.dart b/lib/features/player/widgets/player_controls.dart index b2b8995e..a6002721 100644 --- a/lib/features/player/widgets/player_controls.dart +++ b/lib/features/player/widgets/player_controls.dart @@ -13,6 +13,7 @@ import 'package:flick/features/player/widgets/shuffle_mode_sheet.dart'; import 'package:flick/features/player/widgets/loop_mode_sheet.dart'; import 'package:flick/l10n/l10n.dart'; + class PlayerControls extends StatelessWidget { final PlayerService playerService; final String Function(Duration) formatDuration; @@ -24,7 +25,11 @@ class PlayerControls extends StatelessWidget { final Color? albumColor; final bool showTimeLabels; - const PlayerControls({super.key, + /// Tighter metrics for the right pane of the landscape player. + final bool compact; + + const PlayerControls({ + super.key, required this.playerService, required this.formatDuration, required this.currentSong, @@ -34,6 +39,7 @@ class PlayerControls extends StatelessWidget { this.albumColorMode = AlbumColorMode.off, this.albumColor, this.showTimeLabels = true, + this.compact = false, }); @override @@ -48,6 +54,9 @@ class PlayerControls extends StatelessWidget { final activeAccent = hasAlbumTint ? albumAccent(albumColor!, accentBlend) : AppColors.accent; + final sideSize = compact ? 44.0 : context.responsive(40.0, 44.0, 48.0); + final iconSize = compact ? 18.0 : context.responsive(18.0, 20.0, 22.0); + final gap = compact ? 4.0 : context.responsive(14.0, 18.0, 22.0); return RepaintBoundary( child: ValueListenableBuilder( @@ -79,8 +88,7 @@ class PlayerControls extends StatelessWidget { position: position, duration: duration, formatDuration: formatDuration, - horizontalPadding: - timelineHorizontalPadding, + horizontalPadding: timelineHorizontalPadding, ), const SizedBox(height: 12), ], @@ -88,9 +96,12 @@ class PlayerControls extends StatelessWidget { : const SizedBox.shrink(), ), ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ + FittedBox( + fit: BoxFit.scaleDown, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ // Shuffle ValueListenableBuilder( valueListenable: playerService.shuffleModeNotifier, @@ -107,22 +118,25 @@ class PlayerControls extends StatelessWidget { playerService.toggleShuffle(); final next = playerService.shuffleModeNotifier.value; - ScaffoldMessenger.of(context) - .showFlickSnackBar( - SnackBar( - content: Text(l10n.englishForFilipino.shuffle2(next.label)), - behavior: SnackBarBehavior.floating, - duration: const Duration(seconds: 1), + ScaffoldMessenger.of(context).showFlickSnackBar( + SnackBar( + content: Text( + l10n.englishForFilipino.shuffle2( + next.label, + ), ), - ); + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 1), + ), + ); }, onLongPress: () { AppHaptics.tap(); ShuffleModeSheet.show(context, playerService); }, child: Container( - width: context.responsive(40.0, 44.0, 48.0), - height: context.responsive(40.0, 44.0, 48.0), + width: sideSize, + height: sideSize, decoration: BoxDecoration( color: isActive ? activeAccent.withValues(alpha: 0.25) @@ -139,7 +153,7 @@ class PlayerControls extends StatelessWidget { ), child: Icon( icon, - size: context.responsive(18.0, 20.0, 22.0), + size: iconSize, color: isActive ? activeAccent : Colors.white.withValues(alpha: 0.7), @@ -148,11 +162,11 @@ class PlayerControls extends StatelessWidget { ); }, ), - SizedBox(width: context.responsive(14.0, 18.0, 22.0)), + SizedBox(width: gap), // Previous Container( - width: context.responsive(40.0, 44.0, 48.0), - height: context.responsive(40.0, 44.0, 48.0), + width: sideSize, + height: sideSize, decoration: BoxDecoration( color: buttonSurface.withValues(alpha: 0.6), shape: BoxShape.circle, @@ -162,23 +176,24 @@ class PlayerControls extends StatelessWidget { AppHaptics.tap(); onPrevious(); }, - iconSize: context.responsive(18.0, 20.0, 22.0), + iconSize: iconSize, padding: EdgeInsets.zero, icon: Icon(LucideIcons.skipBack, color: Colors.white), ), ), - SizedBox(width: context.responsive(14.0, 18.0, 22.0)), + SizedBox(width: gap), // Play/Pause _PlayPauseButton( playerService: playerService, albumColorMode: albumColorMode, albumColor: albumColor, + compact: compact, ), - SizedBox(width: context.responsive(14.0, 18.0, 22.0)), + SizedBox(width: gap), // Next Container( - width: context.responsive(40.0, 44.0, 48.0), - height: context.responsive(40.0, 44.0, 48.0), + width: sideSize, + height: sideSize, decoration: BoxDecoration( color: buttonSurface.withValues(alpha: 0.6), shape: BoxShape.circle, @@ -188,7 +203,7 @@ class PlayerControls extends StatelessWidget { AppHaptics.tap(); onNext(); }, - iconSize: context.responsive(18.0, 20.0, 22.0), + iconSize: iconSize, padding: EdgeInsets.zero, icon: Icon( LucideIcons.skipForward, @@ -196,7 +211,7 @@ class PlayerControls extends StatelessWidget { ), ), ), - SizedBox(width: context.responsive(14.0, 18.0, 22.0)), + SizedBox(width: gap), // Repeat/Loop ValueListenableBuilder( valueListenable: playerService.loopModeNotifier, @@ -211,22 +226,23 @@ class PlayerControls extends StatelessWidget { AppHaptics.tap(); playerService.toggleLoopMode(); final next = playerService.loopModeNotifier.value; - ScaffoldMessenger.of(context) - .showFlickSnackBar( - SnackBar( - content: Text(l10n.englishForFilipino.repeat(next.label)), - behavior: SnackBarBehavior.floating, - duration: const Duration(seconds: 1), + ScaffoldMessenger.of(context).showFlickSnackBar( + SnackBar( + content: Text( + l10n.englishForFilipino.repeat(next.label), ), - ); + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 1), + ), + ); }, onLongPress: () { AppHaptics.tap(); LoopModeSheet.show(context, playerService); }, child: Container( - width: context.responsive(40.0, 44.0, 48.0), - height: context.responsive(40.0, 44.0, 48.0), + width: sideSize, + height: sideSize, decoration: BoxDecoration( color: buttonSurface.withValues(alpha: 0.6), shape: BoxShape.circle, @@ -234,11 +250,7 @@ class PlayerControls extends StatelessWidget { child: Stack( alignment: Alignment.center, children: [ - Icon( - icon, - size: context.responsive(18.0, 20.0, 22.0), - color: color, - ), + Icon(icon, size: iconSize, color: color), if (loopMode == LoopMode.all) Positioned( bottom: 6, @@ -257,7 +269,8 @@ class PlayerControls extends StatelessWidget { ); }, ), - ], + ], + ), ), ], ); @@ -275,7 +288,8 @@ class PlaybackTimeLabels extends StatelessWidget { final String Function(Duration) formatDuration; final double horizontalPadding; - const PlaybackTimeLabels({super.key, + const PlaybackTimeLabels({ + super.key, required this.position, required this.duration, required this.formatDuration, @@ -319,10 +333,13 @@ class _PlayPauseButton extends StatelessWidget { final AlbumColorMode albumColorMode; final Color? albumColor; + final bool compact; + const _PlayPauseButton({ required this.playerService, this.albumColorMode = AlbumColorMode.off, this.albumColor, + this.compact = false, }); @override @@ -342,8 +359,12 @@ class _PlayPauseButton extends StatelessWidget { child: ValueListenableBuilder( valueListenable: playerService.isPlayingNotifier, builder: (context, isPlaying, _) { - final buttonSize = context.responsive(58.0, 64.0, 68.0); - final iconSize = context.responsive(26.0, 28.0, 30.0); + final buttonSize = compact + ? 52.0 + : context.responsive(58.0, 64.0, 68.0); + final iconSize = compact + ? 24.0 + : context.responsive(26.0, 28.0, 30.0); return Container( width: buttonSize, diff --git a/lib/features/player/widgets/player_layout_sheet.dart b/lib/features/player/widgets/player_layout_sheet.dart index aff5d3dd..6ce01a1a 100644 --- a/lib/features/player/widgets/player_layout_sheet.dart +++ b/lib/features/player/widgets/player_layout_sheet.dart @@ -68,410 +68,416 @@ class _PlayerLayoutSheetState extends ConsumerState { final immersiveBackgroundStyle = ImmersiveBackgroundStyleX.fromStorageValue( appPrefs.immersiveBackgroundStyle, ); - return SafeArea( - top: false, - bottom: false, - child: Container( - constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height * 0.9, - ), - decoration: AppBottomSheetSurface.decoration, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 0), - child: Row( + return AppBottomSheetSurface( + padding: EdgeInsets.zero, + child: LayoutBuilder( + builder: (context, constraints) { + final isWide = + MediaQuery.sizeOf(context).width > + MediaQuery.sizeOf(context).height; + final header = Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 0), + child: Row( + children: [ + Icon( + Icons.dashboard_customize_rounded, + size: 20, + color: context.adaptiveTextSecondary, + ), + const SizedBox(width: 10), + Text( + l10n.playerLayout, + style: TextStyle( + fontFamily: 'GoogleSansFlex', + fontSize: 18, + fontWeight: FontWeight.w600, + color: context.adaptiveTextPrimary, + ), + ), + ], + ), + ); + final preview = Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: GestureDetector( + onTap: () => _showFullScreenPreview(), + child: Stack( children: [ - Icon( - Icons.dashboard_customize_rounded, - size: 20, - color: context.adaptiveTextSecondary, + _PlayerLayoutPreview( + song: widget.playerService.currentSongNotifier.value, + mode: _mode, + artworkCardArtworkScale: appPrefs.artworkCardArtworkScale, + artworkCardTextScale: appPrefs.artworkCardTextScale, + artworkCardVerticalOffset: + appPrefs.artworkCardVerticalOffset, + artworkCardArtworkOffset: appPrefs.artworkCardArtworkOffset, + artworkCardShowTitle: appPrefs.artworkCardShowTitle, + artworkCardShowArtist: appPrefs.artworkCardShowArtist, + artworkCardShowAlbum: appPrefs.artworkCardShowAlbum, + artworkCardShowFrame: appPrefs.artworkCardShowFrame, + immersiveTextScale: appPrefs.immersiveTextScale, + immersiveVerticalOffset: appPrefs.immersiveVerticalOffset, + immersiveFullViewScale: appPrefs.immersiveFullViewScale, + immersiveShowTitle: appPrefs.immersiveShowTitle, + immersiveShowArtist: appPrefs.immersiveShowArtist, + immersiveBackgroundStyle: immersiveBackgroundStyle, ), - const SizedBox(width: 10), - Text( - l10n.playerLayout, - style: TextStyle( - fontFamily: 'GoogleSansFlex', - fontSize: 18, - fontWeight: FontWeight.w600, - color: context.adaptiveTextPrimary, + Positioned( + top: 8, + right: 8, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.48), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.open_in_full_rounded, + size: 13, + color: Colors.white.withValues(alpha: 0.86), + ), + const SizedBox(width: 4), + Text( + l10n.fullscreen, + style: TextStyle( + fontFamily: 'GoogleSansFlex', + fontSize: 11, + fontWeight: FontWeight.w500, + color: Colors.white.withValues(alpha: 0.86), + ), + ), + ], + ), ), ), ], ), ), - const SizedBox(height: 12), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: GestureDetector( - onTap: () => _showFullScreenPreview(), - child: Stack( + ); + final settings = SingleChildScrollView( + // Surface reaches the bottom edge; content clears the system + // gesture/navigation inset. + padding: EdgeInsets.fromLTRB(20, 12, 20, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _PlayerLayoutOptionTile( + title: PlayerScreenMode.immersive.labelFor(l10n), + subtitle: PlayerScreenMode.immersive.descriptionFor(l10n), + icon: Icons.fit_screen_rounded, + isSelected: _mode == PlayerScreenMode.immersive, + onTap: () { + unawaited(widget.onModeChanged(PlayerScreenMode.immersive)); + setState(() => _mode = PlayerScreenMode.immersive); + }, + ), + const SizedBox(height: 12), + _PlayerLayoutOptionTile( + title: PlayerScreenMode.artworkCard.labelFor(l10n), + subtitle: PlayerScreenMode.artworkCard.descriptionFor(l10n), + icon: Icons.rounded_corner_rounded, + isSelected: _mode == PlayerScreenMode.artworkCard, + onTap: () { + unawaited( + widget.onModeChanged(PlayerScreenMode.artworkCard), + ); + setState(() => _mode = PlayerScreenMode.artworkCard); + }, + ), + const SizedBox(height: 20), + _PlayerCustomizationGroup( + title: l10n.artworkCard, + icon: Icons.rounded_corner_rounded, children: [ - _PlayerLayoutPreview( - song: widget.playerService.currentSongNotifier.value, - mode: _mode, - artworkCardArtworkScale: - appPrefs.artworkCardArtworkScale, - artworkCardTextScale: appPrefs.artworkCardTextScale, - artworkCardVerticalOffset: - appPrefs.artworkCardVerticalOffset, - artworkCardArtworkOffset: - appPrefs.artworkCardArtworkOffset, - artworkCardShowTitle: appPrefs.artworkCardShowTitle, - artworkCardShowArtist: appPrefs.artworkCardShowArtist, - artworkCardShowAlbum: appPrefs.artworkCardShowAlbum, - artworkCardShowFrame: appPrefs.artworkCardShowFrame, - immersiveTextScale: appPrefs.immersiveTextScale, - immersiveVerticalOffset: appPrefs.immersiveVerticalOffset, - immersiveFullViewScale: appPrefs.immersiveFullViewScale, - immersiveShowTitle: appPrefs.immersiveShowTitle, - immersiveShowArtist: appPrefs.immersiveShowArtist, - immersiveBackgroundStyle: immersiveBackgroundStyle, + _PlayerCustomizationSlider( + title: l10n.artworkSize, + value: appPrefs.artworkCardArtworkScale, + min: 0.8, + max: 1.36, + divisions: 28, + valueLabel: + '${(appPrefs.artworkCardArtworkScale * 100).round()}%', + onChanged: prefsNotifier.setArtworkCardArtworkScale, ), - Positioned( - top: 8, - right: 8, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.48), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.open_in_full_rounded, - size: 13, - color: Colors.white.withValues(alpha: 0.86), - ), - const SizedBox(width: 4), - Text( - l10n.fullscreen, - style: TextStyle( - fontFamily: 'GoogleSansFlex', - fontSize: 11, - fontWeight: FontWeight.w500, - color: Colors.white.withValues(alpha: 0.86), - ), - ), - ], - ), + _PlayerCustomizationSlider( + title: l10n.textSize, + value: appPrefs.artworkCardTextScale, + min: 0.82, + max: 1.2, + divisions: 19, + valueLabel: + '${(appPrefs.artworkCardTextScale * 100).round()}%', + onChanged: prefsNotifier.setArtworkCardTextScale, + ), + _PlayerCustomizationSlider( + title: l10n.contentPlacement, + value: appPrefs.artworkCardVerticalOffset, + min: -36, + max: 36, + divisions: 12, + valueLabel: _placementLabel( + appPrefs.artworkCardVerticalOffset, ), + onChanged: prefsNotifier.setArtworkCardVerticalOffset, + ), + _PlayerCustomizationSlider( + title: l10n.artworkPlacement, + value: appPrefs.artworkCardArtworkOffset, + min: -48, + max: 48, + divisions: 16, + valueLabel: _placementLabel( + appPrefs.artworkCardArtworkOffset, + ), + onChanged: prefsNotifier.setArtworkCardArtworkOffset, + ), + const SizedBox(height: 6), + _PlayerCustomizationToggle( + title: l10n.showTitle, + value: appPrefs.artworkCardShowTitle, + onChanged: prefsNotifier.setArtworkCardShowTitle, + ), + _PlayerCustomizationToggle( + title: l10n.showArtist, + value: appPrefs.artworkCardShowArtist, + onChanged: prefsNotifier.setArtworkCardShowArtist, + ), + _PlayerCustomizationToggle( + title: l10n.showAlbum, + value: appPrefs.artworkCardShowAlbum, + onChanged: prefsNotifier.setArtworkCardShowAlbum, + ), + _PlayerCustomizationToggle( + title: l10n.showFileInfo, + value: appPrefs.artworkCardShowFileInfo, + onChanged: prefsNotifier.setArtworkCardShowFileInfo, + ), + _PlayerCustomizationToggle( + title: l10n.showFrame, + value: appPrefs.artworkCardShowFrame, + onChanged: prefsNotifier.setArtworkCardShowFrame, ), ], ), - ), - ), - const SizedBox(height: 12), - Container( - height: 1, - margin: const EdgeInsets.symmetric(horizontal: 20), - color: AppColors.glassBorder, - ), - Flexible( - child: SingleChildScrollView( - // Surface reaches the bottom edge; content clears the system - // gesture/navigation inset. - padding: EdgeInsets.fromLTRB( - 20, - 12, - 20, - 24 + MediaQuery.paddingOf(context).bottom, + const SizedBox(height: 16), + _PlayerCustomizationGroup( + title: l10n.immersive, + icon: Icons.fit_screen_rounded, + children: [ + _PlayerBackgroundStyleSelector( + currentValue: immersiveBackgroundStyle, + onChanged: (style) { + prefsNotifier.setImmersiveBackgroundStyle( + style.storageValue, + ); + }, + ), + _PlayerCustomizationSlider( + title: l10n.textSize, + value: appPrefs.immersiveTextScale, + min: 0.82, + max: 1.2, + divisions: 19, + valueLabel: + '${(appPrefs.immersiveTextScale * 100).round()}%', + onChanged: prefsNotifier.setImmersiveTextScale, + ), + _PlayerCustomizationSlider( + title: l10n.textPlacement, + value: appPrefs.immersiveVerticalOffset, + min: -36, + max: 36, + divisions: 12, + valueLabel: _placementLabel( + appPrefs.immersiveVerticalOffset, + ), + onChanged: prefsNotifier.setImmersiveVerticalOffset, + ), + _PlayerCustomizationSlider( + title: l10n.fullViewCardSize, + value: appPrefs.immersiveFullViewScale, + min: 0.82, + max: 1.18, + divisions: 18, + valueLabel: + '${(appPrefs.immersiveFullViewScale * 100).round()}%', + onChanged: prefsNotifier.setImmersiveFullViewScale, + ), + const SizedBox(height: 6), + _PlayerCustomizationToggle( + title: l10n.showTitle, + value: appPrefs.immersiveShowTitle, + onChanged: prefsNotifier.setImmersiveShowTitle, + ), + _PlayerCustomizationToggle( + title: l10n.showArtist, + value: appPrefs.immersiveShowArtist, + onChanged: prefsNotifier.setImmersiveShowArtist, + ), + _PlayerCustomizationToggle( + title: l10n.showFileInfo, + value: appPrefs.immersiveShowFileInfo, + onChanged: prefsNotifier.setImmersiveShowFileInfo, + ), + ], ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + const SizedBox(height: 16), + _PlayerCustomizationGroup( + title: l10n.quickActions, + icon: Icons.swap_horiz_rounded, children: [ - _PlayerLayoutOptionTile( - title: PlayerScreenMode.immersive.labelFor(l10n), - subtitle: PlayerScreenMode.immersive.descriptionFor(l10n), - icon: Icons.fit_screen_rounded, - isSelected: _mode == PlayerScreenMode.immersive, - onTap: () { - unawaited(widget.onModeChanged(PlayerScreenMode.immersive)); - setState(() => _mode = PlayerScreenMode.immersive); + _PlayerActionButtonSelector( + label: l10n.leftTop, + currentValue: PlayerActionButtonX.fromStorageValue( + appPrefs.leftTopActionButton, + ), + onChanged: (action) { + prefsNotifier.setLeftTopActionButton( + action.storageValue, + ); }, ), - const SizedBox(height: 12), - _PlayerLayoutOptionTile( - title: PlayerScreenMode.artworkCard.labelFor(l10n), - subtitle: PlayerScreenMode.artworkCard.descriptionFor(l10n), - icon: Icons.rounded_corner_rounded, - isSelected: _mode == PlayerScreenMode.artworkCard, - onTap: () { - unawaited(widget.onModeChanged(PlayerScreenMode.artworkCard)); - setState(() => _mode = PlayerScreenMode.artworkCard); + const SizedBox(height: 8), + _PlayerActionButtonSelector( + label: l10n.leftBottom, + currentValue: PlayerActionButtonX.fromStorageValue( + appPrefs.leftActionButton, + ), + onChanged: (action) { + prefsNotifier.setLeftActionButton(action.storageValue); }, ), - const SizedBox(height: 20), - _PlayerCustomizationGroup( - title: l10n.artworkCard, - icon: Icons.rounded_corner_rounded, - children: [ - _PlayerCustomizationSlider( - title: l10n.artworkSize, - value: appPrefs.artworkCardArtworkScale, - min: 0.8, - max: 1.36, - divisions: 28, - valueLabel: - '${(appPrefs.artworkCardArtworkScale * 100).round()}%', - onChanged: prefsNotifier.setArtworkCardArtworkScale, - ), - _PlayerCustomizationSlider( - title: l10n.textSize, - value: appPrefs.artworkCardTextScale, - min: 0.82, - max: 1.2, - divisions: 19, - valueLabel: - '${(appPrefs.artworkCardTextScale * 100).round()}%', - onChanged: prefsNotifier.setArtworkCardTextScale, - ), - _PlayerCustomizationSlider( - title: l10n.contentPlacement, - value: appPrefs.artworkCardVerticalOffset, - min: -36, - max: 36, - divisions: 12, - valueLabel: _placementLabel( - appPrefs.artworkCardVerticalOffset, - ), - onChanged: prefsNotifier.setArtworkCardVerticalOffset, - ), - _PlayerCustomizationSlider( - title: l10n.artworkPlacement, - value: appPrefs.artworkCardArtworkOffset, - min: -48, - max: 48, - divisions: 16, - valueLabel: _placementLabel( - appPrefs.artworkCardArtworkOffset, - ), - onChanged: prefsNotifier.setArtworkCardArtworkOffset, - ), - const SizedBox(height: 6), - _PlayerCustomizationToggle( - title: l10n.showTitle, - value: appPrefs.artworkCardShowTitle, - onChanged: prefsNotifier.setArtworkCardShowTitle, - ), - _PlayerCustomizationToggle( - title: l10n.showArtist, - value: appPrefs.artworkCardShowArtist, - onChanged: prefsNotifier.setArtworkCardShowArtist, - ), - _PlayerCustomizationToggle( - title: l10n.showAlbum, - value: appPrefs.artworkCardShowAlbum, - onChanged: prefsNotifier.setArtworkCardShowAlbum, - ), - _PlayerCustomizationToggle( - title: l10n.showFileInfo, - value: appPrefs.artworkCardShowFileInfo, - onChanged: prefsNotifier.setArtworkCardShowFileInfo, - ), - _PlayerCustomizationToggle( - title: l10n.showFrame, - value: appPrefs.artworkCardShowFrame, - onChanged: prefsNotifier.setArtworkCardShowFrame, - ), - ], + const SizedBox(height: 8), + _PlayerActionButtonSelector( + label: l10n.rightTop, + currentValue: PlayerActionButtonX.fromStorageValue( + appPrefs.rightTopActionButton, + ), + onChanged: (action) { + prefsNotifier.setRightTopActionButton( + action.storageValue, + ); + }, ), - const SizedBox(height: 16), - _PlayerCustomizationGroup( - title: l10n.immersive, - icon: Icons.fit_screen_rounded, - children: [ - _PlayerBackgroundStyleSelector( - currentValue: immersiveBackgroundStyle, - onChanged: (style) { - prefsNotifier.setImmersiveBackgroundStyle( - style.storageValue, - ); - }, - ), - _PlayerCustomizationSlider( - title: l10n.textSize, - value: appPrefs.immersiveTextScale, - min: 0.82, - max: 1.2, - divisions: 19, - valueLabel: - '${(appPrefs.immersiveTextScale * 100).round()}%', - onChanged: prefsNotifier.setImmersiveTextScale, - ), - _PlayerCustomizationSlider( - title: l10n.textPlacement, - value: appPrefs.immersiveVerticalOffset, - min: -36, - max: 36, - divisions: 12, - valueLabel: _placementLabel( - appPrefs.immersiveVerticalOffset, - ), - onChanged: prefsNotifier.setImmersiveVerticalOffset, - ), - _PlayerCustomizationSlider( - title: l10n.fullViewCardSize, - value: appPrefs.immersiveFullViewScale, - min: 0.82, - max: 1.18, - divisions: 18, - valueLabel: - '${(appPrefs.immersiveFullViewScale * 100).round()}%', - onChanged: prefsNotifier.setImmersiveFullViewScale, - ), - const SizedBox(height: 6), - _PlayerCustomizationToggle( - title: l10n.showTitle, - value: appPrefs.immersiveShowTitle, - onChanged: prefsNotifier.setImmersiveShowTitle, - ), - _PlayerCustomizationToggle( - title: l10n.showArtist, - value: appPrefs.immersiveShowArtist, - onChanged: prefsNotifier.setImmersiveShowArtist, - ), - _PlayerCustomizationToggle( - title: l10n.showFileInfo, - value: appPrefs.immersiveShowFileInfo, - onChanged: prefsNotifier.setImmersiveShowFileInfo, - ), - ], + const SizedBox(height: 8), + _PlayerActionButtonSelector( + label: l10n.rightBottom, + currentValue: PlayerActionButtonX.fromStorageValue( + appPrefs.rightActionButton, + ), + onChanged: (action) { + prefsNotifier.setRightActionButton(action.storageValue); + }, ), - const SizedBox(height: 16), - _PlayerCustomizationGroup( - title: l10n.quickActions, - icon: Icons.swap_horiz_rounded, - children: [ - _PlayerActionButtonSelector( - label: l10n.leftTop, - currentValue: PlayerActionButtonX.fromStorageValue( - appPrefs.leftTopActionButton, - ), - onChanged: (action) { - prefsNotifier.setLeftTopActionButton( - action.storageValue, - ); - }, - ), - const SizedBox(height: 8), - _PlayerActionButtonSelector( - label: l10n.leftBottom, - currentValue: PlayerActionButtonX.fromStorageValue( - appPrefs.leftActionButton, - ), - onChanged: (action) { - prefsNotifier.setLeftActionButton( - action.storageValue, - ); - }, + ], + ), + const SizedBox(height: 20), + Row( + children: [ + Icon( + Icons.palette_outlined, + size: 20, + color: context.adaptiveTextSecondary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + l10n.albumColors, + style: TextStyle( + fontFamily: 'GoogleSansFlex', + fontSize: 18, + fontWeight: FontWeight.w600, + color: context.adaptiveTextPrimary, ), - const SizedBox(height: 8), - _PlayerActionButtonSelector( - label: l10n.rightTop, - currentValue: PlayerActionButtonX.fromStorageValue( - appPrefs.rightTopActionButton, - ), - onChanged: (action) { - prefsNotifier.setRightTopActionButton( - action.storageValue, - ); - }, + ), + ), + ], + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: AlbumColorMode.values.map((mode) { + final isSelected = colorMode == mode; + return GestureDetector( + onTap: () { + ref.read(albumColorModeProvider.notifier).setMode(mode); + }, + child: AnimatedContainer( + duration: AppConstants.animationFast, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, ), - const SizedBox(height: 8), - _PlayerActionButtonSelector( - label: l10n.rightBottom, - currentValue: PlayerActionButtonX.fromStorageValue( - appPrefs.rightActionButton, + decoration: BoxDecoration( + color: isSelected + ? AppColors.accent.withValues(alpha: 0.14) + : AppColors.glassBackground, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected + ? AppColors.accent.withValues(alpha: 0.6) + : AppColors.glassBorder, + width: 1, ), - onChanged: (action) { - prefsNotifier.setRightActionButton( - action.storageValue, - ); - }, ), - ], - ), - const SizedBox(height: 20), - Row( - children: [ - Icon( - Icons.palette_outlined, - size: 20, - color: context.adaptiveTextSecondary, - ), - const SizedBox(width: 10), - Text( - l10n.albumColors, + child: Text( + mode.labelFor(l10n), style: TextStyle( fontFamily: 'GoogleSansFlex', - fontSize: 18, - fontWeight: FontWeight.w600, - color: context.adaptiveTextPrimary, + fontSize: 14, + fontWeight: isSelected + ? FontWeight.bold + : FontWeight.normal, + color: isSelected + ? AppColors.textPrimary + : context.adaptiveTextSecondary, ), ), - ], - ), - const SizedBox(height: 12), - Wrap( - spacing: 8, - runSpacing: 8, - children: AlbumColorMode.values.map((mode) { - final isSelected = colorMode == mode; - return GestureDetector( - onTap: () { - ref - .read(albumColorModeProvider.notifier) - .setMode(mode); - }, - child: AnimatedContainer( - duration: AppConstants.animationFast, - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 10, - ), - decoration: BoxDecoration( - color: isSelected - ? AppColors.accent.withValues(alpha: 0.14) - : AppColors.glassBackground, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: isSelected - ? AppColors.accent.withValues(alpha: 0.6) - : AppColors.glassBorder, - width: 1, - ), - ), - child: Text( - mode.labelFor(l10n), - style: TextStyle( - fontFamily: 'GoogleSansFlex', - fontSize: 14, - fontWeight: isSelected - ? FontWeight.bold - : FontWeight.normal, - color: isSelected - ? AppColors.textPrimary - : context.adaptiveTextSecondary, - ), - ), - ), - ); - }).toList(), - ), - ], + ), + ); + }).toList(), ), - ), + ], ), - ], - ), + ); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + header, + const SizedBox(height: 12), + Flexible( + child: isWide + ? Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: math.min(340.0, constraints.maxWidth * 0.38), + child: SingleChildScrollView(child: preview), + ), + Expanded(child: settings), + ], + ) + : Column( + mainAxisSize: MainAxisSize.min, + children: [ + preview, + const SizedBox(height: 12), + Divider(height: 1, color: AppColors.glassBorder), + Flexible(child: settings), + ], + ), + ), + ], + ); + }, ), ); } @@ -707,13 +713,15 @@ class _PlayerCustomizationGroup extends StatelessWidget { children: [ Icon(icon, size: 18, color: context.adaptiveTextSecondary), const SizedBox(width: 8), - Text( - title, - style: TextStyle( - fontFamily: 'GoogleSansFlex', - fontSize: 15, - fontWeight: FontWeight.w600, - color: context.adaptiveTextPrimary, + Expanded( + child: Text( + title, + style: TextStyle( + fontFamily: 'GoogleSansFlex', + fontSize: 15, + fontWeight: FontWeight.w600, + color: context.adaptiveTextPrimary, + ), ), ), ], @@ -744,13 +752,15 @@ class _PlayerCustomizationToggle extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - title, - style: TextStyle( - fontFamily: 'GoogleSansFlex', - fontSize: 13, - fontWeight: FontWeight.w600, - color: context.adaptiveTextPrimary, + Expanded( + child: Text( + title, + style: TextStyle( + fontFamily: 'GoogleSansFlex', + fontSize: 13, + fontWeight: FontWeight.w600, + color: context.adaptiveTextPrimary, + ), ), ), Switch( @@ -793,15 +803,18 @@ class _PlayerCustomizationSlider extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - title, - style: TextStyle( - fontFamily: 'GoogleSansFlex', - fontSize: 13, - fontWeight: FontWeight.w600, - color: context.adaptiveTextPrimary, + Expanded( + child: Text( + title, + style: TextStyle( + fontFamily: 'GoogleSansFlex', + fontSize: 13, + fontWeight: FontWeight.w600, + color: context.adaptiveTextPrimary, + ), ), ), + const SizedBox(width: 12), Text( valueLabel, style: TextStyle( @@ -894,17 +907,19 @@ class _PlayerBackgroundStyleSelector extends StatelessWidget { : context.adaptiveTextSecondary, ), const SizedBox(width: 4), - Text( - style.labelFor(l10n), - style: TextStyle( - fontFamily: 'GoogleSansFlex', - fontSize: 12, - fontWeight: isSelected - ? FontWeight.bold - : FontWeight.normal, - color: isSelected - ? AppColors.textPrimary - : context.adaptiveTextSecondary, + Flexible( + child: Text( + style.labelFor(l10n), + style: TextStyle( + fontFamily: 'GoogleSansFlex', + fontSize: 12, + fontWeight: isSelected + ? FontWeight.bold + : FontWeight.normal, + color: isSelected + ? AppColors.textPrimary + : context.adaptiveTextSecondary, + ), ), ), ], diff --git a/lib/features/player/widgets/rating_button.dart b/lib/features/player/widgets/rating_button.dart index eda875e4..15dd2bc7 100644 --- a/lib/features/player/widgets/rating_button.dart +++ b/lib/features/player/widgets/rating_button.dart @@ -13,6 +13,9 @@ class RatingButton extends StatefulWidget { final Color inactiveBg; final Color inactiveBorder; + /// Draws the icon without a chip, used by the landscape action row. + final bool flat; + const RatingButton({ super.key, required this.currentRating, @@ -24,6 +27,7 @@ class RatingButton extends StatefulWidget { this.accentBlend = 0, required this.inactiveBg, required this.inactiveBorder, + this.flat = false, }); @override @@ -94,8 +98,9 @@ class _RatingButtonState extends State Widget _buildIconButton() { final rating = widget.currentRating; final active = rating > 0; + final activeColor = _activeColor(); final bgColor = active - ? _activeColor().withValues(alpha: 0.28) + ? activeColor.withValues(alpha: 0.28) : widget.inactiveBg; final icon = active ? Icons.star : LucideIcons.star; @@ -110,17 +115,25 @@ class _RatingButtonState extends State }, child: Container( padding: widget.padding, - decoration: BoxDecoration( - color: bgColor, - borderRadius: BorderRadius.circular(widget.borderRadius), - border: active - ? Border.all(color: _activeColor().withValues(alpha: 0.45)) - : Border.all(color: widget.inactiveBorder), - ), + decoration: widget.flat + ? null + : BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(widget.borderRadius), + border: active + ? Border.all(color: activeColor.withValues(alpha: 0.45)) + : Border.all(color: widget.inactiveBorder), + ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, color: Colors.white.withValues(alpha: 0.96), size: widget.iconSize), + Icon( + icon, + color: widget.flat && active + ? activeColor + : Colors.white.withValues(alpha: 0.96), + size: widget.iconSize, + ), if (rating > 0) ...[ SizedBox(width: widget.iconSize * 0.2), Text( @@ -162,9 +175,13 @@ class _RatingButtonState extends State children: List.generate(5, (i) { final starIndex = i + 1; final delay = i * 0.08; - final starProgress = (_starsController.value - delay) - .clamp(0.0, 1.0); - final easedProgress = Curves.easeOutBack.transform(starProgress).clamp(0.001, double.infinity); + final starProgress = (_starsController.value - delay).clamp( + 0.0, + 1.0, + ); + final easedProgress = Curves.easeOutBack + .transform(starProgress) + .clamp(0.001, double.infinity); final isFilled = starIndex <= (widget.currentRating); final isHovered = _hoveredStar > 0 && starIndex <= _hoveredStar; @@ -186,7 +203,10 @@ class _RatingButtonState extends State final box = context.findRenderObject() as RenderBox; final localPos = box.globalToLocal(details.globalPosition); final starWidth = box.size.width / 5; - final hovered = ((localPos.dx / starWidth).ceil()).clamp(0, 5); + final hovered = ((localPos.dx / starWidth).ceil()).clamp( + 0, + 5, + ); if (hovered != _hoveredStar) { setState(() => _hoveredStar = hovered); } @@ -195,7 +215,10 @@ class _RatingButtonState extends State final box = context.findRenderObject() as RenderBox; final localPos = box.globalToLocal(details.globalPosition); final starWidth = box.size.width / 5; - final hovered = ((localPos.dx / starWidth).ceil()).clamp(0, 5); + final hovered = ((localPos.dx / starWidth).ceil()).clamp( + 0, + 5, + ); if (hovered != _hoveredStar) { setState(() => _hoveredStar = hovered); } @@ -207,7 +230,9 @@ class _RatingButtonState extends State horizontal: widget.iconSize * 0.08, ), child: Icon( - (isFilled || isHovered) ? Icons.star : Icons.star_border, + (isFilled || isHovered) + ? Icons.star + : Icons.star_border, color: (isFilled || isHovered) ? Colors.amber.shade400 : Colors.white.withValues(alpha: 0.4), @@ -223,4 +248,4 @@ class _RatingButtonState extends State ), ); } -} \ No newline at end of file +} diff --git a/lib/features/player/widgets/share/share_bottom_sheet.dart b/lib/features/player/widgets/share/share_bottom_sheet.dart index a49a74fa..a7da7a43 100644 --- a/lib/features/player/widgets/share/share_bottom_sheet.dart +++ b/lib/features/player/widgets/share/share_bottom_sheet.dart @@ -221,13 +221,13 @@ class _ShareBottomSheetState extends ConsumerState { Widget build(BuildContext context) { final templates = ShareTemplate.values; - return Container( - decoration: AppBottomSheetSurface.decoration, - padding: EdgeInsets.fromLTRB( + return AppBottomSheetSurface( + scrollable: true, + padding: const EdgeInsets.fromLTRB( 20, 16, 20, - 24 + MediaQuery.paddingOf(context).bottom, + 24, ), child: Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/features/player/widgets/sleep_timer_bottom_sheet.dart b/lib/features/player/widgets/sleep_timer_bottom_sheet.dart index 82531568..0b95d71d 100644 --- a/lib/features/player/widgets/sleep_timer_bottom_sheet.dart +++ b/lib/features/player/widgets/sleep_timer_bottom_sheet.dart @@ -15,6 +15,7 @@ class SleepTimerBottomSheet extends StatefulWidget { useRootNavigator: true, context: context, backgroundColor: Colors.transparent, + isScrollControlled: true, builder: (context) => SleepTimerBottomSheet(playerService: playerService), ); } @@ -31,13 +32,13 @@ class _SleepTimerBottomSheetState extends State { @override Widget build(BuildContext context) { - return Container( - decoration: AppBottomSheetSurface.decoration, - padding: EdgeInsets.fromLTRB( + return AppBottomSheetSurface( + scrollable: true, + padding: const EdgeInsets.fromLTRB( 20, 16, 20, - 24 + MediaQuery.paddingOf(context).bottom, + 24, ), child: Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/features/player/widgets/song_actions_sheet.dart b/lib/features/player/widgets/song_actions_sheet.dart index 6bbd90a1..8581ab15 100644 --- a/lib/features/player/widgets/song_actions_sheet.dart +++ b/lib/features/player/widgets/song_actions_sheet.dart @@ -76,319 +76,309 @@ class SongActionsSheet extends ConsumerWidget { @override Widget build(BuildContext sheetContext, WidgetRef ref) { final context = parentContext; - return ValueListenableBuilder( - valueListenable: playerService.currentSongNotifier, - builder: (sheetContext, currentSong, _) { - final activeSong = currentSong ?? song; - return SafeArea( - top: false, - bottom: false, - child: Container( - constraints: BoxConstraints( - maxHeight: MediaQuery.of(sheetContext).size.height * 0.5, - ), - decoration: AppBottomSheetSurface.decoration, - padding: EdgeInsets.fromLTRB( - context.responsive(16.0, 18.0, 20.0), - context.responsive(10.0, 11.0, 12.0), - context.responsive(16.0, 18.0, 20.0), - // Surface reaches the bottom edge; content clears the system - // gesture/navigation inset. - context.responsive(20.0, 22.0, 24.0) + - MediaQuery.paddingOf(sheetContext).bottom, + return ValueListenableBuilder( + valueListenable: playerService.currentSongNotifier, + builder: (sheetContext, currentSong, _) { + final activeSong = currentSong ?? song; + return AppBottomSheetSurface( + padding: EdgeInsets.fromLTRB( + context.responsive(16.0, 18.0, 20.0), + context.responsive(10.0, 11.0, 12.0), + context.responsive(16.0, 18.0, 20.0), + context.responsive(20.0, 22.0, 24.0), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: AppColors.glassBorderStrong, + borderRadius: BorderRadius.circular(4), + ), ), - child: Column( - mainAxisSize: MainAxisSize.min, + const SizedBox(height: 16), + Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: AppColors.glassBorderStrong, - borderRadius: BorderRadius.circular(4), + ClipRRect( + borderRadius: BorderRadius.circular( + context.responsive(10.0, 11.0, 12.0), ), - ), - const SizedBox(height: 16), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular( - context.responsive(10.0, 11.0, 12.0), + child: SizedBox( + width: context.responsive(56.0, 62.0, 68.0), + height: context.responsive(56.0, 62.0, 68.0), + child: CachedImageWidget( + imagePath: activeSong.albumArt, + audioSourcePath: activeSong.filePath, + fit: BoxFit.cover, + useThumbnail: true, + thumbnailWidth: 136, + thumbnailHeight: 136, + placeholder: Container( + color: AppColors.surfaceLight, + child: const FlickArtworkPlaceholder( + size: 28, + opacity: 0.9, + ), ), - child: SizedBox( - width: context.responsive(56.0, 62.0, 68.0), - height: context.responsive(56.0, 62.0, 68.0), - child: CachedImageWidget( - imagePath: activeSong.albumArt, - audioSourcePath: activeSong.filePath, - fit: BoxFit.cover, - useThumbnail: true, - thumbnailWidth: 136, - thumbnailHeight: 136, - placeholder: Container( - color: AppColors.surfaceLight, - child: const FlickArtworkPlaceholder( - size: 28, - opacity: 0.9, - ), - ), - errorWidget: Container( - color: AppColors.surfaceLight, - child: const FlickArtworkPlaceholder( - size: 28, - opacity: 0.9, - ), - ), + errorWidget: Container( + color: AppColors.surfaceLight, + child: const FlickArtworkPlaceholder( + size: 28, + opacity: 0.9, ), ), ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + activeSong.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontFamily: 'GoogleSansFlex', + fontSize: context.responsive(16.0, 17.0, 18.0), + fontWeight: FontWeight.w600, + color: sheetContext.adaptiveTextPrimary, + ), + ), + const SizedBox(height: 4), + Text( + activeSong.artist, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontFamily: 'GoogleSansFlex', + fontSize: context.responsive(12.0, 13.0, 14.0), + color: sheetContext.adaptiveTextSecondary, + ), + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, children: [ - Text( - activeSong.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontFamily: 'GoogleSansFlex', - fontSize: context.responsive(16.0, 17.0, 18.0), - fontWeight: FontWeight.w600, - color: sheetContext.adaptiveTextPrimary, - ), - ), - const SizedBox(height: 4), - Text( - activeSong.artist, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontFamily: 'GoogleSansFlex', - fontSize: context.responsive(12.0, 13.0, 14.0), - color: sheetContext.adaptiveTextSecondary, - ), + _buildSongInfoChip( + sheetContext, + activeSong.formattedDuration, ), - const SizedBox(height: 10), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _buildSongInfoChip( - sheetContext, - activeSong.formattedDuration, - ), - _buildSongInfoChip( - sheetContext, - activeSong.fileType.toUpperCase(), - ), - ], + _buildSongInfoChip( + sheetContext, + activeSong.fileType.toUpperCase(), ), ], ), - ), - ], + ], + ), ), - const SizedBox(height: 16), - Flexible( - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (!activeSong.isFromLocker) - _buildSongActionTile( - context: sheetContext, - icon: LucideIcons.listPlus, - label: l10n.playerMenuAddToQueue, - onTap: () async { - Navigator.pop(sheetContext); - await navigation.queueSong(context, activeSong); - }, - ), - _buildSongActionTile( - context: sheetContext, - icon: LucideIcons.listMusic, - label: l10n.playerMenuAddToPlaylist, - onTap: () { - Navigator.pop(sheetContext); - AddToPlaylistSheet.show(context, activeSong); - }, - ), - _buildSongActionTile( - context: sheetContext, - icon: LucideIcons.image, - label: l10n.playerMenuSetAlbumArt, - onTap: () { - Navigator.pop(sheetContext); - Future.delayed(Duration.zero, () async { - final changed = - await AlbumArtPickerBottomSheet.show( - context, - activeSong, - ); - if (changed && context.mounted) { - ref.invalidate(songsProvider); - } - }); - }, - ), - _buildSongActionTile( - context: sheetContext, - icon: LucideIcons.refreshCw, - label: l10n.playerMenuRefreshAlbumArt, - onTap: () async { - Navigator.pop(sheetContext); - final albumArtist = activeSong.albumArtist; - await AnimatedArtworkService.instance - .refreshAlbumArtwork( - artist: - (albumArtist != null && - albumArtist.trim().isNotEmpty) - ? albumArtist.trim() - : activeSong.artist, - albumName: activeSong.album, - songTitle: activeSong.title, - ); - if (context.mounted) { - ScaffoldMessenger.of(context).showFlickSnackBar( - SnackBar( - content: Text(l10n.englishForFilipino.motionArtRefreshed), - duration: Duration(seconds: 2), - ), + ], + ), + const SizedBox(height: 16), + Flexible( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!activeSong.isFromLocker) + _buildSongActionTile( + context: sheetContext, + icon: LucideIcons.listPlus, + label: l10n.playerMenuAddToQueue, + onTap: () async { + Navigator.pop(sheetContext); + await navigation.queueSong(context, activeSong); + }, + ), + _buildSongActionTile( + context: sheetContext, + icon: LucideIcons.listMusic, + label: l10n.playerMenuAddToPlaylist, + onTap: () { + Navigator.pop(sheetContext); + AddToPlaylistSheet.show(context, activeSong); + }, + ), + _buildSongActionTile( + context: sheetContext, + icon: LucideIcons.image, + label: l10n.playerMenuSetAlbumArt, + onTap: () { + Navigator.pop(sheetContext); + Future.delayed(Duration.zero, () async { + final changed = + await AlbumArtPickerBottomSheet.show( + context, + activeSong, ); - } - }, - ), - if (activeSong.filePath != null && - activeSong.startOffsetMs == null && - !activeSong.isExternal) - _buildSongActionTile( - context: sheetContext, - icon: LucideIcons.pencil, - label: l10n.playerMenuEditMetadata, - onTap: () { - Navigator.pop(sheetContext); - Future.delayed(Duration.zero, () async { - final saved = - await MetadataEditorBottomSheet.show( + if (changed && context.mounted) { + ref.invalidate(songsProvider); + } + }); + }, + ), + _buildSongActionTile( + context: sheetContext, + icon: LucideIcons.refreshCw, + label: l10n.playerMenuRefreshAlbumArt, + onTap: () async { + Navigator.pop(sheetContext); + final albumArtist = activeSong.albumArtist; + await AnimatedArtworkService.instance + .refreshAlbumArtwork( + artist: + (albumArtist != null && + albumArtist.trim().isNotEmpty) + ? albumArtist.trim() + : activeSong.artist, + albumName: activeSong.album, + songTitle: activeSong.title, + ); + if (context.mounted) { + ScaffoldMessenger.of(context).showFlickSnackBar( + SnackBar( + content: Text( + l10n.englishForFilipino.motionArtRefreshed, + ), + duration: Duration(seconds: 2), + ), + ); + } + }, + ), + if (activeSong.filePath != null && + activeSong.startOffsetMs == null && + !activeSong.isExternal) + _buildSongActionTile( + context: sheetContext, + icon: LucideIcons.pencil, + label: l10n.playerMenuEditMetadata, + onTap: () { + Navigator.pop(sheetContext); + Future.delayed(Duration.zero, () async { + final saved = + await MetadataEditorBottomSheet.show( context, activeSong, ); - if (saved && context.mounted) { - ref.invalidate(songsProvider); - } - }); - }, - ), - _buildSongActionTile( - context: sheetContext, - icon: LucideIcons.info, - label: l10n.viewMetadata, - onTap: () { - Navigator.pop(sheetContext); - SongMetadataSheet.show(context, activeSong); - }, - ), - _buildSongActionTile( - context: sheetContext, - icon: LucideIcons.fileText, - label: l10n.lyrics, - onTap: () { - Navigator.pop(sheetContext); - onShowLyrics(); - }, - ), - _buildSongActionTile( - context: sheetContext, - icon: Icons.graphic_eq_rounded, - label: isVisualizationMode - ? l10n.hideVisualizer2 - : l10n.visualizer, - onTap: () { - Navigator.pop(sheetContext); - onToggleVisualization(!isVisualizationMode); - }, - ), - _buildSongActionTile( - context: sheetContext, - icon: LucideIcons.user, - label: l10n.goToArtist, - onTap: () { - Navigator.pop(sheetContext); - navigation.openArtistFromSong(context, activeSong); - }, - ), - _buildSongActionTile( - context: sheetContext, - icon: LucideIcons.disc, - label: l10n.goToAlbum, - onTap: () { - Navigator.pop(sheetContext); - navigation.openAlbumFromSong(context, activeSong); - }, - ), - _buildSongActionTile( - context: sheetContext, - icon: Icons.dashboard_customize_rounded, - label: l10n.playerLayout, - onTap: () { - Navigator.pop(sheetContext); - onShowPlayerLayout(context); - }, - ), - _buildSongActionTile( - context: sheetContext, - icon: LucideIcons.gauge, - label: l10n.playbackSpeed, - onTap: () { - Navigator.pop(sheetContext); - SpeedBottomSheet.show(context, playerService); - }, - ), - _buildSongActionTile( - context: sheetContext, - icon: LucideIcons.music, - label: l10n.pitch, - onTap: () { - Navigator.pop(sheetContext); - PitchBottomSheet.show(context, playerService); - }, - ), - _buildSongActionTile( - context: sheetContext, - icon: LucideIcons.moonStar, - label: l10n.playerMenuSleepTimer, - onTap: () { - Navigator.pop(sheetContext); - SleepTimerBottomSheet.show(context, playerService); - }, - ), - _buildSongActionTile( - context: sheetContext, - icon: LucideIcons.share2, - label: l10n.playerMenuShare, - onTap: () { - Navigator.pop(sheetContext); - showModalBottomSheet( - useRootNavigator: true, - context: context, - backgroundColor: Colors.transparent, - isScrollControlled: true, - builder: (_) => - ShareBottomSheet(song: activeSong), - ); - }, - ), - ], + if (saved && context.mounted) { + ref.invalidate(songsProvider); + } + }); + }, + ), + _buildSongActionTile( + context: sheetContext, + icon: LucideIcons.info, + label: l10n.viewMetadata, + onTap: () { + Navigator.pop(sheetContext); + SongMetadataSheet.show(context, activeSong); + }, ), - ), + _buildSongActionTile( + context: sheetContext, + icon: LucideIcons.fileText, + label: l10n.lyrics, + onTap: () { + Navigator.pop(sheetContext); + onShowLyrics(); + }, + ), + _buildSongActionTile( + context: sheetContext, + icon: Icons.graphic_eq_rounded, + label: isVisualizationMode + ? l10n.hideVisualizer2 + : l10n.visualizer, + onTap: () { + Navigator.pop(sheetContext); + onToggleVisualization(!isVisualizationMode); + }, + ), + _buildSongActionTile( + context: sheetContext, + icon: LucideIcons.user, + label: l10n.goToArtist, + onTap: () { + Navigator.pop(sheetContext); + navigation.openArtistFromSong(context, activeSong); + }, + ), + _buildSongActionTile( + context: sheetContext, + icon: LucideIcons.disc, + label: l10n.goToAlbum, + onTap: () { + Navigator.pop(sheetContext); + navigation.openAlbumFromSong(context, activeSong); + }, + ), + _buildSongActionTile( + context: sheetContext, + icon: Icons.dashboard_customize_rounded, + label: l10n.playerLayout, + onTap: () { + Navigator.pop(sheetContext); + onShowPlayerLayout(context); + }, + ), + _buildSongActionTile( + context: sheetContext, + icon: LucideIcons.gauge, + label: l10n.playbackSpeed, + onTap: () { + Navigator.pop(sheetContext); + SpeedBottomSheet.show(context, playerService); + }, + ), + _buildSongActionTile( + context: sheetContext, + icon: LucideIcons.music, + label: l10n.pitch, + onTap: () { + Navigator.pop(sheetContext); + PitchBottomSheet.show(context, playerService); + }, + ), + _buildSongActionTile( + context: sheetContext, + icon: LucideIcons.moonStar, + label: l10n.playerMenuSleepTimer, + onTap: () { + Navigator.pop(sheetContext); + SleepTimerBottomSheet.show(context, playerService); + }, + ), + _buildSongActionTile( + context: sheetContext, + icon: LucideIcons.share2, + label: l10n.playerMenuShare, + onTap: () { + Navigator.pop(sheetContext); + showModalBottomSheet( + useRootNavigator: true, + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (_) => ShareBottomSheet(song: activeSong), + ); + }, + ), + ], ), - ], + ), ), - ), - ); - }, + ], + ), + ); + }, ); } diff --git a/lib/features/player/widgets/song_metadata_sheet.dart b/lib/features/player/widgets/song_metadata_sheet.dart index 0d6dd7a6..f5be8719 100644 --- a/lib/features/player/widgets/song_metadata_sheet.dart +++ b/lib/features/player/widgets/song_metadata_sheet.dart @@ -21,13 +21,13 @@ class SongMetadataSheet extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( - decoration: AppBottomSheetSurface.decoration, - padding: EdgeInsets.fromLTRB( + return AppBottomSheetSurface( + scrollable: true, + padding: const EdgeInsets.fromLTRB( 20, 16, 20, - 24 + MediaQuery.paddingOf(context).bottom, + 24, ), child: Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/features/player/widgets/song_stage.dart b/lib/features/player/widgets/song_stage.dart index cb2d1a04..ff448713 100644 --- a/lib/features/player/widgets/song_stage.dart +++ b/lib/features/player/widgets/song_stage.dart @@ -20,6 +20,19 @@ import 'package:flick/features/player/widgets/waveform_layer.dart'; import 'package:flick/widgets/common/cached_image_widget.dart'; import 'package:flick/widgets/common/flick_artwork_placeholder.dart'; +/// Which part of the stage a layout needs. +/// +/// The portrait player always uses [full]. The landscape player splits the +/// same stage across two panes so the artwork keeps its square aspect ratio +/// while playback controls stay pinned beside it. +enum SongStagePane { + full, + artworkOnly, + backdropOnly, + immersiveIdentity, + artworkCardDetails, +} + /// The sliding portion of the player: album art + title/artist/album + /// file-info row + waveform. Background, top chrome and [PlayerControls] /// stay pinned โ€” only this stage translates during a swipe. @@ -27,8 +40,6 @@ import 'package:flick/widgets/common/flick_artwork_placeholder.dart'; /// The file-info row keeps its layout slot even when hidden; the row is /// then drawn pinned by the parent screen ([fileInfoRowVisible]). class SongStage extends ConsumerWidget { - static const double _shortHeightThreshold = 620.0; - final Song song; final bool lyricsMode; final bool visualizationMode; @@ -71,6 +82,9 @@ class SongStage extends ConsumerWidget { final VoidCallback onToggleLyrics; final bool showWaveform; + /// Selects the full portrait stage or one pane of the landscape layout. + final SongStagePane pane; + /// Gates Motion Art on the album art box (e.g. only the settled carousel /// page keeps video running). final bool motionArtEnabled; @@ -119,6 +133,7 @@ class SongStage extends ConsumerWidget { this.onVinylChanged, required this.onToggleLyrics, this.showWaveform = true, + this.pane = SongStagePane.full, this.motionArtEnabled = true, this.active = true, }); @@ -127,12 +142,11 @@ class SongStage extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { // Rebuild the capsule when verification settles, not on every diagnostic tick. ref.watch(uac2BitPerfectIndicatorProvider); - final isShortHeight = - MediaQuery.sizeOf(context).height < _shortHeightThreshold; + if (pane != SongStagePane.full) { + return _buildPane(context); + } final showImmersiveFullView = - immersiveFullView && - playerScreenMode == PlayerScreenMode.immersive && - !isShortHeight; + immersiveFullView && playerScreenMode == PlayerScreenMode.immersive; final showVisualizerOnly = visualizationMode && showImmersiveFullView; // Inner switcher: immersive full-view vs default vs lyrics โ€” keep the @@ -184,6 +198,73 @@ class SongStage extends ConsumerWidget { ); } + // --------------------------------------------------------------------------- + // Landscape panes + // --------------------------------------------------------------------------- + + Widget _buildPane(BuildContext context) { + switch (pane) { + case SongStagePane.full: + case SongStagePane.backdropOnly: + return const SizedBox.shrink(); + case SongStagePane.artworkOnly: + return LayoutBuilder( + builder: (context, constraints) { + final extent = math.min( + constraints.maxWidth, + constraints.maxHeight, + ); + if (extent < 80) return const SizedBox.shrink(); + final artworkSize = math + .max(80.0, extent * 0.92 * artworkCardArtworkScale) + .clamp(80.0, extent) + .toDouble(); + return Center( + child: SizedBox( + width: artworkSize, + height: artworkSize, + child: visualizationMode + ? VisualizerArtBox( + playerService: playerService, + size: artworkSize, + animationStyle: visualizerAnimationStyle, + frequencyMode: visualizerFrequencyMode, + movementMode: visualizerMovementMode, + albumColor: albumColor, + showFrame: artworkCardShowFrame, + ) + : AlbumArtBox( + song: song, + size: artworkSize, + playerService: playerService, + onRotationEnabledChanged: onRotationEnabledChanged, + initialVinyl: vinylMode, + onVinylChanged: onVinylChanged, + showFrame: artworkCardShowFrame, + motionArtEnabled: motionArtEnabled, + ), + ), + ); + }, + ); + case SongStagePane.immersiveIdentity: + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Transform.translate( + offset: Offset(0, immersiveVerticalOffset.clamp(-40.0, 40.0)), + child: _buildImmersiveSongHeader(context, landscape: true), + ), + ), + ); + case SongStagePane.artworkCardDetails: + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), + child: _buildSongIdentity(context, compact: true, landscape: true), + ); + } + } + // --------------------------------------------------------------------------- // Immersive // --------------------------------------------------------------------------- @@ -381,98 +462,107 @@ class SongStage extends ConsumerWidget { context.responsive(16.0, 20.0, 24.0), context.responsive(20.0, 24.0, 28.0), ), - child: Container( - padding: EdgeInsets.all( - context.responsive(10.0, 12.0, 14.0) * immersiveFullViewScale, - ), - decoration: BoxDecoration( - color: const Color(0xFF121212).withValues(alpha: 0.72), - borderRadius: BorderRadius.circular(24), - border: Border.all(color: Colors.white.withValues(alpha: 0.08)), - ), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular( - 18 * immersiveFullViewScale, - ), - child: SizedBox( - width: artworkSize, - height: artworkSize, - child: CachedImageWidget( - imagePath: song.albumArt, - audioSourcePath: song.filePath, - fit: BoxFit.cover, - placeholder: Container( - color: Colors.white.withValues(alpha: 0.05), - child: FlickArtworkPlaceholder( - size: artworkSize * 0.44, - opacity: 0.92, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: Container( + padding: EdgeInsets.all( + context.responsive(10.0, 12.0, 14.0) * immersiveFullViewScale, + ), + decoration: BoxDecoration( + color: const Color(0xFF121212).withValues(alpha: 0.72), + borderRadius: BorderRadius.circular(24), + border: Border.all(color: Colors.white.withValues(alpha: 0.08)), + ), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular( + 18 * immersiveFullViewScale, + ), + child: SizedBox( + width: artworkSize, + height: artworkSize, + child: CachedImageWidget( + imagePath: song.albumArt, + audioSourcePath: song.filePath, + fit: BoxFit.cover, + placeholder: Container( + color: Colors.white.withValues(alpha: 0.05), + child: FlickArtworkPlaceholder( + size: artworkSize * 0.44, + opacity: 0.92, + ), ), - ), - errorWidget: Container( - color: Colors.white.withValues(alpha: 0.05), - child: FlickArtworkPlaceholder( - size: artworkSize * 0.44, - opacity: 0.92, + errorWidget: Container( + color: Colors.white.withValues(alpha: 0.05), + child: FlickArtworkPlaceholder( + size: artworkSize * 0.44, + opacity: 0.92, + ), ), ), ), ), - ), - SizedBox(width: context.responsive(12.0, 14.0, 16.0)), - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (immersiveShowTitle) - Text( - song.title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontFamily: 'GoogleSansFlex', - fontSize: context.responsiveText( - context.responsive(18.0, 19.0, 21.0) * - immersiveTextScale, - ), - fontWeight: FontWeight.w700, - color: Colors.white, - height: 1.12, - ), - ), - if (immersiveShowTitle && immersiveShowArtist) - SizedBox(height: context.responsive(6.0, 7.0, 8.0)), - if (immersiveShowArtist) - GestureDetector( - onTap: () => onNavigateToArtistDetail(song), - child: Text( - song.artist, - maxLines: 1, + SizedBox(width: context.responsive(12.0, 14.0, 16.0)), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (immersiveShowTitle) + Text( + song.title, + maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( fontFamily: 'GoogleSansFlex', fontSize: context.responsiveText( - context.responsive(13.0, 14.0, 15.0) * + context.responsive(18.0, 19.0, 21.0) * immersiveTextScale, ), - fontWeight: FontWeight.w500, - color: Colors.white.withValues(alpha: 0.78), + fontWeight: FontWeight.w700, + color: Colors.white, + height: 1.12, ), ), - ), - ], + if (immersiveShowTitle && immersiveShowArtist) + SizedBox(height: context.responsive(6.0, 7.0, 8.0)), + if (immersiveShowArtist) + GestureDetector( + onTap: () => onNavigateToArtistDetail(song), + child: Text( + song.artist, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontFamily: 'GoogleSansFlex', + fontSize: context.responsiveText( + context.responsive(13.0, 14.0, 15.0) * + immersiveTextScale, + ), + fontWeight: FontWeight.w500, + color: Colors.white.withValues(alpha: 0.78), + ), + ), + ), + ], + ), ), - ), - ], + ], + ), ), ), ), ); } - Widget _buildImmersiveSongHeader(BuildContext context) { + Widget _buildImmersiveSongHeader( + BuildContext context, { + bool landscape = false, + }) { + final titleSize = landscape ? 24.0 : context.responsive(22.0, 24.0, 28.0); + final artistSize = landscape ? 14.0 : context.responsive(13.0, 14.0, 16.0); + final titleGap = landscape ? 8.0 : context.responsive(10.0, 12.0, 14.0); return Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ @@ -489,7 +579,7 @@ class SongStage extends ConsumerWidget { style: TextStyle( fontFamily: 'GoogleSansFlex', fontSize: context.responsiveText( - context.responsive(22.0, 24.0, 28.0) * immersiveTextScale, + titleSize * immersiveTextScale, ), fontWeight: FontWeight.w700, color: Colors.white, @@ -497,7 +587,7 @@ class SongStage extends ConsumerWidget { ), ), if (immersiveShowTitle && immersiveShowArtist) - SizedBox(height: context.responsive(10.0, 12.0, 14.0)), + SizedBox(height: titleGap), if (immersiveShowArtist) GestureDetector( onTap: () => onNavigateToArtistDetail(song), @@ -508,8 +598,7 @@ class SongStage extends ConsumerWidget { style: TextStyle( fontFamily: 'GoogleSansFlex', fontSize: context.responsiveText( - context.responsive(13.0, 14.0, 16.0) * - immersiveTextScale, + artistSize * immersiveTextScale, ), fontWeight: FontWeight.w500, color: Colors.white.withValues(alpha: 0.82), @@ -517,7 +606,7 @@ class SongStage extends ConsumerWidget { ), ), if (immersiveShowTitle || immersiveShowArtist) - SizedBox(height: context.responsive(10.0, 12.0, 14.0)), + SizedBox(height: landscape ? 8.0 : titleGap), ], ), ), @@ -738,16 +827,21 @@ class SongStage extends ConsumerWidget { BuildContext context, { bool compact = false, bool veryCompact = false, + bool landscape = false, }) { final titleSize = - (veryCompact + (landscape + ? 22.0 + : veryCompact ? context.responsive(20.0, 22.0, 25.0) : compact ? context.responsive(22.0, 25.0, 28.0) : context.responsive(25.0, 27.0, 30.0)) * artworkCardTextScale; final artistSize = - (veryCompact + (landscape + ? 14.0 + : veryCompact ? context.responsive(13.0, 14.0, 15.0) : compact ? context.responsive(14.0, 15.0, 16.0) @@ -788,11 +882,14 @@ class SongStage extends ConsumerWidget { final hasAlbum = song.album != null && song.album!.trim().isNotEmpty; return Column( + crossAxisAlignment: landscape + ? CrossAxisAlignment.start + : CrossAxisAlignment.center, children: [ if (artworkCardShowTitle) Text( song.title, - textAlign: TextAlign.center, + textAlign: landscape ? TextAlign.start : TextAlign.center, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( @@ -810,7 +907,7 @@ class SongStage extends ConsumerWidget { onTap: () => onNavigateToArtistDetail(song), child: Text( song.artist, - textAlign: TextAlign.center, + textAlign: landscape ? TextAlign.start : TextAlign.center, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( @@ -827,7 +924,7 @@ class SongStage extends ConsumerWidget { SizedBox(height: artistToAlbumSpacing), if (diagnostics != null && appPrefs.replaceAlbumWithBitPerfectCapsule) Stack( - alignment: Alignment.center, + alignment: landscape ? Alignment.centerLeft : Alignment.center, children: [ if (hasAlbum) AnimatedOpacity( @@ -849,6 +946,9 @@ class SongStage extends ConsumerWidget { onTap: () => onNavigateToAlbumDetail(song), child: Text( song.album!, + textAlign: landscape + ? TextAlign.start + : TextAlign.center, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( @@ -897,6 +997,7 @@ class SongStage extends ConsumerWidget { ), child: Text( song.album!, + textAlign: landscape ? TextAlign.start : TextAlign.center, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( diff --git a/lib/features/player/widgets/song_stage_carousel.dart b/lib/features/player/widgets/song_stage_carousel.dart index 9eda30dd..dc65e2c3 100644 --- a/lib/features/player/widgets/song_stage_carousel.dart +++ b/lib/features/player/widgets/song_stage_carousel.dart @@ -59,7 +59,8 @@ class SongStageCarouselState extends State bool _isDragging = false; bool _isAnimating = false; double _dragStartX = double.infinity; - double _dragStartY = double.infinity; + bool _dragStartedNearSystemBottom = false; + Size? _lastWindowSize; // Frozen stages โ€” prevents the glimpse bug where upNext updates to C // (via _setCurrentIndex) before currentSongNotifier flips from A->B, @@ -110,6 +111,26 @@ class SongStageCarouselState extends State } } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final nextSize = MediaQuery.sizeOf(context); + if (_lastWindowSize != null && + nextSize != _lastWindowSize && + (_isDragging || _dragOffset != 0)) { + // Rotation or resize landed mid-swipe. Settle on the current song + // instead of carrying a half-finished gesture into the new geometry. + _controller.stop(); + _isAnimating = false; + _isDragging = false; + _dragOffset = 0; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) widget.onDragStateChanged?.call(false); + }); + } + _lastWindowSize = nextSize; + } + @override void dispose() { _controller.dispose(); @@ -136,7 +157,10 @@ class SongStageCarouselState extends State Future _animateToTargetAndCommit({required bool isNext}) async { final width = context.size?.width ?? MediaQuery.sizeOf(context).width; final target = isNext ? -width : width; - await _animateOffsetTo(target, commitDuration: const Duration(milliseconds: 320)); + await _animateOffsetTo( + target, + commitDuration: const Duration(milliseconds: 320), + ); // Service changes song; our didUpdateWidget will snap offset. HapticFeedback.lightImpact(); if (isNext) { @@ -149,16 +173,18 @@ class SongStageCarouselState extends State } } - Future _animateOffsetTo(double target, - {required Duration commitDuration}) async { + Future _animateOffsetTo( + double target, { + required Duration commitDuration, + }) async { if (_dragOffset == target) return; _isAnimating = true; _animStartOffset = _dragOffset; _animTargetOffset = target; - _animation = Tween(begin: _animStartOffset, end: _animTargetOffset) - .animate( - CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic), - ); + _animation = Tween( + begin: _animStartOffset, + end: _animTargetOffset, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic)); _controller.duration = commitDuration; _controller.value = 0; await _controller.forward(); @@ -167,8 +193,12 @@ class SongStageCarouselState extends State void _handleDragStart(DragStartDetails details) { if (!widget.enabled || _isAnimating) return; - _dragStartX = details.globalPosition.dx; - _dragStartY = details.globalPosition.dy; + // Carousel-local coordinates; the stage may be a pane inside landscape, + // so global x can never be compared with the local width. + _dragStartX = details.localPosition.dx; + _dragStartedNearSystemBottom = + details.globalPosition.dy >= + MediaQuery.sizeOf(context).height - _edgeExclusion; _controller.stop(); _isAnimating = false; _isDragging = true; @@ -212,15 +242,18 @@ class SongStageCarouselState extends State _isDragging = false; widget.onDragStateChanged?.call(false); - // Edge exclusion: if drag started near screen edge, don't navigate. + // Edge exclusion: left/right use carousel-local coordinates; the bottom + // check uses the real system edge captured at drag start. final width = context.size?.width ?? MediaQuery.sizeOf(context).width; - final height = MediaQuery.sizeOf(context).height; final nearEdge = _dragStartX <= _edgeExclusion || _dragStartX >= width - _edgeExclusion || - _dragStartY >= height - _edgeExclusion; + _dragStartedNearSystemBottom; if (nearEdge) { - await _animateOffsetTo(0, commitDuration: const Duration(milliseconds: 220)); + await _animateOffsetTo( + 0, + commitDuration: const Duration(milliseconds: 220), + ); return; } @@ -236,20 +269,26 @@ class SongStageCarouselState extends State if (shouldNext) { HapticFeedback.mediumImpact(); - await _animateOffsetTo(-width, - commitDuration: const Duration(milliseconds: 320)); + await _animateOffsetTo( + -width, + commitDuration: const Duration(milliseconds: 320), + ); await widget.onNext(); if (mounted) setState(() => _dragOffset = 0.0); } else if (shouldPrev) { HapticFeedback.mediumImpact(); - await _animateOffsetTo(width, - commitDuration: const Duration(milliseconds: 320)); + await _animateOffsetTo( + width, + commitDuration: const Duration(milliseconds: 320), + ); await widget.onPrevious(); if (mounted) setState(() => _dragOffset = 0.0); } else { if (_dragOffset.abs() > 8) AppHaptics.tap(); - await _animateOffsetTo(0, - commitDuration: const Duration(milliseconds: 260)); + await _animateOffsetTo( + 0, + commitDuration: const Duration(milliseconds: 260), + ); } } diff --git a/lib/features/player/widgets/speed_bottom_sheet.dart b/lib/features/player/widgets/speed_bottom_sheet.dart index eba18139..5fce6377 100644 --- a/lib/features/player/widgets/speed_bottom_sheet.dart +++ b/lib/features/player/widgets/speed_bottom_sheet.dart @@ -14,6 +14,7 @@ class SpeedBottomSheet extends StatelessWidget { useRootNavigator: true, context: context, backgroundColor: Colors.transparent, + isScrollControlled: true, builder: (context) => SpeedBottomSheet(playerService: playerService), ); } @@ -23,13 +24,13 @@ class SpeedBottomSheet extends StatelessWidget { const min = 0.5; const max = 2.0; const step = 0.25; - return Container( - decoration: AppBottomSheetSurface.decoration, - padding: EdgeInsets.fromLTRB( + return AppBottomSheetSurface( + scrollable: true, + padding: const EdgeInsets.fromLTRB( 20, 16, 20, - 24 + MediaQuery.paddingOf(context).bottom, + 24, ), child: Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/features/player/widgets/volume_bottom_sheet.dart b/lib/features/player/widgets/volume_bottom_sheet.dart index 3b58261c..91f425bd 100644 --- a/lib/features/player/widgets/volume_bottom_sheet.dart +++ b/lib/features/player/widgets/volume_bottom_sheet.dart @@ -14,6 +14,7 @@ class VolumeBottomSheet extends StatelessWidget { useRootNavigator: true, context: context, backgroundColor: Colors.transparent, + isScrollControlled: true, builder: (context) => VolumeBottomSheet(playerService: playerService), ); } @@ -40,13 +41,13 @@ class VolumeBottomSheet extends StatelessWidget { double currentVolume = initialVolume; return StatefulBuilder( builder: (context, setSheetState) { - return Container( - decoration: AppBottomSheetSurface.decoration, - padding: EdgeInsets.fromLTRB( + return AppBottomSheetSurface( + scrollable: true, + padding: const EdgeInsets.fromLTRB( 20, 16, 20, - 24 + MediaQuery.paddingOf(context).bottom, + 24, ), child: Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/features/settings/screens/equalizer_screen.dart b/lib/features/settings/screens/equalizer_screen.dart index cf739a88..63fb3da5 100644 --- a/lib/features/settings/screens/equalizer_screen.dart +++ b/lib/features/settings/screens/equalizer_screen.dart @@ -79,6 +79,19 @@ class _EqualizerScreenState extends ConsumerState { Widget build(BuildContext context) { final activePresetName = ref.watch(eqActivePresetNameProvider); final currentSong = ref.watch(currentSongProvider); + final size = MediaQuery.sizeOf(context); + final isShortWide = size.width > size.height && size.height < 560.0; + + final header = _Header( + title: l10n.englishForFilipino.eqDynamics, + subtitle: activePresetName != null ? l10n.preset(activePresetName) : null, + onBack: () => Navigator.of(context).pop(), + onPresets: _showPresetsBottomSheet, + ); + final tabs = _EffectsTabBar( + selectedIndex: _currentPage, + onSelected: _switchToPage, + ); return Stack( children: [ @@ -90,78 +103,72 @@ class _EqualizerScreenState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _Header( - title: l10n.englishForFilipino.eqDynamics, - subtitle: activePresetName != null - ? l10n.preset(activePresetName) - : null, - onBack: () => Navigator.of(context).pop(), - onPresets: _showPresetsBottomSheet, - ), - Expanded( - child: Column( + if (isShortWide) + Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - _EffectsTabBar( - selectedIndex: _currentPage, - onSelected: _switchToPage, + Expanded(child: header), + SizedBox( + width: math.min(340.0, size.width * 0.42), + child: tabs, ), - const SizedBox(height: AppConstants.spacingMd), - Expanded( - child: GestureDetector( - onHorizontalDragEnd: (details) { - final velocity = details.primaryVelocity ?? 0; - if (velocity < -250) { - _switchToPage(_currentPage + 1); - } else if (velocity > 250) { - _switchToPage(_currentPage - 1); - } - }, - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeInCubic, - layoutBuilder: - (currentChild, previousChildren) => Stack( - alignment: Alignment.topCenter, - children: [ - ...previousChildren, - ?currentChild, - ], - ), - transitionBuilder: (child, animation) { - final incoming = - (child.key as ValueKey).value == - _currentPage; - final beginX = - (incoming ? _pageDirection : -_pageDirection) - .toDouble(); - return SlideTransition( - position: Tween( - begin: Offset(beginX, 0), - end: Offset.zero, - ).animate(animation), - child: FadeTransition( - opacity: animation, - child: child, - ), - ); - }, - child: KeyedSubtree( - key: ValueKey(_currentPage), - child: switch (_currentPage) { - 0 => _EqPage( - scrollController: _scrollController, - graphicGraphKey: _graphicGraphKey, - parametricGraphKey: _parametricGraphKey, - ), - 1 => const _DynamicsPage(), - _ => const _FxPage(), - }, - ), + const SizedBox(width: AppConstants.spacingMd), + ], + ) + else ...[ + header, + tabs, + const SizedBox(height: AppConstants.spacingMd), + ], + Expanded( + child: GestureDetector( + onHorizontalDragEnd: (details) { + final velocity = details.primaryVelocity ?? 0; + if (velocity < -250) { + _switchToPage(_currentPage + 1); + } else if (velocity > 250) { + _switchToPage(_currentPage - 1); + } + }, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + layoutBuilder: (currentChild, previousChildren) => Stack( + alignment: Alignment.topCenter, + children: [...previousChildren, ?currentChild], + ), + transitionBuilder: (child, animation) { + final incoming = + (child.key as ValueKey).value == _currentPage; + final beginX = + (incoming ? _pageDirection : -_pageDirection) + .toDouble(); + return SlideTransition( + position: Tween( + begin: Offset(beginX, 0), + end: Offset.zero, + ).animate(animation), + child: FadeTransition( + opacity: animation, + child: child, ), - ), + ); + }, + child: KeyedSubtree( + key: ValueKey(_currentPage), + child: switch (_currentPage) { + 0 => _EqPage( + scrollController: _scrollController, + graphicGraphKey: _graphicGraphKey, + parametricGraphKey: _parametricGraphKey, + compact: isShortWide, + ), + 1 => const _DynamicsPage(), + _ => const _FxPage(), + }, ), - ], + ), ), ), ], @@ -233,11 +240,13 @@ class _EqPage extends ConsumerWidget { final ScrollController scrollController; final GlobalKey graphicGraphKey; final GlobalKey parametricGraphKey; + final bool compact; const _EqPage({ required this.scrollController, required this.graphicGraphKey, required this.parametricGraphKey, + this.compact = false, }); @override @@ -245,45 +254,47 @@ class _EqPage extends ConsumerWidget { final enabled = ref.watch(eqEnabledProvider); final mode = ref.watch(eqModeProvider); - return NotificationListener( - onNotification: (_) => true, - child: SingleChildScrollView( - controller: scrollController, - padding: const EdgeInsets.symmetric(horizontal: AppConstants.spacingMd), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _GlassCard( - child: Column( - children: [ - _TopControlsRow(enabled: enabled), - _Divider(), - _PreampRow(enabled: enabled), - _Divider(), - _ModeAndActionsRow(mode: mode), - ], + return NotificationListener( + onNotification: (_) => true, + child: SingleChildScrollView( + controller: scrollController, + padding: const EdgeInsets.symmetric(horizontal: AppConstants.spacingMd), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _GlassCard( + child: Column( + children: [ + _TopControlsRow(enabled: enabled), + _Divider(), + _PreampRow(enabled: enabled), + _Divider(), + _ModeAndActionsRow(mode: mode), + ], + ), ), - ), - const SizedBox(height: AppConstants.spacingLg), - _BmtKnobsRow(enabled: enabled), - const SizedBox(height: AppConstants.spacingLg), - AnimatedSwitcher( - duration: AppConstants.animationNormal, - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeInCubic, - child: mode == EqMode.graphic - ? _GraphicEqView( - key: const ValueKey('graphic'), - graphKey: graphicGraphKey, - ) - : _ParametricEqView( - key: const ValueKey('param'), - graphKey: parametricGraphKey, - ), + const SizedBox(height: AppConstants.spacingLg), + _BmtKnobsRow(enabled: enabled), + const SizedBox(height: AppConstants.spacingLg), + AnimatedSwitcher( + duration: AppConstants.animationNormal, + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + child: mode == EqMode.graphic + ? _GraphicEqView( + key: const ValueKey('graphic'), + graphKey: graphicGraphKey, + compact: compact, + ) + : _ParametricEqView( + key: const ValueKey('param'), + graphKey: parametricGraphKey, + compact: compact, + ), ), - SizedBox(height: AppConstants.navBarHeight + 120), - ], - ), + SizedBox(height: _bottomSpacer(context)), + ], + ), ), ); } @@ -302,7 +313,7 @@ class _DynamicsPage extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ const _DynamicsSection(), - SizedBox(height: AppConstants.navBarHeight + 120), + SizedBox(height: _bottomSpacer(context)), ], ), ), @@ -323,7 +334,7 @@ class _FxPage extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ const _CreativeFxSection(), - SizedBox(height: AppConstants.navBarHeight + 120), + SizedBox(height: _bottomSpacer(context)), ], ), ), @@ -1718,9 +1729,21 @@ class _BmtKnobsRow extends ConsumerWidget { } } +double _bottomSpacer(BuildContext context) { + final size = MediaQuery.sizeOf(context); + return size.width > size.height && size.height < 560 + ? 72.0 + : AppConstants.navBarHeight + 120; +} + class _GraphicEqView extends ConsumerWidget { final GlobalKey? graphKey; - const _GraphicEqView({super.key, this.graphKey}); + final bool compact; + const _GraphicEqView({ + super.key, + this.graphKey, + this.compact = false, + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -1803,9 +1826,9 @@ class _GraphicEqView extends ConsumerWidget { RepaintBoundary( child: Opacity( opacity: enabled ? 1.0 : 0.5, - child: const SizedBox( - height: 220, - child: GraphicEqGraph(), + child: SizedBox( + height: compact ? 160.0 : 220.0, + child: const GraphicEqGraph(), ), ), ), @@ -1828,7 +1851,7 @@ class _GraphicEqView extends ConsumerWidget { ), const SizedBox(height: AppConstants.spacingMd), SizedBox( - height: 332, + height: compact ? 260.0 : 332.0, child: RepaintBoundary( child: SingleChildScrollView( scrollDirection: Axis.horizontal, @@ -2108,7 +2131,12 @@ class _AnimatedSliderState extends State<_AnimatedSlider> class _ParametricEqView extends ConsumerStatefulWidget { final GlobalKey? graphKey; - const _ParametricEqView({super.key, this.graphKey}); + final bool compact; + const _ParametricEqView({ + super.key, + this.graphKey, + this.compact = false, + }); @override ConsumerState<_ParametricEqView> createState() => _ParametricEqViewState(); @@ -2218,9 +2246,9 @@ class _ParametricEqViewState extends ConsumerState<_ParametricEqView> { RepaintBoundary( child: Opacity( opacity: enabled ? 1.0 : 0.5, - child: const SizedBox( - height: 220, - child: ParametricEqGraph(), + child: SizedBox( + height: widget.compact ? 160.0 : 220.0, + child: const ParametricEqGraph(), ), ), ), @@ -2246,7 +2274,7 @@ class _ParametricEqViewState extends ConsumerState<_ParametricEqView> { ), const SizedBox(height: AppConstants.spacingMd), SizedBox( - height: 300, + height: widget.compact ? 260.0 : 300.0, child: ListView.separated( controller: _bandScrollController, scrollDirection: Axis.horizontal, diff --git a/lib/features/settings/screens/ui_customization_settings_screen.dart b/lib/features/settings/screens/ui_customization_settings_screen.dart index 35ca7a29..0b2a80c7 100644 --- a/lib/features/settings/screens/ui_customization_settings_screen.dart +++ b/lib/features/settings/screens/ui_customization_settings_screen.dart @@ -239,7 +239,7 @@ class UiCustomizationSettingsScreen extends ConsumerWidget { SelectionSetting( icon: icon, title: mode.labelFor(l10n.englishForFilipino), - subtitle: mode.description, + subtitle: mode.descriptionFor(l10n), selected: isSelected, onTap: () { ref @@ -258,7 +258,7 @@ class UiCustomizationSettingsScreen extends ConsumerWidget { SelectionSetting( icon: LucideIcons.audioWaveform, title: ProgressBarStyle.waveform.labelFor(l10n.englishForFilipino), - subtitle: ProgressBarStyle.waveform.description, + subtitle: ProgressBarStyle.waveform.descriptionFor(l10n), selected: progressBarStyle == ProgressBarStyle.waveform, onTap: () { ref @@ -270,7 +270,7 @@ class UiCustomizationSettingsScreen extends ConsumerWidget { SelectionSetting( icon: LucideIcons.minus, title: ProgressBarStyle.line.labelFor(l10n.englishForFilipino), - subtitle: ProgressBarStyle.line.description, + subtitle: ProgressBarStyle.line.descriptionFor(l10n), selected: progressBarStyle == ProgressBarStyle.line, onTap: () { ref @@ -348,7 +348,7 @@ class UiCustomizationSettingsScreen extends ConsumerWidget { SelectionSetting( icon: LucideIcons.palette, title: mode.labelFor(l10n.englishForFilipino), - subtitle: mode.description, + subtitle: mode.descriptionFor(l10n), selected: isSelected, onTap: () { ref diff --git a/lib/features/songs/screens/songs_screen.dart b/lib/features/songs/screens/songs_screen.dart index ef4fc301..778956ef 100644 --- a/lib/features/songs/screens/songs_screen.dart +++ b/lib/features/songs/screens/songs_screen.dart @@ -12,6 +12,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:flick/core/theme/app_colors.dart'; import 'package:flick/core/theme/adaptive_color_provider.dart'; import 'package:flick/core/constants/app_constants.dart'; +import 'package:flick/core/utils/adaptive_layout.dart'; import 'package:flick/core/utils/app_haptics.dart'; import 'package:flick/core/utils/responsive.dart'; import 'package:flick/core/utils/navigation_helper.dart'; @@ -135,6 +136,12 @@ class _SongsScreenState extends ConsumerState final songsAsync = ref.watch(songsProvider); final viewMode = ref.watch(songsViewModeProvider); final navBarVisible = ref.watch(navBarVisibleProvider); + final reserveSideMiniPlayer = + viewMode != SongViewMode.list && + songsAsync.value?.sortOption != SongSortOption.album && + AdaptiveLayout.useCompactLandscapeMiniPlayer( + MediaQuery.sizeOf(context), + ); final swipeActionsEnabled = ref .watch(appPreferencesProvider) .swipeActionsEnabled; @@ -143,209 +150,200 @@ class _SongsScreenState extends ConsumerState NavBarButton.search, ); - final sortOption = - songsAsync.value?.sortOption ?? SongSortOption.albumArtist; - final isAlbumMode = sortOption == SongSortOption.album; - final shouldReserveOrbitBottomSpace = - viewMode != SongViewMode.list && navBarVisible && !isAlbumMode; - return Stack( clipBehavior: Clip.none, children: [ SafeArea( bottom: false, - child: Column( - children: [ - // Header with sort option - AnimatedSwitcher( - duration: AppConstants.animationNormal, - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeInCubic, - transitionBuilder: (child, animation) => - FadeTransition(opacity: animation, child: child), - child: _selectionMode - ? KeyedSubtree( - key: const ValueKey('selection_header'), - child: _buildSelectionHeader(), - ) - : KeyedSubtree( - key: const ValueKey('normal_header'), - child: _buildHeader(songsAsync), - ), - ), + child: Padding( + padding: EdgeInsets.only( + right: reserveSideMiniPlayer + ? AdaptiveLayout.sideMiniPlayerContentInset + : 0, + ), + child: Column( + children: [ + // Header with sort option + AnimatedSwitcher( + duration: AppConstants.animationNormal, + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) => + FadeTransition(opacity: animation, child: child), + child: _selectionMode + ? KeyedSubtree( + key: const ValueKey('selection_header'), + child: _buildSelectionHeader(), + ) + : KeyedSubtree( + key: const ValueKey('normal_header'), + child: _buildHeader(songsAsync), + ), + ), - // Search Bar (hidden when Search tab is in the nav bar) - if (!searchInNavBar) ...[ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppConstants.spacingLg, - ), - child: TutorialTargetAnchor( - target: TutorialTarget.songsSearchBar, - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - AppColors.surfaceLight.withValues(alpha: 0.75), - AppColors.surface.withValues(alpha: 0.85), + // Search Bar (hidden when Search tab is in the nav bar) + if (!searchInNavBar) ...[ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppConstants.spacingLg, + ), + child: TutorialTargetAnchor( + target: TutorialTarget.songsSearchBar, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + AppColors.surfaceLight.withValues(alpha: 0.75), + AppColors.surface.withValues(alpha: 0.85), + ], + ), + borderRadius: BorderRadius.circular( + AppConstants.radiusXl, + ), + border: Border.all( + color: AppColors.glassBorder, + width: 1, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 16, + spreadRadius: 1, + offset: const Offset(0, 4), + ), ], ), - borderRadius: BorderRadius.circular( - AppConstants.radiusXl, - ), - border: Border.all( - color: AppColors.glassBorder, - width: 1, + child: GlassSearchBar( + controller: _searchController, + hintText: l10n.searchSongsArtists, + showBackground: false, + onChanged: (value) { + setState(() { + _searchQuery = value.toLowerCase(); + _selectedIndex = 0; + _lastSyncedSong = null; + }); + }, ), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.2), - blurRadius: 16, - spreadRadius: 1, - offset: const Offset(0, 4), - ), - ], - ), - child: GlassSearchBar( - controller: _searchController, - hintText: l10n.searchSongsArtists, - showBackground: false, - onChanged: (value) { - setState(() { - _searchQuery = value.toLowerCase(); - _selectedIndex = 0; - _lastSyncedSong = null; - }); - }, ), ), ), - ), - const SizedBox(height: AppConstants.spacingMd), - ], + const SizedBox(height: AppConstants.spacingMd), + ], - // Content based on async state - Expanded( - child: songsAsync.when( - loading: () => _buildLoadingState(), - error: (error, stack) => _buildErrorState(error), - data: (songsState) { - final isAlbumMode = - songsState.sortOption == SongSortOption.album; - final allSongs = songsState.sortedSongs; - var songs = allSongs; - if (_cachedSongs != songsState.songs) { - _cachedSongs = songsState.songs; - _lastSyncedSong = null; - } - - if (_searchQuery.isNotEmpty) { - songs = songs.where((song) { - return song.title.toLowerCase().contains( - _searchQuery, - ) || - song.artist.toLowerCase().contains(_searchQuery); - }).toList(); - } - _cachedDisplaySongs = songs; - - if (!isAlbumMode) { - _wasInAlbumMode = false; - final sig = - '${songs.length}:${songs.isNotEmpty ? songs.first.id : ''}'; - if (sig != _listPaginationSignature) { - _listPaginationSignature = sig; - _resetListPagination(); + // Content based on async state + Expanded( + child: songsAsync.when( + loading: () => _buildLoadingState(), + error: (error, stack) => _buildErrorState(error), + data: (songsState) { + final isAlbumMode = + songsState.sortOption == SongSortOption.album; + final allSongs = songsState.sortedSongs; + var songs = allSongs; + if (_cachedSongs != songsState.songs) { + _cachedSongs = songsState.songs; + _lastSyncedSong = null; } - } - if (songs.isEmpty && _searchQuery.isEmpty) { - return _buildEmptyState(); - } + if (_searchQuery.isNotEmpty) { + songs = songs.where((song) { + return song.title.toLowerCase().contains( + _searchQuery, + ) || + song.artist.toLowerCase().contains(_searchQuery); + }).toList(); + } + _cachedDisplaySongs = songs; + + if (!isAlbumMode) { + _wasInAlbumMode = false; + final sig = + '${songs.length}:${songs.isNotEmpty ? songs.first.id : ''}'; + if (sig != _listPaginationSignature) { + _listPaginationSignature = sig; + _resetListPagination(); + } + } - if (songs.isEmpty && _searchQuery.isNotEmpty) { - return _buildNoSearchResultsState(); - } + if (songs.isEmpty && _searchQuery.isEmpty) { + return _buildEmptyState(); + } - if (isAlbumMode) { - if (!_wasInAlbumMode) { - _wasInAlbumMode = true; - _visibleAlbumCount = _getAlbumPageSize(); - _albumPaginationSignature = ''; + if (songs.isEmpty && _searchQuery.isNotEmpty) { + return _buildNoSearchResultsState(); } - final albumGroups = songsState.albumGroups; - if (_searchQuery.isNotEmpty) { - final filteredGroups = []; - for (final group in albumGroups) { - final filtered = group.songs.where((song) { - return song.title.toLowerCase().contains( - _searchQuery, - ) || - song.artist.toLowerCase().contains( - _searchQuery, - ); - }).toList(); - if (filtered.isNotEmpty) { - filteredGroups.add( - AlbumGroup( - key: group.key, - albumName: group.albumName, - albumArtist: group.albumArtist, - songs: filtered, - ), - ); + + if (isAlbumMode) { + if (!_wasInAlbumMode) { + _wasInAlbumMode = true; + _visibleAlbumCount = _getAlbumPageSize(); + _albumPaginationSignature = ''; + } + final albumGroups = songsState.albumGroups; + if (_searchQuery.isNotEmpty) { + final filteredGroups = []; + for (final group in albumGroups) { + final filtered = group.songs.where((song) { + return song.title.toLowerCase().contains( + _searchQuery, + ) || + song.artist.toLowerCase().contains( + _searchQuery, + ); + }).toList(); + if (filtered.isNotEmpty) { + filteredGroups.add( + AlbumGroup( + key: group.key, + albumName: group.albumName, + albumArtist: group.albumArtist, + songs: filtered, + ), + ); + } + } + if (filteredGroups.isEmpty) { + return _buildNoSearchResultsState(); } + return _buildAlbumGridView(filteredGroups); } - if (filteredGroups.isEmpty) { - return _buildNoSearchResultsState(); + if (albumGroups.isEmpty) { + return _buildEmptyState(); } - return _buildAlbumGridView(filteredGroups); + return _buildAlbumGridView(albumGroups); } - if (albumGroups.isEmpty) { - return _buildEmptyState(); - } - return _buildAlbumGridView(albumGroups); - } - _alignCurrentSongAfterSongsLoad(songs); + _alignCurrentSongAfterSongsLoad(songs); - if (_selectedIndex >= songs.length) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - setState(() => _selectedIndex = 0); - } - }); - } + if (_selectedIndex >= songs.length) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() => _selectedIndex = 0); + } + }); + } - _syncSelectedTokenForIndex(songs, _selectedIndex); + _syncSelectedTokenForIndex(songs, _selectedIndex); - final tokenToIndexMap = _buildFastIndexMap(songs); - final appPrefs = ref.watch(appPreferencesProvider); + final tokenToIndexMap = _buildFastIndexMap(songs); + final appPrefs = ref.watch(appPreferencesProvider); - return _buildSongsView( - songs, - viewMode, - tokenToIndexMap, - navBarVisible, - swipeActionsEnabled, - appPrefs.fastIndexEnabled, - ); - }, + return _buildSongsView( + songs, + viewMode, + tokenToIndexMap, + navBarVisible, + swipeActionsEnabled, + appPrefs.fastIndexEnabled, + ); + }, + ), ), - ), - - // Reserve space only when needed for orbit view. - // List view handles its own bottom padding. - AnimatedContainer( - duration: AppConstants.animationNormal, - curve: Curves.easeOutCubic, - height: shouldReserveOrbitBottomSpace - ? AppConstants.navBarHeight + AppConstants.spacingLg - : 0, - ), - ], + ], + ), ), ), ], @@ -446,6 +444,10 @@ class _SongsScreenState extends ConsumerState } }, child: OrbitScroll( + scrollDirection: + MediaQuery.sizeOf(context).width > MediaQuery.sizeOf(context).height + ? Axis.horizontal + : Axis.vertical, controller: _orbitScrollController, songs: songs, selectedIndex: _selectedIndex.clamp(0, songs.length - 1).toInt(), @@ -577,9 +579,7 @@ class _SongsScreenState extends ConsumerState final sorted = List.from(albums); switch (_albumSortOption) { case _AlbumGridSortOption.name: - sorted.sort( - (a, b) => compareCaseInsensitive(a.albumName, b.albumName), - ); + sorted.sort((a, b) => compareCaseInsensitive(a.albumName, b.albumName)); case _AlbumGridSortOption.artist: sorted.sort((a, b) { final c = compareCaseInsensitive(a.albumArtist, b.albumArtist); @@ -1860,7 +1860,10 @@ class _SongsScreenState extends ConsumerState content: Text(message), action: onUndo == null ? null - : SnackBarAction(label: l10n.englishForFilipino.undo, onPressed: onUndo), + : SnackBarAction( + label: l10n.englishForFilipino.undo, + onPressed: onUndo, + ), duration: const Duration(seconds: 2), ), ); @@ -2727,7 +2730,9 @@ class _AlbumCardState extends State<_AlbumCard> fit: BoxFit.cover, useThumbnail: true, thumbnailWidth: artworkTargetWidth, - thumbnailHeight: widget.stretchArtwork ? artworkTargetWidth : null, + thumbnailHeight: widget.stretchArtwork + ? artworkTargetWidth + : null, placeholder: _buildPlaceholder(context), errorWidget: _buildPlaceholder(context), ), @@ -2757,7 +2762,9 @@ class _AlbumCardState extends State<_AlbumCard> decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.55), borderRadius: BorderRadius.circular(999), - border: Border.all(color: Colors.white.withValues(alpha: 0.12)), + border: Border.all( + color: Colors.white.withValues(alpha: 0.12), + ), ), child: Text( l10n.tracks6(widget.album.songs.length), @@ -2791,9 +2798,9 @@ class _AlbumCardState extends State<_AlbumCard> const SizedBox(height: _albumCardLineGap), Text( widget.album.albumArtist, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: context.adaptiveTextSecondary, - ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: context.adaptiveTextSecondary), maxLines: 1, overflow: TextOverflow.ellipsis, ), diff --git a/lib/features/songs/widgets/orbit_scroll.dart b/lib/features/songs/widgets/orbit_scroll.dart index e65de111..009ba9db 100644 --- a/lib/features/songs/widgets/orbit_scroll.dart +++ b/lib/features/songs/widgets/orbit_scroll.dart @@ -58,6 +58,10 @@ class OrbitScroll extends StatefulWidget { /// Controller for external jump-to-index actions. final OrbitScrollController? controller; + /// Landscape owns horizontal drags only within its music viewport. The + /// surrounding header and side gutters remain available to tab navigation. + final Axis scrollDirection; + final double radiusRatio; final double centerOffsetRatio; final double centerYRatio; @@ -83,6 +87,7 @@ class OrbitScroll extends StatefulWidget { this.isSelectionMode = false, this.selectedIds = const {}, this.controller, + this.scrollDirection = Axis.vertical, this.radiusRatio = 1.0, this.centerOffsetRatio = -0.5, this.centerYRatio = 0.42, @@ -118,6 +123,9 @@ class _OrbitScrollState extends State bool _artworkGateHeld = false; DateTime _lastScrollTime = DateTime.now(); int _lastReportedIndex = 0; + Size? _layoutSize; + double _dragExtent = 90.0; + int _animationGeneration = 0; // Cache for transform calculations (bounded) final Map _positionCache = {}; @@ -129,10 +137,7 @@ class _OrbitScrollState extends State // user-tunable count takes effect. Upgrade to a memo if this ever shows in a profile. List get _orderedIndices { final visibleRange = widget.visibleItems ~/ 2; - return List.generate( - visibleRange * 2 + 1, - (i) => i - visibleRange, - ) + return List.generate(visibleRange * 2 + 1, (i) => i - visibleRange) ..sort((a, b) => b.abs().compareTo(a.abs())); } @@ -167,14 +172,26 @@ class _OrbitScrollState extends State oldWidget.centerOffsetRatio != widget.centerOffsetRatio || oldWidget.centerYRatio != widget.centerYRatio || oldWidget.cardArtSize != widget.cardArtSize || - oldWidget.cardWidthRatio != widget.cardWidthRatio) { + oldWidget.cardWidthRatio != widget.cardWidthRatio || + oldWidget.scrollDirection != widget.scrollDirection) { _transformCache.clear(); _positionCache.clear(); } + if (oldWidget.scrollDirection != widget.scrollDirection || + oldWidget.songs.length != widget.songs.length) { + _animationGeneration++; + _controller.stop(); + _isScrolling = false; + _scrollOffset.value = _scrollOffset.value + .round() + .clamp(0, math.max(0, widget.songs.length - 1)) + .toDouble(); + } if (widget.selectedIndex != oldWidget.selectedIndex) { _lastReportedIndex = widget.selectedIndex; // If the index changed externally, snap/spring to it - if ((widget.selectedIndex.toDouble() - _scrollOffset.value).abs() > 0.05) { + if ((widget.selectedIndex.toDouble() - _scrollOffset.value).abs() > + 0.05) { _animateTo(widget.selectedIndex.toDouble()); } } @@ -182,6 +199,7 @@ class _OrbitScrollState extends State @override void dispose() { + _animationGeneration++; widget.controller?._detach(); _controller.dispose(); _scrollOffset.dispose(); @@ -256,11 +274,13 @@ class _OrbitScrollState extends State }); } - void _onVerticalDragStart(DragStartDetails details) { + void _onDragStart(DragStartDetails details) { + _animationGeneration++; _controller.stop(); } - void _onVerticalDragUpdate(DragUpdateDetails details) { + void _onDragUpdate(DragUpdateDetails details) { + if (widget.songs.isEmpty) return; final delta = details.primaryDelta ?? 0.0; if (delta == 0) return; @@ -274,15 +294,17 @@ class _OrbitScrollState extends State maxScrollExtent: widget.songs.length.toDouble(), pixels: _scrollOffset.value, viewportDimension: 100, - axisDirection: AxisDirection.down, + axisDirection: widget.scrollDirection == Axis.horizontal + ? AxisDirection.right + : AxisDirection.down, devicePixelRatio: 1.0, ), context: context, direction: direction, ).dispatch(context); - const itemHeight = 90.0; - var itemDelta = -(delta / itemHeight); + var itemDelta = -(delta / _dragExtent); + _markScrollActive(); double newOffset = _scrollOffset.value + itemDelta; if (newOffset < -0.5 || newOffset > widget.songs.length - 0.5) { @@ -303,7 +325,8 @@ class _OrbitScrollState extends State } } - void _onVerticalDragEnd(DragEndDetails details) { + void _onDragEnd(DragEndDetails details) { + if (widget.songs.isEmpty) return; final velocity = details.primaryVelocity ?? 0.0; // Dispatch end notification (idle) @@ -313,7 +336,9 @@ class _OrbitScrollState extends State maxScrollExtent: widget.songs.length.toDouble(), pixels: _scrollOffset.value, viewportDimension: 100, - axisDirection: AxisDirection.down, + axisDirection: widget.scrollDirection == Axis.horizontal + ? AxisDirection.right + : AxisDirection.down, devicePixelRatio: 1.0, ), context: context, @@ -322,8 +347,7 @@ class _OrbitScrollState extends State // Pixels per second // Convert to items per second - const itemHeight = 90.0; - final velocityItemsPerSec = -velocity / itemHeight; + final velocityItemsPerSec = -velocity / _dragExtent; // 1. Predict landing point // We use a FrictionSimulation to see where it WOULD land. @@ -347,6 +371,11 @@ class _OrbitScrollState extends State } void _animateTo(double target, {double velocity = 0.0}) { + final generation = ++_animationGeneration; + if (MediaQuery.disableAnimationsOf(context)) { + _jumpToIndex(target.round(), false); + return; + } // Create a spring simulation from current => target final description = SpringDescription.withDampingRatio( mass: 1.0, @@ -362,6 +391,7 @@ class _OrbitScrollState extends State ); _controller.animateWith(simulation).whenComplete(() { + if (!mounted || generation != _animationGeneration) return; // Ensure we explicitly set the final state to avoid micro-drifts _scrollOffset.value = target; setState(() { @@ -388,54 +418,102 @@ class _OrbitScrollState extends State @override Widget build(BuildContext context) { - final size = MediaQuery.of(context).size; - - // Calculate orbit parameters - final orbitRadius = size.width * widget.radiusRatio; - final orbitCenterX = size.width * widget.centerOffsetRatio; - final orbitCenterY = size.height * widget.centerYRatio; - - return GestureDetector( - onVerticalDragStart: _onVerticalDragStart, - onVerticalDragUpdate: _onVerticalDragUpdate, - onVerticalDragEnd: _onVerticalDragEnd, - behavior: HitTestBehavior.opaque, - child: Container( - width: double.infinity, - height: double.infinity, - color: Colors.transparent, - child: Stack( - clipBehavior: Clip.none, - children: [ - // Background glow - if (widget.showGlow) - _buildSelectionGlow(orbitCenterX, orbitCenterY, orbitRadius), - - // Path - if (widget.showPath) - _buildOrbitPath(orbitCenterX, orbitCenterY, orbitRadius), - - // Songs โ€” only rebuilds when scroll offset changes - ValueListenableBuilder( - valueListenable: _scrollOffset, - builder: (context, _, __) { - return SizedBox.expand( - child: Stack( - clipBehavior: Clip.none, - children: _buildSongItems(orbitCenterX, orbitCenterY, orbitRadius), + final horizontal = widget.scrollDirection == Axis.horizontal; + return Padding( + padding: EdgeInsets.symmetric(horizontal: horizontal ? 28 : 0), + child: LayoutBuilder( + builder: (context, constraints) { + final size = constraints.biggest; + if (_layoutSize != size) { + _layoutSize = size; + _positionCache.clear(); + _transformCache.clear(); + } + // Phone landscape leaves a much shorter stage than a tablet. Keep + // the same orbit metaphor, but lift and shrink it so the flanking + // cards are not pushed off the bottom of the window. + final compactHorizontal = horizontal && size.height < 420; + final orbitRadius = horizontal + ? size.width * widget.radiusRatio.clamp(0.6, 1.4) + : size.width * widget.radiusRatio; + final orbitCenterX = horizontal + ? size.width / 2 + : size.width * widget.centerOffsetRatio; + final orbitCenterY = horizontal + ? size.height * (compactHorizontal ? 0.44 : 0.48) + orbitRadius + : size.height * widget.centerYRatio; + _dragExtent = horizontal + ? (size.width * 0.28).clamp(140.0, 280.0) + : 90; + + final viewport = GestureDetector( + onVerticalDragStart: horizontal ? null : _onDragStart, + onVerticalDragUpdate: horizontal ? null : _onDragUpdate, + onVerticalDragEnd: horizontal ? null : _onDragEnd, + onVerticalDragCancel: horizontal ? null : _onDragCancel, + onHorizontalDragStart: horizontal ? _onDragStart : null, + onHorizontalDragUpdate: horizontal ? _onDragUpdate : null, + onHorizontalDragEnd: horizontal ? _onDragEnd : null, + onHorizontalDragCancel: horizontal ? _onDragCancel : null, + behavior: HitTestBehavior.opaque, + child: Container( + width: double.infinity, + height: double.infinity, + color: Colors.transparent, + child: Stack( + clipBehavior: Clip.hardEdge, + children: [ + // Background glow + if (widget.showGlow) + _buildSelectionGlow( + orbitCenterX, + orbitCenterY, + orbitRadius, + ), + + // Path + if (widget.showPath) + _buildOrbitPath(orbitCenterX, orbitCenterY, orbitRadius), + + // Songs โ€” only rebuilds when scroll offset changes + ValueListenableBuilder( + valueListenable: _scrollOffset, + builder: (context, _, __) { + return SizedBox.expand( + child: Stack( + clipBehavior: Clip.none, + children: _buildSongItems( + orbitCenterX, + orbitCenterY, + orbitRadius, + ), + ), + ); + }, ), - ); - }, + ], + ), ), - ], - ), + ); + // Inner recognizers win Flutter's gesture arena before the ancestor + // PageView. No global physics toggle is needed (or left stuck on cancel). + return viewport; + }, ), ); } + void _onDragCancel() { + if (widget.songs.isEmpty) return; + _animateTo( + _scrollOffset.value.round().clamp(0, widget.songs.length - 1).toDouble(), + ); + } + Widget _buildSelectionGlow(double centerX, double centerY, double radius) { - final x = centerX + radius; - final y = centerY; + final horizontal = widget.scrollDirection == Axis.horizontal; + final x = horizontal ? centerX : centerX + radius; + final y = horizontal ? centerY - radius : centerY; return Positioned( left: x - 120, // Slightly larger glow top: y - 120, @@ -466,6 +544,7 @@ class _OrbitScrollState extends State centerX: centerX, centerY: centerY, radius: radius, + horizontal: widget.scrollDirection == Axis.horizontal, ), ), ); @@ -474,6 +553,25 @@ class _OrbitScrollState extends State List _buildSongItems(double centerX, double centerY, double radius) { final List items = []; + final horizontal = widget.scrollDirection == Axis.horizontal; + final compactHorizontal = horizontal && _layoutSize!.height < 420; + final cardScale = math.max(1.0, widget.selectedScale); + final viewportCardWidth = + (_layoutSize!.width * widget.cardWidthRatio * 0.5) / cardScale; + final compactCardHeight = _layoutSize!.height * 0.72 / cardScale; + final cardWidth = horizontal + ? compactHorizontal + ? math + .min(viewportCardWidth, compactCardHeight * 1.05) + .clamp(88.0, 280.0) + : viewportCardWidth.clamp(160.0, 280.0) + : null; + final cardHeight = horizontal + ? compactHorizontal + ? compactCardHeight + : math.min(380.0, _layoutSize!.height * 0.85) / cardScale + : null; + final centerIndex = _scrollOffset.value.round(); // ponytail: adjacent/distant shrink derived from one Depth slider. @@ -498,16 +596,15 @@ class _OrbitScrollState extends State double scale; if (distanceFromCenter < 1.0) { - scale = widget.selectedScale - - (widget.selectedScale - adjacentScale) * - distanceFromCenter; + scale = + widget.selectedScale - + (widget.selectedScale - adjacentScale) * distanceFromCenter; } else if (distanceFromCenter < 2.0) { - scale = adjacentScale - - (adjacentScale - distantScale) * - (distanceFromCenter - 1.0); + scale = + adjacentScale - + (adjacentScale - distantScale) * (distanceFromCenter - 1.0); } else { - scale = distantScale - - (distanceFromCenter - 2.0) * 0.12; + scale = distantScale - (distanceFromCenter - 2.0) * 0.12; } scale = scale.clamp(0.0, 1.25); @@ -533,16 +630,26 @@ class _OrbitScrollState extends State child: FractionalTranslation( translation: const Offset(-0.5, -0.5), child: SongCard( + key: ValueKey(widget.songs[actualIndex].id), song: widget.songs[actualIndex], scale: transform.scale, opacity: transform.opacity, isSelected: transform.isSelected, - swipeActionsEnabled: widget.swipeActionsEnabled, + // Horizontal drags browse songs here, never queue/favorite. + // Those actions remain in the existing long-press song menu. + swipeActionsEnabled: + widget.swipeActionsEnabled && + widget.scrollDirection == Axis.vertical, isSelectionMode: widget.isSelectionMode, - isMultiSelected: widget.selectedIds.contains(widget.songs[actualIndex].id), + isMultiSelected: widget.selectedIds.contains( + widget.songs[actualIndex].id, + ), artSizeBase: widget.cardArtSize, artSizeLarge: widget.cardArtSize * 1.5625, cardWidthRatio: widget.cardWidthRatio, + artworkAbove: horizontal, + width: cardWidth, + height: cardHeight, artResolutionMultiplier: widget.artResolutionMultiplier, onTap: () { AppHaptics.tap(); @@ -581,8 +688,13 @@ class _OrbitScrollState extends State relativeIndex.sign * splitAmount * math.min(relativeIndex.abs(), 1.0); final angle = adjustedIndex * widget.itemSpacing; - final x = centerX + radius * math.cos(angle); - final y = centerY + radius * math.sin(angle); + final horizontal = widget.scrollDirection == Axis.horizontal; + final x = horizontal + ? centerX + radius * math.sin(angle) + : centerX + radius * math.cos(angle); + final y = horizontal + ? centerY - radius * math.cos(angle) + : centerY + radius * math.sin(angle); final position = _Position(x, y); @@ -616,11 +728,13 @@ class _OrbitPathPainter extends CustomPainter { final double centerX; final double centerY; final double radius; + final bool horizontal; _OrbitPathPainter({ required this.centerX, required this.centerY, required this.radius, + this.horizontal = false, }); @override @@ -635,13 +749,20 @@ class _OrbitPathPainter extends CustomPainter { radius: radius, ); - canvas.drawArc(rect, -math.pi / 2.5, 2 * math.pi / 2.5, false, paint); + canvas.drawArc( + rect, + horizontal ? -math.pi / 2 - math.pi / 2.5 : -math.pi / 2.5, + 2 * math.pi / 2.5, + false, + paint, + ); } @override bool shouldRepaint(covariant _OrbitPathPainter oldDelegate) { return centerX != oldDelegate.centerX || centerY != oldDelegate.centerY || - radius != oldDelegate.radius; + radius != oldDelegate.radius || + horizontal != oldDelegate.horizontal; } } diff --git a/lib/features/songs/widgets/song_card.dart b/lib/features/songs/widgets/song_card.dart index 80f2ea29..9f96d3f6 100644 --- a/lib/features/songs/widgets/song_card.dart +++ b/lib/features/songs/widgets/song_card.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flick/core/theme/app_colors.dart'; @@ -52,6 +53,9 @@ class SongCard extends StatefulWidget { /// Decode-resolution multiplier applied to art size (higher = sharper, heavier). final double artResolutionMultiplier; + final double? width; + final double? height; + final bool artworkAbove; const SongCard({ super.key, @@ -69,6 +73,9 @@ class SongCard extends StatefulWidget { this.artSizeLarge = AppConstants.songCardArtSizeLarge, this.cardWidthRatio = 0.68, this.artResolutionMultiplier = 2.0, + this.width, + this.height, + this.artworkAbove = false, }); @override @@ -85,8 +92,9 @@ class _SongCardState extends State { final artSize = widget.isSelected ? widget.artSizeLarge : widget.artSizeBase; - final cardWidth = MediaQuery.of(context).size.width * widget.cardWidthRatio; - const cardHeight = 130.0; + final cardWidth = widget.width ?? + MediaQuery.sizeOf(context).width * widget.cardWidthRatio; + final cardHeight = widget.height ?? 130.0; final isSwiping = _dragDx.abs() > 0.001; final isFlashing = _queuedFlash || _favoriteFlash; @@ -183,6 +191,7 @@ class _SongCardState extends State { } Widget _buildCardFace(double artSize) { + if (widget.artworkAbove) return _buildArtworkCard(artSize); return ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(AppConstants.radiusLg)), child: Stack( @@ -235,6 +244,131 @@ class _SongCardState extends State { ); } + Widget _buildArtworkCard(double artSize) { + return Semantics( + selected: widget.isSelected || widget.isMultiSelected, + child: ClipRRect( + borderRadius: BorderRadius.circular(AppConstants.radiusLg), + child: ColoredBox( + color: AppColors.surface, + child: LayoutBuilder( + builder: (context, constraints) { + final enlargedText = + MediaQuery.textScalerOf(context).scale(14) > 19; + final showArtwork = + constraints.maxHeight > (enlargedText ? 150 : 100); + final text = Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.song.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: 'GoogleSansFlex', + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + widget.song.artist, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: 'GoogleSansFlex', + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + ); + if (!showArtwork) { + // A short window still shows the music, not a row of text-only + // cards. Overlay the identity instead of squeezing out the art. + return Stack( + fit: StackFit.expand, + children: [ + _buildAlbumWithGradient(artSize), + const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0x33000000), Color(0xE6000000)], + ), + ), + ), + Align( + alignment: Alignment.bottomLeft, + child: SingleChildScrollView(child: text), + ), + if (widget.isSelectionMode) + Positioned( + top: 8, + right: 8, + child: Icon( + widget.isMultiSelected + ? Icons.check_circle_rounded + : Icons.radio_button_unchecked_rounded, + color: AppColors.textPrimary, + ), + ), + ], + ); + } + return Stack( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (showArtwork) + Expanded( + child: _buildRawImage( + widget.song.albumArt ?? '', + audioSourcePath: widget.song.filePath, + artSize: math.max(artSize, constraints.maxWidth), + ), + ), + if (showArtwork) + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: constraints.maxHeight * 0.55, + ), + child: SingleChildScrollView(child: text), + ) + else + Expanded(child: SingleChildScrollView(child: text)), + ], + ), + if (widget.isSelectionMode) + Positioned( + top: 8, + right: 8, + child: Icon( + widget.isMultiSelected + ? Icons.check_circle_rounded + : Icons.radio_button_unchecked_rounded, + color: AppColors.textPrimary, + ), + ), + ], + ); + }, + ), + ), + ), + ); + } + Widget _buildSwipeStack(double cardWidth, double artSize) { final queueProgress = (-_dragDx / 110).clamp(0.0, 1.0); final favoriteProgress = (_dragDx / 110).clamp(0.0, 1.0); diff --git a/lib/main.dart b/lib/main.dart index 565cf8cd..15d23801 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flick/src/rust/frb_generated.dart'; import 'package:flick/app/app.dart'; @@ -29,10 +28,6 @@ Future main() async { // store: new installations start on the adaptive refresh-rate default. await AppPreferencesService.captureInstallFreshness(); - await SystemChrome.setPreferredOrientations([ - DeviceOrientation.portraitUp, - ]); - await AppLog.instance.initializePersistence(); await Uac2PreferencesService().initializeDeveloperModeCache(); FrameTimingsMonitor.instance.start(); diff --git a/lib/widgets/common/detail_header.dart b/lib/widgets/common/detail_header.dart index 70720a98..cfe1a46b 100644 --- a/lib/widgets/common/detail_header.dart +++ b/lib/widgets/common/detail_header.dart @@ -55,8 +55,17 @@ class DetailHeader extends StatelessWidget { }); double _resolveHeight(BuildContext context) { - if (!expanded) return 300; - return (MediaQuery.of(context).size.height * 0.55).clamp(360.0, 560.0); + final size = MediaQuery.sizeOf(context); + final isWide = size.width > size.height; + if (!expanded) { + return isWide ? (size.height * 0.55).clamp(150.0, 220.0) : 300; + } + if (isWide) { + // Keep enough room for the tracklist below instead of letting the + // cover consume almost the whole short landscape viewport. + return (size.height * 0.62).clamp(180.0, 280.0); + } + return (size.height * 0.55).clamp(360.0, 560.0); } @override diff --git a/lib/widgets/common/flick_dialog.dart b/lib/widgets/common/flick_dialog.dart index bf21f25b..47cb9d1e 100644 --- a/lib/widgets/common/flick_dialog.dart +++ b/lib/widgets/common/flick_dialog.dart @@ -16,8 +16,7 @@ Future showFlickDialog({ context: context, barrierDismissible: barrierDismissible, barrierLabel: barrierLabel, - barrierColor: - barrierColor ?? Colors.black.withValues(alpha: 0.5), + barrierColor: barrierColor ?? Colors.black.withValues(alpha: 0.5), transitionDuration: AppConstants.animationDialog, pageBuilder: (context, animation, secondaryAnimation) => const SizedBox.shrink(), @@ -140,9 +139,9 @@ class FlickTextField extends StatelessWidget { keyboardType: keyboardType, textCapitalization: textCapitalization, maxLength: maxLength, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: AppColors.textPrimary, - ), + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: AppColors.textPrimary), decoration: InputDecoration( isDense: true, hintText: hint, @@ -230,7 +229,10 @@ class FlickDialog extends StatelessWidget { @override Widget build(BuildContext context) { - final maxHeight = MediaQuery.of(context).size.height * 0.8; + final mediaQuery = MediaQuery.of(context); + final availableHeight = + mediaQuery.size.height - mediaQuery.viewInsets.bottom; + final maxHeight = availableHeight * 0.8; return Dialog( backgroundColor: Colors.transparent, @@ -310,7 +312,8 @@ class FlickDialog extends StatelessWidget { ); } - Color get _iconColor => destructive ? AppColors.error : AppColors.textSecondary; + Color get _iconColor => + destructive ? AppColors.error : AppColors.textSecondary; } /// One-call helpers for the two most common dialog shapes. diff --git a/lib/widgets/common/floating_mini_player.dart b/lib/widgets/common/floating_mini_player.dart index a86f2833..a97a6509 100644 --- a/lib/widgets/common/floating_mini_player.dart +++ b/lib/widgets/common/floating_mini_player.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flick/core/theme/adaptive_color_provider.dart'; @@ -63,7 +64,27 @@ class _FloatingMiniPlayerState extends ConsumerState @override void didChangeMetrics() { - setState(() => _offset = Offset.zero); + if (!mounted) return; + + // Keep the island where the user left it, re-clamped to the new window. + // Resetting to zero on every metrics change made it jump to center after + // rotation and could leave a dragged island outside a landscape viewport. + final screenSize = MediaQuery.sizeOf(context); + final safeArea = MediaQuery.paddingOf(context); + final t = _expandAnimation.value; + final width = 96.0 + t * 184.0; + final maxDx = math.max(0.0, (screenSize.width - width) / 2 - 8); + final bottomLimit = math.max( + 0.0, + screenSize.height - 36.0 - safeArea.bottom - safeArea.top - 16, + ); + + setState(() { + _offset = Offset( + _offset.dx.clamp(-maxDx, maxDx), + _offset.dy.clamp(0.0, bottomLimit), + ); + }); } void _onTap() { diff --git a/lib/widgets/common/glass_bottom_sheet.dart b/lib/widgets/common/glass_bottom_sheet.dart index 85654252..5aeb9134 100644 --- a/lib/widgets/common/glass_bottom_sheet.dart +++ b/lib/widgets/common/glass_bottom_sheet.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flick/core/constants/app_constants.dart'; import 'package:flick/core/theme/app_colors.dart'; import 'package:flutter/material.dart'; @@ -6,12 +8,14 @@ class AppBottomSheetSurface extends StatelessWidget { final Widget child; final double? maxHeightRatio; final EdgeInsetsGeometry? padding; + final bool scrollable; const AppBottomSheetSurface({ super.key, required this.child, this.maxHeightRatio, this.padding, + this.scrollable = false, }); static final BoxDecoration decoration = BoxDecoration( @@ -39,9 +43,18 @@ class AppBottomSheetSurface extends StatelessWidget { @override Widget build(BuildContext context) { final mediaQuery = MediaQuery.of(context); - final maxHeight = maxHeightRatio != null - ? mediaQuery.size.height * maxHeightRatio! - : mediaQuery.size.height * 0.85; + final availableHeight = + (mediaQuery.size.height - mediaQuery.viewInsets.bottom).clamp( + 0.0, + mediaQuery.size.height, + ); + final isWide = mediaQuery.size.width > mediaQuery.size.height; + final maxHeight = availableHeight * + (maxHeightRatio ?? (isWide ? 0.96 : 0.9)); + final sheetWidth = math.min( + mediaQuery.size.width, + isWide ? 1120.0 : 760.0, + ); // The sheet surface paints all the way to the bottom edge of the screen. // The system gesture/navigation inset is handled by bottom padding so @@ -69,10 +82,15 @@ class AppBottomSheetSurface extends StatelessWidget { onTap: () {}, child: ConstrainedBox( constraints: BoxConstraints(maxHeight: maxHeight), - child: Container( - decoration: decoration, - padding: effectivePadding, - child: child, + child: SizedBox( + width: sheetWidth, + child: Container( + decoration: decoration, + padding: effectivePadding, + child: scrollable + ? SingleChildScrollView(child: child) + : child, + ), ), ), ), @@ -154,8 +172,11 @@ class GlassBottomSheet extends StatelessWidget { isDismissible: isDismissible, enableDrag: enableDrag, backgroundColor: Colors.transparent, + constraints: const BoxConstraints(maxWidth: double.infinity), barrierColor: Colors.black.withValues(alpha: 0.5), - builder: (context) => GlassBottomSheet( + builder: (context) => Padding( + padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom), + child: GlassBottomSheet( title: title, content: content, actions: actions, @@ -164,6 +185,7 @@ class GlassBottomSheet extends StatelessWidget { isScrollControlled: isScrollControlled, maxHeightRatio: maxHeightRatio, contentPadding: contentPadding, + ), ), ); } diff --git a/lib/widgets/common/landscape_side_mini_player.dart b/lib/widgets/common/landscape_side_mini_player.dart new file mode 100644 index 00000000..7ba014ce --- /dev/null +++ b/lib/widgets/common/landscape_side_mini_player.dart @@ -0,0 +1,327 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:flick/core/constants/app_constants.dart'; +import 'package:flick/core/theme/adaptive_color_provider.dart'; +import 'package:flick/core/theme/app_colors.dart'; +import 'package:flick/core/utils/adaptive_layout.dart'; +import 'package:flick/core/utils/navigation_helper.dart'; +import 'package:flick/features/player/widgets/audio_visualizer.dart'; +import 'package:flick/l10n/l10n.dart'; +import 'package:flick/models/mini_player_config.dart'; +import 'package:flick/models/song.dart'; +import 'package:flick/providers/mini_player_config_provider.dart'; +import 'package:flick/services/app_preferences_service.dart'; +import 'package:flick/providers/providers.dart'; +import 'package:flick/widgets/common/cached_image_widget.dart'; +import 'package:flick/widgets/common/flick_artwork_placeholder.dart'; + +/// The compact landscape song orbit needs the stage height that the regular +/// horizontal mini player would otherwise take. This strip keeps the artwork, +/// play/pause and a vertical visualizer while moving the player to the side. +class LandscapeSideMiniPlayer extends ConsumerStatefulWidget { + const LandscapeSideMiniPlayer({super.key}); + + static const String heroTag = 'mini_player_art'; + + @override + ConsumerState createState() => + _LandscapeSideMiniPlayerState(); +} + +class _LandscapeSideMiniPlayerState + extends ConsumerState { + int _songChangeDirection = 0; + + void _next() { + _songChangeDirection = 1; + ref.read(playerProvider.notifier).next(); + } + + void _previous() { + _songChangeDirection = -1; + ref.read(playerProvider.notifier).previous(allowRestart: false); + } + + void _onVerticalDragEnd(DragEndDetails details) { + final velocity = details.primaryVelocity ?? 0; + if (velocity.abs() <= 300) return; + if (velocity < 0) { + _next(); + } else { + _previous(); + } + } + + Future _openPlayer() async { + final result = await NavigationHelper.navigateToFullPlayer( + context, + heroTag: LandscapeSideMiniPlayer.heroTag, + ); + if (result != null && mounted) { + ref.read(navigationIndexProvider.notifier).setIndex(result); + } + } + + @override + Widget build(BuildContext context) { + final song = ref.watch(currentSongProvider); + if (song == null) return const SizedBox.shrink(); + + final prefs = ref.watch(appPreferencesProvider); + final config = ref.watch(miniPlayerConfigProvider); + final isPlaying = ref.watch(isPlayingProvider); + final progress = ref.watch(progressProvider); + final reducedMotion = MediaQuery.disableAnimationsOf(context); + final radius = math.min(config.cornerRadius, 28.0); + final duration = reducedMotion + ? Duration.zero + : AppConstants.animationNormal; + final visualizerEnabled = + prefs.visualizerEnabled && !MediaQuery.disableAnimationsOf(context); + + return SizedBox( + width: AdaptiveLayout.sideMiniPlayerWidth, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: AppConstants.spacingXs), + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + AppColors.surfaceLight.withValues( + alpha: config.backgroundOpacity * (0.86 / 0.94), + ), + AppColors.surface.withValues(alpha: config.backgroundOpacity), + ], + ), + borderRadius: BorderRadius.circular(radius), + boxShadow: _shadow(config), + ), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onVerticalDragEnd: _onVerticalDragEnd, + child: ClipRRect( + borderRadius: BorderRadius.circular(radius), + child: Stack( + fit: StackFit.expand, + children: [ + Column( + children: [ + Expanded( + child: Semantics( + button: true, + label: l10n.openPlayer, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _openPlayer, + child: Column( + // The rotated painter has no intrinsic width; + // give it tight bounds on both axes. + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (config.showArtwork) + _buildArtwork(song, duration) + else + const SizedBox( + height: AppConstants.spacingXs, + ), + const SizedBox(height: AppConstants.spacingXxs), + Expanded( + child: _buildVisualizer( + prefs: prefs, + visualizerEnabled: visualizerEnabled, + ), + ), + ], + ), + ), + ), + ), + Container( + key: const ValueKey('side_mini_player_divider'), + height: 1, + margin: const EdgeInsets.symmetric( + horizontal: AppConstants.spacingSm, + ), + color: Colors.white.withValues(alpha: 0.14), + ), + SizedBox( + height: 52, + child: Center( + child: IconButton( + tooltip: isPlaying ? l10n.pause : l10n.play, + onPressed: () => ref + .read(playerProvider.notifier) + .togglePlayPause(), + icon: Icon( + isPlaying ? LucideIcons.pause : LucideIcons.play, + size: 22, + color: AppColors.accent, + ), + ), + ), + ), + ], + ), + if (config.showProgress) + Positioned( + left: 0, + top: 0, + bottom: 0, + child: _VerticalProgress(progress: progress), + ), + if (config.showBorder) + Positioned.fill( + child: IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(radius), + border: Border.all( + color: Colors.white.withValues(alpha: 0.22), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _buildArtwork(Song song, Duration duration) { + final placeholder = ColoredBox( + color: AppColors.surface, + child: const FlickArtworkPlaceholder(size: 26, opacity: 0.9), + ); + final artwork = song.albumArt != null + ? CachedImageWidget( + imagePath: song.albumArt!, + audioSourcePath: song.filePath, + fit: BoxFit.cover, + useThumbnail: true, + thumbnailWidth: 128, + thumbnailHeight: 128, + placeholder: placeholder, + errorWidget: placeholder, + ) + : placeholder; + + return SizedBox( + height: AdaptiveLayout.sideMiniPlayerWidth, + width: double.infinity, + child: Hero( + tag: LandscapeSideMiniPlayer.heroTag, + child: AnimatedSwitcher( + duration: duration, + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: SlideTransition( + position: Tween( + begin: Offset(0, _songChangeDirection * 0.25), + end: Offset.zero, + ).animate(animation), + child: child, + ), + ), + child: KeyedSubtree(key: ValueKey(song.id), child: artwork), + ), + ), + ); + } + + Widget _buildVisualizer({ + required AppPreferences prefs, + required bool visualizerEnabled, + }) { + if (!visualizerEnabled) { + return Center( + child: Icon( + LucideIcons.audioLines, + size: 18, + color: context.adaptiveTextSecondary.withValues(alpha: 0.35), + ), + ); + } + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppConstants.spacingXs, + vertical: AppConstants.spacingXxs, + ), + child: RotatedBox( + key: const ValueKey('side_mini_player_visualizer'), + quarterTurns: 3, + child: AudioVisualizer( + playerService: ref.read(playerServiceProvider), + animationStyle: prefs.visualizerAnimationStyle, + frequencyMode: prefs.visualizerFrequencyMode, + movementMode: prefs.visualizerMovementMode, + albumColor: ref.watch(albumDominantColorSyncProvider), + enabled: true, + ), + ), + ); + } + + List? _shadow(MiniPlayerConfig config) { + return switch (config.shadow) { + MiniPlayerShadow.off => null, + MiniPlayerShadow.subtle => [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.22), + blurRadius: 14, + offset: const Offset(-4, 0), + ), + ], + MiniPlayerShadow.strong => [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.35), + blurRadius: 24, + offset: const Offset(-8, 0), + ), + ], + }; + } +} + +class _VerticalProgress extends StatelessWidget { + final double progress; + + const _VerticalProgress({required this.progress}); + + @override + Widget build(BuildContext context) { + final value = progress.isFinite ? progress.clamp(0.0, 1.0) : 0.0; + return SizedBox( + width: 2, + child: Stack( + fit: StackFit.expand, + children: [ + ColoredBox(color: Colors.white.withValues(alpha: 0.08)), + if (value > 0) + Align( + alignment: Alignment.bottomCenter, + child: FractionallySizedBox( + heightFactor: value, + widthFactor: 1, + child: ColoredBox( + key: const ValueKey('side_mini_player_progress'), + color: AppColors.accent, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/navigation/flick_nav_bar.dart b/lib/widgets/navigation/flick_nav_bar.dart index 78a3b2c7..40de2635 100644 --- a/lib/widgets/navigation/flick_nav_bar.dart +++ b/lib/widgets/navigation/flick_nav_bar.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flick/core/theme/app_colors.dart'; import 'package:flick/core/constants/app_constants.dart'; +import 'package:flick/core/utils/adaptive_layout.dart'; import 'package:flick/core/utils/app_haptics.dart'; import 'package:flick/core/utils/responsive.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -187,6 +188,274 @@ class _FlickNavBarState extends State { } } +/// Compact, scrollable navigation for wide windows. It reuses the same +/// destination order and item widgets as [FlickNavBar], so user +/// customisation is preserved instead of being re-interpreted. +class FlickNavRail extends StatelessWidget { + final int currentIndex; + final ValueChanged onTap; + final VoidCallback? onBottomBarSettings; + final NavBarConfig config; + + const FlickNavRail({ + super.key, + required this.currentIndex, + required this.onTap, + this.onBottomBarSettings, + required this.config, + }); + + static double width(BuildContext context) => + AdaptiveLayout.railWidth(context) + MediaQuery.paddingOf(context).left; + + @override + Widget build(BuildContext context) { + final mq = MediaQuery.of(context); + final railWidth = AdaptiveLayout.railWidth(context); + final buttons = config.orderedButtons; + final missingEssentials = config.missingEssentials; + final isOnMissingEssential = missingEssentials.any( + (button) => button.pageIndex == currentIndex, + ); + + return Container( + width: railWidth + mq.padding.left, + padding: EdgeInsets.only( + left: mq.padding.left + 6, + right: 6, + top: mq.padding.top + 10, + bottom: mq.padding.bottom + 10, + ), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + AppColors.surfaceLight.withValues(alpha: 0.94), + AppColors.surface.withValues(alpha: 0.98), + ], + ), + border: const Border(right: BorderSide(color: AppColors.glassBorder)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.28), + blurRadius: 20, + offset: const Offset(6, 0), + ), + ], + ), + child: Column( + children: [ + Expanded( + child: SingleChildScrollView( + child: Column( + children: [ + for (final button in buttons) + _RailItem( + button: button, + isSelected: currentIndex == button.pageIndex, + config: config, + onTap: () => onTap(button.pageIndex), + onLongPress: onBottomBarSettings, + ), + if (missingEssentials.isNotEmpty) + _RailOverflowItem( + isSelected: isOnMissingEssential, + config: config, + onTap: () => + _showOverflowSheet(context, missingEssentials), + onLongPress: onBottomBarSettings, + ), + ], + ), + ), + ), + if (onBottomBarSettings != null) ...[ + Divider( + height: 17, + color: AppColors.glassBorder.withValues(alpha: 0.7), + ), + IconButton( + onPressed: AppHaptics.wrap(onBottomBarSettings!), + tooltip: l10n.customizeBottomBar, + icon: Icon( + LucideIcons.settings, + color: AppColors.inactiveState, + size: context.responsiveIcon(AppConstants.iconSizeSm), + ), + ), + ], + ], + ), + ); + } + + void _showOverflowSheet( + BuildContext context, + List missingButtons, + ) { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (sheetContext) { + return SafeArea( + child: Container( + margin: const EdgeInsets.all(12), + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(sheetContext).height * 0.7, + ), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + AppColors.surfaceLight.withValues(alpha: 0.97), + AppColors.surface.withValues(alpha: 0.99), + ], + ), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: AppColors.glassBorder), + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final button in missingButtons) + ListTile( + leading: Icon( + button.icon, + size: 20, + color: AppColors.textSecondary, + ), + title: BaybayinText( + label: baybayinNavigationLabel(button), + fallback: button.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + onTap: () { + Navigator.of(sheetContext).pop(); + onTap(button.pageIndex); + }, + ), + if (onBottomBarSettings != null) ...[ + Divider( + height: 1, + color: AppColors.glassBorder, + indent: 12, + endIndent: 12, + ), + ListTile( + leading: Icon( + LucideIcons.settings, + size: 20, + color: AppColors.accent.withValues(alpha: 0.85), + ), + title: Text(l10n.customizeBottomBar), + onTap: () { + Navigator.of(sheetContext).pop(); + onBottomBarSettings!(); + }, + ), + ], + ], + ), + ), + ), + ); + }, + ); + } +} + +class _RailItem extends StatelessWidget { + final NavBarButton button; + final bool isSelected; + final NavBarConfig config; + final VoidCallback onTap; + final VoidCallback? onLongPress; + + const _RailItem({ + required this.button, + required this.isSelected, + required this.config, + required this.onTap, + this.onLongPress, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onLongPress: onLongPress, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 56), + child: _FlickNavItem( + button: button, + isSelected: isSelected, + config: config, + onTap: onTap, + ), + ), + ); + } +} + +class _RailOverflowItem extends StatelessWidget { + final bool isSelected; + final NavBarConfig config; + final VoidCallback onTap; + final VoidCallback? onLongPress; + + const _RailOverflowItem({ + required this.isSelected, + required this.config, + required this.onTap, + this.onLongPress, + }); + + @override + Widget build(BuildContext context) { + final color = isSelected ? AppColors.activeState : AppColors.inactiveState; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + onLongPress: onLongPress, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 56), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + LucideIcons.ellipsis, + color: color, + size: + context.responsiveIcon(AppConstants.iconSizeSm) * + config.iconSizeFactor, + ), + if (config.showLabels) ...[ + const SizedBox(height: 2), + FittedBox( + fit: BoxFit.scaleDown, + child: Text( + l10n.more, + maxLines: 1, + style: TextStyle( + fontFamily: 'GoogleSansFlex', + fontSize: + context.responsiveText(8.0) * config.barSizeFactor, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, + color: color, + ), + ), + ), + ], + ], + ), + ), + ); + } +} + class _OverflowNavItem extends StatelessWidget { final bool isSelected; final NavBarConfig config; diff --git a/test/core/adaptive_layout_test.dart b/test/core/adaptive_layout_test.dart new file mode 100644 index 00000000..53fe2657 --- /dev/null +++ b/test/core/adaptive_layout_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flick/core/utils/adaptive_layout.dart'; + +void main() { + group('AdaptiveLayout window decisions', () { + test('portrait phones keep the stacked player and bottom navigation', () { + for (final size in const [ + Size(320, 568), + Size(360, 640), + Size(411, 891), + ]) { + expect(AdaptiveLayout.isWide(size), isFalse); + expect(AdaptiveLayout.usePlayerSideLayout(size), isFalse); + expect(AdaptiveLayout.useSideNavigation(size), isFalse); + } + }); + + test('landscape phones get the side player layout and rail', () { + for (final size in const [ + Size(520, 320), + Size(568, 320), + Size(640, 360), + Size(891, 411), + ]) { + expect(AdaptiveLayout.isWide(size), isTrue); + expect(AdaptiveLayout.usePlayerSideLayout(size), isTrue); + expect(AdaptiveLayout.useSideNavigation(size), isTrue); + } + }); + + test('tablets follow window shape, not hardware class', () { + expect( + AdaptiveLayout.usePlayerSideLayout(const Size(800, 1280)), + isFalse, + ); + expect(AdaptiveLayout.usePlayerSideLayout(const Size(1280, 800)), isTrue); + expect(AdaptiveLayout.useSideNavigation(const Size(800, 1280)), isFalse); + expect(AdaptiveLayout.useSideNavigation(const Size(1280, 800)), isTrue); + }); + + test( + 'compact side mini player only belongs to short landscape windows', + () { + for (final size in const [ + Size(520, 320), + Size(640, 360), + Size(891, 411), + ]) { + expect(AdaptiveLayout.useCompactLandscapeMiniPlayer(size), isTrue); + } + for (final size in const [ + Size(360, 640), + Size(640, 480), + Size(1280, 650), + ]) { + expect(AdaptiveLayout.useCompactLandscapeMiniPlayer(size), isFalse); + } + }, + ); + + test('a narrow landscape window is not treated as wide', () { + expect(AdaptiveLayout.usePlayerSideLayout(const Size(520, 600)), isFalse); + expect(AdaptiveLayout.useSideNavigation(const Size(520, 600)), isFalse); + expect(AdaptiveLayout.useSideNavigation(const Size(519, 320)), isFalse); + }); + + test('constraints equivalents agree with window size', () { + const constraints = BoxConstraints.tightFor(width: 640, height: 360); + expect( + AdaptiveLayout.usePlayerSideLayoutFor(constraints), + AdaptiveLayout.usePlayerSideLayout(const Size(640, 360)), + ); + expect( + AdaptiveLayout.usePlayerSideLayoutFor( + const BoxConstraints.tightFor(width: 360, height: 640), + ), + isFalse, + ); + }); + }); +} diff --git a/test/features/songs/orbit_scroll_test.dart b/test/features/songs/orbit_scroll_test.dart new file mode 100644 index 00000000..abc2851b --- /dev/null +++ b/test/features/songs/orbit_scroll_test.dart @@ -0,0 +1,169 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flick/features/songs/widgets/orbit_scroll.dart'; +import 'package:flick/features/songs/widgets/song_card.dart'; +import 'package:flick/models/song.dart'; + +final _songs = List.generate( + 40, + (i) => Song( + id: 'orbit-$i', + title: 'Song $i with a longer title', + artist: 'Artist $i', + duration: const Duration(minutes: 4), + fileType: 'FLAC', + ), +); + +void main() { + void setView(WidgetTester tester, Size size) { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = size; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + } + + Widget host( + PageController pages, { + int selected = 15, + Axis axis = Axis.horizontal, + OrbitScrollController? orbit, + ValueChanged? onChanged, + ValueChanged? onAction, + double textScale = 1, + }) => MaterialApp( + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(textScale)), + child: child!, + ), + home: Scaffold( + body: PageView( + controller: pages, + children: [ + Column( + children: [ + Container( + key: const ValueKey('header'), + height: 64, + color: Colors.black, + ), + Expanded( + child: OrbitScroll( + key: const ValueKey('orbit'), + songs: _songs, + selectedIndex: selected, + scrollDirection: axis, + controller: orbit, + swipeActionsEnabled: true, + onSelectedIndexChanged: onChanged, + onSongSwipedLeft: onAction, + onSongSwipedRight: onAction, + ), + ), + ], + ), + const Center(child: Text('Next destination')), + ], + ), + ), + ); + + testWidgets('horizontal orbit wins over navigation, including on a card', ( + tester, + ) async { + setView(tester, const Size(900, 411)); + final pages = PageController(); + addTearDown(pages.dispose); + var selected = 15; + var actions = 0; + await tester.pumpWidget( + host(pages, onChanged: (i) => selected = i, onAction: (_) => actions++), + ); + final card = find.byWidgetPredicate((w) => w is SongCard && w.isSelected); + await tester.dragFrom(tester.getCenter(card), const Offset(-180, 0)); + await tester.pumpAndSettle(); + expect(selected, greaterThan(15)); + expect(pages.page, 0); + expect(actions, 0); + expect(tester.takeException(), isNull); + }); + + for (final edge in [0, 39]) { + testWidgets('orbit boundary $edge never leaks a fling into navigation', ( + tester, + ) async { + setView(tester, const Size(900, 411)); + final pages = PageController(); + addTearDown(pages.dispose); + await tester.pumpWidget(host(pages, selected: edge)); + await tester.flingFrom( + const Offset(450, 220), + Offset(edge == 0 ? 250 : -250, 0), + 900, + ); + await tester.pumpAndSettle(); + expect(pages.page, 0); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('header and side gutters retain navigation swipes', ( + tester, + ) async { + setView(tester, const Size(900, 411)); + final pages = PageController(); + addTearDown(pages.dispose); + await tester.pumpWidget(host(pages)); + await tester.dragFrom(const Offset(700, 32), const Offset(-600, 0)); + await tester.pumpAndSettle(); + expect(pages.page, 1); + pages.jumpToPage(0); + await tester.pumpAndSettle(); + await tester.flingFrom(const Offset(16, 220), const Offset(-220, 0), 1000); + await tester.pumpAndSettle(); + expect(pages.page, 1); + }); + + testWidgets('cancel and rotation retain selection and external jumps', ( + tester, + ) async { + setView(tester, const Size(900, 411)); + final pages = PageController(); + final orbit = OrbitScrollController(); + addTearDown(pages.dispose); + var selected = 15; + await tester.pumpWidget( + host(pages, orbit: orbit, onChanged: (i) => selected = i), + ); + final gesture = await tester.startGesture(const Offset(450, 220)); + await gesture.moveBy(const Offset(-80, 0)); + await gesture.cancel(); + await tester.pumpAndSettle(); + expect(pages.page, 0); + orbit.jumpToIndex(24, animate: false); + await tester.pump(); + expect(selected, 24); + tester.view.physicalSize = const Size(411, 900); + await tester.pumpWidget( + host(pages, orbit: orbit, axis: Axis.vertical, selected: selected), + ); + await tester.pumpAndSettle(); + final card = tester.widget( + find.byWidgetPredicate((w) => w is SongCard && w.isSelected), + ); + expect(card.song.id, 'orbit-24'); + expect(card.artworkAbove, isFalse); + expect(tester.takeException(), isNull); + }); + + testWidgets('short landscape cards fit with enlarged text', (tester) async { + setView(tester, const Size(640, 240)); + final pages = PageController(); + addTearDown(pages.dispose); + await tester.pumpWidget(host(pages, textScale: 2)); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/l10n/ui_customization_locale_test.dart b/test/l10n/ui_customization_locale_test.dart new file mode 100644 index 00000000..2c96f1c1 --- /dev/null +++ b/test/l10n/ui_customization_locale_test.dart @@ -0,0 +1,166 @@ +// UI Customization is an English surface in Taglish. The option labels already +// used the English copy role, but the model descriptions resolved the active +// catalog directly, so these sections fell back to Tagalog. +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:flick/core/theme/app_theme.dart'; +import 'package:flick/features/settings/screens/ui_customization_settings_screen.dart'; +import 'package:flick/l10n/l10n.dart'; +import 'package:flick/providers/player_provider.dart'; + +Widget _host(Widget child, {Locale locale = kFilipinoLocale}) => ProviderScope( + overrides: [currentSongProvider.overrideWithValue(null)], + child: MaterialApp( + theme: AppTheme.darkTheme, + locale: locale, + supportedLocales: kSupportedLocales, + localizationsDelegates: kAppLocalizationsDelegates, + localeResolutionCallback: resolveAppLocale, + builder: (context, child) { + LocaleController.instance.bind(AppLocalizations.of(context)); + return child!; + }, + home: Scaffold(body: child), + ), +); + +void _setTallView(WidgetTester tester) { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(800, 3000); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); +} + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + tearDown(() => LocaleController.instance.bindLocale(kFallbackLocale)); + + testWidgets('Taglish UI customization options are English', (tester) async { + _setTallView(tester); + final en = lookupFlickLocalizations(kFallbackLocale); + final tl = lookupFlickLocalizations(kFilipinoLocale); + await tester.pumpWidget(_host(const UiCustomizationSettingsScreen())); + await tester.pumpAndSettle(); + + for (final copy in [ + en.uiCustomization, + en.trackThumbnails.toUpperCase(), + en.progressBar.toUpperCase(), + en.albumColors.toUpperCase(), + ]) { + expect(find.text(copy), findsOneWidget); + } + + for (final copy in [ + en.albumArt, + en.trackNumber, + en.numberOnArt, + en.waveform, + en.line, + en.off, + en.subtle, + en.moderate, + en.vibrant, + ]) { + expect(find.text(copy), findsWidgets); + } + + for (final copy in [ + en.showAlbumArtwork, + en.showTheTrackNumber, + en.showTheNumberOverBlurredArt, + en.verticalBarsThatAnimateAcrossThe, + en.cleanStraightLineWithPrecisionScrubbing, + en.useTheDefaultMonochromeTheme, + en.faintHueShiftFromAlbumArt, + en.noticeableTintingFromAlbumArt, + en.boldSaturatedColorsFromAlbumArt, + ]) { + expect(find.text(copy), findsOneWidget); + } + + for (final copy in [ + tl.albumArt, + tl.trackNumber, + tl.numberOnArt, + tl.line, + tl.off, + tl.subtle, + tl.moderate, + tl.vibrant, + tl.showAlbumArtwork, + tl.showTheTrackNumber, + tl.showTheNumberOverBlurredArt, + tl.verticalBarsThatAnimateAcrossThe, + tl.cleanStraightLineWithPrecisionScrubbing, + tl.useTheDefaultMonochromeTheme, + tl.faintHueShiftFromAlbumArt, + tl.noticeableTintingFromAlbumArt, + tl.boldSaturatedColorsFromAlbumArt, + ]) { + expect(find.text(copy), findsNothing); + } + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('Chinese UI customization options stay Chinese', (tester) async { + _setTallView(tester); + final en = lookupFlickLocalizations(kFallbackLocale); + final zh = lookupFlickLocalizations(const Locale('zh', 'CN')); + await tester.pumpWidget( + _host( + const UiCustomizationSettingsScreen(), + locale: const Locale('zh', 'CN'), + ), + ); + await tester.pumpAndSettle(); + + for (final copy in [ + zh.uiCustomization, + zh.trackThumbnails.toUpperCase(), + zh.progressBar.toUpperCase(), + zh.albumColors.toUpperCase(), + zh.albumArt, + zh.trackNumber, + zh.numberOnArt, + zh.waveform, + zh.line, + zh.off, + zh.subtle, + zh.moderate, + zh.vibrant, + zh.showAlbumArtwork, + zh.showTheTrackNumber, + zh.showTheNumberOverBlurredArt, + zh.verticalBarsThatAnimateAcrossThe, + zh.cleanStraightLineWithPrecisionScrubbing, + zh.useTheDefaultMonochromeTheme, + zh.faintHueShiftFromAlbumArt, + zh.noticeableTintingFromAlbumArt, + zh.boldSaturatedColorsFromAlbumArt, + ]) { + expect(find.text(copy), findsWidgets); + } + + for (final copy in [ + en.showAlbumArtwork, + en.showTheTrackNumber, + en.showTheNumberOverBlurredArt, + en.verticalBarsThatAnimateAcrossThe, + en.cleanStraightLineWithPrecisionScrubbing, + en.useTheDefaultMonochromeTheme, + en.faintHueShiftFromAlbumArt, + en.noticeableTintingFromAlbumArt, + en.boldSaturatedColorsFromAlbumArt, + ]) { + expect(find.text(copy), findsNothing); + } + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); +} diff --git a/test/widgets/adaptive_bottom_sheet_test.dart b/test/widgets/adaptive_bottom_sheet_test.dart new file mode 100644 index 00000000..62c738db --- /dev/null +++ b/test/widgets/adaptive_bottom_sheet_test.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flick/core/theme/app_theme.dart'; +import 'package:flick/widgets/common/glass_bottom_sheet.dart'; + +void main() { + for (final size in const [Size(891, 411), Size(1280, 800), Size(375, 800)]) { + testWidgets('modal uses usable width and height at $size', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = size; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.darkTheme, + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => GlassBottomSheet.show( + context: context, + title: 'Options', + content: Column( + children: List.generate( + 80, + (i) => ListTile(title: Text('Option $i')), + ), + ), + ), + child: const Text('Open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + final surface = find.byWidgetPredicate( + (w) => + w is Container && w.decoration == AppBottomSheetSurface.decoration, + ); + final rect = tester.getRect(surface); + expect(rect.width, greaterThan(size.width * 0.85)); + expect(rect.height, greaterThan(size.height * 0.85)); + expect(rect.bottom, lessThanOrEqualTo(size.height)); + await tester.drag( + find.byType(SingleChildScrollView), + const Offset(0, -5000), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('keyboard reduces and lifts the sheet without hiding actions', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(891, 411); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.darkTheme, + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(viewInsets: const EdgeInsets.only(bottom: 180)), + child: child!, + ), + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => GlassBottomSheet.show( + context: context, + title: 'Options', + content: const SizedBox(height: 500, child: Text('Content')), + actions: [ + TextButton(onPressed: () {}, child: const Text('Save')), + ], + ), + child: const Text('Open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + expect(tester.getRect(find.text('Save')).bottom, lessThanOrEqualTo(231)); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/widgets/flick_nav_rail_test.dart b/test/widgets/flick_nav_rail_test.dart new file mode 100644 index 00000000..f84a095f --- /dev/null +++ b/test/widgets/flick_nav_rail_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flick/l10n/l10n.dart'; +import 'package:flick/models/nav_bar_config.dart'; +import 'package:flick/widgets/navigation/flick_nav_bar.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +Widget _host(Widget child, {double textScale = 1}) { + return MaterialApp( + locale: kFallbackLocale, + supportedLocales: kSupportedLocales, + localizationsDelegates: kAppLocalizationsDelegates, + localeResolutionCallback: resolveAppLocale, + home: MediaQuery( + data: MediaQueryData( + size: const Size(568, 320), + textScaler: TextScaler.linear(textScale), + disableAnimations: true, + ), + child: Scaffold( + body: Align( + alignment: Alignment.centerLeft, + child: SizedBox(height: 320, child: child), + ), + ), + ), + ); +} + +void main() { + testWidgets('rail scrolls every configured destination in a short window', ( + tester, + ) async { + final config = NavBarConfig(enabledButtons: NavBarButton.values); + + for (final scale in [1.0, 1.5, 2.0]) { + await tester.pumpWidget( + _host( + FlickNavRail( + currentIndex: NavBarButton.menu.pageIndex, + config: config, + onTap: (_) {}, + onBottomBarSettings: () {}, + ), + textScale: scale, + ), + ); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull, reason: 'textScale=$scale'); + expect(find.byIcon(LucideIcons.search), findsOneWidget); + + await tester.ensureVisible(find.byIcon(LucideIcons.search)); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull, reason: 'scrolled scale=$scale'); + } + }); + + testWidgets('missing essential destinations stay reachable from the rail', ( + tester, + ) async { + const config = NavBarConfig( + enabledButtons: [NavBarButton.albums, NavBarButton.favorites], + ); + + await tester.pumpWidget( + _host( + FlickNavRail( + currentIndex: NavBarButton.albums.pageIndex, + config: config, + onTap: (_) {}, + onBottomBarSettings: () {}, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.byIcon(LucideIcons.ellipsis), findsOneWidget); + + await tester.tap(find.byIcon(LucideIcons.ellipsis)); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + // Menu, Songs and Settings are supplied by the overflow sheet. + expect(find.byIcon(LucideIcons.layoutGrid), findsOneWidget); + expect(find.byIcon(LucideIcons.music), findsOneWidget); + expect(find.byIcon(LucideIcons.settings), findsWidgets); + }); +} diff --git a/test/widgets/landscape_player_layout_test.dart b/test/widgets/landscape_player_layout_test.dart new file mode 100644 index 00000000..a171f404 --- /dev/null +++ b/test/widgets/landscape_player_layout_test.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flick/features/player/widgets/landscape_player_layout.dart'; + +void main() { + for (final size in const [Size(496, 210), Size(850, 300), Size(1280, 650)]) { + for (final artwork in [false, true]) { + testWidgets( + '$size artwork=$artwork keeps identity and playback together', + (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = size; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: LandscapePlayerLayout( + artworkCard: artwork, + stage: const ColoredBox( + key: ValueKey('stage'), + color: Colors.blue, + ), + identity: const SizedBox( + key: ValueKey('identity'), + height: 56, + child: Text('Title and artist'), + ), + actions: const SizedBox(height: 48, child: Text('Actions')), + playback: const SizedBox( + key: ValueKey('playback'), + height: 80, + child: Text('Playback'), + ), + ), + ), + ), + ); + final identity = tester.getRect( + find.byKey(const ValueKey('identity')), + ); + final playback = tester.getRect( + find.byKey(const ValueKey('playback')), + ); + // Artwork-card details keep a little extra breathing room above + // playback, so allow a slightly larger gap than immersive mode. + expect(playback.top - identity.bottom, lessThanOrEqualTo(100)); + expect(playback.bottom, lessThanOrEqualTo(size.height)); + if (artwork) { + expect( + tester.getSize(find.byKey(const ValueKey('stage'))).width, + lessThanOrEqualTo(400), + ); + } + expect(tester.takeException(), isNull); + }, + ); + } + } + + testWidgets('large details scroll without displacing playback', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(640, 240); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: LandscapePlayerLayout( + lyricsMode: true, + stage: const Text('Lyrics'), + identity: Text(List.filled(30, 'A long song title').join('\n')), + playback: const SizedBox( + key: ValueKey('playback'), + height: 100, + child: Text('Playback'), + ), + ), + ), + ), + ); + expect( + tester.getRect(find.byKey(const ValueKey('playback'))).bottom, + lessThanOrEqualTo(240), + ); + await tester.drag( + find.byKey(const ValueKey('landscape-player-details')), + const Offset(0, -200), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/widgets/landscape_side_mini_player_test.dart b/test/widgets/landscape_side_mini_player_test.dart new file mode 100644 index 00000000..1231845d --- /dev/null +++ b/test/widgets/landscape_side_mini_player_test.dart @@ -0,0 +1,283 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:flick/features/player/widgets/audio_visualizer.dart'; +import 'package:flick/l10n/l10n.dart'; +import 'package:flick/models/song.dart'; +import 'package:flick/providers/mini_player_config_provider.dart'; +import 'package:flick/providers/providers.dart'; +import 'package:flick/services/player_service.dart'; +import 'package:flick/widgets/common/landscape_side_mini_player.dart'; + +const _song = Song( + id: 'side-player-track', + title: 'A title that should not appear', + artist: 'An artist that should not appear', + duration: Duration(minutes: 4), + fileType: 'FLAC', +); + +class _Player extends Fake implements PlayerService { + @override + final isPlayingNotifier = ValueNotifier(true); + @override + final positionNotifier = ValueNotifier(const Duration(seconds: 48)); + @override + final currentSongNotifier = ValueNotifier(_song); + @override + final usingRustBackendNotifier = ValueNotifier(false); + + @override + void dispose() { + isPlayingNotifier.dispose(); + positionNotifier.dispose(); + currentSongNotifier.dispose(); + usingRustBackendNotifier.dispose(); + } +} + +Widget _host( + ProviderContainer container, { + required bool animationsEnabled, + double height = 360, +}) => UncontrolledProviderScope( + container: container, + child: MaterialApp( + locale: kFallbackLocale, + supportedLocales: kSupportedLocales, + localizationsDelegates: kAppLocalizationsDelegates, + localeResolutionCallback: resolveAppLocale, + home: MediaQuery( + data: MediaQueryData( + size: Size(640, height), + disableAnimations: !animationsEnabled, + ), + child: Scaffold( + body: Align( + alignment: Alignment.centerRight, + child: SizedBox( + height: height, + child: TickerMode( + enabled: animationsEnabled, + child: const LandscapeSideMiniPlayer(), + ), + ), + ), + ), + ), + ), +); + +ProviderContainer _container(_Player player) { + final container = ProviderContainer( + overrides: [ + currentSongProvider.overrideWithValue(_song), + isPlayingProvider.overrideWithValue(true), + progressProvider.overrideWithValue(0.42), + playerServiceProvider.overrideWithValue(player), + albumDominantColorSyncProvider.overrideWithValue(null), + ], + ); + addTearDown(container.dispose); + return container; +} + +Future _visibleVisualizerPixels(WidgetTester tester) async { + final boundary = tester.renderObject( + find.descendant( + of: find.byType(AudioVisualizer), + matching: find.byType(RepaintBoundary), + ), + ); + return (await tester.runAsync(() async { + final image = await boundary.toImage(); + final data = (await image.toByteData(format: ui.ImageByteFormat.rawRgba))!; + image.dispose(); + var visible = 0; + for (var offset = 3; offset < data.lengthInBytes; offset += 4) { + if (data.getUint8(offset) > 30) visible++; + } + return visible; + }))!; +} + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('keeps the compact orbit controls in one vertical strip', ( + tester, + ) async { + final player = _Player(); + addTearDown(player.dispose); + + await tester.pumpWidget( + _host(_container(player), animationsEnabled: false), + ); + await tester.pump(); + + expect(find.byType(LandscapeSideMiniPlayer), findsOneWidget); + expect(find.text(_song.title), findsNothing); + expect(find.text(_song.artist), findsNothing); + expect(find.byType(AudioVisualizer), findsNothing); + expect( + find.byKey(const ValueKey('side_mini_player_divider')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('side_mini_player_progress')), + findsOneWidget, + ); + expect(find.byTooltip(l10n.pause), findsOneWidget); + + final strip = tester.getRect(find.byType(LandscapeSideMiniPlayer)); + final artwork = tester.getRect(find.byType(Hero)); + final divider = tester.getRect( + find.byKey(const ValueKey('side_mini_player_divider')), + ); + final progress = tester.getRect( + find.byKey(const ValueKey('side_mini_player_progress')), + ); + final play = tester.getRect(find.byTooltip(l10n.pause)); + + expect(strip.width, 64); + expect(artwork.top, lessThan(divider.top)); + expect(divider.bottom, lessThanOrEqualTo(play.top)); + expect(progress.left, strip.left); + expect(progress.right, lessThan(play.center.dx)); + expect(progress.bottom, closeTo(strip.bottom - 8, 0.1)); + }); + + testWidgets('rotates the visualizer so it grows from the right edge', ( + tester, + ) async { + final player = _Player(); + addTearDown(player.dispose); + + await tester.pumpWidget(_host(_container(player), animationsEnabled: true)); + await tester.pump(); + + final visualizerFinder = find.byKey( + const ValueKey('side_mini_player_visualizer'), + ); + final visualizer = tester.widget(visualizerFinder); + expect(visualizer.quarterTurns, 3); + final painter = tester.renderObject( + find.descendant( + of: find.byType(AudioVisualizer), + matching: find.byType(CustomPaint), + ), + ); + expect(painter.size.width, greaterThan(0)); + expect(painter.size.height, 48); + + final bounds = tester.getRect(visualizerFinder); + final lowFrequencyBase = painter.localToGlobal( + Offset(0, painter.size.height), + ); + final highFrequencyBase = painter.localToGlobal( + Offset(painter.size.width, painter.size.height), + ); + expect(lowFrequencyBase.dx, closeTo(bounds.right, 0.01)); + expect(lowFrequencyBase.dy, closeTo(bounds.bottom, 0.01)); + expect(highFrequencyBase.dx, closeTo(bounds.right, 0.01)); + expect(highFrequencyBase.dy, closeTo(bounds.top, 0.01)); + }); + + testWidgets('paints every style with and without artwork in short windows', ( + tester, + ) async { + final player = _Player(); + addTearDown(player.dispose); + final container = _container(player); + await tester.pumpWidget(_host(container, animationsEnabled: true)); + await tester.pump(); + + for (final showArtwork in [true, false]) { + await container + .read(miniPlayerConfigProvider.notifier) + .update((config) => config.copyWith(showArtwork: showArtwork)); + for (final style in [ + 'bars', + 'wave', + 'curved_wave', + 'mirrored', + 'dots', + 'blocks', + ]) { + await container + .read(appPreferencesProvider.notifier) + .setVisualizerAnimationStyle(style); + await tester.pumpWidget( + _host( + container, + animationsEnabled: true, + height: showArtwork ? 320 : 411, + ), + ); + for (var frame = 0; frame < 20; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + } + + final bounds = tester.getRect( + find.byKey(const ValueKey('side_mini_player_visualizer')), + ); + final strip = tester.getRect(find.byType(LandscapeSideMiniPlayer)); + final divider = tester.getRect( + find.byKey(const ValueKey('side_mini_player_divider')), + ); + expect(bounds.width, 48); + expect(bounds.height, greaterThan(100)); + expect(bounds.left, greaterThan(strip.left)); + expect(bounds.right, lessThan(strip.right)); + expect(bounds.bottom, lessThan(divider.top)); + if (showArtwork) { + expect( + bounds.top, + greaterThan(tester.getRect(find.byType(Hero)).bottom), + ); + } + expect( + await _visibleVisualizerPixels(tester), + greaterThan(20), + reason: '$style, artwork: $showArtwork', + ); + expect(tester.takeException(), isNull); + } + } + await tester.pumpWidget(const SizedBox.shrink()); + }); + + testWidgets('visualizer visibility still follows the preference', ( + tester, + ) async { + final player = _Player(); + addTearDown(player.dispose); + final container = _container(player); + await tester.pumpWidget(_host(container, animationsEnabled: true)); + await tester.pump(); + + await container + .read(appPreferencesProvider.notifier) + .setVisualizerEnabled(false); + await tester.pump(); + expect(find.byType(AudioVisualizer), findsNothing); + + await container + .read(appPreferencesProvider.notifier) + .setVisualizerEnabled(true); + await tester.pump(); + expect(find.byType(AudioVisualizer), findsOneWidget); + expect( + tester + .getSize(find.byKey(const ValueKey('side_mini_player_visualizer'))) + .width, + 48, + ); + await tester.pumpWidget(const SizedBox.shrink()); + }); +} diff --git a/test/widgets/player_landscape_sheets_test.dart b/test/widgets/player_landscape_sheets_test.dart new file mode 100644 index 00000000..c501809a --- /dev/null +++ b/test/widgets/player_landscape_sheets_test.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:flick/core/theme/app_theme.dart'; +import 'package:flick/features/player/widgets/player_layout_sheet.dart'; +import 'package:flick/features/player/widgets/song_metadata_sheet.dart'; +import 'package:flick/features/player/widgets/speed_bottom_sheet.dart'; +import 'package:flick/features/player/widgets/pitch_bottom_sheet.dart'; +import 'package:flick/features/player/widgets/sleep_timer_bottom_sheet.dart'; +import 'package:flick/l10n/l10n.dart'; +import 'package:flick/models/player_screen_mode.dart'; +import 'package:flick/models/song.dart'; +import 'package:flick/providers/player_provider.dart'; +import 'package:flick/services/player_service.dart'; + +class _Player extends Fake implements PlayerService { + @override + final currentSongNotifier = ValueNotifier(null); + @override + final playbackSpeedNotifier = ValueNotifier(1.0); + @override + final pitchSemitonesNotifier = ValueNotifier(0.0); + @override + final sleepTimerRemainingNotifier = ValueNotifier(null); + + @override + Future setPlaybackSpeed(double value) async => + playbackSpeedNotifier.value = value; + @override + Future setPitchSemitones(double value) async => + pitchSemitonesNotifier.value = value; + @override + void setSleepTimer(Duration duration) => + sleepTimerRemainingNotifier.value = duration; + @override + void cancelSleepTimer() => sleepTimerRemainingNotifier.value = null; +} + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + tearDown(() => LocaleController.instance.bindLocale(kFallbackLocale)); + + Widget host(Widget child, Locale locale, double scale) => ProviderScope( + overrides: [currentSongProvider.overrideWithValue(null)], + child: MaterialApp( + theme: AppTheme.darkTheme, + locale: locale, + supportedLocales: kSupportedLocales, + localizationsDelegates: kAppLocalizationsDelegates, + builder: (context, child) { + LocaleController.instance.bind(AppLocalizations.of(context)); + return MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(scale)), + child: child!, + ); + }, + home: Scaffold(body: child), + ), + ); + + for (final locale in kSupportedLocales) { + for (final size in const [Size(640, 320), Size(1280, 800)]) { + testWidgets('layout editor uses landscape columns at $size $locale', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = size; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + host( + PlayerLayoutSheet( + playerService: _Player(), + initialMode: PlayerScreenMode.immersive, + onModeChanged: (_) async {}, + song: null, + ), + locale, + 2, + ), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + final text = lookupFlickLocalizations( + locale == kFilipinoLocale ? kFallbackLocale : locale, + ); + final artwork = tester.getRect(find.text(text.artworkCard).first); + final fullscreen = tester.getRect(find.text(text.fullscreen).first); + expect(artwork.left, greaterThan(fullscreen.right)); + }); + } + } + + testWidgets('short landscape utility sheets remain scrollable at 2x text', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(640, 320); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final service = _Player(); + for (final child in [ + SpeedBottomSheet(playerService: service), + PitchBottomSheet(playerService: service), + SleepTimerBottomSheet(playerService: service), + const SongMetadataSheet( + song: Song( + id: 'test', + title: 'A long song title', + artist: 'A long artist name', + album: 'A long album name', + duration: Duration(minutes: 4), + fileType: 'FLAC', + ), + ), + ]) { + await tester.pumpWidget(host(child, kFallbackLocale, 2)); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull, reason: '${child.runtimeType}'); + // Do not drag across a slider: its end callback deliberately closes the + // modal in production, while this fixture hosts each surface directly. + final scroll = tester.state(find.byType(Scrollable).first); + scroll.position.jumpTo(scroll.position.maxScrollExtent); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + } + }); +} From 1feca4587a67da8011cb2e84cdc8d7886f4ef0d9 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Thu, 5 Nov 2026 21:59:52 +0800 Subject: [PATCH 14/14] Use English copy for progress bar style labels in Taglish Add descriptionFor helpers to the progress bar and thumbnail label extensions so callers can resolve copy from a specific locale, and mark the progress bar style strings English-only in the Taglish catalog. --- lib/l10n/app_tl.arb | 10 +++++----- lib/l10n/generated/app_localizations_tl.dart | 10 +++++----- lib/l10n/model_labels.dart | 8 ++++++-- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/lib/l10n/app_tl.arb b/lib/l10n/app_tl.arb index cd17fdf5..52e37679 100644 --- a/lib/l10n/app_tl.arb +++ b/lib/l10n/app_tl.arb @@ -1097,8 +1097,8 @@ "line": "Linya", "lineOf": "Linya {arg1} sa {arg2}", "lineOfWords": "Linya {arg1} sa {arg2} ยท {captured}/{segmentCount} salita", - "lineThickness": "Kapal ng Linya", - "lineThicknessDescription": "Ayusin ang kapal ng linya ng progreso.", + "lineThickness": "Line Thickness", + "lineThicknessDescription": "Adjust how thick the progress line appears.", "linear": "Linear", "lines": "Mga linya", "lines2": "{arg1} linya", @@ -1643,7 +1643,7 @@ "restartTheAppToApplyPlayback": "Simulan muli ang app upang mailapat ang mga pagbabago sa pagtugtog.", "restartYourDeviceToApplyOutput": "Simulan muli ang aparato upang mailapat ang mga pagbabago sa format ng output.", "restartingFlick": "Muling sinisimulan ang Flick", - "restoreDefaultProgressBarSize": "Ibalik sa default na laki ang napiling istilo ng progress bar.", + "restoreDefaultProgressBarSize": "Restore the selected progress bar style to its default size.", "restoreTheOriginalOrbitalLayout": "Ibalik ang orihinal na ayos ng Orbital", "results": "Mga resulta ({arg1})", "resumeAFamiliarFavoriteOrShuffle": "Balikan ang pamilyar na paborito o ihalo ang isa sa mga halong binuo mula sa iyong pakikinig.", @@ -2321,8 +2321,8 @@ "was": "dati: {arg1}", "wave": "Alon", "waveform": "Waveform", - "waveformHeight": "Taas ng Waveform", - "waveformHeightDescription": "Ayusin ang taas ng mga bar ng waveform.", + "waveformHeight": "Waveform Height", + "waveformHeightDescription": "Adjust how tall the waveform bars appear.", "weLlKeepTheBestQuality": "Ititira namin ang pinakamagandang bersiyon sa bawat pangkat (inuuna ang larawan ng album, saka ang pinakamataas na bitrate) at aalisin ang {count, plural, =1{{count} kopya} other{{count} kopya}}. Hindi mabubura ang mga file sa imbakan.", "weLlKeepYourCheckedVersion": "Ititira namin ang {kept, plural, =1{{kept} bersiyong nilagyan mo ng tsek} other{{kept} bersiyong nilagyan mo ng tsek}} at aalisin ang {removed, plural, =1{{removed} iba pang kopya} other{{removed} iba pang kopya}} sa iyong aklatan. Hindi mabubura ang mga audio file sa imbakan.", "webdav": "WebDAV", diff --git a/lib/l10n/generated/app_localizations_tl.dart b/lib/l10n/generated/app_localizations_tl.dart index aa9bd60a..df08a03c 100644 --- a/lib/l10n/generated/app_localizations_tl.dart +++ b/lib/l10n/generated/app_localizations_tl.dart @@ -4486,11 +4486,11 @@ class AppLocalizationsTl extends AppLocalizations { } @override - String get lineThickness => 'Kapal ng Linya'; + String get lineThickness => 'Line Thickness'; @override String get lineThicknessDescription => - 'Ayusin ang kapal ng linya ng progreso.'; + 'Adjust how thick the progress line appears.'; @override String get linear => 'Linear'; @@ -6409,7 +6409,7 @@ class AppLocalizationsTl extends AppLocalizations { @override String get restoreDefaultProgressBarSize => - 'Ibalik sa default na laki ang napiling istilo ng progress bar.'; + 'Restore the selected progress bar style to its default size.'; @override String get restoreTheOriginalOrbitalLayout => @@ -8899,11 +8899,11 @@ class AppLocalizationsTl extends AppLocalizations { String get waveform => 'Waveform'; @override - String get waveformHeight => 'Taas ng Waveform'; + String get waveformHeight => 'Waveform Height'; @override String get waveformHeightDescription => - 'Ayusin ang taas ng mga bar ng waveform.'; + 'Adjust how tall the waveform bars appear.'; @override String get waveformSeekbar => 'Waveform seekbar'; diff --git a/lib/l10n/model_labels.dart b/lib/l10n/model_labels.dart index 8a312fb6..2dfb8b30 100644 --- a/lib/l10n/model_labels.dart +++ b/lib/l10n/model_labels.dart @@ -183,7 +183,9 @@ extension ProgressBarStyleL10n on ProgressBarStyle { ProgressBarStyle.line => l10n.line, }; - String get description => switch (this) { + String get description => descriptionFor(l10n); + + String descriptionFor(AppLocalizations l10n) => switch (this) { ProgressBarStyle.waveform => l10n.verticalBarsThatAnimateAcrossThe, ProgressBarStyle.line => l10n.cleanStraightLineWithPrecisionScrubbing, }; @@ -222,7 +224,9 @@ extension SongTileThumbnailModeL10n on SongTileThumbnailMode { SongTileThumbnailMode.trackNumberOnArt => l10n.numberOnArt, }; - String get description => switch (this) { + String get description => descriptionFor(l10n); + + String descriptionFor(AppLocalizations l10n) => switch (this) { SongTileThumbnailMode.artwork => l10n.showAlbumArtwork, SongTileThumbnailMode.trackNumber => l10n.showTheTrackNumber, SongTileThumbnailMode.trackNumberOnArt => l10n.showTheNumberOverBlurredArt,