From e63e29c6df4ae4f5e5f61e3a7c5f851a10ffb471 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:22:21 +0000 Subject: [PATCH 1/7] Initial plan From 5fd53c0a6acff3945eeb7209a7de5a284468c388 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:27:06 +0000 Subject: [PATCH 2/7] Refactor volume knob mouse behavior with OOP controller Agent-Logs-Url: https://github.com/JP1Q/MusicPlayer/sessions/352e84a2-406e-4012-818a-0e6c047e8304 Co-authored-by: JP1Q <46629197+JP1Q@users.noreply.github.com> --- README.md | 2 +- main.py | 29 +++++++++++---------- tests/test_volume_knob.py | 42 ++++++++++++++++++++++++++++++ volume_knob.py | 55 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 113 insertions(+), 15 deletions(-) create mode 100644 tests/test_volume_knob.py create mode 100644 volume_knob.py diff --git a/README.md b/README.md index 8640feb..d1f539f 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ If you manage to get a clean APK build on real devices, please open a PR with no - Space: play/pause - Left/Right arrows: seek -5s/+5s - Click the progress bar to jump -- Drag the volume knob to change volume +- Click or drag the volume knob to change volume - In the right half header, click top submenu **Download**, then go through **source -> options -> confirm**. - Download UI shows explicit states (idle/queued/downloading/success/error), progress, and error next steps. diff --git a/main.py b/main.py index c91a6c9..321514b 100644 --- a/main.py +++ b/main.py @@ -26,6 +26,7 @@ from library import Library from timed_lyrics import TimedLyrics +from volume_knob import VolumeKnobController CUSTOM_FONT_PATH = None @@ -204,11 +205,17 @@ def _volume_ui_to_gain(x: float) -> float: volume_ui = 0.5 pygame.mixer.music.set_volume(_volume_ui_to_gain(volume_ui)) -is_dragging_vol = False vol_knob_center = (500, 582) vol_knob_radius = 20 VOL_ANGLE_MIN = -135.0 VOL_ANGLE_MAX = 135.0 +volume_knob = VolumeKnobController( + center=vol_knob_center, + radius=vol_knob_radius, + value=volume_ui, + min_angle=VOL_ANGLE_MIN, + max_angle=VOL_ANGLE_MAX, +) # Cat mosh extra state (for beat-ish bursts) prev_energy_pat = 0.0 @@ -985,9 +992,9 @@ def get_music_energy(): elif event.type == MOUSEBUTTONDOWN: if event.button == 1: - dist = math.hypot(mouse_pos[0] - vol_knob_center[0], mouse_pos[1] - vol_knob_center[1]) - if dist <= vol_knob_radius: - is_dragging_vol = True + if volume_knob.start_drag(mouse_pos): + volume_ui = volume_knob.value + pygame.mixer.music.set_volume(_volume_ui_to_gain(volume_ui)) else: mouse_click = True elif event.button == 4: @@ -1069,17 +1076,11 @@ def get_music_energy(): elif event.type == MOUSEBUTTONUP: if event.button == 1: - is_dragging_vol = False + volume_knob.stop_drag() elif event.type == MOUSEMOTION: - if is_dragging_vol: - # lineární ovládání hlasitosti podle úhlu kolem knoflíku x3 - mx, my = mouse_pos - ang = math.degrees(math.atan2(my - vol_knob_center[1], mx - vol_knob_center[0])) - # převod úhlu na rozsah 0..1 (clamp) - ang = max(VOL_ANGLE_MIN, min(VOL_ANGLE_MAX, ang)) - volume_ui = (ang - VOL_ANGLE_MIN) / (VOL_ANGLE_MAX - VOL_ANGLE_MIN) - volume_ui = max(0.0, min(1.0, float(volume_ui))) + if volume_knob.drag(mouse_pos): + volume_ui = volume_knob.value pygame.mixer.music.set_volume(_volume_ui_to_gain(volume_ui)) pygame.draw.rect(screen, (210, 210, 210), (0, 0, HALF_W, HEIGHT)) @@ -1295,7 +1296,7 @@ def get_music_energy(): if vol_knob_img: # lineární mapování volume -> úhel knoflíku x3 - angle_deg = VOL_ANGLE_MAX - (volume_ui * (VOL_ANGLE_MAX - VOL_ANGLE_MIN)) + angle_deg = volume_knob.sprite_rotation_degrees() rotated_knob = pygame.transform.rotate(vol_knob_img, angle_deg) knob_rect = rotated_knob.get_rect(center=vol_knob_center) screen.blit(rotated_knob, knob_rect.topleft) diff --git a/tests/test_volume_knob.py b/tests/test_volume_knob.py new file mode 100644 index 0000000..7ce146b --- /dev/null +++ b/tests/test_volume_knob.py @@ -0,0 +1,42 @@ +import unittest + +from volume_knob import VolumeKnobController + + +class VolumeKnobControllerTests(unittest.TestCase): + def test_start_drag_updates_value_immediately(self): + knob = VolumeKnobController(center=(100, 100), radius=20, value=0.5) + + started = knob.start_drag((100, 80)) # top + + self.assertTrue(started) + self.assertTrue(knob.is_dragging) + self.assertGreater(knob.value, 0.8) + + def test_start_drag_rejects_click_outside_hit_area(self): + knob = VolumeKnobController(center=(100, 100), radius=20, hit_padding=6) + + started = knob.start_drag((140, 100)) + + self.assertFalse(started) + self.assertFalse(knob.is_dragging) + + def test_drag_changes_value_only_while_dragging(self): + knob = VolumeKnobController(center=(100, 100), radius=20, value=0.5) + + self.assertFalse(knob.drag((120, 100))) + self.assertEqual(knob.value, 0.5) + + knob.start_drag((120, 100)) + self.assertTrue(knob.drag((100, 120))) + self.assertLess(knob.value, 0.5) + + def test_sprite_rotation_degrees_maps_value_to_rotation(self): + knob = VolumeKnobController(center=(0, 0), radius=20, value=0.0) + self.assertEqual(knob.sprite_rotation_degrees(), 135.0) + knob.value = 1.0 + self.assertEqual(knob.sprite_rotation_degrees(), -135.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/volume_knob.py b/volume_knob.py new file mode 100644 index 0000000..d14849b --- /dev/null +++ b/volume_knob.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import math +from dataclasses import dataclass + + +@dataclass +class VolumeKnobController: + center: tuple[int, int] + radius: int + value: float = 0.5 + min_angle: float = -135.0 + max_angle: float = 135.0 + hit_padding: int = 6 + is_dragging: bool = False + + def _clamp_value(self, value: float) -> float: + return float(max(0.0, min(1.0, value))) + + def _clamp_angle(self, angle: float) -> float: + return float(max(self.min_angle, min(self.max_angle, angle))) + + def _angle_from_position(self, mouse_pos: tuple[int, int]) -> float: + mx, my = mouse_pos + cx, cy = self.center + return math.degrees(math.atan2(cy - my, mx - cx)) + + def _value_from_position(self, mouse_pos: tuple[int, int]) -> float: + angle = self._clamp_angle(self._angle_from_position(mouse_pos)) + value = (angle - self.min_angle) / (self.max_angle - self.min_angle) + return self._clamp_value(value) + + def contains(self, mouse_pos: tuple[int, int]) -> bool: + mx, my = mouse_pos + cx, cy = self.center + return math.hypot(mx - cx, my - cy) <= (self.radius + self.hit_padding) + + def start_drag(self, mouse_pos: tuple[int, int]) -> bool: + if not self.contains(mouse_pos): + return False + self.is_dragging = True + self.value = self._value_from_position(mouse_pos) + return True + + def stop_drag(self) -> None: + self.is_dragging = False + + def drag(self, mouse_pos: tuple[int, int]) -> bool: + if not self.is_dragging: + return False + self.value = self._value_from_position(mouse_pos) + return True + + def sprite_rotation_degrees(self) -> float: + return self.max_angle - (self.value * (self.max_angle - self.min_angle)) From 620e70dbb337bf6ebaecaad0dfc1dc9b2458a8a8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:30:06 +0000 Subject: [PATCH 3/7] Address review feedback on volume knob state usage Agent-Logs-Url: https://github.com/JP1Q/MusicPlayer/sessions/352e84a2-406e-4012-818a-0e6c047e8304 Co-authored-by: JP1Q <46629197+JP1Q@users.noreply.github.com> --- main.py | 12 +++++------- volume_knob.py | 1 + 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/main.py b/main.py index 321514b..1b4fb46 100644 --- a/main.py +++ b/main.py @@ -955,7 +955,7 @@ def get_music_energy(): # Fallback: deterministic pseudo-signal tied to playback time t = get_playback_seconds() e = abs(math.sin(t * 2.6)) * 60 + abs(math.sin(t * 7.4)) * 25 - audio_vis_energy_smooth = audio_vis_energy_smooth * 0.85 + (e * (0.5 + volume_ui)) * 0.15 + audio_vis_energy_smooth = audio_vis_energy_smooth * 0.85 + (e * (0.5 + volume_knob.value)) * 0.15 return audio_vis_energy_smooth while running: @@ -993,8 +993,7 @@ def get_music_energy(): elif event.type == MOUSEBUTTONDOWN: if event.button == 1: if volume_knob.start_drag(mouse_pos): - volume_ui = volume_knob.value - pygame.mixer.music.set_volume(_volume_ui_to_gain(volume_ui)) + pygame.mixer.music.set_volume(_volume_ui_to_gain(volume_knob.value)) else: mouse_click = True elif event.button == 4: @@ -1080,8 +1079,7 @@ def get_music_energy(): elif event.type == MOUSEMOTION: if volume_knob.drag(mouse_pos): - volume_ui = volume_knob.value - pygame.mixer.music.set_volume(_volume_ui_to_gain(volume_ui)) + pygame.mixer.music.set_volume(_volume_ui_to_gain(volume_knob.value)) pygame.draw.rect(screen, (210, 210, 210), (0, 0, HALF_W, HEIGHT)) pygame.draw.rect(screen, (235, 235, 235), (HALF_W, 0, HALF_W, HEIGHT)) @@ -1302,12 +1300,12 @@ def get_music_energy(): screen.blit(rotated_knob, knob_rect.topleft) else: pygame.draw.circle(screen, (0, 0, 0), vol_knob_center, vol_knob_radius, 3) - angle = -math.pi * 0.75 + (volume_ui * math.pi * 1.5) + angle = -math.pi * 0.75 + (volume_knob.value * math.pi * 1.5) end_x = vol_knob_center[0] + math.sin(angle) * (vol_knob_radius - 4) end_y = vol_knob_center[1] - math.cos(angle) * (vol_knob_radius - 4) pygame.draw.line(screen, (0, 0, 0), vol_knob_center, (end_x, end_y), 4) - vol_text = info_font.render(f"Vol: {int(volume_ui*100)}%", True, (100, 100, 100)) + vol_text = info_font.render(f"Vol: {int(volume_knob.value*100)}%", True, (100, 100, 100)) screen.blit(vol_text, (vol_knob_center[0] - vol_text.get_width()//2, vol_knob_center[1] + vol_knob_radius + 5)) # Progress bar: always visible (neon blue background + pink progress) diff --git a/volume_knob.py b/volume_knob.py index d14849b..f08b0ff 100644 --- a/volume_knob.py +++ b/volume_knob.py @@ -23,6 +23,7 @@ def _clamp_angle(self, angle: float) -> float: def _angle_from_position(self, mouse_pos: tuple[int, int]) -> float: mx, my = mouse_pos cx, cy = self.center + # Screen Y grows downward, invert Y-delta so "up" maps to larger angle/value. return math.degrees(math.atan2(cy - my, mx - cx)) def _value_from_position(self, mouse_pos: tuple[int, int]) -> float: From 7edc16b8e641a3e42e02130f34151b8ce82c505f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:31:27 +0000 Subject: [PATCH 4/7] Align knob rotation mapping and deduplicate volume apply logic Agent-Logs-Url: https://github.com/JP1Q/MusicPlayer/sessions/352e84a2-406e-4012-818a-0e6c047e8304 Co-authored-by: JP1Q <46629197+JP1Q@users.noreply.github.com> --- main.py | 8 ++++++-- tests/test_volume_knob.py | 4 ++-- volume_knob.py | 2 +- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/main.py b/main.py index 1b4fb46..ab8166c 100644 --- a/main.py +++ b/main.py @@ -217,6 +217,10 @@ def _volume_ui_to_gain(x: float) -> float: max_angle=VOL_ANGLE_MAX, ) + +def _apply_volume_from_knob() -> None: + pygame.mixer.music.set_volume(_volume_ui_to_gain(volume_knob.value)) + # Cat mosh extra state (for beat-ish bursts) prev_energy_pat = 0.0 last_mosh_burst_ms = 0 @@ -993,7 +997,7 @@ def get_music_energy(): elif event.type == MOUSEBUTTONDOWN: if event.button == 1: if volume_knob.start_drag(mouse_pos): - pygame.mixer.music.set_volume(_volume_ui_to_gain(volume_knob.value)) + _apply_volume_from_knob() else: mouse_click = True elif event.button == 4: @@ -1079,7 +1083,7 @@ def get_music_energy(): elif event.type == MOUSEMOTION: if volume_knob.drag(mouse_pos): - pygame.mixer.music.set_volume(_volume_ui_to_gain(volume_knob.value)) + _apply_volume_from_knob() pygame.draw.rect(screen, (210, 210, 210), (0, 0, HALF_W, HEIGHT)) pygame.draw.rect(screen, (235, 235, 235), (HALF_W, 0, HALF_W, HEIGHT)) diff --git a/tests/test_volume_knob.py b/tests/test_volume_knob.py index 7ce146b..f914dbc 100644 --- a/tests/test_volume_knob.py +++ b/tests/test_volume_knob.py @@ -33,9 +33,9 @@ def test_drag_changes_value_only_while_dragging(self): def test_sprite_rotation_degrees_maps_value_to_rotation(self): knob = VolumeKnobController(center=(0, 0), radius=20, value=0.0) - self.assertEqual(knob.sprite_rotation_degrees(), 135.0) - knob.value = 1.0 self.assertEqual(knob.sprite_rotation_degrees(), -135.0) + knob.value = 1.0 + self.assertEqual(knob.sprite_rotation_degrees(), 135.0) if __name__ == "__main__": diff --git a/volume_knob.py b/volume_knob.py index f08b0ff..71204b2 100644 --- a/volume_knob.py +++ b/volume_knob.py @@ -53,4 +53,4 @@ def drag(self, mouse_pos: tuple[int, int]) -> bool: return True def sprite_rotation_degrees(self) -> float: - return self.max_angle - (self.value * (self.max_angle - self.min_angle)) + return self.min_angle + (self.value * (self.max_angle - self.min_angle)) From 67e458aaf190bcf06caa30c32af66e6485fce75c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:32:30 +0000 Subject: [PATCH 5/7] Polish knob controller readability tweaks Agent-Logs-Url: https://github.com/JP1Q/MusicPlayer/sessions/352e84a2-406e-4012-818a-0e6c047e8304 Co-authored-by: JP1Q <46629197+JP1Q@users.noreply.github.com> --- main.py | 4 ++-- volume_knob.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/main.py b/main.py index ab8166c..5644eec 100644 --- a/main.py +++ b/main.py @@ -1298,8 +1298,8 @@ def get_music_energy(): if vol_knob_img: # lineární mapování volume -> úhel knoflíku x3 - angle_deg = volume_knob.sprite_rotation_degrees() - rotated_knob = pygame.transform.rotate(vol_knob_img, angle_deg) + rotation_angle = volume_knob.sprite_rotation_degrees() + rotated_knob = pygame.transform.rotate(vol_knob_img, rotation_angle) knob_rect = rotated_knob.get_rect(center=vol_knob_center) screen.blit(rotated_knob, knob_rect.topleft) else: diff --git a/volume_knob.py b/volume_knob.py index 71204b2..a1cb58f 100644 --- a/volume_knob.py +++ b/volume_knob.py @@ -15,10 +15,10 @@ class VolumeKnobController: is_dragging: bool = False def _clamp_value(self, value: float) -> float: - return float(max(0.0, min(1.0, value))) + return max(0.0, min(1.0, value)) def _clamp_angle(self, angle: float) -> float: - return float(max(self.min_angle, min(self.max_angle, angle))) + return max(self.min_angle, min(self.max_angle, angle)) def _angle_from_position(self, mouse_pos: tuple[int, int]) -> float: mx, my = mouse_pos From 246f1e25782956677cf4adfd1f65632fc9d1ffe5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:33:37 +0000 Subject: [PATCH 6/7] Keep knob sprite rotation direction consistent Agent-Logs-Url: https://github.com/JP1Q/MusicPlayer/sessions/352e84a2-406e-4012-818a-0e6c047e8304 Co-authored-by: JP1Q <46629197+JP1Q@users.noreply.github.com> --- tests/test_volume_knob.py | 4 ++-- volume_knob.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_volume_knob.py b/tests/test_volume_knob.py index f914dbc..7ce146b 100644 --- a/tests/test_volume_knob.py +++ b/tests/test_volume_knob.py @@ -33,9 +33,9 @@ def test_drag_changes_value_only_while_dragging(self): def test_sprite_rotation_degrees_maps_value_to_rotation(self): knob = VolumeKnobController(center=(0, 0), radius=20, value=0.0) - self.assertEqual(knob.sprite_rotation_degrees(), -135.0) - knob.value = 1.0 self.assertEqual(knob.sprite_rotation_degrees(), 135.0) + knob.value = 1.0 + self.assertEqual(knob.sprite_rotation_degrees(), -135.0) if __name__ == "__main__": diff --git a/volume_knob.py b/volume_knob.py index a1cb58f..518f52d 100644 --- a/volume_knob.py +++ b/volume_knob.py @@ -53,4 +53,4 @@ def drag(self, mouse_pos: tuple[int, int]) -> bool: return True def sprite_rotation_degrees(self) -> float: - return self.min_angle + (self.value * (self.max_angle - self.min_angle)) + return self.max_angle - (self.value * (self.max_angle - self.min_angle)) From 6807813ede6d04dbabb8cddbcec0f23098f5a5f8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:34:50 +0000 Subject: [PATCH 7/7] Tune knob rotation and avoid redundant drag volume updates Agent-Logs-Url: https://github.com/JP1Q/MusicPlayer/sessions/352e84a2-406e-4012-818a-0e6c047e8304 Co-authored-by: JP1Q <46629197+JP1Q@users.noreply.github.com> --- tests/test_volume_knob.py | 5 +++-- volume_knob.py | 8 +++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/test_volume_knob.py b/tests/test_volume_knob.py index 7ce146b..f42ec0e 100644 --- a/tests/test_volume_knob.py +++ b/tests/test_volume_knob.py @@ -30,12 +30,13 @@ def test_drag_changes_value_only_while_dragging(self): knob.start_drag((120, 100)) self.assertTrue(knob.drag((100, 120))) self.assertLess(knob.value, 0.5) + self.assertFalse(knob.drag((100, 120))) def test_sprite_rotation_degrees_maps_value_to_rotation(self): knob = VolumeKnobController(center=(0, 0), radius=20, value=0.0) - self.assertEqual(knob.sprite_rotation_degrees(), 135.0) - knob.value = 1.0 self.assertEqual(knob.sprite_rotation_degrees(), -135.0) + knob.value = 1.0 + self.assertEqual(knob.sprite_rotation_degrees(), 135.0) if __name__ == "__main__": diff --git a/volume_knob.py b/volume_knob.py index 518f52d..c22a3ef 100644 --- a/volume_knob.py +++ b/volume_knob.py @@ -49,8 +49,10 @@ def stop_drag(self) -> None: def drag(self, mouse_pos: tuple[int, int]) -> bool: if not self.is_dragging: return False - self.value = self._value_from_position(mouse_pos) - return True + next_value = self._value_from_position(mouse_pos) + changed = abs(next_value - self.value) > 1e-6 + self.value = next_value + return changed def sprite_rotation_degrees(self) -> float: - return self.max_angle - (self.value * (self.max_angle - self.min_angle)) + return self.min_angle + (self.value * (self.max_angle - self.min_angle))