push-xmrpvpxvppzw #43
18
Cargo.lock
generated
18
Cargo.lock
generated
@@ -2084,6 +2084,12 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-range-header"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
@@ -3677,6 +3683,8 @@ dependencies = [
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower 0.5.2",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"utoipa",
|
||||
]
|
||||
@@ -5844,14 +5852,24 @@ checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"http-range-header",
|
||||
"httpdate",
|
||||
"iri-string",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower 0.5.2",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,209 +1,221 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, toRef } from 'vue'
|
||||
import { useRenderer, useRenderers } from '@/composables/useRenderers'
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
import { Play, Pause, Square, SkipForward } from 'lucide-vue-next'
|
||||
import { computed, toRef } from "vue";
|
||||
import { useRenderer, useRenderers } from "@/composables/useRenderers";
|
||||
import { useUIStore } from "@/stores/ui";
|
||||
import { Play, Pause, Square, SkipForward } from "lucide-vue-next";
|
||||
|
||||
const props = defineProps<{
|
||||
rendererId: string
|
||||
}>()
|
||||
rendererId: string;
|
||||
}>();
|
||||
|
||||
const { state } = useRenderer(toRef(props, 'rendererId'))
|
||||
const { resumeOrPlayFromQueue, pause, stop, next } = useRenderers()
|
||||
const uiStore = useUIStore()
|
||||
const { state } = useRenderer(toRef(props, "rendererId"));
|
||||
const { resumeOrPlayFromQueue, pause, stop, next } = useRenderers();
|
||||
const uiStore = useUIStore();
|
||||
|
||||
const isPlaying = computed(() => state.value?.transport_state === 'PLAYING')
|
||||
const isPaused = computed(() => state.value?.transport_state === 'PAUSED')
|
||||
const isStopped = computed(() => state.value?.transport_state === 'STOPPED' || state.value?.transport_state === 'NO_MEDIA')
|
||||
const isPlaying = computed(() => state.value?.transport_state === "PLAYING");
|
||||
const isPaused = computed(() => state.value?.transport_state === "PAUSED");
|
||||
const isStopped = computed(
|
||||
() =>
|
||||
state.value?.transport_state === "STOPPED" ||
|
||||
state.value?.transport_state === "NO_MEDIA",
|
||||
);
|
||||
|
||||
async function handlePlay() {
|
||||
try {
|
||||
await resumeOrPlayFromQueue(props.rendererId)
|
||||
} catch (error) {
|
||||
uiStore.notifyError(`Impossible de démarrer la lecture: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||
}
|
||||
try {
|
||||
await resumeOrPlayFromQueue(props.rendererId);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(
|
||||
`Impossible de démarrer la lecture: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePause() {
|
||||
try {
|
||||
await pause(props.rendererId)
|
||||
} catch (error) {
|
||||
uiStore.notifyError(`Impossible de mettre en pause: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||
}
|
||||
try {
|
||||
await pause(props.rendererId);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(
|
||||
`Impossible de mettre en pause: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStop() {
|
||||
try {
|
||||
await stop(props.rendererId)
|
||||
} catch (error) {
|
||||
uiStore.notifyError(`Impossible d'arrêter la lecture: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||
}
|
||||
try {
|
||||
await stop(props.rendererId);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(
|
||||
`Impossible d'arrêter la lecture: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNext() {
|
||||
try {
|
||||
await next(props.rendererId)
|
||||
} catch (error) {
|
||||
uiStore.notifyError(`Impossible de passer au morceau suivant: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||
}
|
||||
try {
|
||||
await next(props.rendererId);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(
|
||||
`Impossible de passer au morceau suivant: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="transport-controls">
|
||||
<button
|
||||
class="btn btn-icon btn-primary"
|
||||
:disabled="isPlaying"
|
||||
@click="handlePlay"
|
||||
title="Lecture"
|
||||
>
|
||||
<Play :size="20" />
|
||||
</button>
|
||||
<div class="transport-controls">
|
||||
<button
|
||||
class="btn btn-icon btn-primary"
|
||||
:disabled="isPlaying"
|
||||
@click="handlePlay"
|
||||
title="Lecture"
|
||||
>
|
||||
<Play :size="20" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-icon"
|
||||
:disabled="isPaused || isStopped"
|
||||
@click="handlePause"
|
||||
title="Pause"
|
||||
>
|
||||
<Pause :size="20" />
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-icon"
|
||||
:disabled="isPaused || isStopped"
|
||||
@click="handlePause"
|
||||
title="Pause"
|
||||
>
|
||||
<Pause :size="20" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-icon"
|
||||
:disabled="isStopped"
|
||||
@click="handleStop"
|
||||
title="Stop"
|
||||
>
|
||||
<Square :size="20" />
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-icon"
|
||||
:disabled="isStopped"
|
||||
@click="handleStop"
|
||||
title="Stop"
|
||||
>
|
||||
<Square :size="20" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-icon"
|
||||
:disabled="!state?.queue_len"
|
||||
@click="handleNext"
|
||||
title="Suivant"
|
||||
>
|
||||
<SkipForward :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-icon"
|
||||
:disabled="!state?.queue_len"
|
||||
@click="handleNext"
|
||||
title="Suivant"
|
||||
>
|
||||
<SkipForward :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.transport-controls {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-lg);
|
||||
border-radius: 24px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
box-shadow:
|
||||
0 8px 32px 0 rgba(31, 38, 135, 0.15),
|
||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.3);
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-lg);
|
||||
border-radius: 24px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
box-shadow:
|
||||
0 8px 32px 0 rgba(31, 38, 135, 0.15),
|
||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.transport-controls {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
.transport-controls {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
/* Boutons avec effet glass */
|
||||
.transport-controls .btn-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
min-width: 56px;
|
||||
min-height: 56px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
backdrop-filter: blur(10px) saturate(150%);
|
||||
-webkit-backdrop-filter: blur(10px) saturate(150%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transform: translateZ(0);
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
min-width: 56px;
|
||||
min-height: 56px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
backdrop-filter: blur(10px) saturate(150%);
|
||||
-webkit-backdrop-filter: blur(10px) saturate(150%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.transport-controls .btn-icon:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15);
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.transport-controls .btn-icon:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.transport-controls .btn-icon:disabled {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Bouton primary (play) avec accent vert */
|
||||
.transport-controls .btn-primary {
|
||||
background: rgba(34, 197, 94, 0.3);
|
||||
border-color: rgba(34, 197, 94, 0.5);
|
||||
background: rgba(34, 197, 94, 0.3);
|
||||
border-color: rgba(34, 197, 94, 0.5);
|
||||
}
|
||||
|
||||
.transport-controls .btn-primary:hover:not(:disabled) {
|
||||
background: rgba(34, 197, 94, 0.4);
|
||||
border-color: rgba(34, 197, 94, 0.6);
|
||||
background: rgba(34, 197, 94, 0.4);
|
||||
border-color: rgba(34, 197, 94, 0.6);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.transport-controls .btn-icon {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
.transport-controls .btn-icon {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.transport-controls .btn-icon:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
.transport-controls .btn-icon:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
/* Fallback pour navigateurs sans backdrop-filter */
|
||||
@supports not (backdrop-filter: blur(20px)) {
|
||||
.transport-controls {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.transport-controls .btn-icon {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.transport-controls {
|
||||
background: rgba(0, 0, 0, 0.95);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.transport-controls .btn-icon {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.transport-controls {
|
||||
background: rgba(0, 0, 0, 0.95);
|
||||
}
|
||||
|
||||
.transport-controls .btn-icon {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Mode kiosque - compactage pour petites hauteurs (800x600) */
|
||||
@media (max-height: 700px) and (orientation: landscape) {
|
||||
.transport-controls {
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
}
|
||||
.transport-controls {
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
}
|
||||
|
||||
.transport-controls .btn-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
.transport-controls .btn-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -168,6 +168,20 @@ class PMOControlAPI {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek à une position spécifique (en secondes)
|
||||
* POST /api/control/renderers/{id}/seek
|
||||
*/
|
||||
async seekTo(id: string, seconds: number): Promise<SuccessResponse> {
|
||||
return this.request<SuccessResponse>(
|
||||
`/renderers/${encodeURIComponent(id)}/seek`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ seconds }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saute à un index spécifique dans la queue
|
||||
* POST /api/control/renderers/{id}/queue/seek
|
||||
|
||||
@@ -1,84 +1,86 @@
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
zoom: 0.85;
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ utoipa = { version = "5.3", optional = true }
|
||||
|
||||
# Feature pour pmoserver (extension HTTP)
|
||||
axum = { version = "0.8", optional = true }
|
||||
tower = { version = "0.5", optional = true }
|
||||
tower-http = { version = "0.6", features = ["fs"], optional = true }
|
||||
|
||||
# Feature pour pmoconfig (extension de configuration)
|
||||
pmoconfig = { path = "../pmoconfig", optional = true }
|
||||
@@ -49,5 +51,5 @@ tempfile = "3"
|
||||
[features]
|
||||
default = []
|
||||
openapi = ["dep:utoipa"]
|
||||
pmoserver = ["dep:axum"]
|
||||
pmoserver = ["dep:axum", "dep:tower", "dep:tower-http"]
|
||||
pmoconfig = ["dep:pmoconfig", "dep:serde_yaml"]
|
||||
|
||||
@@ -90,10 +90,11 @@ async fn get_file<C: CacheConfig + 'static>(
|
||||
Option<ParamGenerator<C>>,
|
||||
)>,
|
||||
Path(pk): Path<String>,
|
||||
request: axum::http::Request<Body>,
|
||||
) -> Response {
|
||||
// Utiliser le param par défaut
|
||||
let param = C::default_param();
|
||||
serve_file_with_streaming(&cache, &pk, param, content_type, param_generator).await
|
||||
serve_file_with_streaming(&cache, &pk, param, content_type, param_generator, request).await
|
||||
}
|
||||
|
||||
/// Handler générique pour GET /{cache_name}/{cache_type}/{pk}/{param}
|
||||
@@ -106,8 +107,9 @@ async fn get_file_with_param<C: CacheConfig + 'static>(
|
||||
Option<ParamGenerator<C>>,
|
||||
)>,
|
||||
Path((pk, param)): Path<(String, String)>,
|
||||
request: axum::http::Request<Body>,
|
||||
) -> Response {
|
||||
serve_file_with_streaming(&cache, &pk, ¶m, content_type, param_generator).await
|
||||
serve_file_with_streaming(&cache, &pk, ¶m, content_type, param_generator, request).await
|
||||
}
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
@@ -116,6 +118,7 @@ async fn serve_finalized_pk<C: CacheConfig + 'static>(
|
||||
pk: &str,
|
||||
param: &str,
|
||||
content_type: &'static str,
|
||||
request: axum::http::Request<Body>,
|
||||
) -> Response {
|
||||
let qualifier = param.to_string();
|
||||
let file_path = cache.get_file_path_with_qualifier(pk, param);
|
||||
@@ -129,7 +132,8 @@ async fn serve_finalized_pk<C: CacheConfig + 'static>(
|
||||
// la lecture pendant le téléchargement tout en préservant la durée/position
|
||||
if let Some(download) = cache.get_download(pk).await {
|
||||
if !download.finished().await {
|
||||
let response = stream_file_progressive(file_path, download, content_type).await;
|
||||
let response =
|
||||
stream_file_progressive(file_path, download, content_type, request).await;
|
||||
|
||||
if response.status().is_success() {
|
||||
cache.notify_broadcast(pk, &qualifier).await;
|
||||
@@ -140,9 +144,8 @@ async fn serve_finalized_pk<C: CacheConfig + 'static>(
|
||||
}
|
||||
|
||||
// ROUTE 2 : Fichier complètement téléchargé
|
||||
// Utilise l'ancien système éprouvé qui garantit un passage correct
|
||||
// de toutes les informations (Content-Length automatique, etc.)
|
||||
let response = serve_complete_file(file_path, content_type).await;
|
||||
// Utilise ServeFile avec support des Range requests pour le seek
|
||||
let response = serve_complete_file(file_path, content_type, request).await;
|
||||
|
||||
if response.status().is_success() {
|
||||
cache.notify_broadcast(pk, &qualifier).await;
|
||||
@@ -166,6 +169,7 @@ async fn serve_lazy_audio_file<C: CacheConfig + 'static>(
|
||||
lazy_pk: &str,
|
||||
param: &str,
|
||||
content_type: &'static str,
|
||||
request: axum::http::Request<Body>,
|
||||
) -> Response {
|
||||
tracing::info!("Lazy download triggered for pk: {}", lazy_pk);
|
||||
|
||||
@@ -176,7 +180,7 @@ async fn serve_lazy_audio_file<C: CacheConfig + 'static>(
|
||||
lazy_pk,
|
||||
real_pk
|
||||
);
|
||||
return serve_finalized_pk(cache, &real_pk, param, content_type).await;
|
||||
return serve_finalized_pk(cache, &real_pk, param, content_type, request).await;
|
||||
}
|
||||
|
||||
// 2. Télécharger en résolvant l'URL via la DB ou un provider
|
||||
@@ -196,7 +200,7 @@ async fn serve_lazy_audio_file<C: CacheConfig + 'static>(
|
||||
cache.broadcast_lazy_downloaded(lazy_pk, &real_pk).await;
|
||||
|
||||
// 5. Servir directement le fichier téléchargé
|
||||
serve_finalized_pk(cache, &real_pk, param, content_type).await
|
||||
serve_finalized_pk(cache, &real_pk, param, content_type, request).await
|
||||
}
|
||||
|
||||
/// Fonction utilitaire pour servir un fichier avec streaming progressif
|
||||
@@ -211,10 +215,11 @@ async fn serve_file_with_streaming<C: CacheConfig + 'static>(
|
||||
param: &str,
|
||||
content_type: &'static str,
|
||||
param_generator: Option<ParamGenerator<C>>,
|
||||
request: axum::http::Request<Body>,
|
||||
) -> Response {
|
||||
// LAZY PK SUPPORT: Détecter si c'est un lazy PK
|
||||
if crate::cache::is_lazy_pk(pk) {
|
||||
return serve_lazy_audio_file(cache, pk, param, content_type).await;
|
||||
return serve_lazy_audio_file(cache, pk, param, content_type, request).await;
|
||||
}
|
||||
|
||||
let file_path = cache.get_file_path_with_qualifier(pk, param);
|
||||
@@ -246,8 +251,9 @@ async fn serve_file_with_streaming<C: CacheConfig + 'static>(
|
||||
if let Some(download) = cache.get_download(pk).await {
|
||||
// Le fichier est en cours de téléchargement
|
||||
if !download.finished().await {
|
||||
// Streaming progressif
|
||||
let response = stream_file_progressive(file_path, download, content_type).await;
|
||||
// Streaming progressif avec support Range
|
||||
let response =
|
||||
stream_file_progressive(file_path, download, content_type, request).await;
|
||||
|
||||
if response.status().is_success() {
|
||||
cache.notify_broadcast(pk, &qualifier).await;
|
||||
@@ -257,8 +263,8 @@ async fn serve_file_with_streaming<C: CacheConfig + 'static>(
|
||||
}
|
||||
}
|
||||
|
||||
// Fichier terminé ou pas de download en cours, servir normalement
|
||||
let response = serve_complete_file(file_path, content_type).await;
|
||||
// Fichier terminé ou pas de download en cours, servir normalement avec support Range
|
||||
let response = serve_complete_file(file_path, content_type, request).await;
|
||||
|
||||
if response.status().is_success() {
|
||||
cache.notify_broadcast(pk, &qualifier).await;
|
||||
@@ -268,13 +274,17 @@ async fn serve_file_with_streaming<C: CacheConfig + 'static>(
|
||||
}
|
||||
|
||||
/// Stream un fichier en cours de téléchargement de manière progressive
|
||||
///
|
||||
/// Supporte les Range requests pour permettre le seek même pendant le téléchargement
|
||||
#[cfg(feature = "pmoserver")]
|
||||
async fn stream_file_progressive(
|
||||
file_path: std::path::PathBuf,
|
||||
download: Arc<crate::download::Download>,
|
||||
content_type: &'static str,
|
||||
request: axum::http::Request<Body>,
|
||||
) -> Response {
|
||||
use axum::http::header;
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||
|
||||
// Attendre qu'au moins 64 KB soient disponibles avant de commencer
|
||||
const MIN_SIZE_TO_START: u64 = 64 * 1024;
|
||||
@@ -291,8 +301,14 @@ async fn stream_file_progressive(
|
||||
return (StatusCode::NOT_FOUND, "File not available").into_response();
|
||||
}
|
||||
|
||||
// Récupérer la taille attendue du fichier si disponible
|
||||
let expected_size = download.expected_size().await;
|
||||
|
||||
// Vérifier s'il y a un header Range
|
||||
let range_header = request.headers().get(header::RANGE);
|
||||
|
||||
// Ouvrir le fichier en lecture
|
||||
let file = match tokio::fs::File::open(&file_path).await {
|
||||
let mut file = match tokio::fs::File::open(&file_path).await {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
warn!("Error opening file {:?}: {}", file_path, e);
|
||||
@@ -300,24 +316,65 @@ async fn stream_file_progressive(
|
||||
}
|
||||
};
|
||||
|
||||
// Créer un stream à partir du fichier
|
||||
// Parser le Range header si présent
|
||||
if let Some(range_value) = range_header {
|
||||
if let Ok(range_str) = range_value.to_str() {
|
||||
// Format: "bytes=start-end" ou "bytes=start-"
|
||||
if let Some(range_spec) = range_str.strip_prefix("bytes=") {
|
||||
if let Some((start_str, _)) = range_spec.split_once('-') {
|
||||
if let Ok(start) = start_str.parse::<u64>() {
|
||||
// Seek vers la position demandée
|
||||
if let Err(e) = file.seek(std::io::SeekFrom::Start(start)).await {
|
||||
warn!("Error seeking to position {}: {}", start, e);
|
||||
return (StatusCode::RANGE_NOT_SATISFIABLE, "Invalid range")
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Créer un stream à partir de la position
|
||||
let stream = ReaderStream::new(file);
|
||||
let body = Body::from_stream(stream);
|
||||
|
||||
// Calculer la range
|
||||
let end = expected_size.map(|s| s - 1).unwrap_or(start + 1_000_000);
|
||||
let content_length = expected_size.map(|s| s - start);
|
||||
|
||||
// Retourner une réponse 206 Partial Content
|
||||
let mut response_builder = axum::http::Response::builder()
|
||||
.status(StatusCode::PARTIAL_CONTENT)
|
||||
.header(header::CONTENT_TYPE, content_type)
|
||||
.header(header::ACCEPT_RANGES, "bytes");
|
||||
|
||||
if let Some(total_size) = expected_size {
|
||||
response_builder = response_builder.header(
|
||||
header::CONTENT_RANGE,
|
||||
format!("bytes {}-{}/{}", start, end, total_size),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(length) = content_length {
|
||||
response_builder =
|
||||
response_builder.header(header::CONTENT_LENGTH, length);
|
||||
}
|
||||
|
||||
return response_builder.body(body).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pas de Range header : retourner le fichier complet
|
||||
let stream = ReaderStream::new(file);
|
||||
let body = Body::from_stream(stream);
|
||||
|
||||
// Récupérer la taille attendue du fichier si disponible
|
||||
let expected_size = download.expected_size().await;
|
||||
|
||||
// Construire la réponse avec Content-Length si connu
|
||||
let mut response = axum::http::Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, content_type);
|
||||
.header(header::CONTENT_TYPE, content_type)
|
||||
.header(header::ACCEPT_RANGES, "bytes");
|
||||
|
||||
if let Some(size) = expected_size {
|
||||
// Si on connaît la taille finale, l'envoyer au renderer
|
||||
// pour qu'il puisse calculer la durée et afficher la position
|
||||
response = response.header(header::CONTENT_LENGTH, size);
|
||||
} else {
|
||||
// Sinon, utiliser chunked encoding
|
||||
response = response.header(header::TRANSFER_ENCODING, "chunked");
|
||||
}
|
||||
|
||||
@@ -325,21 +382,45 @@ async fn stream_file_progressive(
|
||||
}
|
||||
|
||||
/// Sert un fichier complet déjà téléchargé
|
||||
///
|
||||
/// Utilise `tower_http::services::ServeFile` pour bénéficier automatiquement de :
|
||||
/// - Support des HTTP Range requests (seek)
|
||||
/// - Header `Accept-Ranges: bytes`
|
||||
/// - Réponses `206 Partial Content`
|
||||
/// - Streaming efficace sans chargement complet en RAM
|
||||
#[cfg(feature = "pmoserver")]
|
||||
async fn serve_complete_file(
|
||||
file_path: std::path::PathBuf,
|
||||
content_type: &'static str,
|
||||
request: axum::http::Request<Body>,
|
||||
) -> Response {
|
||||
use axum::http::header;
|
||||
use tower::ServiceExt; // Pour oneshot()
|
||||
use tower_http::services::ServeFile;
|
||||
|
||||
if !file_path.exists() {
|
||||
warn!("File not found: {:?}", file_path);
|
||||
return (StatusCode::NOT_FOUND, "File not found").into_response();
|
||||
}
|
||||
|
||||
match tokio::fs::read(&file_path).await {
|
||||
Ok(data) => (StatusCode::OK, [("content-type", content_type)], data).into_response(),
|
||||
// Utiliser ServeFile avec support automatique des Range requests
|
||||
// ServeFile traite automatiquement les headers Range: dans la requête
|
||||
let serve_file = ServeFile::new(file_path);
|
||||
|
||||
match serve_file.oneshot(request).await {
|
||||
Ok(response) => {
|
||||
// Convertir la réponse ServeFile en Response<Body> avec le bon Content-Type
|
||||
let (mut parts, body) = response.into_parts();
|
||||
parts
|
||||
.headers
|
||||
.insert(header::CONTENT_TYPE, content_type.parse().unwrap());
|
||||
|
||||
// Convertir ServeFileSystemResponseBody en Body
|
||||
axum::http::Response::from_parts(parts, Body::new(body))
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Error reading file {:?}: {}", file_path, e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Error reading file").into_response()
|
||||
warn!("Error serving file: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Error serving file").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1318,8 +1318,17 @@ impl ControlPoint {
|
||||
);
|
||||
}
|
||||
|
||||
// 5b. Detach playlist binding from source renderer
|
||||
self.detach_queue_playlist(source_renderer_id);
|
||||
tracing::debug!(
|
||||
source = source_renderer_id.0.as_str(),
|
||||
"Detached playlist binding from source renderer"
|
||||
);
|
||||
|
||||
// 6. Start playback on destination renderer (if there was a current item)
|
||||
if source_snapshot.current_index.is_some() && !source_snapshot.items.is_empty() {
|
||||
// play() détecte automatiquement la queue et joue le track courant
|
||||
// (comportement unifié pour tous les backends)
|
||||
if let Err(e) = dest_renderer.play() {
|
||||
tracing::warn!(
|
||||
dest = dest_renderer_id.0.as_str(),
|
||||
|
||||
@@ -298,8 +298,24 @@ impl MusicRenderer {
|
||||
}
|
||||
|
||||
/// Transport control: play
|
||||
///
|
||||
/// Démarre ou reprend la lecture. Si une queue non vide existe,
|
||||
/// joue le track courant de la queue automatiquement (comportement unifié pour tous les backends).
|
||||
pub fn play(&self) -> Result<(), ControlPointError> {
|
||||
self.backend.lock().expect("Backend mutex poisoned").play()
|
||||
// Vérifier si on a une queue non vide
|
||||
let queue = self.queue.lock().expect("Queue mutex poisoned");
|
||||
let queue_not_empty = queue.len().unwrap_or(0) > 0;
|
||||
drop(queue); // Libérer le lock avant l'appel au backend
|
||||
|
||||
if queue_not_empty {
|
||||
// Si on a des items dans la queue, jouer le track courant (ou le premier si aucun n'est sélectionné)
|
||||
// peek_current() initialise automatiquement l'index à 0 si nécessaire
|
||||
// Cela fonctionne pour tous les backends (UPnP interne, OpenHome, etc.)
|
||||
self.play_current_from_queue()
|
||||
} else {
|
||||
// Queue vide : déléguer au backend (reprend la lecture en cours, etc.)
|
||||
self.backend.lock().expect("Backend mutex poisoned").play()
|
||||
}
|
||||
}
|
||||
|
||||
/// Transport control: pause
|
||||
@@ -336,6 +352,16 @@ impl MusicRenderer {
|
||||
.seek_rel_time(hhmmss)
|
||||
}
|
||||
|
||||
/// Seek to a specific position in seconds
|
||||
pub fn seek(&self, seconds: u32) -> Result<(), ControlPointError> {
|
||||
// Convert seconds to HH:MM:SS format
|
||||
let hours = seconds / 3600;
|
||||
let minutes = (seconds % 3600) / 60;
|
||||
let secs = seconds % 60;
|
||||
let hhmmss = format!("{:02}:{:02}:{:02}", hours, minutes, secs);
|
||||
self.seek_rel_time(&hhmmss)
|
||||
}
|
||||
|
||||
/// Volume control: get current volume
|
||||
pub fn volume(&self) -> Result<u16, ControlPointError> {
|
||||
self.backend
|
||||
|
||||
@@ -266,6 +266,14 @@ pub struct SeekQueueRequest {
|
||||
pub index: usize,
|
||||
}
|
||||
|
||||
/// Requête pour seek à une position spécifique (en secondes)
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[derive(Debug, Clone, Deserialize, ToSchema)]
|
||||
pub struct SeekRequest {
|
||||
/// Position en secondes
|
||||
pub seconds: u32,
|
||||
}
|
||||
|
||||
/// Requête pour transférer une queue d'un renderer vers un autre
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[derive(Debug, Clone, Deserialize, ToSchema)]
|
||||
@@ -412,6 +420,7 @@ GET /control/servers/{server_id}/containers/{container_id}
|
||||
crate::pmoserver_ext::stop_renderer,
|
||||
crate::pmoserver_ext::resume_renderer,
|
||||
crate::pmoserver_ext::next_renderer,
|
||||
crate::pmoserver_ext::seek_renderer,
|
||||
crate::pmoserver_ext::seek_queue_index,
|
||||
crate::pmoserver_ext::set_renderer_volume,
|
||||
crate::pmoserver_ext::volume_up_renderer,
|
||||
@@ -445,6 +454,7 @@ GET /control/servers/{server_id}/containers/{container_id}
|
||||
AttachPlaylistRequest,
|
||||
PlayContentRequest,
|
||||
SeekQueueRequest,
|
||||
SeekRequest,
|
||||
TransferQueueRequest,
|
||||
SuccessResponse,
|
||||
ErrorResponse,
|
||||
|
||||
@@ -14,7 +14,7 @@ use crate::openapi::{
|
||||
AttachPlaylistRequest, AttachedPlaylistInfo, BrowseResponse, ContainerEntry, ErrorResponse,
|
||||
FullRendererSnapshot, MediaServerSummary, PlayContentRequest, QueueSnapshot,
|
||||
RendererCapabilitiesSummary, RendererProtocolSummary, RendererState, RendererSummary,
|
||||
SeekQueueRequest, SuccessResponse, TransferQueueRequest, VolumeSetRequest,
|
||||
SeekQueueRequest, SeekRequest, SuccessResponse, TransferQueueRequest, VolumeSetRequest,
|
||||
};
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use crate::queue::PlaybackItem;
|
||||
@@ -718,6 +718,88 @@ async fn seek_queue_index(
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /control/renderers/{renderer_id}/seek - Seek à une position spécifique (en secondes)
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/control/renderers/{renderer_id}/seek",
|
||||
tag = "control",
|
||||
params(
|
||||
("renderer_id" = String, Path, description = "ID unique du renderer")
|
||||
),
|
||||
request_body = SeekRequest,
|
||||
responses(
|
||||
(status = 200, description = "Seek effectué avec succès", body = SuccessResponse),
|
||||
(status = 404, description = "Renderer non trouvé", body = ErrorResponse),
|
||||
(status = 504, description = "Timeout lors du seek", body = ErrorResponse),
|
||||
(status = 500, description = "Erreur interne", body = ErrorResponse),
|
||||
)
|
||||
)]
|
||||
async fn seek_renderer(
|
||||
State(state): State<ControlPointState>,
|
||||
Path(renderer_id): Path<String>,
|
||||
Json(payload): Json<SeekRequest>,
|
||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let rid = DeviceId(renderer_id.clone());
|
||||
let renderer = state
|
||||
.control_point
|
||||
.music_renderer_by_id(&rid)
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Renderer {} not found", renderer_id),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let seconds = payload.seconds;
|
||||
let seek_task = tokio::task::spawn_blocking(move || renderer.seek(seconds));
|
||||
|
||||
time::timeout(TRANSPORT_COMMAND_TIMEOUT, seek_task)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
warn!(
|
||||
"Seek 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 seek: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Internal task error: {}", e),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
.map_err(|e| {
|
||||
warn!(
|
||||
"Failed to seek to {} seconds for renderer {}: {}",
|
||||
seconds, renderer_id, e
|
||||
);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Failed to seek to {} seconds: {}", seconds, e),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Json(SuccessResponse {
|
||||
message: format!("Seeked to {} seconds", seconds),
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /control/renderers/{renderer_id}/volume/set - Définit le volume
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[utoipa::path(
|
||||
@@ -1882,6 +1964,7 @@ 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))
|
||||
.route("/renderers/{renderer_id}/seek", post(seek_renderer))
|
||||
// Queue control
|
||||
.route(
|
||||
"/renderers/{renderer_id}/queue/seek",
|
||||
|
||||
Reference in New Issue
Block a user