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..5644eec 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,21 @@ 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, +) + + +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 @@ -948,7 +959,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: @@ -985,9 +996,8 @@ 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): + _apply_volume_from_knob() else: mouse_click = True elif event.button == 4: @@ -1069,18 +1079,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))) - pygame.mixer.music.set_volume(_volume_ui_to_gain(volume_ui)) + if volume_knob.drag(mouse_pos): + _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)) @@ -1295,18 +1298,18 @@ 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)) - 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: 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/tests/test_volume_knob.py b/tests/test_volume_knob.py new file mode 100644 index 0000000..f42ec0e --- /dev/null +++ b/tests/test_volume_knob.py @@ -0,0 +1,43 @@ +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) + 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) + + +if __name__ == "__main__": + unittest.main() diff --git a/volume_knob.py b/volume_knob.py new file mode 100644 index 0000000..c22a3ef --- /dev/null +++ b/volume_knob.py @@ -0,0 +1,58 @@ +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 max(0.0, min(1.0, value)) + + def _clamp_angle(self, angle: float) -> float: + 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 + 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: + 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 + 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.min_angle + (self.value * (self.max_angle - self.min_angle))