Fix playback state handling and metadata caching
This commit addresses several issues related to playback state reporting and metadata handling: 1. In the backend (pmocontrol), the music renderer now correctly uses raw playback states instead of computed logical states for event emission, and fixes stream time calculation to only occur when the device is actually playing. 2. The frontend (CurrentTrack.vue) now logs additional debug information for duration and transport state to help trace playback issues. 3. The SSE event handling in useRenderers.ts has been enhanced with more detailed logging and uses deep copying to prevent shared state issues between renderers. 4. The OpenHome queue implementation now uses a Mutex for metadata caching and implements logic to prevent duration from decreasing for the same track, which helps stabilize metadata for streaming content. These changes improve the reliability and accuracy of playback state and metadata display across all components.
This commit is contained in:
@@ -100,7 +100,14 @@ function formatTime(ms: number | null | undefined): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentTime = computed(() => formatTime(state.value?.position_ms));
|
const currentTime = computed(() => formatTime(state.value?.position_ms));
|
||||||
const totalTime = computed(() => formatTime(state.value?.duration_ms));
|
const totalTime = computed(() => {
|
||||||
|
const duration = state.value?.duration_ms;
|
||||||
|
const transport = state.value?.transport_state;
|
||||||
|
console.log(
|
||||||
|
`[CurrentTrack] rendererId=${props.rendererId}, duration_ms=${duration}, transport=${transport}, title=${state.value?.current_track?.title}`,
|
||||||
|
);
|
||||||
|
return formatTime(duration);
|
||||||
|
});
|
||||||
|
|
||||||
const hasCover = computed(
|
const hasCover = computed(
|
||||||
() => !!metadata.value?.album_art_uri && !imageError.value,
|
() => !!metadata.value?.album_art_uri && !imageError.value,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* - Les snapshots complets proviennent de /renderers/{id}/full
|
* - Les snapshots complets proviennent de /renderers/{id}/full
|
||||||
* - Les événements SSE ne servent qu'à déclencher un refetch.
|
* - Les événements SSE ne servent qu'à déclencher un refetch.
|
||||||
*/
|
*/
|
||||||
import { ref, reactive, computed, type Ref } from "vue";
|
import { ref, reactive, computed, toRaw, type Ref } from "vue";
|
||||||
import { api } from "../services/pmocontrol/api";
|
import { api } from "../services/pmocontrol/api";
|
||||||
import { sse } from "../services/pmocontrol/sse";
|
import { sse } from "../services/pmocontrol/sse";
|
||||||
import type {
|
import type {
|
||||||
@@ -109,6 +109,9 @@ function ensureSSEConnected() {
|
|||||||
snapshotState.lastEventAt.set(rendererId, timestamp);
|
snapshotState.lastEventAt.set(rendererId, timestamp);
|
||||||
|
|
||||||
const snapshot = snapshotState.snapshots.get(rendererId);
|
const snapshot = snapshotState.snapshots.get(rendererId);
|
||||||
|
console.log(
|
||||||
|
`[SSE Event] type=${event.type}, rendererId=${rendererId}, snapshot exists=${!!snapshot}, transport=${snapshot?.state?.transport_state}`,
|
||||||
|
);
|
||||||
|
|
||||||
// Si pas de snapshot, on doit fetch
|
// Si pas de snapshot, on doit fetch
|
||||||
if (!snapshot) {
|
if (!snapshot) {
|
||||||
@@ -123,6 +126,11 @@ function ensureSSEConnected() {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "position_changed":
|
case "position_changed":
|
||||||
|
// Debug: log pour tracer les oscillations
|
||||||
|
console.log(
|
||||||
|
`[position_changed] renderer=${rendererId}, duration=${event.track_duration}`,
|
||||||
|
);
|
||||||
|
|
||||||
// Mettre à jour position et durée de manière atomique pour garantir la cohérence
|
// Mettre à jour position et durée de manière atomique pour garantir la cohérence
|
||||||
// Le backend envoie TOUJOURS les deux valeurs (même si null)
|
// Le backend envoie TOUJOURS les deux valeurs (même si null)
|
||||||
|
|
||||||
@@ -157,9 +165,22 @@ function ensureSSEConnected() {
|
|||||||
snapshot.state.duration_ms = null;
|
snapshot.state.duration_ms = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Important: Trigger reactivity en réassignant l'objet complet
|
// Important: Trigger reactivity en réassignant l'objet complet avec deep copy
|
||||||
// Cela garantit que position_ms et duration_ms sont mis à jour atomiquement
|
// Le shallow copy ne suffit pas car snapshot.state est partagé entre renderers
|
||||||
snapshotState.snapshots.set(rendererId, { ...snapshot });
|
// Il faut copier state aussi pour éviter que les modifications d'un renderer
|
||||||
|
// n'affectent les autres renderers
|
||||||
|
// IMPORTANT: Utiliser toRaw() pour obtenir l'objet brut non-réactif avant de copier
|
||||||
|
// sinon Vue copie les getters réactifs qui continuent à pointer vers l'objet d'origine
|
||||||
|
const rawState = toRaw(snapshot.state);
|
||||||
|
const newState = { ...rawState };
|
||||||
|
const newSnapshot = {
|
||||||
|
...snapshot,
|
||||||
|
state: newState,
|
||||||
|
};
|
||||||
|
console.log(
|
||||||
|
`[position_changed] Setting new snapshot for ${rendererId}, state ref=${Object.prototype.toString.call(newState)}, transport=${newState.transport_state}`,
|
||||||
|
);
|
||||||
|
snapshotState.snapshots.set(rendererId, newSnapshot);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "volume_changed":
|
case "volume_changed":
|
||||||
@@ -183,8 +204,11 @@ function ensureSSEConnected() {
|
|||||||
snapshot.state.current_track.artist = event.artist;
|
snapshot.state.current_track.artist = event.artist;
|
||||||
snapshot.state.current_track.album = event.album;
|
snapshot.state.current_track.album = event.album;
|
||||||
snapshot.state.current_track.album_art_uri = event.album_art_uri;
|
snapshot.state.current_track.album_art_uri = event.album_art_uri;
|
||||||
// Important: Trigger reactivity en réassignant l'objet complet
|
// Important: Trigger reactivity en réassignant l'objet complet avec deep copy
|
||||||
snapshotState.snapshots.set(rendererId, { ...snapshot });
|
snapshotState.snapshots.set(rendererId, {
|
||||||
|
...snapshot,
|
||||||
|
state: { ...snapshot.state },
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "queue_updated":
|
case "queue_updated":
|
||||||
|
|||||||
@@ -3,42 +3,45 @@
|
|||||||
* Onglets renderer auto-générés depuis la liste des renderers online.
|
* Onglets renderer auto-générés depuis la liste des renderers online.
|
||||||
* Onglets server ouverts manuellement via le drawer (fermables).
|
* Onglets server ouverts manuellement via le drawer (fermables).
|
||||||
*/
|
*/
|
||||||
import { reactive, computed, watch, onMounted, type Component } from 'vue'
|
import { reactive, computed, watch, onMounted, type Component } from "vue";
|
||||||
import { useMediaQuery } from '@vueuse/core'
|
import { useMediaQuery } from "@vueuse/core";
|
||||||
import { Radio, Server } from 'lucide-vue-next'
|
import { Radio, Server } from "lucide-vue-next";
|
||||||
import type { RendererSummary, MediaServerSummary } from '../services/pmocontrol/types'
|
import type {
|
||||||
|
RendererSummary,
|
||||||
|
MediaServerSummary,
|
||||||
|
} from "../services/pmocontrol/types";
|
||||||
|
|
||||||
export interface Tab {
|
export interface Tab {
|
||||||
id: string // "renderer-{id}", "server-{id}"
|
id: string; // "renderer-{id}", "server-{id}"
|
||||||
type: 'renderer' | 'server'
|
type: "renderer" | "server";
|
||||||
title: string // Nom affiché (tronqué sur mobile)
|
title: string; // Nom affiché (tronqué sur mobile)
|
||||||
fullTitle: string // Nom complet (pour tooltip)
|
fullTitle: string; // Nom complet (pour tooltip)
|
||||||
icon: Component
|
icon: Component;
|
||||||
metadata?: {
|
metadata?: {
|
||||||
rendererId?: string
|
rendererId?: string;
|
||||||
serverId?: string
|
serverId?: string;
|
||||||
}
|
};
|
||||||
closeable: boolean // renderer: false (auto-géré), server: true (manuel)
|
closeable: boolean; // renderer: false (auto-géré), server: true (manuel)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TabsState {
|
interface TabsState {
|
||||||
tabs: Tab[]
|
tabs: Tab[];
|
||||||
activeTabId: string
|
activeTabId: string;
|
||||||
tabHistory: string[] // Pour back/forward navigation
|
tabHistory: string[]; // Pour back/forward navigation
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_TABS = 12 // Augmenté car onglets auto-générés
|
const MAX_TABS = 12; // Augmenté car onglets auto-générés
|
||||||
const STORAGE_KEY = 'pmo-tabs-state'
|
const STORAGE_KEY = "pmo-tabs-state";
|
||||||
|
|
||||||
// État global partagé entre toutes les instances du composable
|
// État global partagé entre toutes les instances du composable
|
||||||
const state = reactive<TabsState>({
|
const state = reactive<TabsState>({
|
||||||
tabs: [],
|
tabs: [],
|
||||||
activeTabId: '',
|
activeTabId: "",
|
||||||
tabHistory: [],
|
tabHistory: [],
|
||||||
})
|
});
|
||||||
|
|
||||||
// Flag pour éviter les boucles de sauvegarde
|
// Flag pour éviter les boucles de sauvegarde
|
||||||
let isRestoringFromStorage = false
|
let isRestoringFromStorage = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retourne le titre complet sans troncature
|
* Retourne le titre complet sans troncature
|
||||||
@@ -46,7 +49,7 @@ let isRestoringFromStorage = false
|
|||||||
*/
|
*/
|
||||||
function truncateTitle(title: string): string {
|
function truncateTitle(title: string): string {
|
||||||
// Retourner le titre complet, le CSS gère l'ellipsis de façon stable
|
// Retourner le titre complet, le CSS gère l'ellipsis de façon stable
|
||||||
return title
|
return title;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,13 +57,13 @@ function truncateTitle(title: string): string {
|
|||||||
* Note: On ne sauvegarde que les onglets server (les renderer tabs sont auto-générés)
|
* Note: On ne sauvegarde que les onglets server (les renderer tabs sont auto-générés)
|
||||||
*/
|
*/
|
||||||
function saveToLocalStorage() {
|
function saveToLocalStorage() {
|
||||||
if (isRestoringFromStorage) return
|
if (isRestoringFromStorage) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stateToSave = {
|
const stateToSave = {
|
||||||
// Sauvegarder uniquement les onglets server (fermables manuellement)
|
// Sauvegarder uniquement les onglets server (fermables manuellement)
|
||||||
tabs: state.tabs
|
tabs: state.tabs
|
||||||
.filter((tab) => tab.type === 'server')
|
.filter((tab) => tab.type === "server")
|
||||||
.map((tab) => ({
|
.map((tab) => ({
|
||||||
...tab,
|
...tab,
|
||||||
// On ne peut pas sauvegarder les composants Vue, on sauve juste le type
|
// On ne peut pas sauvegarder les composants Vue, on sauve juste le type
|
||||||
@@ -68,10 +71,10 @@ function saveToLocalStorage() {
|
|||||||
})),
|
})),
|
||||||
activeTabId: state.activeTabId,
|
activeTabId: state.activeTabId,
|
||||||
tabHistory: state.tabHistory,
|
tabHistory: state.tabHistory,
|
||||||
}
|
};
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave))
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[useTabs] Erreur sauvegarde localStorage:', error)
|
console.error("[useTabs] Erreur sauvegarde localStorage:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,37 +84,37 @@ function saveToLocalStorage() {
|
|||||||
*/
|
*/
|
||||||
function restoreFromLocalStorage() {
|
function restoreFromLocalStorage() {
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem(STORAGE_KEY)
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
if (!saved) return
|
if (!saved) return;
|
||||||
|
|
||||||
isRestoringFromStorage = true
|
isRestoringFromStorage = true;
|
||||||
const savedState = JSON.parse(saved)
|
const savedState = JSON.parse(saved);
|
||||||
|
|
||||||
// Reconstituer uniquement les tabs server avec les bonnes icônes
|
// Reconstituer uniquement les tabs server avec les bonnes icônes
|
||||||
const serverTabs = (savedState.tabs || [])
|
const serverTabs = (savedState.tabs || [])
|
||||||
.filter((tab: Tab) => tab.type === 'server')
|
.filter((tab: Tab) => tab.type === "server")
|
||||||
.map((tab: Tab) => ({
|
.map((tab: Tab) => ({
|
||||||
...tab,
|
...tab,
|
||||||
icon: Server,
|
icon: Server,
|
||||||
closeable: true,
|
closeable: true,
|
||||||
fullTitle: tab.fullTitle || tab.title, // Fallback si fullTitle n'existe pas
|
fullTitle: tab.fullTitle || tab.title, // Fallback si fullTitle n'existe pas
|
||||||
}))
|
}));
|
||||||
|
|
||||||
// Ajouter les tabs server restaurés (les renderer tabs seront ajoutés par syncWithRenderers)
|
// Ajouter les tabs server restaurés (les renderer tabs seront ajoutés par syncWithRenderers)
|
||||||
state.tabs.push(...serverTabs)
|
state.tabs.push(...serverTabs);
|
||||||
|
|
||||||
state.activeTabId = savedState.activeTabId || ''
|
state.activeTabId = savedState.activeTabId || "";
|
||||||
state.tabHistory = savedState.tabHistory || []
|
state.tabHistory = savedState.tabHistory || [];
|
||||||
|
|
||||||
// Vérifier que l'onglet actif existe toujours (sera validé après syncWithRenderers)
|
// Vérifier que l'onglet actif existe toujours (sera validé après syncWithRenderers)
|
||||||
if (!state.tabs.find((t) => t.id === state.activeTabId)) {
|
if (!state.tabs.find((t) => t.id === state.activeTabId)) {
|
||||||
state.activeTabId = ''
|
state.activeTabId = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
isRestoringFromStorage = false
|
isRestoringFromStorage = false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[useTabs] Erreur restauration localStorage:', error)
|
console.error("[useTabs] Erreur restauration localStorage:", error);
|
||||||
isRestoringFromStorage = false
|
isRestoringFromStorage = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,31 +122,33 @@ function restoreFromLocalStorage() {
|
|||||||
* Trouve un onglet par son ID
|
* Trouve un onglet par son ID
|
||||||
*/
|
*/
|
||||||
function findTab(tabId: string): Tab | undefined {
|
function findTab(tabId: string): Tab | undefined {
|
||||||
return state.tabs.find((t) => t.id === tabId)
|
return state.tabs.find((t) => t.id === tabId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ouvre un nouvel onglet ou active un onglet existant
|
* Ouvre un nouvel onglet ou active un onglet existant
|
||||||
*/
|
*/
|
||||||
function openTab(newTab: Omit<Tab, 'id' | 'fullTitle'> & { id?: string; fullTitle?: string }): string {
|
function openTab(
|
||||||
|
newTab: Omit<Tab, "id" | "fullTitle"> & { id?: string; fullTitle?: string },
|
||||||
|
): string {
|
||||||
// Générer un ID si non fourni
|
// Générer un ID si non fourni
|
||||||
const tabId =
|
const tabId =
|
||||||
newTab.id ||
|
newTab.id ||
|
||||||
(newTab.type === 'renderer'
|
(newTab.type === "renderer"
|
||||||
? `renderer-${newTab.metadata?.rendererId}`
|
? `renderer-${newTab.metadata?.rendererId}`
|
||||||
: `server-${newTab.metadata?.serverId}`)
|
: `server-${newTab.metadata?.serverId}`);
|
||||||
|
|
||||||
// Si l'onglet existe déjà, on le sélectionne
|
// Si l'onglet existe déjà, on le sélectionne
|
||||||
const existingTab = findTab(tabId)
|
const existingTab = findTab(tabId);
|
||||||
if (existingTab) {
|
if (existingTab) {
|
||||||
switchTab(tabId)
|
switchTab(tabId);
|
||||||
return tabId
|
return tabId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vérifier la limite max
|
// Vérifier la limite max
|
||||||
if (state.tabs.length >= MAX_TABS) {
|
if (state.tabs.length >= MAX_TABS) {
|
||||||
console.warn(`[useTabs] Limite max de ${MAX_TABS} onglets atteinte`)
|
console.warn(`[useTabs] Limite max de ${MAX_TABS} onglets atteinte`);
|
||||||
return state.activeTabId
|
return state.activeTabId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Créer le nouvel onglet
|
// Créer le nouvel onglet
|
||||||
@@ -155,12 +160,12 @@ function openTab(newTab: Omit<Tab, 'id' | 'fullTitle'> & { id?: string; fullTitl
|
|||||||
icon: newTab.icon,
|
icon: newTab.icon,
|
||||||
metadata: newTab.metadata,
|
metadata: newTab.metadata,
|
||||||
closeable: newTab.closeable !== false, // true par défaut sauf si explicitement false
|
closeable: newTab.closeable !== false, // true par défaut sauf si explicitement false
|
||||||
}
|
};
|
||||||
|
|
||||||
state.tabs.push(tab)
|
state.tabs.push(tab);
|
||||||
switchTab(tabId)
|
switchTab(tabId);
|
||||||
|
|
||||||
return tabId
|
return tabId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -168,24 +173,26 @@ function openTab(newTab: Omit<Tab, 'id' | 'fullTitle'> & { id?: string; fullTitl
|
|||||||
* Les onglets renderer sont auto-gérés et ne peuvent pas être fermés manuellement
|
* Les onglets renderer sont auto-gérés et ne peuvent pas être fermés manuellement
|
||||||
*/
|
*/
|
||||||
function closeTab(tabId: string) {
|
function closeTab(tabId: string) {
|
||||||
const tab = findTab(tabId)
|
const tab = findTab(tabId);
|
||||||
if (!tab) return
|
if (!tab) return;
|
||||||
|
|
||||||
// Ne fermer que les onglets server (closeable = true)
|
// Ne fermer que les onglets server (closeable = true)
|
||||||
// Les renderer tabs sont auto-gérés par syncWithRenderers
|
// Les renderer tabs sont auto-gérés par syncWithRenderers
|
||||||
if (!tab.closeable || tab.type === 'renderer') {
|
if (!tab.closeable || tab.type === "renderer") {
|
||||||
console.warn('[useTabs] Impossible de fermer un onglet renderer (auto-géré)')
|
console.warn(
|
||||||
return
|
"[useTabs] Impossible de fermer un onglet renderer (auto-géré)",
|
||||||
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabIndex = state.tabs.findIndex((t) => t.id === tabId)
|
const tabIndex = state.tabs.findIndex((t) => t.id === tabId);
|
||||||
if (tabIndex === -1) return
|
if (tabIndex === -1) return;
|
||||||
|
|
||||||
// Supprimer l'onglet
|
// Supprimer l'onglet
|
||||||
state.tabs.splice(tabIndex, 1)
|
state.tabs.splice(tabIndex, 1);
|
||||||
|
|
||||||
// Supprimer de l'historique
|
// Supprimer de l'historique
|
||||||
state.tabHistory = state.tabHistory.filter((id) => id !== tabId)
|
state.tabHistory = state.tabHistory.filter((id) => id !== tabId);
|
||||||
|
|
||||||
// Si c'était l'onglet actif, basculer vers le précédent dans l'historique
|
// Si c'était l'onglet actif, basculer vers le précédent dans l'historique
|
||||||
if (state.activeTabId === tabId) {
|
if (state.activeTabId === tabId) {
|
||||||
@@ -193,17 +200,17 @@ function closeTab(tabId: string) {
|
|||||||
const previousTab = state.tabHistory
|
const previousTab = state.tabHistory
|
||||||
.slice()
|
.slice()
|
||||||
.reverse()
|
.reverse()
|
||||||
.find((id) => state.tabs.some((t) => t.id === id))
|
.find((id) => state.tabs.some((t) => t.id === id));
|
||||||
|
|
||||||
if (previousTab) {
|
if (previousTab) {
|
||||||
switchTab(previousTab)
|
switchTab(previousTab);
|
||||||
} else {
|
} else {
|
||||||
// Fallback sur le premier onglet disponible (ou vide)
|
// Fallback sur le premier onglet disponible (ou vide)
|
||||||
const firstTab = state.tabs[0]
|
const firstTab = state.tabs[0];
|
||||||
if (firstTab) {
|
if (firstTab) {
|
||||||
switchTab(firstTab.id)
|
switchTab(firstTab.id);
|
||||||
} else {
|
} else {
|
||||||
state.activeTabId = ''
|
state.activeTabId = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -213,21 +220,21 @@ function closeTab(tabId: string) {
|
|||||||
* Change l'onglet actif
|
* Change l'onglet actif
|
||||||
*/
|
*/
|
||||||
function switchTab(tabId: string) {
|
function switchTab(tabId: string) {
|
||||||
const tab = findTab(tabId)
|
const tab = findTab(tabId);
|
||||||
if (!tab) {
|
if (!tab) {
|
||||||
console.warn(`[useTabs] Onglet ${tabId} introuvable`)
|
console.warn(`[useTabs] Onglet ${tabId} introuvable`);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
state.activeTabId = tabId
|
state.activeTabId = tabId;
|
||||||
|
|
||||||
// Ajouter à l'historique (en évitant les doublons consécutifs)
|
// Ajouter à l'historique (en évitant les doublons consécutifs)
|
||||||
if (state.tabHistory[state.tabHistory.length - 1] !== tabId) {
|
if (state.tabHistory[state.tabHistory.length - 1] !== tabId) {
|
||||||
state.tabHistory.push(tabId)
|
state.tabHistory.push(tabId);
|
||||||
|
|
||||||
// Limiter la taille de l'historique
|
// Limiter la taille de l'historique
|
||||||
if (state.tabHistory.length > 20) {
|
if (state.tabHistory.length > 20) {
|
||||||
state.tabHistory.shift()
|
state.tabHistory.shift();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,20 +243,21 @@ function switchTab(tabId: string) {
|
|||||||
* Onglet suivant (pour swipe gesture)
|
* Onglet suivant (pour swipe gesture)
|
||||||
*/
|
*/
|
||||||
function nextTab() {
|
function nextTab() {
|
||||||
const currentIndex = state.tabs.findIndex((t) => t.id === state.activeTabId)
|
const currentIndex = state.tabs.findIndex((t) => t.id === state.activeTabId);
|
||||||
const nextIndex = (currentIndex + 1) % state.tabs.length
|
const nextIndex = (currentIndex + 1) % state.tabs.length;
|
||||||
const nextTabObj = state.tabs[nextIndex]
|
const nextTabObj = state.tabs[nextIndex];
|
||||||
if (nextTabObj) switchTab(nextTabObj.id)
|
if (nextTabObj) switchTab(nextTabObj.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Onglet précédent (pour swipe gesture)
|
* Onglet précédent (pour swipe gesture)
|
||||||
*/
|
*/
|
||||||
function previousTab() {
|
function previousTab() {
|
||||||
const currentIndex = state.tabs.findIndex((t) => t.id === state.activeTabId)
|
const currentIndex = state.tabs.findIndex((t) => t.id === state.activeTabId);
|
||||||
const previousIndex = currentIndex === 0 ? state.tabs.length - 1 : currentIndex - 1
|
const previousIndex =
|
||||||
const prevTabObj = state.tabs[previousIndex]
|
currentIndex === 0 ? state.tabs.length - 1 : currentIndex - 1;
|
||||||
if (prevTabObj) switchTab(prevTabObj.id)
|
const prevTabObj = state.tabs[previousIndex];
|
||||||
|
if (prevTabObj) switchTab(prevTabObj.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -257,56 +265,63 @@ function previousTab() {
|
|||||||
* Les renderer tabs sont automatiquement créés/supprimés selon l'état online
|
* Les renderer tabs sont automatiquement créés/supprimés selon l'état online
|
||||||
*/
|
*/
|
||||||
function syncWithRenderers(renderers: RendererSummary[]) {
|
function syncWithRenderers(renderers: RendererSummary[]) {
|
||||||
const onlineRenderers = renderers.filter((r) => r.online)
|
const onlineRenderers = renderers.filter((r) => r.online);
|
||||||
|
|
||||||
// Extraire les tabs renderer actuels
|
// Extraire les tabs renderer actuels
|
||||||
const currentRendererTabs = state.tabs.filter((t) => t.type === 'renderer')
|
const currentRendererTabs = state.tabs.filter((t) => t.type === "renderer");
|
||||||
|
|
||||||
// IDs des renderers online
|
// IDs des renderers online
|
||||||
const onlineRendererIds = new Set(onlineRenderers.map((r) => `renderer-${r.id}`))
|
const onlineRendererIds = new Set(
|
||||||
|
onlineRenderers.map((r) => `renderer-${r.id}`),
|
||||||
|
);
|
||||||
|
|
||||||
// Supprimer les tabs des renderers qui ne sont plus online
|
// Supprimer les tabs des renderers qui ne sont plus online
|
||||||
const renderersToRemove = currentRendererTabs.filter((t) => !onlineRendererIds.has(t.id))
|
const renderersToRemove = currentRendererTabs.filter(
|
||||||
|
(t) => !onlineRendererIds.has(t.id),
|
||||||
|
);
|
||||||
renderersToRemove.forEach((tab) => {
|
renderersToRemove.forEach((tab) => {
|
||||||
const index = state.tabs.findIndex((t) => t.id === tab.id)
|
const index = state.tabs.findIndex((t) => t.id === tab.id);
|
||||||
if (index !== -1) state.tabs.splice(index, 1)
|
if (index !== -1) state.tabs.splice(index, 1);
|
||||||
state.tabHistory = state.tabHistory.filter((id) => id !== tab.id)
|
state.tabHistory = state.tabHistory.filter((id) => id !== tab.id);
|
||||||
})
|
});
|
||||||
|
|
||||||
// Ajouter les nouveaux renderers online
|
// Ajouter les nouveaux renderers online
|
||||||
const currentRendererIds = new Set(currentRendererTabs.map((t) => t.id))
|
const currentRendererIds = new Set(currentRendererTabs.map((t) => t.id));
|
||||||
onlineRenderers.forEach((renderer) => {
|
onlineRenderers.forEach((renderer) => {
|
||||||
const tabId = `renderer-${renderer.id}`
|
const tabId = `renderer-${renderer.id}`;
|
||||||
if (!currentRendererIds.has(tabId)) {
|
if (!currentRendererIds.has(tabId)) {
|
||||||
const newTab: Tab = {
|
const newTab: Tab = {
|
||||||
id: tabId,
|
id: tabId,
|
||||||
type: 'renderer',
|
type: "renderer",
|
||||||
title: truncateTitle(renderer.friendly_name),
|
title: truncateTitle(renderer.friendly_name),
|
||||||
fullTitle: renderer.friendly_name,
|
fullTitle: renderer.friendly_name,
|
||||||
icon: Radio,
|
icon: Radio,
|
||||||
metadata: { rendererId: renderer.id },
|
metadata: { rendererId: renderer.id },
|
||||||
closeable: false, // renderer tabs ne sont pas fermables manuellement
|
closeable: false, // renderer tabs ne sont pas fermables manuellement
|
||||||
}
|
};
|
||||||
state.tabs.unshift(newTab) // Ajouter au début
|
state.tabs.unshift(newTab); // Ajouter au début
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
// Vérifier que l'onglet actif existe toujours
|
// Vérifier que l'onglet actif existe toujours
|
||||||
if (state.activeTabId && !state.tabs.find((t) => t.id === state.activeTabId)) {
|
if (
|
||||||
|
state.activeTabId &&
|
||||||
|
!state.tabs.find((t) => t.id === state.activeTabId)
|
||||||
|
) {
|
||||||
// Basculer vers le premier onglet disponible
|
// Basculer vers le premier onglet disponible
|
||||||
const firstTab = state.tabs[0]
|
const firstTab = state.tabs[0];
|
||||||
if (firstTab) {
|
if (firstTab) {
|
||||||
state.activeTabId = firstTab.id
|
state.activeTabId = firstTab.id;
|
||||||
} else {
|
} else {
|
||||||
state.activeTabId = ''
|
state.activeTabId = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si aucun onglet actif et qu'il y a des onglets, sélectionner le premier
|
// Si aucun onglet actif et qu'il y a des onglets, sélectionner le premier
|
||||||
if (!state.activeTabId && state.tabs.length > 0) {
|
if (!state.activeTabId && state.tabs.length > 0) {
|
||||||
const firstTab = state.tabs[0]
|
const firstTab = state.tabs[0];
|
||||||
if (firstTab) {
|
if (firstTab) {
|
||||||
state.activeTabId = firstTab.id
|
state.activeTabId = firstTab.id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -315,15 +330,15 @@ function syncWithRenderers(renderers: RendererSummary[]) {
|
|||||||
* Ouvre un onglet server (manuel)
|
* Ouvre un onglet server (manuel)
|
||||||
*/
|
*/
|
||||||
function openServer(server: MediaServerSummary | undefined) {
|
function openServer(server: MediaServerSummary | undefined) {
|
||||||
if (!server) return ''
|
if (!server) return "";
|
||||||
|
|
||||||
return openTab({
|
return openTab({
|
||||||
type: 'server',
|
type: "server",
|
||||||
title: server.friendly_name,
|
title: server.friendly_name,
|
||||||
icon: Server,
|
icon: Server,
|
||||||
metadata: { serverId: server.id },
|
metadata: { serverId: server.id },
|
||||||
closeable: true,
|
closeable: true,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -334,29 +349,45 @@ export function useTabs() {
|
|||||||
watch(
|
watch(
|
||||||
() => [state.tabs, state.activeTabId, state.tabHistory],
|
() => [state.tabs, state.activeTabId, state.tabHistory],
|
||||||
() => {
|
() => {
|
||||||
saveToLocalStorage()
|
saveToLocalStorage();
|
||||||
},
|
},
|
||||||
{ deep: true },
|
{ deep: true },
|
||||||
)
|
);
|
||||||
|
|
||||||
// Restaurer au montage (uniquement les server tabs)
|
// Restaurer au montage (uniquement les server tabs)
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// Restaurer seulement si pas déjà fait
|
// Restaurer seulement si pas déjà fait
|
||||||
if (state.tabs.filter((t) => t.type === 'server').length === 0) {
|
if (state.tabs.filter((t) => t.type === "server").length === 0) {
|
||||||
restoreFromLocalStorage()
|
restoreFromLocalStorage();
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
// Détection de la largeur d'écran pour le mode compact
|
// Détection de la largeur d'écran pour le mode compact
|
||||||
// Mode compact sur les écrans < 900px (tablettes et mobiles)
|
// Mode compact sur les écrans < 900px (tablettes et mobiles)
|
||||||
const isNarrowScreen = useMediaQuery('(max-width: 900px)')
|
const isNarrowScreen = useMediaQuery("(max-width: 900px)");
|
||||||
|
|
||||||
|
// Watch activeTabId changes for debugging
|
||||||
|
watch(
|
||||||
|
() => state.activeTabId,
|
||||||
|
(newId, oldId) => {
|
||||||
|
if (newId !== oldId) {
|
||||||
|
const newTab = findTab(newId);
|
||||||
|
const oldTab = findTab(oldId);
|
||||||
|
console.log(
|
||||||
|
`[useTabs] activeTabId changed from ${oldId} (${oldTab?.fullTitle}) to ${newId} (${newTab?.fullTitle})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Computed properties
|
// Computed properties
|
||||||
const activeTab = computed(() => findTab(state.activeTabId) || state.tabs[0] || null)
|
const activeTab = computed(
|
||||||
const hasMultipleTabs = computed(() => state.tabs.length > 1)
|
() => findTab(state.activeTabId) || state.tabs[0] || null,
|
||||||
const canAddTab = computed(() => state.tabs.length < MAX_TABS)
|
);
|
||||||
const isEmpty = computed(() => state.tabs.length === 0)
|
const hasMultipleTabs = computed(() => state.tabs.length > 1);
|
||||||
const compactMode = computed(() => isNarrowScreen.value)
|
const canAddTab = computed(() => state.tabs.length < MAX_TABS);
|
||||||
|
const isEmpty = computed(() => state.tabs.length === 0);
|
||||||
|
const compactMode = computed(() => isNarrowScreen.value);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// State
|
// State
|
||||||
@@ -378,5 +409,5 @@ export function useTabs() {
|
|||||||
previousTab,
|
previousTab,
|
||||||
openServer,
|
openServer,
|
||||||
findTab,
|
findTab,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ use crate::music_renderer::openhome_renderer::OpenHomeRenderer;
|
|||||||
use crate::music_renderer::sleep_timer::SleepTimer;
|
use crate::music_renderer::sleep_timer::SleepTimer;
|
||||||
use crate::music_renderer::upnp_renderer::UpnpRenderer;
|
use crate::music_renderer::upnp_renderer::UpnpRenderer;
|
||||||
use crate::music_renderer::watcher::{
|
use crate::music_renderer::watcher::{
|
||||||
WatchStrategy, WatchedState, compute_logical_playback_state, extract_track_metadata,
|
WatchStrategy, WatchedState, extract_track_metadata, playback_position_equal,
|
||||||
playback_position_equal, playback_state_equal,
|
playback_state_equal,
|
||||||
};
|
};
|
||||||
use crate::online::DeviceConnectionState;
|
use crate::online::DeviceConnectionState;
|
||||||
use crate::queue::{
|
use crate::queue::{
|
||||||
@@ -541,22 +541,18 @@ impl MusicRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Poll state every tick
|
// Poll state every tick
|
||||||
|
// Note: Device-specific bugs (like Arylic/LinkPlay reporting STOPPED while playing)
|
||||||
|
// should be corrected in the backend's playback_state() method, not here.
|
||||||
if let Some(raw_state) = raw_state {
|
if let Some(raw_state) = raw_state {
|
||||||
let logical_state = compute_logical_playback_state(
|
|
||||||
&raw_state,
|
|
||||||
prev_position.as_ref(),
|
|
||||||
watched.position.as_ref(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let changed = watched
|
let changed = watched
|
||||||
.state
|
.state
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|prev| !playback_state_equal(prev, &logical_state))
|
.map(|prev| !playback_state_equal(prev, &raw_state))
|
||||||
.unwrap_or(true);
|
.unwrap_or(true);
|
||||||
|
|
||||||
// Emit event only for non-transient states to reduce noise
|
// Emit event only for non-transient states to reduce noise
|
||||||
if changed && !matches!(logical_state, PlaybackState::Transitioning) {
|
if changed && !matches!(raw_state, PlaybackState::Transitioning) {
|
||||||
let state_clone = logical_state.clone();
|
let state_clone = raw_state.clone();
|
||||||
drop(watched);
|
drop(watched);
|
||||||
self.emit_event(RendererEvent::StateChanged {
|
self.emit_event(RendererEvent::StateChanged {
|
||||||
id: self.id(),
|
id: self.id(),
|
||||||
@@ -572,7 +568,7 @@ impl MusicRenderer {
|
|||||||
.expect("WatchedState mutex poisoned");
|
.expect("WatchedState mutex poisoned");
|
||||||
}
|
}
|
||||||
|
|
||||||
watched.state = Some(logical_state);
|
watched.state = Some(raw_state);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle volume/mute updates (polled every other tick)
|
// Handle volume/mute updates (polled every other tick)
|
||||||
@@ -1014,22 +1010,28 @@ impl MusicRenderer {
|
|||||||
|
|
||||||
// Pour les flux continus uniquement : calculer rel_time depuis track_start_time
|
// Pour les flux continus uniquement : calculer rel_time depuis track_start_time
|
||||||
// Pour les fichiers normaux : garder les valeurs du backend
|
// Pour les fichiers normaux : garder les valeurs du backend
|
||||||
|
// IMPORTANT: Ne calculer le temps que si le lecteur est en PLAYING
|
||||||
let is_stream = self.is_playing_a_stream();
|
let is_stream = self.is_playing_a_stream();
|
||||||
if is_stream {
|
if is_stream {
|
||||||
if let Some(start_time) = self.track_start_time() {
|
// Vérifier que le lecteur est en lecture avant de calculer le temps écoulé
|
||||||
if let Ok(elapsed) = start_time.elapsed() {
|
let is_playing = matches!(self.playback_state().ok(), Some(PlaybackState::Playing));
|
||||||
let secs = elapsed.as_secs() as u32;
|
|
||||||
let hours = secs / 3600;
|
if is_playing {
|
||||||
let minutes = (secs % 3600) / 60;
|
if let Some(start_time) = self.track_start_time() {
|
||||||
let seconds = secs % 60;
|
if let Ok(elapsed) = start_time.elapsed() {
|
||||||
let new_rel_time = format!("{:02}:{:02}:{:02}", hours, minutes, seconds);
|
let secs = elapsed.as_secs() as u32;
|
||||||
tracing::debug!(
|
let hours = secs / 3600;
|
||||||
"MusicRenderer [{}]: Stream - calculating rel_time from track_start_time: {} (elapsed={}s)",
|
let minutes = (secs % 3600) / 60;
|
||||||
self.info.friendly_name(),
|
let seconds = secs % 60;
|
||||||
new_rel_time,
|
let new_rel_time = format!("{:02}:{:02}:{:02}", hours, minutes, seconds);
|
||||||
secs
|
tracing::debug!(
|
||||||
);
|
"MusicRenderer [{}]: Stream - calculating rel_time from track_start_time: {} (elapsed={}s)",
|
||||||
position_info.rel_time = Some(new_rel_time);
|
self.info.friendly_name(),
|
||||||
|
new_rel_time,
|
||||||
|
secs
|
||||||
|
);
|
||||||
|
position_info.rel_time = Some(new_rel_time);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,60 +117,6 @@ pub fn playback_position_equal(a: &PlaybackPositionInfo, b: &PlaybackPositionInf
|
|||||||
&& a.track_uri == b.track_uri
|
&& a.track_uri == b.track_uri
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compute a logical playback state by combining the raw AVTransport state
|
|
||||||
/// with previous and current position information.
|
|
||||||
///
|
|
||||||
/// This is designed to compensate for buggy LinkPlay/Arylic devices that
|
|
||||||
/// report:
|
|
||||||
/// - STOPPED while the time actually advances,
|
|
||||||
/// - NO_MEDIA_PRESENT while track duration is known.
|
|
||||||
pub fn compute_logical_playback_state(
|
|
||||||
raw: &PlaybackState,
|
|
||||||
prev_position: Option<&PlaybackPositionInfo>,
|
|
||||||
current_position: Option<&PlaybackPositionInfo>,
|
|
||||||
) -> PlaybackState {
|
|
||||||
// Rule 1: Arylic / LinkPlay sometimes report STOPPED while the stream is
|
|
||||||
// actually playing. If we detect that the relative time advances between
|
|
||||||
// two polls, we treat this as Playing.
|
|
||||||
if let PlaybackState::Stopped = raw {
|
|
||||||
if let (Some(prev), Some(curr)) = (prev_position, current_position) {
|
|
||||||
if let (Some(prev_rel), Some(curr_rel)) = (
|
|
||||||
parse_optional_hms_to_secs(&prev.rel_time),
|
|
||||||
parse_optional_hms_to_secs(&curr.rel_time),
|
|
||||||
) {
|
|
||||||
if curr_rel > prev_rel {
|
|
||||||
let delta = curr_rel - prev_rel;
|
|
||||||
// Our poll loop runs every 500ms; accept small jitter in the delta.
|
|
||||||
if delta <= 5 {
|
|
||||||
return PlaybackState::Playing;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rule 2: Some devices report NO_MEDIA_PRESENT while exposing a non-zero
|
|
||||||
// track duration. In practice this behaves like a stopped transport with
|
|
||||||
// a loaded track.
|
|
||||||
if let PlaybackState::NoMedia = raw {
|
|
||||||
let duration_secs = current_position
|
|
||||||
.and_then(|p| parse_optional_hms_to_secs(&p.track_duration))
|
|
||||||
.or_else(|| prev_position.and_then(|p| parse_optional_hms_to_secs(&p.track_duration)));
|
|
||||||
|
|
||||||
if matches!(duration_secs, Some(d) if d > 0) {
|
|
||||||
return PlaybackState::Stopped;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: keep the raw (already normalized) state.
|
|
||||||
raw.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse an optional HH:MM:SS time string to seconds.
|
|
||||||
fn parse_optional_hms_to_secs(value: &Option<String>) -> Option<u64> {
|
|
||||||
value.as_ref().and_then(|s| parse_hms_to_secs(s))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse "HH:MM:SS" style time strings to seconds.
|
/// Parse "HH:MM:SS" style time strings to seconds.
|
||||||
///
|
///
|
||||||
/// Returns None for empty or sentinel values such as "NOT_IMPLEMENTED" or "-:--:--".
|
/// Returns None for empty or sentinel values such as "NOT_IMPLEMENTED" or "-:--:--".
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ pub struct OpenHomeQueue {
|
|||||||
/// Cache des métadonnées par ID OpenHome.
|
/// Cache des métadonnées par ID OpenHome.
|
||||||
/// Permet de maintenir des métadonnées à jour même si le service OpenHome
|
/// Permet de maintenir des métadonnées à jour même si le service OpenHome
|
||||||
/// ne permet pas de les modifier directement.
|
/// ne permet pas de les modifier directement.
|
||||||
metadata_cache: HashMap<u32, Option<crate::model::TrackMetadata>>,
|
metadata_cache: Mutex<HashMap<u32, Option<crate::model::TrackMetadata>>>,
|
||||||
/// Cache for track IDs to avoid redundant IdArray SOAP calls
|
/// Cache for track IDs to avoid redundant IdArray SOAP calls
|
||||||
track_ids_cache: Arc<Mutex<TrackIdsCache>>,
|
track_ids_cache: Arc<Mutex<TrackIdsCache>>,
|
||||||
/// Cache for current track ID to avoid redundant Id SOAP calls
|
/// Cache for current track ID to avoid redundant Id SOAP calls
|
||||||
@@ -144,7 +144,7 @@ impl OpenHomeQueue {
|
|||||||
playlist_client: playlist,
|
playlist_client: playlist,
|
||||||
info_client,
|
info_client,
|
||||||
product_client,
|
product_client,
|
||||||
metadata_cache: HashMap::new(),
|
metadata_cache: Mutex::new(HashMap::new()),
|
||||||
track_ids_cache: Arc::new(Mutex::new(TrackIdsCache::new())),
|
track_ids_cache: Arc::new(Mutex::new(TrackIdsCache::new())),
|
||||||
current_track_id_cache: Arc::new(Mutex::new(CurrentTrackIdCache::new())),
|
current_track_id_cache: Arc::new(Mutex::new(CurrentTrackIdCache::new())),
|
||||||
}
|
}
|
||||||
@@ -190,17 +190,130 @@ impl OpenHomeQueue {
|
|||||||
metadata: Option<crate::model::TrackMetadata>,
|
metadata: Option<crate::model::TrackMetadata>,
|
||||||
) -> Result<(), ControlPointError> {
|
) -> Result<(), ControlPointError> {
|
||||||
let track_id = self.position_to_id(index)?;
|
let track_id = self.position_to_id(index)?;
|
||||||
self.metadata_cache.insert(track_id, metadata);
|
self.cache_metadata(track_id, metadata);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Insère ou met à jour les métadonnées dans le cache.
|
||||||
|
/// RÈGLE: Pour une même chanson (même titre ET même artiste), la durée ne peut jamais diminuer.
|
||||||
|
/// Si le titre ou l'artiste change, c'est une nouvelle chanson donc toute durée est acceptée.
|
||||||
|
/// Cette fonction est le SEUL point d'entrée pour modifier le cache.
|
||||||
|
fn cache_metadata(&self, track_id: u32, new_metadata: Option<crate::model::TrackMetadata>) {
|
||||||
|
let mut cache = self.metadata_cache.lock().unwrap();
|
||||||
|
|
||||||
|
// Vérifier s'il y a déjà des métadonnées en cache
|
||||||
|
if let Some(cached_meta) = cache.get(&track_id) {
|
||||||
|
// Vérifier si c'est la même chanson (titre ET artiste identiques)
|
||||||
|
let same_title = cached_meta.as_ref().and_then(|m| m.title.as_ref())
|
||||||
|
== new_metadata.as_ref().and_then(|m| m.title.as_ref());
|
||||||
|
let same_artist = cached_meta.as_ref().and_then(|m| m.artist.as_ref())
|
||||||
|
== new_metadata.as_ref().and_then(|m| m.artist.as_ref());
|
||||||
|
|
||||||
|
let same_track = same_title && same_artist;
|
||||||
|
|
||||||
|
if same_track {
|
||||||
|
// Même chanson: vérifier que la durée n'a pas diminué
|
||||||
|
let should_update = match (
|
||||||
|
cached_meta.as_ref().and_then(|m| m.duration.as_ref()),
|
||||||
|
new_metadata.as_ref().and_then(|m| m.duration.as_ref()),
|
||||||
|
) {
|
||||||
|
(Some(cached_dur), Some(new_dur)) => {
|
||||||
|
// Parser les durées (format HH:MM:SS)
|
||||||
|
let parse_duration = |dur: &str| -> Option<u32> {
|
||||||
|
let parts: Vec<&str> = dur.split(':').collect();
|
||||||
|
if parts.len() == 3 {
|
||||||
|
let h: u32 = parts[0].parse().ok()?;
|
||||||
|
let m: u32 = parts[1].parse().ok()?;
|
||||||
|
let s: u32 = parts[2].parse().ok()?;
|
||||||
|
Some(h * 3600 + m * 60 + s)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let (Some(cached_secs), Some(new_secs)) =
|
||||||
|
(parse_duration(cached_dur), parse_duration(new_dur))
|
||||||
|
{
|
||||||
|
if new_secs < cached_secs {
|
||||||
|
// Durée a diminué pour la même chanson: refuser
|
||||||
|
tracing::trace!(
|
||||||
|
"OpenHome cache_metadata: track_id={}, REJECTING update (same track, duration decreased): {} -> {}",
|
||||||
|
track_id,
|
||||||
|
cached_dur,
|
||||||
|
new_dur
|
||||||
|
);
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
// Durée a augmenté ou est égale: accepter
|
||||||
|
if new_secs > cached_secs {
|
||||||
|
tracing::debug!(
|
||||||
|
"OpenHome cache_metadata: track_id={}, same track, duration increased: {} -> {}",
|
||||||
|
track_id,
|
||||||
|
cached_dur,
|
||||||
|
new_dur
|
||||||
|
);
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Impossible de parser: accepter par défaut
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => true, // Pas de durée ou une seule des deux: accepter
|
||||||
|
};
|
||||||
|
|
||||||
|
if should_update {
|
||||||
|
cache.insert(track_id, new_metadata);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Chanson différente: accepter sans vérification
|
||||||
|
tracing::debug!(
|
||||||
|
"OpenHome cache_metadata: track_id={}, different track (title or artist changed), accepting update",
|
||||||
|
track_id
|
||||||
|
);
|
||||||
|
cache.insert(track_id, new_metadata);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Pas dans le cache: insérer directement
|
||||||
|
tracing::trace!(
|
||||||
|
"OpenHome cache_metadata: track_id={}, inserting first time, duration={:?}",
|
||||||
|
track_id,
|
||||||
|
new_metadata.as_ref().and_then(|m| m.duration.as_ref())
|
||||||
|
);
|
||||||
|
cache.insert(track_id, new_metadata);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn playback_item_from_entry(&self, entry: &OhTrackEntry) -> PlaybackItem {
|
fn playback_item_from_entry(&self, entry: &OhTrackEntry) -> PlaybackItem {
|
||||||
// Utiliser les métadonnées du cache si disponibles, sinon celles de l'entrée
|
// TOUJOURS préférer les métadonnées du cache si disponibles
|
||||||
let metadata = self
|
// Le cache contient les métadonnées stables mises lors de l'insertion
|
||||||
.metadata_cache
|
// Les métadonnées de l'entry (venant de ReadList) changent pour les streams
|
||||||
.get(&entry.id)
|
let metadata = {
|
||||||
.cloned()
|
let cache = self.metadata_cache.lock().unwrap();
|
||||||
.unwrap_or_else(|| entry.metadata());
|
if let Some(cached_meta) = cache.get(&entry.id) {
|
||||||
|
// Utiliser les métadonnées stables du cache
|
||||||
|
tracing::trace!(
|
||||||
|
"OpenHome playback_item_from_entry: track_id={}, using CACHE, duration={:?}",
|
||||||
|
entry.id,
|
||||||
|
cached_meta.as_ref().and_then(|m| m.duration.as_ref())
|
||||||
|
);
|
||||||
|
cached_meta.clone()
|
||||||
|
} else {
|
||||||
|
// Pas dans le cache (piste existante avant démarrage de PMOMusic ou ajoutée par autre control point)
|
||||||
|
// Utiliser les métadonnées fraîches de l'entry et les mettre en cache pour stabiliser
|
||||||
|
let fresh = entry.metadata();
|
||||||
|
tracing::debug!(
|
||||||
|
"OpenHome playback_item_from_entry: track_id={}, caching metadata from entry (first read), duration={:?}",
|
||||||
|
entry.id,
|
||||||
|
fresh.as_ref().and_then(|m| m.duration.as_ref())
|
||||||
|
);
|
||||||
|
drop(cache); // Libérer le lock avant d'appeler cache_metadata
|
||||||
|
// Mettre en cache pour éviter les oscillations sur les flux radio
|
||||||
|
self.cache_metadata(entry.id, fresh.clone());
|
||||||
|
fresh
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let didl_id = entry
|
let didl_id = entry
|
||||||
.didl_id()
|
.didl_id()
|
||||||
@@ -228,7 +341,7 @@ impl OpenHomeQueue {
|
|||||||
.insert(after_id, &item.uri, &metadata_xml)?;
|
.insert(after_id, &item.uri, &metadata_xml)?;
|
||||||
|
|
||||||
// Enregistrer les métadonnées dans le cache
|
// Enregistrer les métadonnées dans le cache
|
||||||
self.metadata_cache.insert(new_id, item.metadata);
|
self.cache_metadata(new_id, item.metadata);
|
||||||
|
|
||||||
Ok(new_id)
|
Ok(new_id)
|
||||||
}
|
}
|
||||||
@@ -250,7 +363,7 @@ impl OpenHomeQueue {
|
|||||||
for &track_id in current_track_ids.iter().rev() {
|
for &track_id in current_track_ids.iter().rev() {
|
||||||
if track_id as usize != playing_id {
|
if track_id as usize != playing_id {
|
||||||
self.playlist_client.delete_id_if_exists(track_id)?;
|
self.playlist_client.delete_id_if_exists(track_id)?;
|
||||||
self.metadata_cache.remove(&track_id);
|
self.metadata_cache.lock().unwrap().remove(&track_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,7 +376,7 @@ impl OpenHomeQueue {
|
|||||||
.insert(previous_id, &item.uri, &metadata)?;
|
.insert(previous_id, &item.uri, &metadata)?;
|
||||||
|
|
||||||
// Enregistrer les métadonnées dans le cache
|
// Enregistrer les métadonnées dans le cache
|
||||||
self.metadata_cache.insert(new_id, item.metadata);
|
self.cache_metadata(new_id, item.metadata);
|
||||||
|
|
||||||
previous_id = new_id;
|
previous_id = new_id;
|
||||||
}
|
}
|
||||||
@@ -296,7 +409,7 @@ impl OpenHomeQueue {
|
|||||||
track_id
|
track_id
|
||||||
);
|
);
|
||||||
self.playlist_client.delete_id_if_exists(track_id)?;
|
self.playlist_client.delete_id_if_exists(track_id)?;
|
||||||
self.metadata_cache.remove(&track_id);
|
self.metadata_cache.lock().unwrap().remove(&track_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -329,8 +442,7 @@ impl OpenHomeQueue {
|
|||||||
previous_id = existing_id;
|
previous_id = existing_id;
|
||||||
|
|
||||||
// Mettre à jour les métadonnées de l'item existant conservé
|
// Mettre à jour les métadonnées de l'item existant conservé
|
||||||
self.metadata_cache
|
self.cache_metadata(existing_id, item.metadata.clone());
|
||||||
.insert(existing_id, item.metadata.clone());
|
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
renderer = self.renderer_id.0.as_str(),
|
renderer = self.renderer_id.0.as_str(),
|
||||||
@@ -346,7 +458,7 @@ impl OpenHomeQueue {
|
|||||||
.insert(previous_id, &item.uri, &metadata)?;
|
.insert(previous_id, &item.uri, &metadata)?;
|
||||||
|
|
||||||
// Enregistrer les métadonnées du nouvel item
|
// Enregistrer les métadonnées du nouvel item
|
||||||
self.metadata_cache.insert(new_id, item.metadata.clone());
|
self.cache_metadata(new_id, item.metadata.clone());
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
renderer = self.renderer_id.0.as_str(),
|
renderer = self.renderer_id.0.as_str(),
|
||||||
@@ -424,8 +536,7 @@ impl OpenHomeQueue {
|
|||||||
let previous_id = pivot_id as u32;
|
let previous_id = pivot_id as u32;
|
||||||
|
|
||||||
// Mettre à jour les métadonnées du pivot
|
// Mettre à jour les métadonnées du pivot
|
||||||
self.metadata_cache
|
self.cache_metadata(previous_id, new_items[pivot_idx_new].metadata.clone());
|
||||||
.insert(previous_id, new_items[pivot_idx_new].metadata.clone());
|
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
renderer = self.renderer_id.0.as_str(),
|
renderer = self.renderer_id.0.as_str(),
|
||||||
@@ -492,7 +603,7 @@ impl OpenHomeQueue {
|
|||||||
"Using delete_all() for complete replacement (more robust for live playlists)"
|
"Using delete_all() for complete replacement (more robust for live playlists)"
|
||||||
);
|
);
|
||||||
self.playlist_client.delete_all()?;
|
self.playlist_client.delete_all()?;
|
||||||
self.metadata_cache.clear();
|
self.metadata_cache.lock().unwrap().clear();
|
||||||
} else {
|
} else {
|
||||||
// Selective deletion when keeping some items
|
// Selective deletion when keeping some items
|
||||||
for idx in (0..current_track_ids.len()).rev() {
|
for idx in (0..current_track_ids.len()).rev() {
|
||||||
@@ -501,7 +612,7 @@ impl OpenHomeQueue {
|
|||||||
// Use delete_id_if_exists() to handle cases where another control point
|
// Use delete_id_if_exists() to handle cases where another control point
|
||||||
// may have already modified the playlist
|
// may have already modified the playlist
|
||||||
self.playlist_client.delete_id_if_exists(track_id)?;
|
self.playlist_client.delete_id_if_exists(track_id)?;
|
||||||
self.metadata_cache.remove(&track_id);
|
self.metadata_cache.lock().unwrap().remove(&track_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -534,7 +645,8 @@ impl OpenHomeQueue {
|
|||||||
previous_id = existing_id;
|
previous_id = existing_id;
|
||||||
|
|
||||||
// Mettre à jour les métadonnées de l'item existant conservé
|
// Mettre à jour les métadonnées de l'item existant conservé
|
||||||
self.metadata_cache.insert(existing_id, item.metadata);
|
// La fonction cache_metadata gère la protection contre la diminution de durée
|
||||||
|
self.cache_metadata(existing_id, item.metadata);
|
||||||
} else {
|
} else {
|
||||||
let metadata = build_metadata_xml(&item);
|
let metadata = build_metadata_xml(&item);
|
||||||
let new_id = self
|
let new_id = self
|
||||||
@@ -542,7 +654,7 @@ impl OpenHomeQueue {
|
|||||||
.insert(previous_id, &item.uri, &metadata)?;
|
.insert(previous_id, &item.uri, &metadata)?;
|
||||||
|
|
||||||
// Enregistrer les métadonnées du nouvel item
|
// Enregistrer les métadonnées du nouvel item
|
||||||
self.metadata_cache.insert(new_id, item.metadata);
|
self.cache_metadata(new_id, item.metadata);
|
||||||
|
|
||||||
previous_id = new_id;
|
previous_id = new_id;
|
||||||
}
|
}
|
||||||
@@ -761,6 +873,7 @@ impl QueueBackend for OpenHomeQueue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Read metadata for all tracks (batched)
|
// Read metadata for all tracks (batched)
|
||||||
|
// playback_item_from_entry() will prioritize cached metadata over entry metadata
|
||||||
const MAX_BATCH: usize = 64;
|
const MAX_BATCH: usize = 64;
|
||||||
let mut entries = Vec::with_capacity(ids.len());
|
let mut entries = Vec::with_capacity(ids.len());
|
||||||
for chunk in ids.chunks(MAX_BATCH) {
|
for chunk in ids.chunks(MAX_BATCH) {
|
||||||
@@ -837,7 +950,7 @@ impl QueueBackend for OpenHomeQueue {
|
|||||||
|
|
||||||
self.ensure_playlist_source_selected()?;
|
self.ensure_playlist_source_selected()?;
|
||||||
self.playlist_client.delete_all()?;
|
self.playlist_client.delete_all()?;
|
||||||
self.metadata_cache.clear();
|
self.metadata_cache.lock().unwrap().clear();
|
||||||
|
|
||||||
// Invalidate caches after delete_all (clears queue and current track)
|
// Invalidate caches after delete_all (clears queue and current track)
|
||||||
self.track_ids_cache.lock().unwrap().invalidate();
|
self.track_ids_cache.lock().unwrap().invalidate();
|
||||||
@@ -856,7 +969,7 @@ impl QueueBackend for OpenHomeQueue {
|
|||||||
.insert(previous_id, &item.uri, &metadata)?;
|
.insert(previous_id, &item.uri, &metadata)?;
|
||||||
|
|
||||||
// Enregistrer les métadonnées dans le cache
|
// Enregistrer les métadonnées dans le cache
|
||||||
self.metadata_cache.insert(new_id, item.metadata);
|
self.cache_metadata(new_id, item.metadata);
|
||||||
|
|
||||||
previous_id = new_id;
|
previous_id = new_id;
|
||||||
}
|
}
|
||||||
@@ -871,7 +984,7 @@ impl QueueBackend for OpenHomeQueue {
|
|||||||
self.ensure_playlist_source_selected()?;
|
self.ensure_playlist_source_selected()?;
|
||||||
if items.is_empty() {
|
if items.is_empty() {
|
||||||
self.playlist_client.delete_all()?;
|
self.playlist_client.delete_all()?;
|
||||||
self.metadata_cache.clear();
|
self.metadata_cache.lock().unwrap().clear();
|
||||||
// Invalidate caches after delete_all (clears queue and current track)
|
// Invalidate caches after delete_all (clears queue and current track)
|
||||||
self.track_ids_cache.lock().unwrap().invalidate();
|
self.track_ids_cache.lock().unwrap().invalidate();
|
||||||
self.current_track_id_cache.lock().unwrap().invalidate();
|
self.current_track_id_cache.lock().unwrap().invalidate();
|
||||||
@@ -1002,8 +1115,8 @@ impl QueueBackend for OpenHomeQueue {
|
|||||||
.insert(before_id, &item.uri, &metadata)?;
|
.insert(before_id, &item.uri, &metadata)?;
|
||||||
|
|
||||||
// Mettre à jour le cache avec les nouvelles métadonnées
|
// Mettre à jour le cache avec les nouvelles métadonnées
|
||||||
self.metadata_cache.remove(&track_id);
|
self.metadata_cache.lock().unwrap().remove(&track_id);
|
||||||
self.metadata_cache.insert(new_id, item.metadata);
|
self.cache_metadata(new_id, item.metadata);
|
||||||
|
|
||||||
if ci == Some(index) {
|
if ci == Some(index) {
|
||||||
self.playlist_client.seek_id(new_id)?;
|
self.playlist_client.seek_id(new_id)?;
|
||||||
|
|||||||
1193
pmocontrol/src/queue/openhome.rs.bak
Normal file
1193
pmocontrol/src/queue/openhome.rs.bak
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user