feat: add playback seeking
Backend / Lint i formatowanie (pull_request) Successful in 4s
Backend / Testy i kontrakt API (pull_request) Successful in 4s
Backend / Testowy build obrazu (pull_request) Successful in 11s
Panel / Lint, typy i build (pull_request) Successful in 21s

This commit is contained in:
mmacierzanka committed 2026-08-21 18:53:38 +02:00
1 parent ed13d4af06
commit 7ef0a43693
12 files changed
+170 -8

No files matched your search

+1
View File
@@ -51,6 +51,7 @@ indeksów ręcznie.
| `/move track_id before_id` | Przenosi utwór przed wybrany utwór docelowy |
| `/trackplays track_id count` | Ustawia liczbę odtworzeń utworu |
| `/pause`, `/resume` | Wstrzymuje lub wznawia odtwarzanie |
| `/seek seconds` | Przewija bieżący utwór do wskazanej sekundy |
| `/skip` | Pomija bieżący utwór |
| `/stop` | Zatrzymuje odtwarzanie i czyści kolejkę |
| `/volume volume` | Ustawia głośność w zakresie 1–100 |
+1
View File
@@ -50,6 +50,7 @@ export const api = {
playFile: (filename: string) => post('/api/play_file', { filename }),
pause: () => post('/api/pause'),
resume: () => post('/api/resume'),
seek: (seconds: number) => post<{ seconds: number }>('/api/seek', { seconds }),
skip: () => post('/api/skip'),
stop: () => post('/api/stop'),
setVolume: (volume: number) => post('/api/volume', { volume }),
+15
View File
@@ -476,6 +476,21 @@ body {
border: 1px solid rgba(255, 255, 255, 0.03);
}
.track-progress-slider {
position: absolute;
inset: -7px 0;
width: 100%;
height: 20px;
margin: 0;
opacity: 0;
cursor: pointer;
z-index: 1;
}
.track-progress-slider:disabled {
cursor: default;
}
.progress-bar-bg:hover {
height: 8px;
background: rgba(255, 255, 255, 0.15);
+32 -2
View File
@@ -27,6 +27,9 @@ const { elapsed, duration, percent } = useTrackProgress(toRef(props, 'status'))
const current = computed(() => props.status?.current ?? null)
const playing = computed(() => props.status?.playing ?? false)
const loopMode = computed(() => loopModeOf(props.status))
const seeking = ref(false)
const seekPosition = ref(0)
const displayedElapsed = computed(() => seeking.value ? seekPosition.value : elapsed.value)
/** Podczas przeciągania suwaka pokazujemy wartość lokalną, nie tę z serwera. */
const draggedVolume = ref<number | null>(null)
@@ -92,6 +95,21 @@ async function togglePlayback() {
)
}
function onSeekInput(event: Event) {
seeking.value = true
seekPosition.value = Number((event.target as HTMLInputElement).value)
}
async function commitSeek() {
if (!current.value || duration.value <= 0) return
const position = seekPosition.value
try {
await run(() => api.seek(position), 'Przewinięto utwór', 'Błąd przewijania utworu')
} finally {
seeking.value = false
}
}
const skip = () => run(() => api.skip(), 'Pominięto utwór', 'Błąd pomijania utworu')
const stop = () =>
@@ -163,12 +181,24 @@ async function cycleLoop() {
v-if="current"
class="track-progress-container"
>
<span class="time-text">{{ formatTime(elapsed) }}</span>
<span class="time-text">{{ formatTime(displayedElapsed) }}</span>
<div class="progress-bar-bg">
<div
class="progress-bar-fill"
:style="{ width: `${percent}%` }"
:style="{ width: `${seeking ? (duration > 0 ? (seekPosition / duration) * 100 : 0) : percent}%` }"
/>
<input
type="range"
class="track-progress-slider"
min="0"
:max="duration"
step="0.1"
:value="seeking ? seekPosition : elapsed"
:disabled="duration <= 0"
aria-label="Pozycja utworu"
@input="onSeekInput"
@change="commitSeek"
>
</div>
<span class="time-text">{{ duration > 0 ? formatTime(duration) : 'Live' }}</span>
</div>
+3 -1
View File
@@ -59,9 +59,11 @@ def build_player(default_plays: int = 5) -> tuple[MusicPlayer, FakeVoiceClient]:
bot.loop = asyncio.get_event_loop()
voice_client = FakeVoiceClient()
async def recreate(track, loop=None):
async def recreate(track, loop=None, start_at=0.0):
fresh = FakeTrack(track.id)
fresh.plays_left = track.plays_left
fresh.duration = track.duration
fresh.elapsed = start_at
return fresh
tracks = MagicMock()
+1
View File
@@ -15,6 +15,7 @@ EXPECTED_ENDPOINTS = {
("post", "/api/play_file"),
("post", "/api/pause"),
("post", "/api/resume"),
("post", "/api/seek"),
("post", "/api/skip"),
("post", "/api/stop"),
("post", "/api/volume"),
+10
View File
@@ -103,6 +103,16 @@ class MusicPlayerQueueTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(state.current.id, "b")
self.assertEqual(state.queue[0].plays_left, 4)
async def test_seek_restarts_current_track_at_requested_position(self):
player, voice_client, state = await self._queue_abc()
position = await player.seek(GUILD, 4.5)
self.assertEqual(position, 4.5)
self.assertEqual(state.current.id, "a")
self.assertEqual(state.current.elapsed, 4.5)
self.assertIs(voice_client.playing, state.current)
async def test_loop_track_replays_same_index(self):
player, _, state = await self._queue_abc()
await player.play_next(GUILD, advance=True)
+24 -5
View File
@@ -35,6 +35,7 @@ class TrackFactory:
*,
loop: asyncio.AbstractEventLoop | None = None,
stream: bool = True,
start_at: float = 0.0,
) -> YTDLSource:
loop = loop or asyncio.get_running_loop()
data = await loop.run_in_executor(
@@ -44,10 +45,14 @@ class TrackFactory:
data = data["entries"][0]
filename = data["url"] if stream else self._ytdl.prepare_filename(data)
source = discord.FFmpegPCMAudio(filename, **YtdlOptions.STREAM_FFMPEG)
source = discord.FFmpegPCMAudio(
filename, **self._ffmpeg_options(YtdlOptions.STREAM_FFMPEG, start_at)
)
return YTDLSource(source, data=data)
async def from_file(self, filepath: str, *, title: str | None = None) -> YTDLSource:
async def from_file(
self, filepath: str, *, title: str | None = None, start_at: float = 0.0
) -> YTDLSource:
duration = await self._probe.duration(filepath)
data: dict[str, Any] = {
"title": title or os.path.splitext(os.path.basename(filepath))[0],
@@ -57,7 +62,9 @@ class TrackFactory:
"is_local": True,
"filepath": filepath,
}
source = discord.FFmpegPCMAudio(filepath, **YtdlOptions.LOCAL_FFMPEG)
source = discord.FFmpegPCMAudio(
filepath, **self._ffmpeg_options(YtdlOptions.LOCAL_FFMPEG, start_at)
)
return YTDLSource(source, data=data)
async def recreate(
@@ -65,12 +72,24 @@ class TrackFactory:
track: YTDLSource,
*,
loop: asyncio.AbstractEventLoop | None = None,
start_at: float = 0.0,
) -> YTDLSource:
"""Tworzy świeże źródło dla już odtworzonego utworu, zachowując jego tożsamość."""
if track.is_local:
player = await self.from_file(track.filepath or "", title=track.title)
player = await self.from_file(
track.filepath or "", title=track.title, start_at=start_at
)
else:
player = await self.from_url(track.display_url or "", loop=loop)
player = await self.from_url(track.display_url or "", loop=loop, start_at=start_at)
player.id = track.id
player.plays_left = track.plays_left
player.elapsed = start_at
return player
@staticmethod
def _ffmpeg_options(options: dict[str, str], start_at: float) -> dict[str, str]:
if start_at <= 0:
return options
result = dict(options)
result["before_options"] = f"-ss {start_at:.3f} {result.get('before_options', '')}".strip()
return result
+33
View File
@@ -292,6 +292,39 @@ class MusicPlayer:
state.jump_pending = True
await self._restart_playback(guild_id)
async def seek(self, guild_id: int, seconds: float) -> float:
state = self.state(guild_id)
current = state.current
voice_client = self.voice_client(guild_id)
if current is None or voice_client is None:
raise ValueError("Nothing is currently playing")
if current.duration is None:
raise ValueError("The current track cannot be seeked")
position = min(max(seconds, 0.0), current.duration)
was_paused = voice_client.is_paused()
self._playback_tokens[guild_id] = self._playback_tokens.get(guild_id, 0) + 1
self._interrupted_playbacks.discard(guild_id)
if voice_client.is_playing() or was_paused:
voice_client.stop()
track = await self._tracks.recreate(current, loop=self._bot.loop, start_at=position)
track.played = True
track.volume = state.volume
track.plays_left = self.plays_left(current, guild_id)
track.elapsed = position
state.queue[state.index] = track
state.current = track
playback_token = self._playback_tokens[guild_id]
voice_client.play(
track,
after=lambda error: self._schedule_next(guild_id, error, playback_token),
)
if was_paused:
voice_client.pause()
await self.trigger_state_change()
return position
async def _restart_playback(self, guild_id: int) -> None:
voice_client = self.voice_client(guild_id)
voice_is_active = bool(
+7
View File
@@ -0,0 +1,7 @@
from pydantic import BaseModel, Field
class SeekRequest(BaseModel):
"""Pozycja odtwarzania w sekundach."""
seconds: float = Field(ge=0)
@@ -20,6 +20,7 @@ from zyrarbot.ui.api.dependencies import (
from zyrarbot.ui.api.requests.loop_request import LoopRequest
from zyrarbot.ui.api.requests.play_file_request import PlayFileRequest
from zyrarbot.ui.api.requests.play_request import PlayRequest
from zyrarbot.ui.api.requests.seek_request import SeekRequest
from zyrarbot.ui.api.requests.volume_request import VolumeRequest
from zyrarbot.ui.api.status_broadcaster import StatusBroadcaster
@@ -109,6 +110,21 @@ async def resume(
return {"status": "resumed"}
@router.post("/seek")
async def seek(
request: SeekRequest,
guild: discord.Guild = Depends(get_active_guild),
player: MusicPlayer = Depends(get_player),
broadcaster: StatusBroadcaster = Depends(get_broadcaster),
) -> dict[str, float | str]:
try:
seconds = await player.seek(guild.id, request.seconds)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
await broadcaster.broadcast()
return {"status": "seeked", "seconds": seconds}
@router.post("/skip")
async def skip(
guild: discord.Guild = Depends(get_active_guild),
+27
View File
@@ -51,6 +51,33 @@ class ControlCog(MusicCogBase, name="Control"):
await self.player.trigger_state_change()
await interaction.response.send_message(embed=message_embed("Odtwarzanie", "Wznowiono."))
@app_commands.command(name="seek", description="Przewiń bieżący utwór do wskazanej sekundy")
@app_commands.describe(seconds="Pozycja utworu w sekundach, np. 90")
async def seek(self, interaction: discord.Interaction, seconds: float) -> None:
current = self.player.state(interaction.guild.id).current
if current is None:
await interaction.response.send_message(
embed=message_embed("Przewijanie", "Nic nie jest obecnie odtwarzane."),
ephemeral=True,
)
return
try:
position = await self.player.seek(interaction.guild.id, seconds)
except ValueError as error:
await interaction.response.send_message(
embed=message_embed("Przewijanie", str(error)), ephemeral=True
)
return
minutes, remaining = divmod(int(position), 60)
await interaction.response.send_message(
embed=message_embed(
"Przewinięto utwór",
f"**{discord.utils.escape_markdown(current.title or 'Nieznany tytuł')}**\n"
f"Pozycja: **{minutes}:{remaining:02d}**",
)
)
@app_commands.command(name="stop", description="Zatrzymaj odtwarzanie i wyczyść kolejkę")
async def stop(self, interaction: discord.Interaction) -> None:
guild_id = interaction.guild.id