mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
feat(waveform): real amplitude waveform via Symphonia + smooth crossfade
Add Rust command `compute_waveform` that downloads the audio file, seek-samples 500 evenly-spaced frames with Symphonia (fast path) and computes peak amplitudes. Percentile-based normalisation (p5→p95) preserves visible dynamics even for heavily compressed music. Frontend caches results per track (module-level Map), shows the pseudo-random fallback immediately and crossfades to the real waveform over 400 ms (ease-in-out) once the computation finishes. Also: Settings input-tab reset buttons now use btn-danger styling. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2822,3 +2822,161 @@ pub fn audio_set_crossfade(enabled: bool, secs: f32, state: State<'_, AudioEngin
|
|||||||
pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) {
|
pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) {
|
||||||
state.gapless_enabled.store(enabled, Ordering::Relaxed);
|
state.gapless_enabled.store(enabled, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Waveform analysis ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Download `url`, decode with Symphonia and return `bars` normalised peak
|
||||||
|
/// amplitudes in [0, 1]. Runs the CPU-bound decode on the blocking thread
|
||||||
|
/// pool so the async runtime is not starved.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn compute_waveform(
|
||||||
|
url: String,
|
||||||
|
state: State<'_, AudioEngine>,
|
||||||
|
) -> Result<Vec<f32>, String> {
|
||||||
|
const BARS: usize = 500;
|
||||||
|
|
||||||
|
// Fetch bytes — prefer local offline file, fall back to HTTP.
|
||||||
|
let data: Vec<u8> = if let Some(path) = url.strip_prefix("psysonic-local://") {
|
||||||
|
tokio::fs::read(path).await.map_err(|e| e.to_string())?
|
||||||
|
} else {
|
||||||
|
let resp = state
|
||||||
|
.http_client
|
||||||
|
.get(&url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(format!("HTTP {}", resp.status().as_u16()));
|
||||||
|
}
|
||||||
|
resp.bytes().await.map_err(|e| e.to_string())?.to_vec()
|
||||||
|
};
|
||||||
|
|
||||||
|
tokio::task::spawn_blocking(move || decode_peaks(data, BARS))
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_peaks(data: Vec<u8>, bars: usize) -> Result<Vec<f32>, String> {
|
||||||
|
use symphonia::core::audio::SampleBuffer as SB;
|
||||||
|
|
||||||
|
let data_len = data.len() as u64;
|
||||||
|
let source = SizedCursorSource { inner: Cursor::new(data), len: data_len };
|
||||||
|
let mss = MediaSourceStream::new(
|
||||||
|
Box::new(source) as Box<dyn MediaSource>,
|
||||||
|
MediaSourceStreamOptions { buffer_len: 512 * 1024 },
|
||||||
|
);
|
||||||
|
|
||||||
|
let format_opts = FormatOptions { enable_gapless: false, ..Default::default() };
|
||||||
|
let meta_opts = symphonia::core::meta::MetadataOptions {
|
||||||
|
limit_visual_bytes: symphonia::core::meta::Limit::Maximum(8 * 1024 * 1024),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut format = symphonia::default::get_probe()
|
||||||
|
.format(&Hint::new(), mss, &format_opts, &meta_opts)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.format;
|
||||||
|
|
||||||
|
let track = format
|
||||||
|
.tracks()
|
||||||
|
.iter()
|
||||||
|
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL && t.codec_params.sample_rate.is_some())
|
||||||
|
.ok_or("no audio track")?;
|
||||||
|
|
||||||
|
let track_id = track.id;
|
||||||
|
let mut decoder = symphonia::default::get_codecs()
|
||||||
|
.make(&track.codec_params, &DecoderOptions::default())
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Determine total duration so we can seek to evenly-spaced positions
|
||||||
|
// instead of decoding every frame sequentially.
|
||||||
|
let duration_secs: Option<f64> = track.codec_params.time_base
|
||||||
|
.zip(track.codec_params.n_frames)
|
||||||
|
.map(|(tb, nf)| {
|
||||||
|
let t = tb.calc_time(nf);
|
||||||
|
t.seconds as f64 + t.frac
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut pooled: Vec<f32> = Vec::with_capacity(bars);
|
||||||
|
let mut sbuf: Option<SB<f32>> = None;
|
||||||
|
|
||||||
|
if let Some(dur) = duration_secs.filter(|&d| d > 0.1) {
|
||||||
|
// ── Fast path: seek to each bar's timestamp, decode one frame ─────────
|
||||||
|
// Reduces decode work from ~thousands of frames to exactly `bars` frames.
|
||||||
|
for bar in 0..bars {
|
||||||
|
let target = (bar as f64 / bars as f64) * dur;
|
||||||
|
let seek_time = symphonia::core::units::Time {
|
||||||
|
seconds: target as u64,
|
||||||
|
frac: target.fract(),
|
||||||
|
};
|
||||||
|
let _ = format.seek(
|
||||||
|
SeekMode::Coarse,
|
||||||
|
SeekTo::Time { time: seek_time, track_id: Some(track_id) },
|
||||||
|
);
|
||||||
|
decoder.reset();
|
||||||
|
|
||||||
|
// Try up to 16 packets to find a decodable audio packet near the
|
||||||
|
// seek position (some packets may be non-audio or malformed).
|
||||||
|
let peak = 'packet: {
|
||||||
|
for _ in 0..16 {
|
||||||
|
match format.next_packet() {
|
||||||
|
Ok(pkt) if pkt.track_id() == track_id => {
|
||||||
|
if let Ok(decoded) = decoder.decode(&pkt) {
|
||||||
|
let buf = sbuf.get_or_insert_with(|| {
|
||||||
|
SB::<f32>::new(decoded.capacity() as u64, *decoded.spec())
|
||||||
|
});
|
||||||
|
buf.copy_interleaved_ref(decoded);
|
||||||
|
break 'packet buf.samples().iter().map(|s| s.abs()).fold(0.0f32, f32::max);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(_) => continue,
|
||||||
|
Err(_) => break 'packet 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
pooled.push(peak);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// ── Slow path: sequential decode, then downsample ─────────────────────
|
||||||
|
// Used when the format doesn't report n_frames (rare edge case).
|
||||||
|
let mut frame_peaks: Vec<f32> = Vec::with_capacity(8192);
|
||||||
|
loop {
|
||||||
|
let packet = match format.next_packet() {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(symphonia::core::errors::Error::ResetRequired) => { let _ = decoder.reset(); continue; }
|
||||||
|
Err(_) => break,
|
||||||
|
};
|
||||||
|
if packet.track_id() != track_id { continue; }
|
||||||
|
let decoded = match decoder.decode(&packet) { Ok(d) => d, Err(_) => continue };
|
||||||
|
let buf = sbuf.get_or_insert_with(|| {
|
||||||
|
SB::<f32>::new(decoded.capacity() as u64, *decoded.spec())
|
||||||
|
});
|
||||||
|
buf.copy_interleaved_ref(decoded);
|
||||||
|
frame_peaks.push(buf.samples().iter().map(|s| s.abs()).fold(0.0f32, f32::max));
|
||||||
|
}
|
||||||
|
if frame_peaks.is_empty() {
|
||||||
|
return Ok(vec![0.5f32; bars]);
|
||||||
|
}
|
||||||
|
let n = frame_peaks.len();
|
||||||
|
pooled = (0..bars).map(|i| {
|
||||||
|
let start = (i * n) / bars;
|
||||||
|
let end = (((i + 1) * n) / bars).max(start + 1).min(n);
|
||||||
|
frame_peaks[start..end].iter().cloned().fold(0.0f32, f32::max)
|
||||||
|
}).collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Percentile-based normalisation: always spreads the full value range
|
||||||
|
// across the visual height, which preserves relative dynamics even for
|
||||||
|
// heavily compressed ("loudness war") music where all peaks cluster near 1.0.
|
||||||
|
let mut sorted = pooled.clone();
|
||||||
|
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
let nb = sorted.len();
|
||||||
|
let p5 = sorted[(nb * 5 / 100).max(0)];
|
||||||
|
let p95 = sorted[(nb * 95 / 100).min(nb - 1)];
|
||||||
|
let range = (p95 - p5).max(1e-6);
|
||||||
|
|
||||||
|
Ok(pooled.iter()
|
||||||
|
.map(|&v| 0.12 + 0.88 * ((v - p5) / range).clamp(0.0, 1.0))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|||||||
@@ -994,6 +994,7 @@ pub fn run() {
|
|||||||
audio::audio_play_radio,
|
audio::audio_play_radio,
|
||||||
audio::audio_set_crossfade,
|
audio::audio_set_crossfade,
|
||||||
audio::audio_set_gapless,
|
audio::audio_set_gapless,
|
||||||
|
audio::compute_waveform,
|
||||||
audio::audio_chain_preload,
|
audio::audio_chain_preload,
|
||||||
discord::discord_update_presence,
|
discord::discord_update_presence,
|
||||||
discord::discord_clear_presence,
|
discord::discord_clear_presence,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
|
import { buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
|
||||||
|
import { resolvePlaybackUrl } from '../utils/resolvePlaybackUrl';
|
||||||
import CachedImage from './CachedImage';
|
import CachedImage from './CachedImage';
|
||||||
import WaveformSeek from './WaveformSeek';
|
import WaveformSeek from './WaveformSeek';
|
||||||
import Equalizer from './Equalizer';
|
import Equalizer from './Equalizer';
|
||||||
@@ -39,6 +40,7 @@ export default function PlayerBar() {
|
|||||||
starredOverrides, setStarredOverride,
|
starredOverrides, setStarredOverride,
|
||||||
} = usePlayerStore();
|
} = usePlayerStore();
|
||||||
const { lastfmSessionKey } = useAuthStore();
|
const { lastfmSessionKey } = useAuthStore();
|
||||||
|
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||||
|
|
||||||
const isRadio = !!currentRadio;
|
const isRadio = !!currentRadio;
|
||||||
|
|
||||||
@@ -207,7 +209,10 @@ export default function PlayerBar() {
|
|||||||
<>
|
<>
|
||||||
<span className="player-time">{formatTime(currentTime)}</span>
|
<span className="player-time">{formatTime(currentTime)}</span>
|
||||||
<div className="player-waveform-wrap">
|
<div className="player-waveform-wrap">
|
||||||
<WaveformSeek trackId={currentTrack?.id} />
|
<WaveformSeek
|
||||||
|
trackId={currentTrack?.id}
|
||||||
|
url={currentTrack ? resolvePlaybackUrl(currentTrack.id, serverId) : undefined}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="player-time">{formatTime(duration)}</span>
|
<span className="player-time">{formatTime(duration)}</span>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
|
|
||||||
function fmt(s: number): string {
|
function fmt(s: number): string {
|
||||||
@@ -8,6 +9,8 @@ function fmt(s: number): string {
|
|||||||
|
|
||||||
const BAR_COUNT = 500;
|
const BAR_COUNT = 500;
|
||||||
|
|
||||||
|
// ── Pseudo-random fallback (shown while real waveform loads) ──────────────────
|
||||||
|
|
||||||
function hashStr(str: string): number {
|
function hashStr(str: string): number {
|
||||||
let h = 0x811c9dc5;
|
let h = 0x811c9dc5;
|
||||||
for (let i = 0; i < str.length; i++) {
|
for (let i = 0; i < str.length; i++) {
|
||||||
@@ -17,20 +20,18 @@ function hashStr(str: string): number {
|
|||||||
return h;
|
return h;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeHeights(trackId: string): Float32Array {
|
function makeFallbackHeights(trackId: string): Float32Array {
|
||||||
let s = hashStr(trackId);
|
let s = hashStr(trackId);
|
||||||
const h = new Float32Array(BAR_COUNT);
|
const h = new Float32Array(BAR_COUNT);
|
||||||
for (let i = 0; i < BAR_COUNT; i++) {
|
for (let i = 0; i < BAR_COUNT; i++) {
|
||||||
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
|
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
|
||||||
h[i] = s / 0xffffffff;
|
h[i] = s / 0xffffffff;
|
||||||
}
|
}
|
||||||
// Smooth for an organic look
|
|
||||||
for (let pass = 0; pass < 5; pass++) {
|
for (let pass = 0; pass < 5; pass++) {
|
||||||
for (let i = 1; i < BAR_COUNT - 1; i++) {
|
for (let i = 1; i < BAR_COUNT - 1; i++) {
|
||||||
h[i] = h[i - 1] * 0.25 + h[i] * 0.5 + h[i + 1] * 0.25;
|
h[i] = h[i - 1] * 0.25 + h[i] * 0.5 + h[i + 1] * 0.25;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Normalize to [0.12, 1.0]
|
|
||||||
let max = 0;
|
let max = 0;
|
||||||
for (let i = 0; i < BAR_COUNT; i++) if (h[i] > max) max = h[i];
|
for (let i = 0; i < BAR_COUNT; i++) if (h[i] > max) max = h[i];
|
||||||
if (max > 0) {
|
if (max > 0) {
|
||||||
@@ -39,6 +40,11 @@ function makeHeights(trackId: string): Float32Array {
|
|||||||
return h;
|
return h;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Real waveform cache (module-level, persists across re-renders) ────────────
|
||||||
|
const waveformCache = new Map<string, Float32Array>();
|
||||||
|
|
||||||
|
// ── Canvas drawing ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function drawWaveform(
|
function drawWaveform(
|
||||||
canvas: HTMLCanvasElement,
|
canvas: HTMLCanvasElement,
|
||||||
heights: Float32Array | null,
|
heights: Float32Array | null,
|
||||||
@@ -76,11 +82,10 @@ function drawWaveform(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use fractional x positions so adjacent bars share exact pixel boundaries — no gaps.
|
|
||||||
const x1Of = (i: number) => (i / BAR_COUNT) * w;
|
const x1Of = (i: number) => (i / BAR_COUNT) * w;
|
||||||
const x2Of = (i: number) => ((i + 1) / BAR_COUNT) * w;
|
const x2Of = (i: number) => ((i + 1) / BAR_COUNT) * w;
|
||||||
|
|
||||||
// Pass 1 — unplayed (dim)
|
// Pass 1 — unplayed
|
||||||
ctx.globalAlpha = 0.28;
|
ctx.globalAlpha = 0.28;
|
||||||
ctx.fillStyle = colorUnplayed;
|
ctx.fillStyle = colorUnplayed;
|
||||||
for (let i = 0; i < BAR_COUNT; i++) {
|
for (let i = 0; i < BAR_COUNT; i++) {
|
||||||
@@ -90,7 +95,7 @@ function drawWaveform(
|
|||||||
ctx.fillRect(x, (h - barH) / 2, x2Of(i) - x, barH);
|
ctx.fillRect(x, (h - barH) / 2, x2Of(i) - x, barH);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pass 2 — buffered (slightly brighter)
|
// Pass 2 — buffered
|
||||||
ctx.globalAlpha = 0.45;
|
ctx.globalAlpha = 0.45;
|
||||||
ctx.fillStyle = colorBuffered;
|
ctx.fillStyle = colorBuffered;
|
||||||
for (let i = 0; i < BAR_COUNT; i++) {
|
for (let i = 0; i < BAR_COUNT; i++) {
|
||||||
@@ -101,7 +106,7 @@ function drawWaveform(
|
|||||||
ctx.fillRect(x, (h - barH) / 2, x2Of(i) - x, barH);
|
ctx.fillRect(x, (h - barH) / 2, x2Of(i) - x, barH);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pass 3 — played (accent color + glow)
|
// Pass 3 — played
|
||||||
if (progress > 0) {
|
if (progress > 0) {
|
||||||
ctx.globalAlpha = 1;
|
ctx.globalAlpha = 1;
|
||||||
ctx.fillStyle = colorAccent;
|
ctx.fillStyle = colorAccent;
|
||||||
@@ -119,16 +124,27 @@ function drawWaveform(
|
|||||||
ctx.globalAlpha = 1;
|
ctx.globalAlpha = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
const CROSSFADE_MS = 400;
|
||||||
trackId: string | undefined;
|
|
||||||
|
function easeInOut(t: number): number {
|
||||||
|
return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function WaveformSeek({ trackId }: Props) {
|
// ── Component ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
trackId: string | undefined;
|
||||||
|
url: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WaveformSeek({ trackId, url }: Props) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const heightsRef = useRef<Float32Array | null>(null);
|
const heightsRef = useRef<Float32Array | null>(null);
|
||||||
const progressRef = useRef(0);
|
const progressRef = useRef(0);
|
||||||
const bufferedRef = useRef(0);
|
const bufferedRef = useRef(0);
|
||||||
const isDragging = useRef(false);
|
const isDragging = useRef(false);
|
||||||
|
const computingId = useRef<string | null>(null);
|
||||||
|
const rafRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const [hoverPct, setHoverPct] = useState<number | null>(null);
|
const [hoverPct, setHoverPct] = useState<number | null>(null);
|
||||||
|
|
||||||
@@ -140,9 +156,64 @@ export default function WaveformSeek({ trackId }: Props) {
|
|||||||
progressRef.current = progress;
|
progressRef.current = progress;
|
||||||
bufferedRef.current = buffered;
|
bufferedRef.current = buffered;
|
||||||
|
|
||||||
|
const startCrossfade = (from: Float32Array, to: Float32Array) => {
|
||||||
|
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
|
||||||
|
const start = performance.now();
|
||||||
|
const blended = new Float32Array(BAR_COUNT);
|
||||||
|
|
||||||
|
const step = (now: number) => {
|
||||||
|
const t = easeInOut(Math.min((now - start) / CROSSFADE_MS, 1));
|
||||||
|
for (let i = 0; i < BAR_COUNT; i++) blended[i] = from[i] * (1 - t) + to[i] * t;
|
||||||
|
heightsRef.current = blended;
|
||||||
|
if (canvasRef.current) drawWaveform(canvasRef.current, blended, progressRef.current, bufferedRef.current);
|
||||||
|
if (t < 1) {
|
||||||
|
rafRef.current = requestAnimationFrame(step);
|
||||||
|
} else {
|
||||||
|
heightsRef.current = to;
|
||||||
|
rafRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
rafRef.current = requestAnimationFrame(step);
|
||||||
|
};
|
||||||
|
|
||||||
|
// When track changes: show fallback immediately, then compute real waveform
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
heightsRef.current = trackId ? makeHeights(trackId) : null;
|
if (rafRef.current !== null) { cancelAnimationFrame(rafRef.current); rafRef.current = null; }
|
||||||
}, [trackId]);
|
|
||||||
|
if (!trackId || !url) {
|
||||||
|
heightsRef.current = null;
|
||||||
|
computingId.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cache first — no transition needed, just draw immediately
|
||||||
|
const cached = waveformCache.get(trackId);
|
||||||
|
if (cached) {
|
||||||
|
heightsRef.current = cached;
|
||||||
|
if (canvasRef.current) drawWaveform(canvasRef.current, cached, progressRef.current, bufferedRef.current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show pseudo-random while loading
|
||||||
|
const fallback = makeFallbackHeights(trackId);
|
||||||
|
heightsRef.current = fallback;
|
||||||
|
if (canvasRef.current) drawWaveform(canvasRef.current, fallback, progressRef.current, bufferedRef.current);
|
||||||
|
|
||||||
|
// Kick off background computation
|
||||||
|
const id = trackId;
|
||||||
|
computingId.current = id;
|
||||||
|
|
||||||
|
invoke<number[]>('compute_waveform', { url })
|
||||||
|
.then(values => {
|
||||||
|
if (computingId.current !== id) return; // track changed while computing
|
||||||
|
const real = new Float32Array(values);
|
||||||
|
waveformCache.set(id, real);
|
||||||
|
// Crossfade from whatever is currently showing to the real waveform
|
||||||
|
const current = heightsRef.current ?? fallback;
|
||||||
|
startCrossfade(current, real);
|
||||||
|
})
|
||||||
|
.catch(() => { /* keep pseudo-random on error */ });
|
||||||
|
}, [trackId, url]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (canvasRef.current) {
|
if (canvasRef.current) {
|
||||||
|
|||||||
@@ -1161,7 +1161,7 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="settings-card">
|
<div className="settings-card">
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '12px' }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '12px' }}>
|
||||||
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={() => { kb.resetToDefaults(); setListeningFor(null); }}>
|
<button className="btn btn-danger" style={{ fontSize: 12 }} onClick={() => { kb.resetToDefaults(); setListeningFor(null); }}>
|
||||||
{t('settings.shortcutsReset')}
|
{t('settings.shortcutsReset')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1247,7 +1247,7 @@ export default function Settings() {
|
|||||||
</p>
|
</p>
|
||||||
<div className="settings-card">
|
<div className="settings-card">
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '12px' }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '12px' }}>
|
||||||
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={() => { gs.resetAll(); setListeningForGlobal(null); }}>
|
<button className="btn btn-danger" style={{ fontSize: 12 }} onClick={() => { gs.resetAll(); setListeningForGlobal(null); }}>
|
||||||
{t('settings.shortcutsReset')}
|
{t('settings.shortcutsReset')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user