From 073c9e3b650eabb8b0edf4f4d74b4a852e60cad9 Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Fri, 31 Jul 2026 20:23:58 -0700 Subject: [PATCH 1/2] =?UTF-8?q?fix(music):=20=E4=BF=AE=E5=A4=8D=E8=BE=93?= =?UTF-8?q?=E5=85=A5=E4=B8=A2=E5=A4=B1=E3=80=81=E7=84=A6=E7=82=B9=E6=AE=8B?= =?UTF-8?q?=E7=95=99=E4=B8=8E=E6=8B=96=E6=8B=BD=E7=8A=B6=E6=80=81=E8=84=B1?= =?UTF-8?q?=E7=A6=BB=E6=A1=86=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三处用户可感的问题: initGui 在每次窗口尺寸变化时都会重跑,而它无条件重建 searchField / qqUin / qqKey —— 于是改一下窗口大小就静默清空用户已输入的内容,包括粘贴进去的 QQ musickey(获取过程相当繁琐)。改为跨重建保留文本。 搜索框只在搜索标签页渲染时才有机会失焦(渲染路径里的 mouseClicked 调用)。 切到别的标签页后焦点残留,keyTyped 继续把按键喂进一个已经不在屏幕上的输入框。 改为切标签时清除焦点。 进度条与音量条的拖拽用私有布尔跟踪,不参与 GuiDragState 仲裁。私有标志永远 不会知道鼠标是在窗口外面松开的,所以 alt-tab 出去松手再回来,滑块仍然黏在 光标上。改为走 beginPointerCapture / isPointerCapturedBy,与 ClickGUI 的滑块、 滚动条、取色器一致,由 drawScreen 的每帧清扫统一释放。进度条保留「松手才提交 seek」的语义,避免每帧 seek 冲击解码器。 未改动:render 仍以 consumePressInBounds(0, 0, guiWidth, guiHeight) 一次性 消费整屏,再由 33 处私有 in(...) 各自重测。这使 consume-once 与 z-order 门控 对本界面无效,但该界面的控件几乎不重叠、模态也已用坐标置 -1 屏蔽,实际影响 远小于上述三项,留待后续单独处理。 Co-Authored-By: Claude --- .../ui/screens/music/MusicScreen.java | 57 ++++++++++++------- 1 file changed, 38 insertions(+), 19 deletions(-) diff --git a/src/main/java/top/fpsmaster/ui/screens/music/MusicScreen.java b/src/main/java/top/fpsmaster/ui/screens/music/MusicScreen.java index f02ecc57..f1efbb54 100644 --- a/src/main/java/top/fpsmaster/ui/screens/music/MusicScreen.java +++ b/src/main/java/top/fpsmaster/ui/screens/music/MusicScreen.java @@ -82,6 +82,9 @@ private void refreshTheme() { private boolean loginOpen = false; private QrLoginState lastQrState = null; + private static final String PROGRESS_CAPTURE = "music.progress"; + private static final String VOLUME_CAPTURE = "music.volume"; + private boolean draggingProgress = false; private float previewFrac = 0; private boolean draggingVolume = false; @@ -98,6 +101,14 @@ public void initGui() { f18 = FPSMaster.fontManager.s18; f20 = FPSMaster.fontManager.s20; f24 = FPSMaster.fontManager.s24; + + // initGui also runs on every window resize. Rebuilding the fields unconditionally meant + // resizing the window silently threw away whatever the user had typed — including a pasted + // QQ musickey, which is tedious to obtain. + String previousSearch = searchField != null ? searchField.getText() : ""; + String previousUin = qqUin != null ? qqUin.getText() : ""; + String previousKey = qqKey != null ? qqKey.getText() : ""; + searchField = new TextField(f16, "搜索歌曲 / 歌手…", CARD, TEXT, 60, new Runnable() { @Override public void run() { @@ -106,6 +117,10 @@ public void run() { }); qqUin = new TextField(f14, "musicid (uin)", CARD, TEXT, 32); qqKey = new TextField(f14, "musickey (qm_keyst)", CARD, TEXT, 256); + + searchField.setText(previousSearch); + qqUin.setText(previousUin); + qqKey.setText(previousKey); } private int accent() { @@ -219,6 +234,12 @@ private float sidebarNav(float x, float baseY, float w, String label, Tab t, int if (in(cx, cy, ix, baseY, iw, ih) && tab != t) { tab = t; scroll = 0; + // The search box only gets a chance to lose focus while the search tab is rendering, so + // without this it stays focused after navigating away and keeps swallowing keystrokes into + // a field that is no longer on screen. + if (searchField != null) { + searchField.setFocused(false); + } } return baseY + ih + 3; } @@ -576,31 +597,29 @@ private void drawSlider(float x, float y, float w, float frac, int mx, int my, i Rects.rounded(knobX - 3, ty + trackH / 2f - 3, 6, 6, 3, 0xFFFFFFFF); } - boolean down = isMouseDown(0); - boolean startHere = in(cx, cy, x, y - 4, w, 11); + // Routed through the shared drag capture rather than a private boolean, so the capture is + // released centrally when the button goes up — including when it goes up outside the window, + // which a locally-tracked flag never learns about and which used to leave the knob glued to + // the cursor after an alt-tab. + Object captureId = isProgress ? PROGRESS_CAPTURE : VOLUME_CAPTURE; + beginPointerCapture(captureId, 0, x, y - 4, w, 11); + boolean capturing = isPointerCapturedBy(captureId, 0); if (isProgress) { - if (startHere) { + if (capturing) { draggingProgress = true; previewFrac = clamp01((mx - x) / w); - } - if (draggingProgress) { - if (down) { - previewFrac = clamp01((mx - x) / w); - } else { - m.seekFraction(previewFrac); - draggingProgress = false; - } + } else if (draggingProgress) { + // Commit on release, not continuously — seeking every frame would thrash the decoder. + m.seekFraction(previewFrac); + draggingProgress = false; } } else { - if (startHere) draggingVolume = true; - if (draggingVolume) { - if (down) { - int v = Math.round(clamp01((mx - x) / w) * 100); - m.setVolume(v); - } else { - draggingVolume = false; - } + if (capturing) { + draggingVolume = true; + m.setVolume(Math.round(clamp01((mx - x) / w) * 100)); + } else { + draggingVolume = false; } } } From d4c1846def1490e8ff90533999ee6c7f200774bd Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Fri, 31 Jul 2026 21:21:21 -0700 Subject: [PATCH 2/2] =?UTF-8?q?fix(music):=20=E4=BF=AE=E5=A4=8D=20seek=20?= =?UTF-8?q?=E7=99=BD=E5=99=AA=E9=9F=B3=E3=80=81=E6=90=9C=E7=B4=A2=E6=A1=86?= =?UTF-8?q?=E4=B8=8D=E6=98=BE=E7=A4=BA=E4=B8=8E=E8=BF=9B=E5=BA=A6=E6=9D=A1?= =?UTF-8?q?=E6=97=B6=E7=81=B5=E6=97=B6=E4=B8=8D=E7=81=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三处音频帧对齐问题,表现为拖动进度条后变成白噪音、再拖一次又恢复: seek 是在解码后的 PCM 流上按字节跳过的,但跳过量 (startMs * bytesPerMs) 只是碰巧才会是帧长的倍数。16-bit 立体声一帧 4 字节,落在帧中间会让之后 每个采样的高低字节与左右声道全部错位 —— 解出来就是白噪音。对齐概率约 1/4, 所以是「有时候」;下次拖动重新计算,碰巧对齐就恢复,所以「再拉一下就好」。 改为向下对齐到帧边界。 line.write 的长度同样不保证是整帧。SourceDataLine.write 的文档明确写明 长度非整帧时行为未定义,而 mp3 解码流不承诺帧对齐的读取量。改为只写完整帧, 余数用 carry 带入下次读取。 seek 时直接关闭 SourceDataLine 而不 flush,行缓冲里还排着旧位置解码出的 PCM,会在新流开始前播出来 —— seek 瞬间的短促爆音。改为先 stop() + flush()。 搜索框输入不显示:initGui 里对新建的 TextField 调 setText 会走 setCursorPosition,后者用 getWidth() 推算滚动偏移,而 width 只在 drawTextBox 里赋值 —— 对一个从未绘制过的输入框读到 0,于是把 lineScrollOffset 推过文本末尾,此后 substring 恒为空。改为完全不重建 (仅在 null 时创建),既保住文本又不触发这条路径。 进度条时灵时不灵:beginPointerCapture 要求当帧存在未被消费的 press,而 in() 已改为命中即消费,两条消费路径争同一个事件。新增 ScaledGuiScreen.acquireDrag,由 in() 确认归属后直接取得所有权,不做二次 消费;释放仍由 GuiDragState 统一处理,alt-tab 保护不受影响。 点击播放键会同时切换音源:render 此前一次性消费整屏 press,再把裸坐标交给 33 处私有 in() 各自重测,所有命中的控件全部触发。改为 peek 取坐标、由命中的 控件在 in() 内认领。根因则是侧边栏内容高度固定而可用高度随窗口收缩,窗口偏矮 时音源按钮会溢出到播放条上 —— 加了面板最小高度。 Co-Authored-By: Claude --- .../fpsmaster/modules/music/AudioEngine.java | 38 +++++++-- .../ui/screens/music/MusicScreen.java | 80 +++++++++++++------ .../utils/render/gui/ScaledGuiScreen.java | 12 +++ 3 files changed, 100 insertions(+), 30 deletions(-) diff --git a/src/main/java/top/fpsmaster/modules/music/AudioEngine.java b/src/main/java/top/fpsmaster/modules/music/AudioEngine.java index df6cb212..c72b5134 100644 --- a/src/main/java/top/fpsmaster/modules/music/AudioEngine.java +++ b/src/main/java/top/fpsmaster/modules/music/AudioEngine.java @@ -139,12 +139,23 @@ private void playLoop(File file, Runnable onEnded) throws Exception { applyGain(line); double bytesPerMs = decoded.getFrameRate() * decoded.getFrameSize() / 1000.0; + int frameSize = Math.max(1, decoded.getFrameSize()); if (startMs > 0) { - skipDecoded(din, (long) (startMs * bytesPerMs)); + // PCM has to be skipped a whole frame at a time — one frame is channels × 2 bytes + // for 16-bit audio. Landing mid-frame shifts every following sample by a byte or + // two, so the high/low halves of each sample and the left/right channels are read + // out of position and the result is white noise. startMs * bytesPerMs is only a + // multiple of the frame size by luck, which is why it happened on some seeks and + // not others, and why seeking again "fixed" it. + long skipBytes = (long) (startMs * bytesPerMs); + skipBytes -= skipBytes % frameSize; + skipDecoded(din, skipBytes); } long posBytes = 0; byte[] buf = new byte[4096]; + // Bytes left over from the previous read that do not yet complete a frame. + int carry = 0; while (!stopped) { long seek = pendingSeekMs; @@ -152,20 +163,37 @@ private void playLoop(File file, Runnable onEnded) throws Exception { pendingSeekMs = -1; startMs = seek; seekBreak = true; + // Discard whatever is still queued in the line before tearing it down. The + // buffer holds PCM decoded at the *old* position; closing without flushing + // lets it play out as a burst of noise just before the new stream starts. + // stop() first so flush() is not racing an active playback pointer. + line.stop(); + line.flush(); break; } if (paused) { Thread.sleep(30); continue; } - int n = din.read(buf, 0, buf.length); + int n = din.read(buf, carry, buf.length - carry); if (n < 0) { endedNaturally = true; break; } - line.write(buf, 0, n); - posBytes += n; - positionMs = startMs + (long) (posBytes / bytesPerMs); + // SourceDataLine.write is documented as undefined unless the length is a whole + // number of frames, and the decoder does not promise frame-aligned reads. Write + // only the complete frames and carry the remainder into the next read. + int available = carry + n; + int writable = available - (available % frameSize); + if (writable > 0) { + line.write(buf, 0, writable); + posBytes += writable; + positionMs = startMs + (long) (posBytes / bytesPerMs); + } + carry = available - writable; + if (carry > 0) { + System.arraycopy(buf, writable, buf, 0, carry); + } } } finally { closeQuietly(line, din, fileIn); diff --git a/src/main/java/top/fpsmaster/ui/screens/music/MusicScreen.java b/src/main/java/top/fpsmaster/ui/screens/music/MusicScreen.java index f1efbb54..6842dd51 100644 --- a/src/main/java/top/fpsmaster/ui/screens/music/MusicScreen.java +++ b/src/main/java/top/fpsmaster/ui/screens/music/MusicScreen.java @@ -39,6 +39,9 @@ private enum Tab {DISCOVER, PLAYLISTS, SEARCH, LYRICS} // 间距系统:统一用 PAD 作为面板内边距基准 private static final float PAD = 20f; + /** Sidebar content (≈215px) + player bar (46px) + margin. Below this the sidebar overflows. */ + private static final float MIN_PANEL_HEIGHT = 300f; + // 配色(跟随 ClickGUI 主题,见 refreshTheme) private static final int DIM = 0xCC000000; private static final int BADGE = 0xFFD9A441; @@ -102,25 +105,26 @@ public void initGui() { f20 = FPSMaster.fontManager.s20; f24 = FPSMaster.fontManager.s24; - // initGui also runs on every window resize. Rebuilding the fields unconditionally meant - // resizing the window silently threw away whatever the user had typed — including a pasted - // QQ musickey, which is tedious to obtain. - String previousSearch = searchField != null ? searchField.getText() : ""; - String previousUin = qqUin != null ? qqUin.getText() : ""; - String previousKey = qqKey != null ? qqKey.getText() : ""; - - searchField = new TextField(f16, "搜索歌曲 / 歌手…", CARD, TEXT, 60, new Runnable() { - @Override - public void run() { - doSearch(); - } - }); - qqUin = new TextField(f14, "musicid (uin)", CARD, TEXT, 32); - qqKey = new TextField(f14, "musickey (qm_keyst)", CARD, TEXT, 256); - - searchField.setText(previousSearch); - qqUin.setText(previousUin); - qqKey.setText(previousKey); + // initGui also runs on every window resize, and rebuilding the fields there threw away whatever + // the user had typed — including a pasted QQ musickey, which is tedious to obtain. Keep the + // existing instances instead of recreating and re-populating them: setText() runs + // setCursorPosition(), which derives the scroll offset from getWidth(), and width is only + // assigned inside drawTextBox() — so on a field that has never been drawn it reads 0, pushes + // lineScrollOffset past the end of the text, and the box renders empty from then on. + if (searchField == null) { + searchField = new TextField(f16, "搜索歌曲 / 歌手…", CARD, TEXT, 60, new Runnable() { + @Override + public void run() { + doSearch(); + } + }); + } + if (qqUin == null) { + qqUin = new TextField(f14, "musicid (uin)", CARD, TEXT, 32); + } + if (qqKey == null) { + qqKey = new TextField(f14, "musickey (qm_keyst)", CARD, TEXT, 256); + } } private int accent() { @@ -134,15 +138,20 @@ public void render(int mouseX, int mouseY, float partialTicks) { refreshTheme(); int mx = getMouseX(); int my = getMouseY(); - ScaledGuiScreen.PointerEvent pe = consumePressInBounds(0, 0, guiWidth, guiHeight, 0); - boolean click = pe != null; + // Peek, don't consume: whichever widget actually contains the press claims it inside in(). + ScaledGuiScreen.PointerEvent pe = peekAnyPress(); + boolean click = pe != null && pe.button == 0; int cx = click ? pe.x : -1; int cy = click ? pe.y : -1; Rects.fill(0, 0, guiWidth, guiHeight, DIM); float pw = Math.min(guiWidth - 30, 500); - float ph = Math.min(guiHeight - 30, 316); + // The sidebar's content height is fixed (title + 3 nav rows + source header + 2 source rows + + // the bottom-aligned login button), so a panel shorter than this let the source rows spill past + // contentBottom and land on top of the player bar — where a single click hit both, starting + // playback and switching source at once. Keep the panel tall enough that it cannot happen. + float ph = Math.max(Math.min(guiHeight - 30, 316), MIN_PANEL_HEIGHT); float px = (guiWidth - pw) / 2f; float py = (guiHeight - ph) / 2f; @@ -601,8 +610,14 @@ private void drawSlider(float x, float y, float w, float frac, int mx, int my, i // released centrally when the button goes up — including when it goes up outside the window, // which a locally-tracked flag never learns about and which used to leave the knob glued to // the cursor after an alt-tab. + // in() establishes the hit and claims the press; acquireDrag then takes ownership without + // asking for a second press that no longer exists. Using beginPointerCapture here instead made + // the two consumption paths race for the same event, which is why dragging only worked + // sometimes. Release is still handled centrally on button-up, so an alt-tab cannot strand it. Object captureId = isProgress ? PROGRESS_CAPTURE : VOLUME_CAPTURE; - beginPointerCapture(captureId, 0, x, y - 4, w, 11); + if (in(cx, cy, x, y - 4, w, 11)) { + acquireDrag(captureId, 0); + } boolean capturing = isPointerCapturedBy(captureId, 0); if (isProgress) { @@ -824,8 +839,23 @@ private void clampScroll(float total, float viewH) { if (scroll < 0) scroll = 0; } - private static boolean in(int cx, int cy, float x, float y, float w, float h) { - return cx >= 0 && cx >= x && cx < x + w && cy >= y && cy < y + h; + /** + * Hit-test that also claims the press. + * + *

{@code render} used to consume the whole screen up front and hand the raw coordinates to + * every widget, which then re-tested them independently. Two consequences: overlapping widgets all + * fired on one press (clicking play while the sidebar had overflowed onto it both started playback + * and switched source), and {@code beginPointerCapture} could never acquire, because the press it + * needs had already been eaten — which is why the progress bar could not be dragged. + * + *

Claiming here makes a press belong to exactly one widget, and leaves it available to the + * capture-based sliders when no plain widget wants it. + */ + private boolean in(int cx, int cy, float x, float y, float w, float h) { + if (cx < 0 || cx < x || cx >= x + w || cy < y || cy >= y + h) { + return false; + } + return consumePressInBounds(x, y, w, h, 0) != null; } private static float clamp01(float v) { diff --git a/src/main/java/top/fpsmaster/utils/render/gui/ScaledGuiScreen.java b/src/main/java/top/fpsmaster/utils/render/gui/ScaledGuiScreen.java index b7ca4561..85ee33a8 100644 --- a/src/main/java/top/fpsmaster/utils/render/gui/ScaledGuiScreen.java +++ b/src/main/java/top/fpsmaster/utils/render/gui/ScaledGuiScreen.java @@ -238,6 +238,18 @@ public boolean beginDrag(Object owner, int button, float x, float y, float width return dragState.acquire(owner, button); } + /** + * Takes drag ownership without re-consuming a press. + * + *

{@link #beginDrag} both claims a press and acquires, which is right for widgets that hit-test + * by calling it. A caller that has already established the hit some other way — and consumed the + * press doing so — would otherwise find no press left to claim and never acquire. Release still + * happens centrally on button-up. + */ + public boolean acquireDrag(Object owner, int button) { + return dragState.acquire(owner, button); + } + public boolean isDragging(Object owner) { return dragState.isDragging(owner) && isMouseDown(dragState.getButton()); }