CONF_ENTRY_CROSSFADE_DURATION = ConfigEntry(
key=CONF_CROSSFADE_DURATION,
type=ConfigEntryType.INTEGER,
- range=(1, 10),
+ range=(1, 15),
default_value=8,
label="Crossfade duration",
description="Duration in seconds of the crossfade between tracks (if enabled)",
category="advanced",
)
-CONF_ENTRY_CROSSFADE_DURATION_HIDDEN = ConfigEntry.from_dict(
- {**CONF_ENTRY_CROSSFADE_DURATION.to_dict(), "hidden": True}
-)
-
CONF_ENTRY_HIDE_PLAYER_IN_UI = ConfigEntry(
key=CONF_HIDE_PLAYER_IN_UI,
type=ConfigEntryType.STRING,
from types import NoneType
from typing import TYPE_CHECKING, Any, TypedDict, cast
+import shortuuid
from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType
from music_assistant_models.enums import (
ConfigEntryType,
queue.flow_mode_stream_log = []
queue.flow_mode = await self.mass.config.get_player_config_value(queue_id, CONF_FLOW_MODE)
queue.current_item = queue_item
+ # always update session id when we start a new playback session
+ queue.session_id = shortuuid.random(length=8)
# handle resume point of audiobook(chapter) or podcast(episode)
if not seek_position and (
self.logger.debug("PlayerQueue %s loaded item %s in buffer", queue.display_name, item_id)
self.signal_update(queue_id)
# enqueue next track on the player
- self._enqueue_next_item(queue_id, self._get_next_item(queue_id, item_id))
+ self._enqueue_next_item(queue_id, self.get_next_item(queue_id, item_id))
# preload next streamdetails
self._preload_next_item(queue_id, queue.index_in_buffer)
if (
insert_at_index == (index_in_buffer + 1)
and queue.state != PlayerState.IDLE
- and (next_item := self._get_next_item(queue_id, index_in_buffer))
+ and (next_item := self.get_next_item(queue_id, index_in_buffer))
+ and queue.next_item_id_enqueued != next_item.queue_item_id
):
self._enqueue_next_item(queue_id, next_item)
"""Update the existing queue items, mostly caused by reordering."""
self._queue_items[queue_id] = queue_items
self._queues[queue_id].items = len(self._queue_items[queue_id])
+ # to track if the queue items changed we set a timestamp
+ # this is a simple way to detect changes in the list of items
+ # without having to compare the entire list
+ self._queues[queue_id].items_last_updated = time.time()
self.signal_update(queue_id, True)
# Helper methods
self, queue_item: QueueItem, flow_mode: bool
) -> PlayerMedia:
"""Parse PlayerMedia from QueueItem."""
+ queue = self._queues[queue_item.queue_id]
+ if queue_item.streamdetails:
+ # prefer netto duration
+ # when seeking, the player only receives the remaining duration
+ duration = queue_item.streamdetails.duration or queue_item.duration
+ if duration and queue_item.streamdetails.seek_position:
+ duration = duration - queue_item.streamdetails.seek_position
+ else:
+ duration = queue_item.duration
media = PlayerMedia(
- uri=await self.mass.streams.resolve_stream_url(queue_item, flow_mode=flow_mode),
+ uri=await self.mass.streams.resolve_stream_url(
+ queue.session_id, queue_item, flow_mode=flow_mode
+ ),
media_type=MediaType.FLOW_STREAM if flow_mode else queue_item.media_type,
title="Music Assistant" if flow_mode else queue_item.name,
image_url=MASS_LOGO_ONLINE,
- duration=queue_item.duration,
+ duration=duration,
queue_id=queue_item.queue_id,
queue_item_id=queue_item.queue_item_id,
)
# all other: just the next index
return cur_index + 1
- def _get_next_item(self, queue_id: str, cur_index: int | str | None = None) -> QueueItem | None:
+ def get_next_item(self, queue_id: str, cur_index: int | str | None = None) -> QueueItem | None:
"""Return next QueueItem for given queue."""
if isinstance(cur_index, str):
cur_index = self.index_by_id(queue_id, cur_index)
player_id=queue_id,
media=await self.player_media_from_queue_item(next_item, False),
)
+ queue.next_item_id_enqueued = next_item.queue_item_id
self.logger.debug(
"Enqueued next track %s on queue %s",
next_item.name,
# Enqueue the next item immediately once the player started
# buffering/playing an item (with a small debounce delay).
task_id = f"enqueue_next_item_{queue_id}"
- self.mass.call_later(1, _enqueue_next_item_on_player, next_item, task_id=task_id)
+ self.mass.call_later(0.5, _enqueue_next_item_on_player, next_item, task_id=task_id)
def _preload_next_item(self, queue_id: str, item_id_in_buffer: str) -> None:
"""
if current_item.media_type == MediaType.RADIO or not current_item.duration:
# radio items or no duration, nothing to do
return
- if not (next_item := self._get_next_item(queue_id, item_id_in_buffer)):
+ if not (next_item := self.get_next_item(queue_id, item_id_in_buffer)):
return # nothing to do
if next_item.available and next_item.streamdetails:
# streamdetails already loaded, nothing to do
# preload the streamdetails for the next item 60 seconds before the current item ends
# this should be enough time to load the stream details and start buffering
# NOTE: we use the duration of the current item, not the next item
- delay = max(0, current_item.duration - 60)
+ netto_duration = current_item.duration - current_item.streamdetails.seek_position
+ delay = max(0, netto_duration - 60)
task_id = f"preload_next_item_{queue_id}"
self.mass.call_later(delay, _preload_streamdetails, task_id=task_id)
# get current/next item based on current index
queue.current_index = current_index
queue.current_item = current_item = self.get_item(queue_id, current_index)
- queue.next_item = self._get_next_item(queue_id, current_index) if current_item else None
+ queue.next_item = self.get_next_item(queue_id, current_index) if current_item else None
# correct elapsed time when seeking
if (
import shutil
import urllib.parse
from collections.abc import AsyncGenerator
+from contextlib import suppress
+from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from aiofiles.os import wrap
ConfigEntryType,
ContentType,
MediaType,
+ PlayerFeature,
StreamType,
VolumeNormalizationMode,
)
)
from music_assistant.helpers.audio import LOGGER as AUDIO_LOGGER
from music_assistant.helpers.ffmpeg import LOGGER as FFMPEG_LOGGER
-from music_assistant.helpers.ffmpeg import check_ffmpeg_version, get_ffmpeg_stream
+from music_assistant.helpers.ffmpeg import FFMpeg, check_ffmpeg_version, get_ffmpeg_stream
from music_assistant.helpers.util import (
get_folder_size,
get_free_space,
return (sample_rate, sample_size, channels)
+@dataclass
+class CrossfadeData:
+ """Data class to hold crossfade data."""
+
+ fadeout_part: bytes = b""
+ pcm_format: AudioFormat = field(default_factory=AudioFormat)
+ queue_item_id: str | None = None
+ session_id: str | None = None
+
+
class StreamsController(CoreController):
"""Webserver Controller to stream audio to players."""
# prefer /tmp/.audio as audio cache dir
self._audio_cache_dir = os.path.join("/tmp/.audio") # noqa: S108
self.allow_cache_default = "auto"
+ self._crossfade_data: dict[str, CrossfadeData] = {}
@property
def base_url(self) -> str:
static_routes=[
(
"*",
- "/flow/{queue_id}/{queue_item_id}.{fmt}",
+ "/flow/{session_id}/{queue_id}/{queue_item_id}.{fmt}",
self.serve_queue_flow_stream,
),
(
"*",
- "/single/{queue_id}/{queue_item_id}.{fmt}",
+ "/single/{session_id}/{queue_id}/{queue_item_id}.{fmt}",
self.serve_queue_item_stream,
),
(
async def resolve_stream_url(
self,
+ session_id: str,
queue_item: QueueItem,
flow_mode: bool = False,
player_id: str | None = None,
if output_codec.is_pcm() and ";" not in fmt:
fmt += f";codec=pcm;rate={44100};bitrate={16};channels={2}"
base_path = "flow" if flow_mode else "single"
- return f"{self._server.base_url}/{base_path}/{queue_item.queue_id}/{queue_item.queue_item_id}.{fmt}" # noqa: E501
+ return f"{self._server.base_url}/{base_path}/{session_id}/{queue_item.queue_id}/{queue_item.queue_item_id}.{fmt}" # noqa: E501
async def get_plugin_source_url(
self,
queue = self.mass.player_queues.get(queue_id)
if not queue:
raise web.HTTPNotFound(reason=f"Unknown Queue: {queue_id}")
+ session_id = request.match_info["session_id"]
+ if session_id != queue.session_id:
+ raise web.HTTPNotFound(reason=f"Unknown (or invalid) session: {session_id}")
queue_player = self.mass.players.get(queue_id)
queue_item_id = request.match_info["queue_item_id"]
queue_item = self.mass.player_queues.get_item(queue_id, queue_item_id)
queue_item.available = False
raise web.HTTPNotFound(reason=f"No streamdetails for Queue item: {queue_item_id}")
- # pick pcm format based on the streamdetails and player capabilities
- pcm_format = AudioFormat(
- content_type=DEFAULT_PCM_FORMAT.content_type,
- sample_rate=queue_item.streamdetails.audio_format.sample_rate,
- bit_depth=DEFAULT_PCM_FORMAT.bit_depth,
- channels=2,
- )
- # work out output format/details
+ # pick output format based on the streamdetails and player capabilities
output_format = await self.get_output_format(
output_format_str=request.match_info["fmt"],
player=queue_player,
- content_sample_rate=pcm_format.sample_rate,
- content_bit_depth=pcm_format.bit_depth,
+ content_sample_rate=queue_item.streamdetails.audio_format.sample_rate,
+ # always use f32 internally for extra headroom for filters etc
+ content_bit_depth=DEFAULT_PCM_FORMAT.bit_depth,
)
# prepare request, add some DLNA/UPNP compatible headers
if request.method != "GET":
return resp
- # all checks passed, start streaming!
- self.logger.debug(
- "Start serving audio stream for QueueItem %s (%s) to %s",
- queue_item.name,
- queue_item.uri,
- queue.display_name,
+ # work out pcm format based on output format
+ pcm_format = AudioFormat(
+ content_type=DEFAULT_PCM_FORMAT.content_type,
+ sample_rate=output_format.sample_rate,
+ # always use f32 internally for extra headroom for filters etc
+ bit_depth=DEFAULT_PCM_FORMAT.bit_depth,
+ channels=2,
)
- chunk_num = 0
-
# inform the queue that the track is now loaded in the buffer
# so for example the next track can be enqueued
self.mass.player_queues.track_loaded_in_buffer(queue_id, queue_item_id)
+
+ # work out crossfade details
+ self._crossfade_data.setdefault(queue_id, CrossfadeData())
+ crossfade_data = self._crossfade_data[queue_id]
+ enable_crossfade = self._get_crossfade_config(queue_item, flow_mode=False)
+
async for chunk in get_ffmpeg_stream(
audio_input=self.get_queue_item_stream(
queue_item=queue_item,
pcm_format=pcm_format,
+ enable_crossfade=enable_crossfade,
+ crossfade_data=crossfade_data,
+ session_id=session_id,
),
input_format=pcm_format,
output_format=output_format,
):
try:
await resp.write(chunk)
- chunk_num += 1
except (BrokenPipeError, ConnectionResetError, ConnectionError):
break
if queue_item.streamdetails.stream_error:
await resp.write(chunk)
except (BrokenPipeError, ConnectionResetError, ConnectionError):
break
+ return resp
+ # lookup next item in queue to determine additional actions
+ next_item = self.mass.player_queues.get_next_item(queue_id, queue_item_id)
+ if not next_item:
+ # end of queue reached: make sure we yield the last_fadeout_part
+ if crossfade_data and crossfade_data.fadeout_part:
+ await resp.write(crossfade_data.fadeout_part)
+ crossfade_data.fadeout_part = b""
+ if (
+ crossfade_data.fadeout_part
+ and next_item
+ and next_item.streamdetails
+ and next_item.streamdetails.audio_format.sample_rate
+ != crossfade_data.pcm_format.sample_rate
+ and PlayerFeature.GAPLESS_DIFFERENT_SAMPLERATE not in queue_player.supported_features
+ ):
+ # next track's sample rate differs from current track
+ # most players do not properly support gapless playback between different sample rates
+ # so let's just output the fadeout data
+ crossfade_data.session_id = ""
+ self.logger.debug("Skipping crossfade: sample rate mismatch")
+ async with FFMpeg(
+ audio_input="-",
+ input_format=crossfade_data.pcm_format,
+ output_format=output_format,
+ ) as ffmpeg:
+ res = await ffmpeg.communicate(crossfade_data.fadeout_part)
+ with suppress(BrokenPipeError, ConnectionResetError, ConnectionError):
+ await resp.write(res[0])
+
return resp
async def serve_queue_flow_stream(self, request: web.Request) -> web.Response:
queue = self.mass.player_queues.get(queue_id)
if not queue:
raise web.HTTPNotFound(reason=f"Unknown Queue: {queue_id}")
+ session_id = request.match_info["session_id"]
+ if session_id != queue.session_id:
+ raise web.HTTPNotFound(reason=f"Unknown (or invalid) session: {session_id}")
if not (queue_player := self.mass.players.get(queue_id)):
raise web.HTTPNotFound(reason=f"Unknown Player: {queue_id}")
start_queue_item_id = request.match_info["queue_item_id"]
"""Get a flow stream of all tracks in the queue as raw PCM audio."""
# ruff: noqa: PLR0915
assert pcm_format.content_type.is_pcm()
- queue_track = None
- last_fadeout_part = b""
+ queue_item: QueueItem | None = None
+ crossfade_data = CrossfadeData(b"", pcm_format)
queue.flow_mode = True
- use_crossfade = await self.mass.config.get_player_config_value(
- queue.queue_id, CONF_CROSSFADE
- )
+
if not start_queue_item:
# this can happen in some (edge case) race conditions
return
- if start_queue_item.media_type != MediaType.TRACK:
- use_crossfade = False
+
pcm_sample_size = int(
pcm_format.sample_rate * (pcm_format.bit_depth / 8) * pcm_format.channels
)
self.logger.info(
- "Start Queue Flow stream for Queue %s - crossfade: %s",
+ "Start Queue Flow stream for Queue %s",
queue.display_name,
- use_crossfade,
)
- total_bytes_sent = 0
-
while True:
# get (next) queue item to stream
- if queue_track is None:
- queue_track = start_queue_item
+ if queue_item is None:
+ queue_item = start_queue_item
else:
try:
- queue_track = await self.mass.player_queues.preload_next_queue_item(
- queue.queue_id, queue_track.queue_item_id
+ queue_item = await self.mass.player_queues.preload_next_queue_item(
+ queue.queue_id, queue_item.queue_item_id
)
except QueueEmpty:
break
- if queue_track.streamdetails is None:
+ if queue_item.streamdetails is None:
raise RuntimeError(
"No Streamdetails known for queue item %s",
- queue_track.queue_item_id,
+ queue_item.queue_item_id,
)
- self.logger.debug(
- "Start Streaming queue track: %s (%s) for queue %s",
- queue_track.streamdetails.uri,
- queue_track.name,
- queue.display_name,
- )
- self.mass.player_queues.track_loaded_in_buffer(
- queue.queue_id, queue_track.queue_item_id
- )
+ self.mass.player_queues.track_loaded_in_buffer(queue.queue_id, queue_item.queue_item_id)
# append to play log so the queue controller can work out which track is playing
- play_log_entry = PlayLogEntry(queue_track.queue_item_id)
+ play_log_entry = PlayLogEntry(queue_item.queue_item_id)
queue.flow_mode_stream_log.append(play_log_entry)
- # set some basic vars
- pcm_sample_size = int(pcm_format.sample_rate * (pcm_format.bit_depth / 8) * 2)
- crossfade_duration = self.mass.config.get_raw_player_config_value(
- queue.queue_id, CONF_CROSSFADE_DURATION, 10
- )
- crossfade_size = int(pcm_sample_size * crossfade_duration)
- bytes_written = 0
- buffer = b""
+ # work out crossfade details
+ enable_crossfade = self._get_crossfade_config(queue_item, flow_mode=True)
+
# handle incoming audio chunks
async for chunk in self.get_queue_item_stream(
- queue_track,
+ queue_item,
pcm_format=pcm_format,
+ enable_crossfade=enable_crossfade,
+ crossfade_data=crossfade_data,
):
- # buffer size needs to be big enough to include the crossfade part
- req_buffer_size = pcm_sample_size if not use_crossfade else crossfade_size
-
- # ALWAYS APPEND CHUNK TO BUFFER
- buffer += chunk
- del chunk
- if len(buffer) < req_buffer_size:
- # buffer is not full enough, move on
- continue
-
- #### HANDLE CROSSFADE OF PREVIOUS TRACK AND NEW TRACK
- if last_fadeout_part:
- # perform crossfade
- fadein_part = buffer[:crossfade_size]
- remaining_bytes = buffer[crossfade_size:]
- crossfade_part = await crossfade_pcm_parts(
- fadein_part,
- last_fadeout_part,
- pcm_format=pcm_format,
- )
- # send crossfade_part (as one big chunk)
- bytes_written += len(crossfade_part)
- yield crossfade_part
-
- # also write the leftover bytes from the crossfade action
- if remaining_bytes:
- yield remaining_bytes
- bytes_written += len(remaining_bytes)
- del remaining_bytes
- # clear vars
- last_fadeout_part = b""
- buffer = b""
-
- #### OTHER: enough data in buffer, feed to output
- while len(buffer) > req_buffer_size:
- yield buffer[:pcm_sample_size]
- bytes_written += pcm_sample_size
- buffer = buffer[pcm_sample_size:]
+ yield chunk
#### HANDLE END OF TRACK
- if last_fadeout_part:
- # edge case: we did not get enough data to make the crossfade
- yield last_fadeout_part
- bytes_written += len(last_fadeout_part)
- last_fadeout_part = b""
- if use_crossfade:
- # if crossfade is enabled, save fadeout part to pickup for next track
- last_fadeout_part = buffer[-crossfade_size:]
- remaining_bytes = buffer[:-crossfade_size]
- if remaining_bytes:
- yield remaining_bytes
- bytes_written += len(remaining_bytes)
- del remaining_bytes
- elif buffer:
- # no crossfade enabled, just yield the buffer last part
- bytes_written += len(buffer)
- yield buffer
- # make sure the buffer gets cleaned up
- del buffer
-
- # update duration details based on the actual pcm data we sent
- # this also accounts for crossfade and silence stripping
- seconds_streamed = bytes_written / pcm_sample_size
- queue_track.streamdetails.seconds_streamed = seconds_streamed
- queue_track.streamdetails.duration = (
- queue_track.streamdetails.seek_position + seconds_streamed
- )
- play_log_entry.seconds_streamed = seconds_streamed
- play_log_entry.duration = queue_track.streamdetails.duration
- total_bytes_sent += bytes_written
- self.logger.debug(
- "Finished Streaming queue track: %s (%s) on queue %s",
- queue_track.streamdetails.uri,
- queue_track.name,
- queue.display_name,
- )
+ play_log_entry.seconds_streamed = queue_item.streamdetails.seconds_streamed
+ play_log_entry.duration = queue_item.streamdetails.duration
+
#### HANDLE END OF QUEUE FLOW STREAM
# end of queue flow: make sure we yield the last_fadeout_part
- if last_fadeout_part:
- yield last_fadeout_part
+ if crossfade_data and crossfade_data.fadeout_part:
+ yield crossfade_data.fadeout_part
# correct seconds streamed/duration
- last_part_seconds = len(last_fadeout_part) / pcm_sample_size
- queue_track.streamdetails.seconds_streamed += last_part_seconds
- queue_track.streamdetails.duration += last_part_seconds
- del last_fadeout_part
- total_bytes_sent += bytes_written
+ last_part_seconds = len(crossfade_data.fadeout_part) / pcm_sample_size
+ queue_item.streamdetails.seconds_streamed += last_part_seconds
+ queue_item.streamdetails.duration += last_part_seconds
+ del crossfade_data
self.logger.info("Finished Queue Flow stream for Queue %s", queue.display_name)
async def get_announcement_stream(
self,
queue_item: QueueItem,
pcm_format: AudioFormat,
+ enable_crossfade: bool = False,
+ crossfade_data: CrossfadeData | None = None,
+ session_id: str | None = None,
) -> AsyncGenerator[bytes, None]:
"""Get the audio stream for a single queue item as raw PCM audio."""
# collect all arguments for ffmpeg
streamdetails = queue_item.streamdetails
assert streamdetails
filter_params = []
+ crossfade_duration = self.mass.config.get_raw_player_config_value(
+ queue_item.queue_id, CONF_CROSSFADE_DURATION, 10
+ )
+
+ queue = self.mass.player_queues.get(queue_item.queue_id)
+ self.logger.debug(
+ "Start Streaming queue track: %s (%s) for queue %s - crossfade: %s",
+ queue_item.streamdetails.uri,
+ queue_item.name,
+ queue.display_name,
+ f"{crossfade_duration}s" if enable_crossfade else "disabled",
+ )
# handle volume normalization
gain_correct: float | None = None
# if the stream does not provide a look ahead buffer
pad_silence_seconds = 4
+ if crossfade_data.session_id != session_id:
+ # invalidate expired crossfade data
+ crossfade_data.fadeout_part = b""
+
first_chunk_received = False
+ buffer = b""
+ bytes_written = 0
+ pcm_sample_size = int(pcm_format.sample_rate * (pcm_format.bit_depth / 8) * 2)
+ # buffer size needs to be big enough to include the crossfade part
+
+ crossfade_size = int(pcm_sample_size * crossfade_duration)
+ req_buffer_size = pcm_sample_size
+ if enable_crossfade or (crossfade_data and crossfade_data.fadeout_part):
+ # crossfade is enabled, so we need to make sure we have enough data in the buffer
+ # to perform the crossfade
+ req_buffer_size += crossfade_size
+
async for chunk in get_media_stream(
self.mass,
streamdetails=streamdetails,
async for silence in get_silence(pad_silence_seconds, pcm_format):
yield silence
del silence
- yield chunk
+
+ # ALWAYS APPEND CHUNK TO BUFFER
+ buffer += chunk
del chunk
+ if len(buffer) < req_buffer_size:
+ # buffer is not full enough, move on
+ continue
+
+ #### HANDLE CROSSFADE OF PREVIOUS TRACK AND NEW TRACK
+ if crossfade_data and crossfade_data.fadeout_part:
+ # perform crossfade
+ fade_in_part = buffer[:crossfade_size]
+ remaining_bytes = buffer[crossfade_size:]
+ crossfade_part = await crossfade_pcm_parts(
+ fade_in_part=fade_in_part,
+ fade_out_part=crossfade_data.fadeout_part,
+ pcm_format=pcm_format,
+ fade_out_pcm_format=crossfade_data.pcm_format,
+ )
+ # send crossfade_part (as one big chunk)
+ bytes_written += len(crossfade_part)
+ yield crossfade_part
+
+ # also write the leftover bytes from the crossfade action
+ if remaining_bytes:
+ yield remaining_bytes
+ bytes_written += len(remaining_bytes)
+ del remaining_bytes
+ # clear vars
+ crossfade_data.fadeout_part = b""
+ buffer = b""
+ del fade_in_part
+
+ #### OTHER: enough data in buffer, feed to output
+ while len(buffer) > req_buffer_size:
+ yield buffer[:pcm_sample_size]
+ bytes_written += pcm_sample_size
+ buffer = buffer[pcm_sample_size:]
+
+ #### HANDLE END OF TRACK
+ if crossfade_data and crossfade_data.fadeout_part:
+ # edge case: we did not get enough data to make the crossfade
+ if crossfade_data.pcm_format == pcm_format:
+ yield crossfade_data.fadeout_part
+ bytes_written += len(crossfade_data.fadeout_part)
+ crossfade_data.fadeout_part = b""
+ if enable_crossfade:
+ # if crossfade is enabled, save fadeout part to pickup for next track
+ crossfade_data.fadeout_part = buffer[-crossfade_size:]
+ crossfade_data.pcm_format = pcm_format
+ crossfade_data.session_id = session_id
+ crossfade_data.queue_item_id = queue_item.queue_item_id
+ remaining_bytes = buffer[:-crossfade_size]
+ if remaining_bytes:
+ yield remaining_bytes
+ bytes_written += len(remaining_bytes)
+ del remaining_bytes
+ elif buffer:
+ # no crossfade enabled, just yield the buffer last part
+ bytes_written += len(buffer)
+ yield buffer
+ # make sure the buffer gets cleaned up
+ del buffer
+
+ # update duration details based on the actual pcm data we sent
+ # this also accounts for crossfade and silence stripping
+ seconds_streamed = bytes_written / pcm_sample_size
+ streamdetails.seconds_streamed = seconds_streamed
+ streamdetails.duration = streamdetails.seek_position + seconds_streamed
+ queue_item.duration = streamdetails.duration
+ if queue_item.media_type:
+ queue_item.media_item.duration = streamdetails.duration
+ self.logger.debug(
+ "Finished Streaming queue track: %s (%s) on queue %s",
+ queue_item.streamdetails.uri,
+ queue_item.name,
+ queue.display_name,
+ )
def _log_request(self, request: web.Request) -> None:
"""Log request."""
await asyncio.to_thread(_clean_old_files, foldersize)
# reschedule self
self.mass.call_later(3600, self._clean_audio_cache)
+
+ def _get_crossfade_config(self, queue_item: QueueItem, flow_mode: bool = False) -> bool:
+ """Get the crossfade config for a queue item."""
+ if not (queue_player := self.mass.players.get(queue_item.queue_id)):
+ return False # just a guard
+ use_crossfade = self.mass.config.get_raw_player_config_value(
+ queue_item.queue_id, CONF_CROSSFADE, False
+ )
+ if not use_crossfade:
+ return False
+ if not flow_mode and PlayerFeature.GAPLESS_PLAYBACK not in queue_player.supported_features:
+ # crossfade is not supported on this player due to missing gapless playback
+ self.logger.debug("Skipping crossfade: gapless playback not supported on player")
+ return False
+ if queue_item.media_type != MediaType.TRACK:
+ return False
+ # check if the next item is part of the same album
+ next_item = self.mass.player_queues.get_next_item(
+ queue_item.queue_id, queue_item.queue_item_id
+ )
+ if not next_item:
+ return False
+ if (
+ queue_item.media_item
+ and queue_item.media_item.album
+ and next_item.media_item
+ and next_item.media_item.album
+ and queue_item.media_item.album == next_item.media_item.album
+ ):
+ # in general, crossfade is not desired for tracks of the same (gapless) album
+ # because we have no accurate way to determine if the album is gapless or not,
+ # for now we just never crossfade between tracks of the same album
+ self.logger.debug("Skipping crossfade: next item is part of the same album")
+ return False
+ # check if next item is a track
+ if next_item.media_type != MediaType.TRACK:
+ self.logger.debug("Skipping crossfade: next item is not a track")
+ return False
+ # check if next item sample rate matches
+ if (
+ not flow_mode
+ and next_item.streamdetails
+ and (
+ queue_item.streamdetails.audio_format.sample_rate
+ != next_item.streamdetails.audio_format.sample_rate
+ )
+ and (queue_player := self.mass.players.get(queue_item.queue_id))
+ and PlayerFeature.GAPLESS_DIFFERENT_SAMPLERATE not in queue_player.supported_features
+ ):
+ self.logger.debug("Skipping crossfade: sample rate mismatch")
+ return 0
+ # all checks passed, crossfade is enabled/allowed
+ return True
fade_in_part: bytes,
fade_out_part: bytes,
pcm_format: AudioFormat,
+ fade_out_pcm_format: AudioFormat | None = None,
) -> bytes:
"""Crossfade two chunks of pcm/raw audio using ffmpeg."""
- sample_size = pcm_format.pcm_sample_size
+ if fade_out_pcm_format is None:
+ fade_out_pcm_format = pcm_format
+
# calculate the fade_length from the smallest chunk
- fade_length = min(len(fade_in_part), len(fade_out_part)) / sample_size
+ fade_length = min(
+ len(fade_in_part) / pcm_format.pcm_sample_size,
+ len(fade_out_part) / fade_out_pcm_format.pcm_sample_size,
+ )
+ # write the fade_out_part to a temporary file
fadeout_filename = f"/tmp/{shortuuid.random(20)}.pcm" # noqa: S108
async with aiofiles.open(fadeout_filename, "wb") as outfile:
await outfile.write(fade_out_part)
+
args = [
# generic args
"ffmpeg",
"quiet",
# fadeout part (as file)
"-acodec",
- pcm_format.content_type.name.lower(),
- "-f",
- pcm_format.content_type.value,
+ fade_out_pcm_format.content_type.name.lower(),
"-ac",
- str(pcm_format.channels),
+ str(fade_out_pcm_format.channels),
"-ar",
- str(pcm_format.sample_rate),
+ str(fade_out_pcm_format.sample_rate),
+ "-channel_layout",
+ "mono" if fade_out_pcm_format.channels == 1 else "stereo",
+ "-f",
+ fade_out_pcm_format.content_type.value,
"-i",
fadeout_filename,
# fade_in part (stdin)
"-acodec",
pcm_format.content_type.name.lower(),
- "-f",
- pcm_format.content_type.value,
"-ac",
str(pcm_format.channels),
+ "-channel_layout",
+ "mono" if pcm_format.channels == 1 else "stereo",
"-ar",
str(pcm_format.sample_rate),
+ "-f",
+ pcm_format.content_type.value,
"-i",
"-",
# filter args
"-filter_complex",
f"[0][1]acrossfade=d={fade_length}",
# output args
+ "-acodec",
+ pcm_format.content_type.name.lower(),
+ "-ac",
+ str(pcm_format.channels),
+ "-channel_layout",
+ "mono" if pcm_format.channels == 1 else "stereo",
+ "-ar",
+ str(pcm_format.sample_rate),
"-f",
pcm_format.content_type.value,
"-",
len(fade_in_part),
len(fade_out_part),
)
+ if fade_out_pcm_format.sample_rate != pcm_format.sample_rate:
+ # Edge case: the sample rates are different,
+ # we need to resample the fade_out part to the same sample rate as the fade_in part
+ async with FFMpeg(
+ audio_input="-",
+ input_format=fade_out_pcm_format,
+ output_format=pcm_format,
+ ) as ffmpeg:
+ res = await ffmpeg.communicate(fade_out_part)
+ return res[0] + fade_in_part
return fade_out_part + fade_in_part
if isinstance(self.audio_input, AsyncGenerator):
self._stdin_task = asyncio.create_task(self._feed_stdin())
+ async def communicate(
+ self,
+ input: bytes | None = None, # noqa: A002
+ timeout: float | None = None,
+ ) -> tuple[bytes, bytes]:
+ """Override communicate to avoid blocking."""
+ if self._stdin_task and not self._stdin_task.done():
+ self._stdin_task.cancel()
+ with suppress(asyncio.CancelledError):
+ await self._stdin_task
+ if self._logger_task and not self._logger_task.done():
+ self._logger_task.cancel()
+ return await super().communicate(input, timeout)
+
async def close(self, send_signal: bool = True) -> None:
"""Close/terminate the process and wait for exit."""
if self.closed:
continue
yield line
+ async def communicate(
+ self,
+ input: bytes | None = None, # noqa: A002
+ timeout: float | None = None,
+ ) -> tuple[bytes, bytes]:
+ """Communicate with the process and return stdout and stderr."""
+ if self.closed:
+ raise RuntimeError("communicate called while process already done")
+ # abort existing readers on stderr/stdout first before we send communicate
+ waiter: asyncio.Future
+ if self.proc.stdout and (waiter := self.proc.stdout._waiter):
+ self.proc.stdout._waiter = None
+ if waiter and not waiter.done():
+ waiter.set_exception(asyncio.CancelledError())
+ if self.proc.stderr and (waiter := self.proc.stderr._waiter):
+ self.proc.stderr._waiter = None
+ if waiter and not waiter.done():
+ waiter.set_exception(asyncio.CancelledError())
+ stdout, stderr = await asyncio.wait_for(self.proc.communicate(input), timeout)
+ return (stdout, stderr)
+
async def close(self, send_signal: bool = False) -> None:
"""Close/terminate the process and wait for exit."""
self._close_called = True
CONF_ENTRY_ANNOUNCE_VOLUME_MIN,
CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY,
CONF_ENTRY_AUTO_PLAY,
+ CONF_ENTRY_CROSSFADE,
CONF_ENTRY_CROSSFADE_DURATION,
CONF_ENTRY_CROSSFADE_FLOW_MODE_REQUIRED,
CONF_ENTRY_EXPOSE_PLAYER_TO_HA,
# config entries that are valid for all/most players
CONF_ENTRY_PLAYER_ICON,
CONF_ENTRY_FLOW_MODE,
- CONF_ENTRY_CROSSFADE_FLOW_MODE_REQUIRED,
+ CONF_ENTRY_CROSSFADE,
CONF_ENTRY_CROSSFADE_DURATION,
CONF_ENTRY_VOLUME_NORMALIZATION,
CONF_ENTRY_OUTPUT_LIMITER,
if not (player := self.mass.players.get(player_id)):
return base_entries
+ if PlayerFeature.GAPLESS_PLAYBACK not in player.supported_features:
+ base_entries += (CONF_ENTRY_CROSSFADE_FLOW_MODE_REQUIRED,)
+
if player.type == PlayerType.GROUP:
# return group player specific entries
return (
# the queue controller will simply call this play_media method for
# each item in the queue to play them one by one.
- # In order to support true gapless and/or crossfade, we offer the option of
+ # In order to support true gapless and/or enqueuing, we offer the option of
# 'flow_mode' playback. In that case the queue controller will stitch together
# all songs in the playback queue into a single stream and send that to the player.
# In that case the URI (and metadata) received here is that of the 'flow mode' stream.
from zeroconf.asyncio import AsyncServiceInfo
from music_assistant.constants import (
- CONF_ENTRY_CROSSFADE,
- CONF_ENTRY_CROSSFADE_DURATION,
CONF_ENTRY_DEPRECATED_EQ_BASS,
CONF_ENTRY_DEPRECATED_EQ_MID,
CONF_ENTRY_DEPRECATED_EQ_TREBLE,
PLAYER_CONFIG_ENTRIES = (
CONF_ENTRY_FLOW_MODE_ENFORCED,
- CONF_ENTRY_CROSSFADE,
- CONF_ENTRY_CROSSFADE_DURATION,
CONF_ENTRY_DEPRECATED_EQ_BASS,
CONF_ENTRY_DEPRECATED_EQ_MID,
CONF_ENTRY_DEPRECATED_EQ_TREBLE,
from zeroconf import ServiceStateChange
from music_assistant.constants import (
- CONF_ENTRY_CROSSFADE,
CONF_ENTRY_ENABLE_ICY_METADATA,
CONF_ENTRY_FLOW_MODE_ENFORCED,
CONF_ENTRY_HTTP_PROFILE_FORCED_2,
base_entries = await super().get_player_config_entries(self.player_id)
if not self.bluos_players.get(player_id):
# TODO fix player entries
- return (*base_entries, CONF_ENTRY_CROSSFADE)
+ return (*base_entries,)
return (
*base_entries,
CONF_ENTRY_HTTP_PROFILE_FORCED_2,
- CONF_ENTRY_CROSSFADE,
CONF_ENTRY_OUTPUT_CODEC,
CONF_ENTRY_FLOW_MODE_ENFORCED,
CONF_ENTRY_ENABLE_ICY_METADATA,
from music_assistant_models.player import DeviceInfo, Player, PlayerMedia
from music_assistant.constants import (
- CONF_ENTRY_CROSSFADE,
- CONF_ENTRY_CROSSFADE_DURATION,
CONF_ENTRY_FLOW_MODE_ENFORCED,
CONF_ENTRY_HTTP_PROFILE,
CONF_ENTRY_HTTP_PROFILE_HIDDEN,
return (
*await super().get_player_config_entries(player_id),
CONF_ENTRY_FLOW_MODE_ENFORCED,
- CONF_ENTRY_CROSSFADE,
- CONF_ENTRY_CROSSFADE_DURATION,
CONF_ENTRY_HTTP_PROFILE,
# Hide power/volume/mute control options since they are guaranteed to work
ConfigEntry(
from pychromecast.socket_client import CONNECTION_STATUS_CONNECTED, CONNECTION_STATUS_DISCONNECTED
from music_assistant.constants import (
- CONF_ENTRY_CROSSFADE_DURATION,
- CONF_ENTRY_CROSSFADE_FLOW_MODE_REQUIRED,
CONF_ENTRY_HTTP_PROFILE,
CONF_ENTRY_MANUAL_DISCOVERY_IPS,
CONF_ENTRY_OUTPUT_CODEC,
CAST_PLAYER_CONFIG_ENTRIES = (
- CONF_ENTRY_CROSSFADE_FLOW_MODE_REQUIRED,
- CONF_ENTRY_CROSSFADE_DURATION,
CONF_ENTRY_OUTPUT_CODEC,
CONF_ENTRY_HTTP_PROFILE,
)
from music_assistant_models.player import DeviceInfo, Player, PlayerMedia
from music_assistant.constants import (
- CONF_ENTRY_CROSSFADE_DURATION,
- CONF_ENTRY_CROSSFADE_FLOW_MODE_REQUIRED,
CONF_ENTRY_ENABLE_ICY_METADATA,
CONF_ENTRY_FLOW_MODE_DEFAULT_ENABLED,
CONF_ENTRY_HTTP_PROFILE,
PLAYER_CONFIG_ENTRIES = (
- CONF_ENTRY_CROSSFADE_FLOW_MODE_REQUIRED,
- CONF_ENTRY_CROSSFADE_DURATION,
CONF_ENTRY_OUTPUT_CODEC,
CONF_ENTRY_HTTP_PROFILE,
CONF_ENTRY_ENABLE_ICY_METADATA,
# so we simply assume it does and if it doesn't
# you'll find out at playback time and we log a warning
PlayerFeature.ENQUEUE,
+ PlayerFeature.GAPLESS_PLAYBACK,
}
if dlna_player.device.has_volume_level:
supported_features.add(PlayerFeature.VOLUME_SET)
from music_assistant_models.player import DeviceInfo, Player, PlayerMedia
from music_assistant.constants import (
- CONF_ENTRY_CROSSFADE,
- CONF_ENTRY_CROSSFADE_DURATION,
CONF_ENTRY_FLOW_MODE_ENFORCED,
CONF_ENTRY_HTTP_PROFILE,
CONF_ENTRY_OUTPUT_CODEC_DEFAULT_MP3,
return (
*base_entries,
CONF_ENTRY_FLOW_MODE_ENFORCED,
- CONF_ENTRY_CROSSFADE,
- CONF_ENTRY_CROSSFADE_DURATION,
CONF_ENTRY_OUTPUT_CODEC_DEFAULT_MP3,
CONF_ENTRY_HTTP_PROFILE,
)
CONF_CROSSFADE,
CONF_CROSSFADE_DURATION,
CONF_ENABLE_ICY_METADATA,
- CONF_ENTRY_CROSSFADE,
- CONF_ENTRY_CROSSFADE_DURATION,
CONF_ENTRY_FLOW_MODE_ENFORCED,
CONF_FLOW_MODE,
CONF_GROUP_MEMBERS,
*base_entries,
group_members,
CONFIG_ENTRY_UGP_NOTE,
- CONF_ENTRY_CROSSFADE,
- CONF_ENTRY_CROSSFADE_DURATION,
CONF_ENTRY_SAMPLE_RATES_UGP,
CONF_ENTRY_FLOW_MODE_ENFORCED,
)
from zeroconf.asyncio import AsyncServiceInfo
from music_assistant.constants import (
- CONF_ENTRY_CROSSFADE,
- CONF_ENTRY_CROSSFADE_DURATION,
CONF_ENTRY_FLOW_MODE_ENFORCED,
CONF_ENTRY_OUTPUT_CODEC_HIDDEN,
DEFAULT_PCM_FORMAT,
return (
*base_entries,
CONF_ENTRY_FLOW_MODE_ENFORCED,
- CONF_ENTRY_CROSSFADE,
- CONF_ENTRY_CROSSFADE_DURATION,
CONF_ENTRY_SAMPLE_RATES_SNAPCAST,
CONF_ENTRY_OUTPUT_CODEC_HIDDEN,
)
PlayerFeature.NEXT_PREVIOUS,
PlayerFeature.SEEK,
PlayerFeature.SELECT_SOURCE,
+ PlayerFeature.GAPLESS_PLAYBACK,
+ PlayerFeature.GAPLESS_DIFFERENT_SAMPLERATE,
}
SOURCE_LINE_IN = "line_in"
from collections.abc import Callable
from typing import TYPE_CHECKING
-import shortuuid
from aiohttp.client_exceptions import ClientConnectorError
from aiosonos.api.models import ContainerType, MusicService, SonosCapability
from aiosonos.client import SonosLocalApiClient
# We can do some smart stuff if we link them together where possible.
# The player we can just guess from the sonos player id (mac address).
self.airplay_player_id = f"ap{self.player_id[7:-5].lower()}"
- self.queue_version: str = shortuuid.random(8)
self._on_cleanup_callbacks: list[Callable[[], None]] = []
@property
# sync crossfade and repeat modes
await self.sync_play_modes(event.object_id)
elif event.event == EventType.QUEUE_ITEMS_UPDATED:
- # update the queue version to force a refresh
- self.queue_version = shortuuid.random(8)
+ # refresh cloud queue
+ if session_id := self.client.player.group.active_session_id:
+ await self.client.api.playback_session.refresh_cloud_queue(session_id)
async def sync_play_modes(self, queue_id: str) -> None:
"""Sync the play modes between MA and Sonos."""
import time
from typing import TYPE_CHECKING, Any
-import shortuuid
from aiohttp import web
from aiohttp.client_exceptions import ClientError
from aiosonos.api.models import SonosCapability
from zeroconf import ServiceStateChange
from music_assistant.constants import (
- CONF_CROSSFADE,
- CONF_ENTRY_CROSSFADE,
- CONF_ENTRY_CROSSFADE_DURATION_HIDDEN,
CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED,
CONF_ENTRY_HTTP_PROFILE_DEFAULT_2,
CONF_ENTRY_MANUAL_DISCOVERY_IPS,
"""Return Config Entries for the given player."""
base_entries = (
*await super().get_player_config_entries(player_id),
- CONF_ENTRY_CROSSFADE,
- CONF_ENTRY_CROSSFADE_DURATION_HIDDEN,
CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED,
CONF_ENTRY_OUTPUT_CODEC,
CONF_ENTRY_HTTP_PROFILE_DEFAULT_2,
) -> None:
"""Handle PLAY MEDIA on given player."""
sonos_player = self.sonos_players[player_id]
- sonos_player.queue_version = shortuuid.random(8)
mass_player = self.mass.players.get(player_id)
if sonos_player.client.player.is_passive:
# this should be already handled by the player manager, but just in case...
# Regular Queue item playback
# create a sonos cloud queue and load it
cloud_queue_url = f"{self.mass.streams.base_url}/sonos_queue/v2.3/"
+ mass_queue = self.mass.player_queues.get(media.queue_id)
await sonos_player.client.player.group.play_cloud_queue(
cloud_queue_url,
http_authorization=media.queue_id,
item_id=media.queue_item_id,
- queue_version=sonos_player.queue_version,
+ queue_version=str(int(mass_queue.items_last_updated)),
)
self.mass.call_later(5, sonos_player.sync_play_modes, media.queue_id)
return
self.logger.log(VERBOSE_LOG_LEVEL, "Cloud Queue Version request: %s", request.query)
sonos_playback_id = request.headers["X-Sonos-Playback-Id"]
sonos_player_id = sonos_playback_id.split(":")[0]
- if not (sonos_player := self.sonos_players.get(sonos_player_id)):
+ if not (self.sonos_players.get(sonos_player_id)):
return web.Response(status=501)
+ mass_queue = self.mass.player_queues.get_active_queue(sonos_player_id)
context_version = request.query.get("contextVersion") or "1"
- queue_version = sonos_player.queue_version
+ queue_version = str(int(mass_queue.items_last_updated))
result = {"contextVersion": context_version, "queueVersion": queue_version}
return web.json_response(result)
sonos_player_id = sonos_playback_id.split(":")[0]
if not (mass_queue := self.mass.player_queues.get_active_queue(sonos_player_id)):
return web.Response(status=501)
- if not (sonos_player := self.sonos_players.get(sonos_player_id)):
+ if not (self.sonos_players.get(sonos_player_id)):
return web.Response(status=501)
result = {
"contextVersion": "1",
- "queueVersion": sonos_player.queue_version,
+ "queueVersion": str(int(mass_queue.items_last_updated)),
"container": {
"type": "playlist",
"name": "Music Assistant",
"canSeek": False,
"canRepeat": True,
"canRepeatOne": True,
- "canCrossfade": True,
+ "canCrossfade": False, # crossfading is handled by our streams controller
"canShuffle": True,
},
}
async def _parse_sonos_queue_item(self, queue_item: QueueItem) -> dict[str, Any]:
"""Parse a Sonos queue item to a PlayerMedia object."""
- stream_url = await self.mass.streams.resolve_stream_url(queue_item)
+ queue = self.mass.player_queues.get(queue_item.queue_id)
+ assert queue # for type checking
+ stream_url = await self.mass.streams.resolve_stream_url(queue.session_id, queue_item)
if streamdetails := queue_item.streamdetails:
duration = streamdetails.duration or queue_item.duration
if duration and streamdetails.seek_position:
"id": queue_item.queue_item_id,
"deleted": not queue_item.available,
"policies": {
- "canCrossfade": True,
+ "canCrossfade": False, # crossfading is handled by our streams controller
"canSkip": True,
"canSkipBack": True,
"canSkipToItem": True,
},
"track": {
"type": "track",
- "mediaUrl": await self.mass.streams.resolve_stream_url(queue_item),
+ "mediaUrl": stream_url,
"contentType": f"audio/{stream_url.split('.')[-1]}",
"service": {
"name": "Music Assistant",
f"Failed to send command to Sonos player: {resp.status} {resp.reason}"
)
- # set crossfade mode if needed
- crossfade = bool(
- await self.mass.config.get_player_config_value(sonos_player.player_id, CONF_CROSSFADE)
- )
- if sonos_player.client.player.group.play_modes.crossfade != crossfade:
- await sonos_player.client.player.group.set_play_modes(crossfade=True)
+ # disable crossfade mode if needed
+ # crossfading is handled by our streams controller
+ if sonos_player.client.player.group.play_modes.crossfade:
+ await sonos_player.client.player.group.set_play_modes(crossfade=False)
from soco.discovery import discover, scan_network
from music_assistant.constants import (
- CONF_CROSSFADE,
- CONF_ENTRY_CROSSFADE,
- CONF_ENTRY_CROSSFADE_DURATION_HIDDEN,
CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED,
CONF_ENTRY_HTTP_PROFILE_DEFAULT_1,
CONF_ENTRY_MANUAL_DISCOVERY_IPS,
PlayerFeature.VOLUME_MUTE,
PlayerFeature.PAUSE,
PlayerFeature.ENQUEUE,
+ PlayerFeature.GAPLESS_PLAYBACK,
+ PlayerFeature.GAPLESS_DIFFERENT_SAMPLERATE,
}
CONF_NETWORK_SCAN = "network_scan"
base_entries = await super().get_player_config_entries(player_id)
return (
*base_entries,
- CONF_ENTRY_CROSSFADE,
- CONF_ENTRY_CROSSFADE_DURATION_HIDDEN,
CONF_ENTRY_SAMPLE_RATES,
CONF_ENTRY_OUTPUT_CODEC,
CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED,
"""Handle enqueuing of the next queue item on the player."""
sonos_player = self.sonosplayers[player_id]
didl_metadata = create_didl_metadata(media)
- # set crossfade according to player setting
- crossfade = bool(await self.mass.config.get_player_config_value(player_id, CONF_CROSSFADE))
- if sonos_player.crossfade != crossfade:
+
+ # disable crossfade mode if needed
+ # crossfading is handled by our streams controller
+ if sonos_player.crossfade:
def set_crossfade() -> None:
try:
- sonos_player.soco.cross_fade = crossfade
- sonos_player.crossfade = crossfade
+ sonos_player.soco.cross_fade = False
+ sonos_player.crossfade = False
except Exception as err:
self.logger.warning(
"Unable to set crossfade for player %s: %s", sonos_player.zone_name, err
from aiohttp import web
from aioslimproto.client import PlayerState as SlimPlayerState
from aioslimproto.client import SlimClient
-from aioslimproto.client import TransitionType as SlimTransition
from aioslimproto.models import EventType as SlimEventType
from aioslimproto.models import Preset as SlimPreset
from aioslimproto.models import VisualisationType as SlimVisualisationType
from music_assistant_models.player import DeviceInfo, Player, PlayerMedia
from music_assistant.constants import (
- CONF_CROSSFADE,
- CONF_CROSSFADE_DURATION,
- CONF_ENTRY_CROSSFADE,
- CONF_ENTRY_CROSSFADE_DURATION,
CONF_ENTRY_DEPRECATED_EQ_BASS,
CONF_ENTRY_DEPRECATED_EQ_MID,
CONF_ENTRY_DEPRECATED_EQ_TREBLE,
base_entries
+ preset_entries
+ (
- CONF_ENTRY_CROSSFADE,
CONF_ENTRY_DEPRECATED_EQ_BASS,
CONF_ENTRY_DEPRECATED_EQ_MID,
CONF_ENTRY_DEPRECATED_EQ_TREBLE,
- CONF_ENTRY_CROSSFADE_DURATION,
CONF_ENTRY_OUTPUT_CODEC,
CONF_ENTRY_SYNC_ADJUST,
CONF_ENTRY_DISPLAY,
auto_play: bool = False,
) -> None:
"""Handle playback of an url on slimproto player(s)."""
- player_id = slimplayer.player_id
- if crossfade := await self.mass.config.get_player_config_value(player_id, CONF_CROSSFADE):
- transition_duration = await self.mass.config.get_player_config_value(
- player_id, CONF_CROSSFADE_DURATION
- )
- else:
- transition_duration = 0
-
metadata = {
"item_id": media.uri,
"title": media.title,
metadata=metadata,
enqueue=enqueue,
send_flush=send_flush,
- transition=SlimTransition.CROSSFADE if crossfade else SlimTransition.NONE,
- transition_duration=transition_duration,
# if autoplay=False playback will not start automatically
# instead 'buffer ready' will be called when the buffer is full
# to coordinate a start of multiple synced players
metadata=metadata,
enqueue=True,
send_flush=False,
- transition=SlimTransition.CROSSFADE if crossfade else SlimTransition.NONE,
- transition_duration=transition_duration,
autostart=True,
),
)
PlayerFeature.PAUSE,
PlayerFeature.VOLUME_MUTE,
PlayerFeature.ENQUEUE,
+ PlayerFeature.GAPLESS_PLAYBACK,
},
can_group_with={self.instance_id},
)