Compare commits
1 Commits
6240179397
...
push-lxpmr
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f5fa69e1a |
@@ -78,7 +78,7 @@ LABEL org.opencontainers.image.title="PMOMusic UPNP Music Server"
|
||||
LABEL org.opencontainers.image.version="0.1"
|
||||
LABEL org.opencontainers.image.authors="eric@coissac.eu"
|
||||
LABEL org.opencontainers.image.licenses="CeCILL-2.0"
|
||||
LABEL org.opencontainers.image.url="https://gargoton.petite-maison-orange.fr/eric/pmomusic"
|
||||
LABEL org.opencontainers.image.source="https://gargoton.petite-maison-orange.fr/eric/pmomusic"
|
||||
LABEL org.opencontainers.image.description="PMOMusic est une application Rust proposant en un binaire unique une Serveur de Musique, et une point de controle UPNP controlable via une application web."
|
||||
|
||||
# Install only runtime dependencies
|
||||
|
||||
@@ -6,10 +6,19 @@ defineProps<{
|
||||
item: QueueItem
|
||||
isCurrent: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [item: QueueItem]
|
||||
}>()
|
||||
|
||||
function handleClick(item: QueueItem) {
|
||||
console.log('[QueueItem] Click detected on item:', item.index, item.title)
|
||||
emit('click', item)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['queue-item', { current: isCurrent }]">
|
||||
<div :class="['queue-item', { current: isCurrent }]" @click="handleClick(item)">
|
||||
<!-- Indicateur piste en cours -->
|
||||
<div class="current-indicator" v-if="isCurrent">
|
||||
<Play :size="16" fill="currentColor" />
|
||||
@@ -51,6 +60,7 @@ defineProps<{
|
||||
border-radius: var(--radius-md);
|
||||
transition: background-color var(--transition-fast);
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.queue-item:hover {
|
||||
|
||||
@@ -3,17 +3,26 @@ import { computed, ref, watch, nextTick, toRef } from 'vue'
|
||||
import { useRenderer } from '@/composables/useRenderers'
|
||||
import QueueItem from './QueueItem.vue'
|
||||
import { Link } from 'lucide-vue-next'
|
||||
import type { QueueItem as QueueItemType } from '@/services/pmocontrol/types'
|
||||
|
||||
const props = defineProps<{
|
||||
rendererId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
clickItem: [item: QueueItemType]
|
||||
}>()
|
||||
|
||||
const { queue, binding } = useRenderer(toRef(props, 'rendererId'))
|
||||
|
||||
const isAttached = computed(() => !!binding.value)
|
||||
|
||||
const queueContainer = ref<HTMLElement | null>(null)
|
||||
|
||||
function handleItemClick(item: QueueItemType) {
|
||||
emit('clickItem', item)
|
||||
}
|
||||
|
||||
// Auto-scroll vers la piste courante lors de l'ouverture
|
||||
watch(() => queue.value?.current_index, async (currentIndex) => {
|
||||
if (currentIndex !== null && currentIndex !== undefined && queueContainer.value) {
|
||||
@@ -53,6 +62,7 @@ watch(() => queue.value?.current_index, async (currentIndex) => {
|
||||
:key="item.index"
|
||||
:item="item"
|
||||
:is-current="item.index === queue.current_index"
|
||||
@click="handleItemClick"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import StatusBadge from '@/components/pmocontrol/StatusBadge.vue'
|
||||
import { ChevronUp, ChevronDown, Link } from 'lucide-vue-next'
|
||||
import { useRenderers } from '@/composables/useRenderers'
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
import { api } from '@/services/pmocontrol/api'
|
||||
import type { QueueItem } from '@/services/pmocontrol/types'
|
||||
|
||||
const props = defineProps<{
|
||||
rendererId: string
|
||||
@@ -48,6 +50,17 @@ async function handleDetachPlaylist() {
|
||||
uiStore.notifyError(`Erreur: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Gérer le clic sur un item de la queue
|
||||
async function handleQueueItemClick(item: QueueItem) {
|
||||
try {
|
||||
await api.seekQueueIndex(props.rendererId, item.index)
|
||||
console.log('[RendererTabContent] Jumped to queue index:', item.index, item.title)
|
||||
} catch (error) {
|
||||
console.error('[RendererTabContent] Error seeking to queue index:', error)
|
||||
uiStore.notifyError(`Erreur: ${error instanceof Error ? error.message : 'Impossible de sauter à cet item'}`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -100,7 +113,7 @@ async function handleDetachPlaylist() {
|
||||
|
||||
<!-- Colonne droite: Queue (desktop et landscape uniquement) -->
|
||||
<div v-if="!isMobilePortrait" class="queue-column">
|
||||
<QueueViewer :renderer-id="rendererId" class="queue-viewer" />
|
||||
<QueueViewer :renderer-id="rendererId" class="queue-viewer" @click-item="handleQueueItemClick" />
|
||||
</div>
|
||||
|
||||
<!-- Drawer queue (mobile portrait uniquement) -->
|
||||
@@ -114,7 +127,7 @@ async function handleDetachPlaylist() {
|
||||
|
||||
<!-- Contenu du drawer -->
|
||||
<div class="queue-drawer-content">
|
||||
<QueueViewer :renderer-id="rendererId" />
|
||||
<QueueViewer :renderer-id="rendererId" @click-item="handleQueueItemClick" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -147,6 +147,17 @@ class PMOControlAPI {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Saute à un index spécifique dans la queue
|
||||
* POST /api/control/renderers/{id}/queue/seek
|
||||
*/
|
||||
async seekQueueIndex(id: string, index: number): Promise<SuccessResponse> {
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/queue/seek`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ index }),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CONTRÔLE VOLUME
|
||||
// ============================================================================
|
||||
|
||||
@@ -1570,6 +1570,118 @@ impl ControlPoint {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Jumps to a specific index in the queue and starts playback.
|
||||
///
|
||||
/// For OpenHome renderers, this uses the playlist's native SeekId capability.
|
||||
/// For internal queues, this updates the current index and starts playback.
|
||||
pub fn play_queue_index(&self, renderer_id: &RendererId, index: usize) -> anyhow::Result<()> {
|
||||
if !self.runtime.has_entry(renderer_id) {
|
||||
let err = Self::runtime_entry_missing(renderer_id);
|
||||
warn!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
"Cannot jump to index: renderer not registered in runtime"
|
||||
);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
// For OpenHome, use select_track_index which handles the track_id lookup
|
||||
if self.runtime.uses_openhome_playlist(renderer_id) {
|
||||
info!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
index,
|
||||
"Seeking to OpenHome queue index"
|
||||
);
|
||||
|
||||
// Use the queue to select the track by index
|
||||
self.runtime.with_music_queue_mut(renderer_id, |queue| {
|
||||
if let crate::control_point::music_queue::MusicQueue::OpenHome(oh_queue) = queue {
|
||||
oh_queue.select_track_index(index)
|
||||
} else {
|
||||
Err(anyhow!("Expected OpenHome queue"))
|
||||
}
|
||||
})?;
|
||||
|
||||
info!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
index,
|
||||
"Successfully seeked to OpenHome queue index"
|
||||
);
|
||||
|
||||
self.runtime
|
||||
.set_playback_source(renderer_id, PlaybackSource::FromQueue);
|
||||
return self.sync_openhome_playlist_for(renderer_id);
|
||||
}
|
||||
|
||||
// For internal queue, set the index and play
|
||||
self.runtime.with_music_queue_mut(renderer_id, |queue| {
|
||||
queue.set_index(Some(index))
|
||||
})?;
|
||||
|
||||
let Some((item, remaining)) = self.runtime.peek_current(renderer_id) else {
|
||||
debug!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
index,
|
||||
"play_queue_index: no item at index"
|
||||
);
|
||||
self.runtime
|
||||
.set_playback_source(renderer_id, PlaybackSource::None);
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
debug!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
index,
|
||||
queue_len = remaining + 1,
|
||||
uri = item.uri.as_str(),
|
||||
"Playing item at index from queue"
|
||||
);
|
||||
|
||||
let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| {
|
||||
warn!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
"Renderer disappeared before queue playback could start"
|
||||
);
|
||||
anyhow!("Renderer {} not found", renderer_id.0)
|
||||
})?;
|
||||
|
||||
let playback = (|| -> anyhow::Result<()> {
|
||||
let didl_metadata = playback_item_to_didl(&item);
|
||||
renderer.play_uri(&item.uri, &didl_metadata)?;
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
match playback {
|
||||
Ok(()) => {
|
||||
info!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
index,
|
||||
uri = item.uri.as_str(),
|
||||
"Queue playback started at index"
|
||||
);
|
||||
|
||||
let metadata = playback_item_track_metadata(&item);
|
||||
self.runtime.update_snapshot_with(renderer_id, |snapshot| {
|
||||
snapshot.last_metadata = Some(metadata);
|
||||
});
|
||||
|
||||
self.runtime
|
||||
.set_playback_source(renderer_id, PlaybackSource::FromQueue);
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => {
|
||||
error!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
index,
|
||||
error = %err,
|
||||
"Failed to start playback for queued item at index"
|
||||
);
|
||||
self.runtime
|
||||
.set_playback_source(renderer_id, PlaybackSource::None);
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn start_queue_playback_if_idle(&self, renderer_id: &RendererId) -> anyhow::Result<()> {
|
||||
let snapshot = self.runtime.snapshot_for(renderer_id);
|
||||
let renderer_playing = matches!(snapshot.state, Some(PlaybackState::Playing));
|
||||
|
||||
@@ -58,11 +58,9 @@ impl OpenHomeQueue {
|
||||
track_ids.push(entry.id);
|
||||
}
|
||||
|
||||
// Try multiple methods to determine the currently playing track, from most to least reliable:
|
||||
// 1. Info.Id() - Direct ID query (fastest, but fails if track no longer in playlist)
|
||||
// 2. Info.Track() - Returns URI, which we can search for (works even if track removed)
|
||||
// 3. None - No current track can be determined
|
||||
let current_id = self.playlist.id()?;
|
||||
// Try to get the currently playing track ID from the renderer
|
||||
// If this fails (renderer doesn't support it or no track playing), we'll just set current_index to None
|
||||
let current_id = self.playlist.id().ok();
|
||||
// {
|
||||
// // Try Info.Id() first
|
||||
// if let Ok(id) = client.id() {
|
||||
@@ -105,9 +103,13 @@ impl OpenHomeQueue {
|
||||
// let current_index = current_id
|
||||
// .and_then(|id| track_ids.iter().position(|entry_id| *entry_id == id));
|
||||
|
||||
// Convert track ID to array index by searching in track_ids
|
||||
let current_index = current_id
|
||||
.and_then(|id| track_ids.iter().position(|&entry_id| entry_id == id));
|
||||
|
||||
self.items = items;
|
||||
self.track_ids = track_ids;
|
||||
self.current_index = Some(current_id as usize);
|
||||
self.current_index = current_index;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -163,6 +165,20 @@ impl OpenHomeQueue {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Selects and plays a track by its queue index (0-based).
|
||||
pub fn select_track_index(&mut self, index: usize) -> Result<()> {
|
||||
let track_id = self
|
||||
.track_ids
|
||||
.get(index)
|
||||
.copied()
|
||||
.ok_or_else(|| anyhow!("Index {} out of bounds (queue length: {})", index, self.track_ids.len()))?;
|
||||
|
||||
self.ensure_playlist_source_selected()?;
|
||||
self.playlist.play_id(track_id)?;
|
||||
self.current_index = Some(index);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) -> Result<()> {
|
||||
self.ensure_playlist_source_selected()?;
|
||||
self.playlist.delete_all()?;
|
||||
|
||||
@@ -280,6 +280,14 @@ pub struct PlayContentRequest {
|
||||
pub object_id: String,
|
||||
}
|
||||
|
||||
/// Requête pour sauter à un index spécifique dans la queue
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[derive(Debug, Clone, Deserialize, ToSchema)]
|
||||
pub struct SeekQueueRequest {
|
||||
/// Index de l'item dans la queue (0-based)
|
||||
pub index: usize,
|
||||
}
|
||||
|
||||
/// Réponse générique de succès
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
|
||||
@@ -16,7 +16,8 @@ use crate::openapi::{
|
||||
AttachPlaylistRequest, AttachedPlaylistInfo, BrowseResponse, ContainerEntry, ErrorResponse,
|
||||
FullRendererSnapshot, MediaServerSummary, OpenHomePlaylistAddRequest, OpenHomePlaylistSnapshot,
|
||||
PlayContentRequest, QueueItem, QueueSnapshot, RendererCapabilitiesSummary,
|
||||
RendererProtocolSummary, RendererState, RendererSummary, SuccessResponse, VolumeSetRequest,
|
||||
RendererProtocolSummary, RendererState, RendererSummary, SeekQueueRequest, SuccessResponse,
|
||||
VolumeSetRequest,
|
||||
};
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use crate::queue_backend::PlaybackItem;
|
||||
@@ -621,6 +622,89 @@ async fn next_renderer(
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /control/renderers/{renderer_id}/queue/seek - Saute à un index spécifique dans la queue
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/renderers/{renderer_id}/queue/seek",
|
||||
tag = "control",
|
||||
request_body = SeekQueueRequest,
|
||||
responses(
|
||||
(status = 200, description = "Lecture démarrée à l'index spécifié", body = SuccessResponse),
|
||||
(status = 404, description = "Renderer non trouvé", body = ErrorResponse),
|
||||
(status = 400, description = "Index invalide", body = ErrorResponse),
|
||||
(status = 500, description = "Erreur interne", body = ErrorResponse),
|
||||
)
|
||||
)]
|
||||
async fn seek_queue_index(
|
||||
State(state): State<ControlPointState>,
|
||||
Path(renderer_id): Path<String>,
|
||||
Json(payload): Json<SeekQueueRequest>,
|
||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let rid = RendererId(renderer_id.clone());
|
||||
state
|
||||
.control_point
|
||||
.music_renderer_by_id(&rid)
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Renderer {} not found", renderer_id),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let control_point = Arc::clone(&state.control_point);
|
||||
let rid_for_task = rid.clone();
|
||||
let index = payload.index;
|
||||
let seek_task = tokio::task::spawn_blocking(move || {
|
||||
control_point.play_queue_index(&rid_for_task, index)
|
||||
});
|
||||
|
||||
time::timeout(TRANSPORT_COMMAND_TIMEOUT, seek_task)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
warn!(
|
||||
"Seek queue command for renderer {} exceeded {:?}",
|
||||
renderer_id, TRANSPORT_COMMAND_TIMEOUT
|
||||
);
|
||||
(
|
||||
StatusCode::GATEWAY_TIMEOUT,
|
||||
Json(ErrorResponse {
|
||||
error: format!(
|
||||
"Seek command timed out after {}s",
|
||||
TRANSPORT_COMMAND_TIMEOUT.as_secs()
|
||||
),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
.map_err(|e| {
|
||||
warn!("Task join error during queue seek: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Internal task error: {}", e),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
.map_err(|e| {
|
||||
warn!(
|
||||
"Failed to seek to index {} for renderer {}: {}",
|
||||
index, renderer_id, e
|
||||
);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Failed to seek to index {}: {}", index, e),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Json(SuccessResponse {
|
||||
message: format!("Playing item at index {}", index),
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /control/renderers/{renderer_id}/volume/set - Définit le volume
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[utoipa::path(
|
||||
@@ -1939,6 +2023,8 @@ pub fn create_api_router(state: ControlPointState, control_point: Arc<ControlPoi
|
||||
.route("/renderers/{renderer_id}/stop", post(stop_renderer))
|
||||
.route("/renderers/{renderer_id}/resume", post(resume_renderer))
|
||||
.route("/renderers/{renderer_id}/next", post(next_renderer))
|
||||
// Queue control
|
||||
.route("/renderers/{renderer_id}/queue/seek", post(seek_queue_index))
|
||||
// Volume control
|
||||
.route(
|
||||
"/renderers/{renderer_id}/volume/set",
|
||||
|
||||
Reference in New Issue
Block a user