feat: add playback seeking
This commit is contained in:
12 files changed
+170
-8
No files matched your search
@@ -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 |
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user