feat: add playback seeking
This commit is contained in:
12 files changed
+170
-8
No files matched your search
@@ -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