Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
41 changes: 22 additions & 19 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@

from library import Library
from timed_lyrics import TimedLyrics
from volume_knob import VolumeKnobController

CUSTOM_FONT_PATH = None

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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)
Expand Down
43 changes: 43 additions & 0 deletions tests/test_volume_knob.py
Original file line number Diff line number Diff line change
@@ -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()
58 changes: 58 additions & 0 deletions volume_knob.py
Original file line number Diff line number Diff line change
@@ -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))