première version de la web UI du control point
This commit is contained in:
447
Plan_d_implementation_webui_control_point.md
Normal file
447
Plan_d_implementation_webui_control_point.md
Normal file
@@ -0,0 +1,447 @@
|
|||||||
|
# PMOControl WebUI - Design Recommendations & Implementation Plan
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
Based on my analysis of the existing codebase, I'm providing comprehensive recommendations for implementing a Vue.js WebUI for PMOControl. The system already has:
|
||||||
|
- A complete REST API with OpenAPI documentation (`/api/control/*`)
|
||||||
|
- SSE endpoints for real-time updates (`/api/control/events/*`)
|
||||||
|
- Vue 3 + TypeScript + Vite setup
|
||||||
|
- Existing components (GenericMusicPlayer, UpnpExplorer, Cache Managers, LogView)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Decisions & Recommendations
|
||||||
|
|
||||||
|
### 1. State Management: **Use Pinia**
|
||||||
|
|
||||||
|
**Recommendation: Pinia (Vue 3's official state management)**
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- **Centralized real-time state**: Essential for managing SSE updates from multiple sources (renderers, media servers)
|
||||||
|
- **Multi-client synchronization**: Single source of truth for renderer states, volumes, playback positions
|
||||||
|
- **TypeScript native**: Better type inference than Vuex
|
||||||
|
- **DevTools integration**: Built-in debugging for SSE event flows
|
||||||
|
- **Composition API friendly**: Matches existing Vue 3 patterns in codebase
|
||||||
|
- **Performance**: Lightweight (~1KB), modular stores
|
||||||
|
- **Official Vue 3 recommendation**: Future-proof choice
|
||||||
|
|
||||||
|
**Store Architecture:**
|
||||||
|
```typescript
|
||||||
|
// stores/renderers.ts - Renderer state (SSE updates)
|
||||||
|
// stores/mediaServers.ts - Media server state (SSE updates)
|
||||||
|
// stores/playback.ts - Current playback session
|
||||||
|
// stores/ui.ts - UI state (selected renderer, view preferences)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits for your use case:**
|
||||||
|
- Handle 20+ concurrent clients with shared state
|
||||||
|
- Real-time SSE event synchronization across all views
|
||||||
|
- Easy to scale with multi-renderer, multi-server, multi-session architecture
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. UI Component Library: **Headless UI + Custom Components**
|
||||||
|
|
||||||
|
**Recommendation: Hybrid approach - Headless UI components + custom styling**
|
||||||
|
|
||||||
|
**Component Library: Shadcn-vue (Headless UI primitives)**
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- **Lightweight & performant**: Only import what you need
|
||||||
|
- **Full style control**: Match "carte uniforme, responsive, colorée selon statut" spec exactly
|
||||||
|
- **TypeScript-first**: Perfect type safety
|
||||||
|
- **Accessibility built-in**: ARIA compliance out of the box
|
||||||
|
- **No theme lock-in**: Complete CSS freedom
|
||||||
|
- **Composable primitives**: Card, Dialog, Dropdown, Slider components
|
||||||
|
|
||||||
|
**Why NOT a full framework (Vuetify, Element Plus)?**
|
||||||
|
- Heavy bundle size (100-500KB vs ~10KB for headless)
|
||||||
|
- Theme customization overhead
|
||||||
|
- Your spec requires custom status-based coloring
|
||||||
|
- Performance critical with 20 concurrent clients
|
||||||
|
|
||||||
|
**Alternative if you prefer pre-styled:** PrimeVue
|
||||||
|
- Good performance
|
||||||
|
- Customizable themes
|
||||||
|
- Strong TypeScript support
|
||||||
|
- But: 150KB+ bundle size
|
||||||
|
|
||||||
|
**Custom Components to Build:**
|
||||||
|
- `RendererCard` - Status-colored cards for each renderer
|
||||||
|
- `TransportControls` - Play/Pause/Stop/Next buttons
|
||||||
|
- `VolumeControl` - Slider with mute toggle
|
||||||
|
- `QueueViewer` - Playlist display with drag-drop
|
||||||
|
- `MediaServerBrowser` - Container navigation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Existing Components: **Reorganize into Debug Section**
|
||||||
|
|
||||||
|
**Recommendation: Keep existing components, create new PMOControl home**
|
||||||
|
|
||||||
|
**Structure:**
|
||||||
|
```
|
||||||
|
/app (root) → PMOControl Dashboard (NEW)
|
||||||
|
/app/debug → Dropdown menu
|
||||||
|
├─ /logs → LogView
|
||||||
|
├─ /upnp → UpnpExplorer
|
||||||
|
├─ /covers-cache → CoverCacheManager
|
||||||
|
├─ /audio-cache → AudioCacheManager
|
||||||
|
├─ /api-dashboard → APIDashboard
|
||||||
|
└─ /radio-paradise → RadioParadiseExplorer
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- Existing components are valuable for development/debugging
|
||||||
|
- Don't break existing functionality
|
||||||
|
- PMOControl becomes primary interface as specified
|
||||||
|
- Debug tools remain accessible but not prominent
|
||||||
|
- Matches current App.vue dropdown pattern
|
||||||
|
|
||||||
|
**Home Screen (/) - PMOControl Dashboard:**
|
||||||
|
- Grid of renderer cards (status-colored)
|
||||||
|
- Active playback session viewer
|
||||||
|
- Quick controls (play/pause/volume)
|
||||||
|
- Media server browser panel
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Responsive Design: **Mobile-first with 3 breakpoints**
|
||||||
|
|
||||||
|
**Recommendation: Follow existing 768px pattern + add tablet/desktop**
|
||||||
|
|
||||||
|
**Breakpoints:**
|
||||||
|
```css
|
||||||
|
/* Mobile: < 768px (existing pattern) */
|
||||||
|
- Single column layout
|
||||||
|
- Stacked renderer cards
|
||||||
|
- Bottom-fixed playback controls
|
||||||
|
- Collapsible media browser
|
||||||
|
|
||||||
|
/* Tablet: 768px - 1024px */
|
||||||
|
- Two column layout
|
||||||
|
- Grid of renderer cards (2 columns)
|
||||||
|
- Side panel for media browser
|
||||||
|
- Floating playback controls
|
||||||
|
|
||||||
|
/* Desktop: > 1024px */
|
||||||
|
- Three column layout
|
||||||
|
- Renderer cards grid (3-4 columns)
|
||||||
|
- Persistent media browser sidebar
|
||||||
|
- Always-visible playback controls
|
||||||
|
```
|
||||||
|
|
||||||
|
**Target Devices:**
|
||||||
|
- **Primary**: Desktop browsers (control station)
|
||||||
|
- **Secondary**: Tablets (remote control)
|
||||||
|
- **Tertiary**: Mobile phones (quick controls)
|
||||||
|
|
||||||
|
**Performance considerations:**
|
||||||
|
- Virtualized lists for 20+ renderers (use vue-virtual-scroller)
|
||||||
|
- Lazy load album art
|
||||||
|
- Throttle SSE position updates (max 1/sec per renderer)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Icons: **Lucide Icons (SVG library)**
|
||||||
|
|
||||||
|
**Recommendation: Lucide Icons (NOT emoji)**
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- **Professional appearance**: Emojis inconsistent across platforms
|
||||||
|
- **Customizable**: Size, color, stroke width
|
||||||
|
- **Lightweight**: Tree-shakeable SVG imports (~1KB per icon)
|
||||||
|
- **Status coloring**: Icons can match card status colors
|
||||||
|
- **Accessibility**: Proper ARIA labels
|
||||||
|
- **Vue components**: `lucide-vue-next` package
|
||||||
|
|
||||||
|
**Icon mapping:**
|
||||||
|
```typescript
|
||||||
|
Play → PlayCircle
|
||||||
|
Pause → PauseCircle
|
||||||
|
Stop → StopCircle
|
||||||
|
Next → SkipForward
|
||||||
|
Volume → Volume2 / VolumeX (muted)
|
||||||
|
Renderer → Speaker / MonitorSpeaker
|
||||||
|
Server → Server / Database
|
||||||
|
Queue → ListMusic
|
||||||
|
```
|
||||||
|
|
||||||
|
**Alternative if you prefer minimal bundle:** Heroicons
|
||||||
|
- Smaller set (fewer icons)
|
||||||
|
- Tailwind CSS integration
|
||||||
|
- But: less comprehensive for music player needs
|
||||||
|
|
||||||
|
**Why NOT emoji:**
|
||||||
|
- Platform inconsistencies (iOS ≠ Android ≠ Windows)
|
||||||
|
- No color control
|
||||||
|
- Accessibility issues
|
||||||
|
- Unprofessional for production UI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Architecture
|
||||||
|
|
||||||
|
### Real-time SSE Integration
|
||||||
|
|
||||||
|
**SSE Event Handling:**
|
||||||
|
```typescript
|
||||||
|
// services/controlPointSSE.ts
|
||||||
|
class ControlPointSSE {
|
||||||
|
private eventSource: EventSource
|
||||||
|
private renderersStore: ReturnType<typeof useRenderersStore>
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.eventSource = new EventSource('/api/control/events')
|
||||||
|
|
||||||
|
this.eventSource.addEventListener('control', (e) => {
|
||||||
|
const event = JSON.parse(e.data)
|
||||||
|
|
||||||
|
if (event.category === 'renderer') {
|
||||||
|
this.handleRendererEvent(event)
|
||||||
|
} else if (event.category === 'media_server') {
|
||||||
|
this.handleServerEvent(event)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
handleRendererEvent(event: RendererEventPayload) {
|
||||||
|
switch (event.type) {
|
||||||
|
case 'state_changed':
|
||||||
|
this.renderersStore.updateState(event.renderer_id, event.state)
|
||||||
|
break
|
||||||
|
case 'volume_changed':
|
||||||
|
this.renderersStore.updateVolume(event.renderer_id, event.volume)
|
||||||
|
break
|
||||||
|
// ... handle all event types
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Store Integration:**
|
||||||
|
```typescript
|
||||||
|
// stores/renderers.ts
|
||||||
|
export const useRenderersStore = defineStore('renderers', () => {
|
||||||
|
const renderers = ref<Map<string, RendererState>>(new Map())
|
||||||
|
|
||||||
|
// SSE updates
|
||||||
|
function updateState(id: string, state: string) {
|
||||||
|
const renderer = renderers.value.get(id)
|
||||||
|
if (renderer) {
|
||||||
|
renderer.transport_state = state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// REST API calls
|
||||||
|
async function play(id: string) {
|
||||||
|
await fetch(`/api/control/renderers/${id}/play`, { method: 'POST' })
|
||||||
|
// SSE will update state automatically
|
||||||
|
}
|
||||||
|
|
||||||
|
return { renderers, updateState, play }
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Performance Optimizations
|
||||||
|
|
||||||
|
**For 20+ concurrent clients:**
|
||||||
|
|
||||||
|
1. **Throttle position updates**:
|
||||||
|
```typescript
|
||||||
|
const throttledPositionUpdate = throttle((id, pos) => {
|
||||||
|
store.updatePosition(id, pos)
|
||||||
|
}, 1000) // Max 1 update/second
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Virtual scrolling** for renderer lists:
|
||||||
|
```bash
|
||||||
|
npm install vue-virtual-scroller
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Lazy load album art**:
|
||||||
|
```vue
|
||||||
|
<img :src="albumArt" loading="lazy" />
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Debounce volume sliders**:
|
||||||
|
```typescript
|
||||||
|
const debouncedVolumeChange = debounce((id, vol) => {
|
||||||
|
api.setVolume(id, vol)
|
||||||
|
}, 300)
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Memoize computed properties**:
|
||||||
|
```typescript
|
||||||
|
const activeRenderers = computed(() =>
|
||||||
|
renderers.value.filter(r => r.online)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Roadmap
|
||||||
|
|
||||||
|
### Phase 1: Core Infrastructure (Week 1)
|
||||||
|
1. Install Pinia + configure stores
|
||||||
|
2. Install Lucide Icons
|
||||||
|
3. Create SSE service layer
|
||||||
|
4. Setup store structure (renderers, servers, playback, ui)
|
||||||
|
5. Connect SSE events to stores
|
||||||
|
|
||||||
|
### Phase 2: UI Components (Week 2)
|
||||||
|
5. Build RendererCard component (status-colored)
|
||||||
|
6. Build TransportControls component
|
||||||
|
7. Build VolumeControl component
|
||||||
|
8. Build QueueViewer component
|
||||||
|
9. Create responsive grid layouts
|
||||||
|
|
||||||
|
### Phase 3: Dashboard Assembly (Week 3)
|
||||||
|
10. Create PMOControl home view
|
||||||
|
11. Integrate all components
|
||||||
|
12. Add media server browser panel
|
||||||
|
13. Implement responsive breakpoints
|
||||||
|
14. Add loading states & error handling
|
||||||
|
|
||||||
|
### Phase 4: Polish & Testing (Week 4)
|
||||||
|
15. Test with 20+ concurrent clients
|
||||||
|
16. Performance profiling & optimization
|
||||||
|
17. Accessibility audit (ARIA, keyboard nav)
|
||||||
|
18. Cross-browser testing
|
||||||
|
19. Mobile/tablet testing
|
||||||
|
20. Documentation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies to Install
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"pinia": "^2.2.8",
|
||||||
|
"lucide-vue-next": "^0.470.0",
|
||||||
|
"vue-virtual-scroller": "^2.0.0-beta.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
// Already installed: vue, vue-router, typescript, vite
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Total bundle size estimate:** +15KB gzipped (Pinia + Lucide + Virtual Scroller)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status-based Coloring Scheme
|
||||||
|
|
||||||
|
Based on "carte uniforme, responsive, colorée selon statut" spec:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Renderer Card Status Colors */
|
||||||
|
.renderer-card.playing {
|
||||||
|
border-color: #22c55e; /* green */
|
||||||
|
background: linear-gradient(135deg, #22c55e10, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.renderer-card.paused {
|
||||||
|
border-color: #f59e0b; /* amber */
|
||||||
|
background: linear-gradient(135deg, #f59e0b10, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.renderer-card.stopped {
|
||||||
|
border-color: #6b7280; /* gray */
|
||||||
|
background: linear-gradient(135deg, #6b728010, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.renderer-card.offline {
|
||||||
|
border-color: #ef4444; /* red */
|
||||||
|
background: linear-gradient(135deg, #ef444410, transparent);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.renderer-card.transitioning {
|
||||||
|
border-color: #3b82f6; /* blue */
|
||||||
|
background: linear-gradient(135deg, #3b82f610, transparent);
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Answers to Your Specific Questions
|
||||||
|
|
||||||
|
### 1. State Management?
|
||||||
|
**Answer: Pinia** - Vue 3 official, perfect for SSE real-time updates, TypeScript native, lightweight
|
||||||
|
|
||||||
|
### 2. UI Component Library?
|
||||||
|
**Answer: Headless UI (Shadcn-vue) + Custom Components** - Full control over status-based styling, lightweight, no theme lock-in
|
||||||
|
|
||||||
|
### 3. Keep existing components?
|
||||||
|
**Answer: Yes, reorganize into Debug section** - Keep valuable dev tools, make PMOControl the new home screen
|
||||||
|
|
||||||
|
### 4. Responsive breakpoints?
|
||||||
|
**Answer: Mobile-first with 3 breakpoints** - <768px (mobile), 768-1024px (tablet), >1024px (desktop)
|
||||||
|
|
||||||
|
### 5. Icons?
|
||||||
|
**Answer: Lucide Icons (SVG library)** - Professional, customizable, status-colored, NOT emoji
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risk Mitigation
|
||||||
|
|
||||||
|
**Potential challenges:**
|
||||||
|
|
||||||
|
1. **SSE connection management across tabs**
|
||||||
|
- Solution: Use BroadcastChannel API for cross-tab sync
|
||||||
|
- Fallback: LocalStorage events
|
||||||
|
|
||||||
|
2. **20+ renderers performance**
|
||||||
|
- Solution: Virtual scrolling + throttled updates
|
||||||
|
- Monitor: Chrome DevTools Performance profiler
|
||||||
|
|
||||||
|
3. **Network reliability (SSE reconnection)**
|
||||||
|
- Solution: Exponential backoff reconnection
|
||||||
|
- UI indicator for connection status
|
||||||
|
|
||||||
|
4. **Album art loading (CORS, 404s)**
|
||||||
|
- Solution: Proxy through backend
|
||||||
|
- Fallback: Default placeholder image
|
||||||
|
|
||||||
|
5. **Browser compatibility (SSE support)**
|
||||||
|
- Chrome/Edge: Native support ✅
|
||||||
|
- Firefox: Native support ✅
|
||||||
|
- Safari: Native support ✅
|
||||||
|
- IE11: Use EventSource polyfill
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Metrics
|
||||||
|
|
||||||
|
**Performance targets:**
|
||||||
|
- Initial load: <2s (FCP)
|
||||||
|
- SSE event latency: <100ms
|
||||||
|
- UI interaction: <16ms (60fps)
|
||||||
|
- Memory usage: <50MB with 20 renderers
|
||||||
|
- Bundle size: <250KB gzipped
|
||||||
|
|
||||||
|
**Functionality checklist:**
|
||||||
|
- [ ] Display all discovered renderers in real-time
|
||||||
|
- [ ] Show accurate playback state (play/pause/stop)
|
||||||
|
- [ ] Volume control works across all renderer types
|
||||||
|
- [ ] Queue display syncs with server
|
||||||
|
- [ ] Media server browsing functional
|
||||||
|
- [ ] Playlist attachment working
|
||||||
|
- [ ] Responsive on mobile/tablet/desktop
|
||||||
|
- [ ] Accessible (WCAG AA compliance)
|
||||||
|
- [ ] 20+ concurrent clients supported
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. **Review & approve** this plan with stakeholders
|
||||||
|
2. **Clarify any ambiguities** in requirements
|
||||||
|
3. **Set up development environment** (install dependencies)
|
||||||
|
4. **Begin Phase 1** (Core Infrastructure)
|
||||||
|
|
||||||
|
Would you like me to proceed with implementation, or do you have questions about any of these recommendations?
|
||||||
187
pmoapp/webapp/package-lock.json
generated
187
pmoapp/webapp/package-lock.json
generated
@@ -9,9 +9,12 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dompurify": "^3.2.7",
|
"dompurify": "^3.2.7",
|
||||||
|
"lucide-vue-next": "^0.555.0",
|
||||||
"marked": "^16.3.0",
|
"marked": "^16.3.0",
|
||||||
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.21",
|
"vue": "^3.5.21",
|
||||||
"vue-router": "^4.5.1"
|
"vue-router": "^4.5.1",
|
||||||
|
"vue-virtual-scroller": "^2.0.0-beta.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.0.5",
|
||||||
@@ -957,6 +960,36 @@
|
|||||||
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
|
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@vue/devtools-kit": {
|
||||||
|
"version": "7.7.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz",
|
||||||
|
"integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vue/devtools-shared": "^7.7.9",
|
||||||
|
"birpc": "^2.3.0",
|
||||||
|
"hookable": "^5.5.3",
|
||||||
|
"mitt": "^3.0.1",
|
||||||
|
"perfect-debounce": "^1.0.0",
|
||||||
|
"speakingurl": "^14.0.1",
|
||||||
|
"superjson": "^2.2.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vue/devtools-kit/node_modules/mitt": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@vue/devtools-shared": {
|
||||||
|
"version": "7.7.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz",
|
||||||
|
"integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"rfdc": "^1.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vue/language-core": {
|
"node_modules/@vue/language-core": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.1.0.tgz",
|
||||||
@@ -1057,6 +1090,30 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/birpc": {
|
||||||
|
"version": "2.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
|
||||||
|
"integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/antfu"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/copy-anything": {
|
||||||
|
"version": "4.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz",
|
||||||
|
"integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-what": "^5.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/mesqueeb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||||
@@ -1165,6 +1222,33 @@
|
|||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hookable": {
|
||||||
|
"version": "5.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
|
||||||
|
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/is-what": {
|
||||||
|
"version": "5.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
|
||||||
|
"integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/mesqueeb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lucide-vue-next": {
|
||||||
|
"version": "0.555.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-0.555.0.tgz",
|
||||||
|
"integrity": "sha512-7hczPsiMD/y+VNLpal5Q5Wv09kQxlHS0l/cM1xagrd+MA3i5umMm+PUXqllvsbgwAl3PHv27fo59h4PN02GM5A==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": ">=3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.19",
|
"version": "0.30.19",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz",
|
||||||
@@ -1186,6 +1270,12 @@
|
|||||||
"node": ">= 20"
|
"node": ">= 20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mitt": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mitt/-/mitt-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-ILj2TpLiysu2wkBbWjAmww7TkZb65aiQO+DkVdUTBpBXq+MHYiETENkKFMtsJZX1Lf4pe4QOrTSjIfUwN5lRdg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/muggle-string": {
|
"node_modules/muggle-string": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
|
||||||
@@ -1218,6 +1308,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/perfect-debounce": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -1238,6 +1334,36 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pinia": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vue/devtools-api": "^7.7.7"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/posva"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": ">=4.5.0",
|
||||||
|
"vue": "^3.5.11"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pinia/node_modules/@vue/devtools-api": {
|
||||||
|
"version": "7.7.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz",
|
||||||
|
"integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vue/devtools-kit": "^7.7.9"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.6",
|
"version": "8.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||||
@@ -1266,6 +1392,12 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rfdc": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.52.3",
|
"version": "4.52.3",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.3.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.3.tgz",
|
||||||
@@ -1317,6 +1449,27 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/speakingurl": {
|
||||||
|
"version": "14.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
|
||||||
|
"integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/superjson": {
|
||||||
|
"version": "2.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz",
|
||||||
|
"integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"copy-anything": "^4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
@@ -1454,6 +1607,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vue-observe-visibility": {
|
||||||
|
"version": "2.0.0-alpha.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue-observe-visibility/-/vue-observe-visibility-2.0.0-alpha.1.tgz",
|
||||||
|
"integrity": "sha512-flFbp/gs9pZniXR6fans8smv1kDScJ8RS7rEpMjhVabiKeq7Qz3D9+eGsypncjfIyyU84saU88XZ0zjbD6Gq/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vue-resize": {
|
||||||
|
"version": "2.0.0-alpha.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue-resize/-/vue-resize-2.0.0-alpha.1.tgz",
|
||||||
|
"integrity": "sha512-7+iqOueLU7uc9NrMfrzbG8hwMqchfVfSzpVlCMeJQe4pyibqyoifDNbKTZvwxZKDvGkB+PdFeKvnGZMoEb8esg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vue-router": {
|
"node_modules/vue-router": {
|
||||||
"version": "4.5.1",
|
"version": "4.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz",
|
||||||
@@ -1485,6 +1656,20 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": ">=5.0.0"
|
"typescript": ">=5.0.0"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vue-virtual-scroller": {
|
||||||
|
"version": "2.0.0-beta.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue-virtual-scroller/-/vue-virtual-scroller-2.0.0-beta.8.tgz",
|
||||||
|
"integrity": "sha512-b8/f5NQ5nIEBRTNi6GcPItE4s7kxNHw2AIHLtDp+2QvqdTjVN0FgONwX9cr53jWRgnu+HRLPaWDOR2JPI5MTfQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mitt": "^2.1.0",
|
||||||
|
"vue-observe-visibility": "^2.0.0-alpha.1",
|
||||||
|
"vue-resize": "^2.0.0-alpha.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.2.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,12 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dompurify": "^3.2.7",
|
"dompurify": "^3.2.7",
|
||||||
|
"lucide-vue-next": "^0.555.0",
|
||||||
"marked": "^16.3.0",
|
"marked": "^16.3.0",
|
||||||
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.21",
|
"vue": "^3.5.21",
|
||||||
"vue-router": "^4.5.1"
|
"vue-router": "^4.5.1",
|
||||||
|
"vue-virtual-scroller": "^2.0.0-beta.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.0.5",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-container">
|
<div class="app-container">
|
||||||
<nav class="main-nav">
|
<nav class="main-nav">
|
||||||
<router-link to="/">🏠 Accueil</router-link>
|
<router-link to="/" class="nav-logo">PMOControl</router-link>
|
||||||
|
|
||||||
<!-- Menu déroulant Debug -->
|
<!-- Menu déroulant Debug -->
|
||||||
<div class="dropdown" @mouseenter="showDebugMenu = true" @mouseleave="showDebugMenu = false">
|
<div class="dropdown" @mouseenter="showDebugMenu = true" @mouseleave="showDebugMenu = false">
|
||||||
@@ -10,14 +10,15 @@
|
|||||||
<span class="arrow">{{ showDebugMenu ? '▼' : '▶' }}</span>
|
<span class="arrow">{{ showDebugMenu ? '▼' : '▶' }}</span>
|
||||||
</button>
|
</button>
|
||||||
<div v-show="showDebugMenu" class="dropdown-menu">
|
<div v-show="showDebugMenu" class="dropdown-menu">
|
||||||
<router-link to="/logs" @click="showDebugMenu = false">📋 Logs</router-link>
|
<router-link to="/debug/generic-player" @click="showDebugMenu = false">🎵 Generic Player</router-link>
|
||||||
<router-link to="/upnp" @click="showDebugMenu = false">🎵 UPnP Explorer</router-link>
|
<router-link to="/debug/logs" @click="showDebugMenu = false">📋 Logs</router-link>
|
||||||
<router-link to="/covers-cache" @click="showDebugMenu = false">🎨 Cover Cache</router-link>
|
<router-link to="/debug/upnp" @click="showDebugMenu = false">🎵 UPnP Explorer</router-link>
|
||||||
<router-link to="/audio-cache" @click="showDebugMenu = false">🎵 Audio Cache</router-link>
|
<router-link to="/debug/covers-cache" @click="showDebugMenu = false">🎨 Cover Cache</router-link>
|
||||||
<router-link to="/api-dashboard" @click="showDebugMenu = false">🚀 API Dashboard</router-link>
|
<router-link to="/debug/audio-cache" @click="showDebugMenu = false">🎵 Audio Cache</router-link>
|
||||||
|
<router-link to="/debug/api-dashboard" @click="showDebugMenu = false">🚀 API Dashboard</router-link>
|
||||||
|
|
||||||
<div class="submenu-divider">Sources</div>
|
<div class="submenu-divider">Sources</div>
|
||||||
<router-link to="/radio-paradise" @click="showDebugMenu = false">📻 Radio Paradise</router-link>
|
<router-link to="/debug/radio-paradise" @click="showDebugMenu = false">📻 Radio Paradise</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -35,7 +36,7 @@ const showDebugMenu = ref(false)
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
const isDebugRoute = computed(() => {
|
const isDebugRoute = computed(() => {
|
||||||
return ['/logs', '/upnp', '/covers-cache', '/audio-cache', '/api-dashboard', '/radio-paradise'].includes(route.path)
|
return route.path.startsWith('/debug/')
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -63,6 +64,25 @@ const isDebugRoute = computed(() => {
|
|||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-logo {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff !important;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
padding: 0.5rem 1rem !important;
|
||||||
|
border-radius: 6px;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-logo:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
|
||||||
|
}
|
||||||
|
|
||||||
.main-nav a {
|
.main-nav a {
|
||||||
color: #eee;
|
color: #eee;
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
@@ -77,7 +97,7 @@ const isDebugRoute = computed(() => {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-nav a.router-link-active {
|
.main-nav a.router-link-active:not(.nav-logo) {
|
||||||
background: #569cd6;
|
background: #569cd6;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
|||||||
436
pmoapp/webapp/src/assets/styles/pmocontrol.css
Normal file
436
pmoapp/webapp/src/assets/styles/pmocontrol.css
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
/* Styles PMOControl spécifiques */
|
||||||
|
@import './variables.css';
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Reset & Base Styles
|
||||||
|
======================================== */
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
color: var(--color-text);
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Touch-friendly tap targets on mobile */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
button, a, [role="button"] {
|
||||||
|
min-height: 44px;
|
||||||
|
min-width: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus visible for accessibility */
|
||||||
|
*:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Status Badge Styles
|
||||||
|
======================================== */
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.playing {
|
||||||
|
background-color: var(--status-playing-bg);
|
||||||
|
color: var(--status-playing);
|
||||||
|
border: 1px solid var(--status-playing);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.paused {
|
||||||
|
background-color: var(--status-paused-bg);
|
||||||
|
color: var(--status-paused);
|
||||||
|
border: 1px solid var(--status-paused);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.stopped {
|
||||||
|
background-color: var(--status-stopped-bg);
|
||||||
|
color: var(--status-stopped);
|
||||||
|
border: 1px solid var(--status-stopped);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.offline {
|
||||||
|
background-color: var(--status-offline-bg);
|
||||||
|
color: var(--status-offline);
|
||||||
|
border: 1px solid var(--status-offline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.transitioning {
|
||||||
|
background-color: var(--status-transitioning-bg);
|
||||||
|
color: var(--status-transitioning);
|
||||||
|
border: 1px solid var(--status-transitioning);
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Card Styles
|
||||||
|
======================================== */
|
||||||
|
.pmo-card {
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: box-shadow var(--transition-base), transform var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pmo-card:hover {
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pmo-card.active {
|
||||||
|
border-color: var(--status-playing);
|
||||||
|
box-shadow: 0 0 0 2px var(--status-playing-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Renderer Card Status Borders
|
||||||
|
======================================== */
|
||||||
|
.renderer-card {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.renderer-card.playing {
|
||||||
|
border-left: 4px solid var(--status-playing);
|
||||||
|
}
|
||||||
|
|
||||||
|
.renderer-card.paused {
|
||||||
|
border-left: 4px solid var(--status-paused);
|
||||||
|
}
|
||||||
|
|
||||||
|
.renderer-card.stopped {
|
||||||
|
border-left: 4px solid var(--status-stopped);
|
||||||
|
}
|
||||||
|
|
||||||
|
.renderer-card.offline {
|
||||||
|
border-left: 4px solid var(--status-offline);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Queue Item Styles
|
||||||
|
======================================== */
|
||||||
|
.queue-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition: background-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item:hover {
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item.current {
|
||||||
|
background-color: var(--status-playing-bg);
|
||||||
|
border: 1px solid var(--status-playing);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item.current::before {
|
||||||
|
content: '▶';
|
||||||
|
color: var(--status-playing);
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
margin-right: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Button Styles
|
||||||
|
======================================== */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
padding: var(--spacing-sm) var(--spacing-lg);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
background-color: var(--color-bg-tertiary);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--status-playing);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background-color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
aspect-ratio: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Grid Layouts (Responsive)
|
||||||
|
======================================== */
|
||||||
|
.grid-cards {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tablet: 2 columns */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.grid-cards {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Desktop: 3 columns */
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.grid-cards {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Animations
|
||||||
|
======================================== */
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInRight {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in {
|
||||||
|
animation: fadeIn var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduced motion support */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Performance Optimizations
|
||||||
|
======================================== */
|
||||||
|
/* Use will-change for frequently animated elements */
|
||||||
|
.progress-bar-fill,
|
||||||
|
input[type="range"]::-webkit-slider-thumb,
|
||||||
|
input[type="range"]::-moz-range-thumb {
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover,
|
||||||
|
.pmo-card:hover {
|
||||||
|
will-change: transform, box-shadow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* GPU acceleration for smoother animations */
|
||||||
|
.fade-in,
|
||||||
|
.spin,
|
||||||
|
.status-badge.transitioning {
|
||||||
|
transform: translateZ(0);
|
||||||
|
backface-visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Volume Slider
|
||||||
|
======================================== */
|
||||||
|
input[type="range"] {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background-color: var(--color-bg-tertiary);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--status-playing);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-webkit-slider-thumb:hover {
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-moz-range-thumb {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--status-playing);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-moz-range-thumb:hover {
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Progress Bar (Seekbar)
|
||||||
|
======================================== */
|
||||||
|
.progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
background-color: var(--color-bg-tertiary);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--status-playing);
|
||||||
|
transition: width 100ms linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Notification Toast
|
||||||
|
======================================== */
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: var(--spacing-lg);
|
||||||
|
right: var(--spacing-lg);
|
||||||
|
z-index: var(--z-toast);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-item {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
animation: fadeIn var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-item.success {
|
||||||
|
background-color: var(--status-playing);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-item.error {
|
||||||
|
background-color: var(--status-offline);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-item.warning {
|
||||||
|
background-color: var(--status-paused);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-item.info {
|
||||||
|
background-color: var(--status-transitioning);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Utilities
|
||||||
|
======================================== */
|
||||||
|
.text-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.truncate {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border-width: 0;
|
||||||
|
}
|
||||||
113
pmoapp/webapp/src/assets/styles/variables.css
Normal file
113
pmoapp/webapp/src/assets/styles/variables.css
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
/* Variables CSS globales pour PMOControl */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* ========================================
|
||||||
|
Status Colors
|
||||||
|
======================================== */
|
||||||
|
--status-playing: #22c55e; /* Vert pour en lecture */
|
||||||
|
--status-playing-bg: #22c55e15; /* Background playing */
|
||||||
|
--status-paused: #f59e0b; /* Orange pour en pause */
|
||||||
|
--status-paused-bg: #f59e0b15; /* Background paused */
|
||||||
|
--status-stopped: #6b7280; /* Gris pour arrêté */
|
||||||
|
--status-stopped-bg: #6b728015; /* Background stopped */
|
||||||
|
--status-offline: #ef4444; /* Rouge pour offline */
|
||||||
|
--status-offline-bg: #ef444415; /* Background offline */
|
||||||
|
--status-transitioning: #3b82f6; /* Bleu pour transition */
|
||||||
|
--status-transitioning-bg: #3b82f615; /* Background transitioning */
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Breakpoints (pour media queries)
|
||||||
|
======================================== */
|
||||||
|
--breakpoint-mobile: 768px;
|
||||||
|
--breakpoint-tablet: 1024px;
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Spacing Scale
|
||||||
|
======================================== */
|
||||||
|
--spacing-xs: 0.25rem; /* 4px */
|
||||||
|
--spacing-sm: 0.5rem; /* 8px */
|
||||||
|
--spacing-md: 1rem; /* 16px */
|
||||||
|
--spacing-lg: 1.5rem; /* 24px */
|
||||||
|
--spacing-xl: 2rem; /* 32px */
|
||||||
|
--spacing-2xl: 3rem; /* 48px */
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Border Radius
|
||||||
|
======================================== */
|
||||||
|
--radius-sm: 0.25rem; /* 4px */
|
||||||
|
--radius-md: 0.5rem; /* 8px */
|
||||||
|
--radius-lg: 1rem; /* 16px */
|
||||||
|
--radius-full: 9999px; /* Pill shape */
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Shadows
|
||||||
|
======================================== */
|
||||||
|
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||||
|
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
|
||||||
|
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1);
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Typography
|
||||||
|
======================================== */
|
||||||
|
--font-sans: system-ui, -apple-system, sans-serif;
|
||||||
|
--font-mono: ui-monospace, monospace;
|
||||||
|
|
||||||
|
--text-xs: 0.75rem; /* 12px */
|
||||||
|
--text-sm: 0.875rem; /* 14px */
|
||||||
|
--text-base: 1rem; /* 16px */
|
||||||
|
--text-lg: 1.125rem; /* 18px */
|
||||||
|
--text-xl: 1.25rem; /* 20px */
|
||||||
|
--text-2xl: 1.5rem; /* 24px */
|
||||||
|
--text-3xl: 1.875rem; /* 30px */
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Colors (neutral palette)
|
||||||
|
======================================== */
|
||||||
|
--color-bg: #ffffff;
|
||||||
|
--color-bg-secondary: #f3f4f6;
|
||||||
|
--color-bg-tertiary: #e5e7eb;
|
||||||
|
|
||||||
|
--color-text: #111827;
|
||||||
|
--color-text-secondary: #6b7280;
|
||||||
|
--color-text-tertiary: #9ca3af;
|
||||||
|
|
||||||
|
--color-border: #d1d5db;
|
||||||
|
--color-border-light: #e5e7eb;
|
||||||
|
|
||||||
|
/* Primary color (brand) */
|
||||||
|
--color-primary: #667eea;
|
||||||
|
--color-primary-hover: #5568d3;
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Transitions
|
||||||
|
======================================== */
|
||||||
|
--transition-fast: 150ms ease-in-out;
|
||||||
|
--transition-normal: 250ms ease-in-out;
|
||||||
|
--transition-base: 300ms ease-in-out;
|
||||||
|
--transition-slow: 500ms ease-in-out;
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Z-index layers
|
||||||
|
======================================== */
|
||||||
|
--z-dropdown: 100;
|
||||||
|
--z-modal: 200;
|
||||||
|
--z-toast: 300;
|
||||||
|
--z-tooltip: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode support (optionnel pour l'avenir) */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--color-bg: #111827;
|
||||||
|
--color-bg-secondary: #1f2937;
|
||||||
|
--color-bg-tertiary: #374151;
|
||||||
|
|
||||||
|
--color-text: #f9fafb;
|
||||||
|
--color-text-secondary: #d1d5db;
|
||||||
|
--color-text-tertiary: #9ca3af;
|
||||||
|
|
||||||
|
--color-border: #4b5563;
|
||||||
|
--color-border-light: #374151;
|
||||||
|
}
|
||||||
|
}
|
||||||
283
pmoapp/webapp/src/components/pmocontrol/ActionMenu.vue
Normal file
283
pmoapp/webapp/src/components/pmocontrol/ActionMenu.vue
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useRenderersStore } from '@/stores/renderers'
|
||||||
|
import { Play, ListPlus, Link, ChevronRight } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
type: 'container' | 'item'
|
||||||
|
entryId: string
|
||||||
|
serverId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
playNow: [rendererId: string]
|
||||||
|
addToQueue: [rendererId: string]
|
||||||
|
attachPlaylist: [rendererId: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const renderersStore = useRenderersStore()
|
||||||
|
const showMenu = ref(false)
|
||||||
|
const showRendererSubmenu = ref<'play' | 'queue' | 'attach' | null>(null)
|
||||||
|
|
||||||
|
const onlineRenderers = computed(() => renderersStore.onlineRenderers)
|
||||||
|
|
||||||
|
function handleAction(action: 'play' | 'queue' | 'attach', rendererId: string) {
|
||||||
|
showMenu.value = false
|
||||||
|
showRendererSubmenu.value = null
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'play':
|
||||||
|
emit('playNow', rendererId)
|
||||||
|
break
|
||||||
|
case 'queue':
|
||||||
|
emit('addToQueue', rendererId)
|
||||||
|
break
|
||||||
|
case 'attach':
|
||||||
|
emit('attachPlaylist', rendererId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMenu() {
|
||||||
|
showMenu.value = !showMenu.value
|
||||||
|
if (!showMenu.value) {
|
||||||
|
showRendererSubmenu.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="action-menu-container" @click.stop>
|
||||||
|
<button class="action-menu-trigger" @click="toggleMenu" :title="'Actions'">
|
||||||
|
⋮
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="showMenu" class="action-menu" @click.stop>
|
||||||
|
<!-- Play Now -->
|
||||||
|
<div
|
||||||
|
class="menu-item"
|
||||||
|
@mouseenter="showRendererSubmenu = 'play'"
|
||||||
|
@mouseleave="showRendererSubmenu = null"
|
||||||
|
>
|
||||||
|
<Play :size="16" />
|
||||||
|
<span>Lire maintenant</span>
|
||||||
|
<ChevronRight :size="16" class="submenu-arrow" />
|
||||||
|
|
||||||
|
<!-- Submenu renderers -->
|
||||||
|
<div v-if="showRendererSubmenu === 'play'" class="renderer-submenu">
|
||||||
|
<div
|
||||||
|
v-for="renderer in onlineRenderers"
|
||||||
|
:key="renderer.id"
|
||||||
|
class="submenu-item"
|
||||||
|
@click="handleAction('play', renderer.id)"
|
||||||
|
>
|
||||||
|
{{ renderer.friendly_name }}
|
||||||
|
</div>
|
||||||
|
<div v-if="onlineRenderers.length === 0" class="submenu-empty">
|
||||||
|
Aucun renderer disponible
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add to Queue -->
|
||||||
|
<div
|
||||||
|
class="menu-item"
|
||||||
|
@mouseenter="showRendererSubmenu = 'queue'"
|
||||||
|
@mouseleave="showRendererSubmenu = null"
|
||||||
|
>
|
||||||
|
<ListPlus :size="16" />
|
||||||
|
<span>Ajouter à la queue</span>
|
||||||
|
<ChevronRight :size="16" class="submenu-arrow" />
|
||||||
|
|
||||||
|
<div v-if="showRendererSubmenu === 'queue'" class="renderer-submenu">
|
||||||
|
<div
|
||||||
|
v-for="renderer in onlineRenderers"
|
||||||
|
:key="renderer.id"
|
||||||
|
class="submenu-item"
|
||||||
|
@click="handleAction('queue', renderer.id)"
|
||||||
|
>
|
||||||
|
{{ renderer.friendly_name }}
|
||||||
|
</div>
|
||||||
|
<div v-if="onlineRenderers.length === 0" class="submenu-empty">
|
||||||
|
Aucun renderer disponible
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Attach Playlist (only for containers) -->
|
||||||
|
<div
|
||||||
|
v-if="type === 'container'"
|
||||||
|
class="menu-item"
|
||||||
|
@mouseenter="showRendererSubmenu = 'attach'"
|
||||||
|
@mouseleave="showRendererSubmenu = null"
|
||||||
|
>
|
||||||
|
<Link :size="16" />
|
||||||
|
<span>Attacher la queue de</span>
|
||||||
|
<ChevronRight :size="16" class="submenu-arrow" />
|
||||||
|
|
||||||
|
<div v-if="showRendererSubmenu === 'attach'" class="renderer-submenu">
|
||||||
|
<div
|
||||||
|
v-for="renderer in onlineRenderers"
|
||||||
|
:key="renderer.id"
|
||||||
|
class="submenu-item"
|
||||||
|
@click="handleAction('attach', renderer.id)"
|
||||||
|
>
|
||||||
|
{{ renderer.friendly_name }}
|
||||||
|
</div>
|
||||||
|
<div v-if="onlineRenderers.length === 0" class="submenu-empty">
|
||||||
|
Aucun renderer disponible
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Backdrop to close menu -->
|
||||||
|
<div v-if="showMenu" class="menu-backdrop" @click="showMenu = false"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.action-menu-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-menu-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-menu-trigger:hover {
|
||||||
|
background-color: var(--color-bg-tertiary);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-menu {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 100%;
|
||||||
|
margin-top: var(--spacing-xs);
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
min-width: 200px;
|
||||||
|
z-index: var(--z-dropdown);
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item:hover {
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item:first-child {
|
||||||
|
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item:last-child {
|
||||||
|
border-radius: 0 0 var(--radius-md) var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item:only-child {
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item svg:first-child {
|
||||||
|
color: var(--color-primary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item span {
|
||||||
|
flex: 1;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu-arrow {
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.renderer-submenu {
|
||||||
|
position: absolute;
|
||||||
|
left: 100%;
|
||||||
|
top: 0;
|
||||||
|
margin-left: var(--spacing-xs);
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
min-width: 180px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: calc(var(--z-dropdown) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu-item {
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu-item:hover {
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu-empty {
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: calc(var(--z-dropdown) - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar for long renderer lists */
|
||||||
|
.renderer-submenu::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.renderer-submenu::-webkit-scrollbar-track {
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.renderer-submenu::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-border);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
.renderer-submenu::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
156
pmoapp/webapp/src/components/pmocontrol/Breadcrumb.vue
Normal file
156
pmoapp/webapp/src/components/pmocontrol/Breadcrumb.vue
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ChevronRight, Home } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
export interface BreadcrumbItem {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
items: BreadcrumbItem[]
|
||||||
|
serverId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
navigate: [containerId: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function handleNavigate(containerId: string) {
|
||||||
|
emit('navigate', containerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function goHome() {
|
||||||
|
emit('navigate', '0')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<nav class="breadcrumb" aria-label="Fil d'Ariane">
|
||||||
|
<ol class="breadcrumb-list">
|
||||||
|
<!-- Home -->
|
||||||
|
<li class="breadcrumb-item">
|
||||||
|
<button class="breadcrumb-link" @click="goHome" title="Accueil">
|
||||||
|
<Home :size="16" />
|
||||||
|
<span>Accueil</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<!-- Path items -->
|
||||||
|
<template v-for="(item, index) in items" :key="item.id">
|
||||||
|
<li class="breadcrumb-separator" aria-hidden="true">
|
||||||
|
<ChevronRight :size="16" />
|
||||||
|
</li>
|
||||||
|
<li class="breadcrumb-item">
|
||||||
|
<button
|
||||||
|
v-if="index < items.length - 1"
|
||||||
|
class="breadcrumb-link"
|
||||||
|
@click="handleNavigate(item.id)"
|
||||||
|
:title="item.title"
|
||||||
|
>
|
||||||
|
{{ item.title }}
|
||||||
|
</button>
|
||||||
|
<span v-else class="breadcrumb-current" :title="item.title">
|
||||||
|
{{ item.title }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.breadcrumb {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
padding: var(--spacing-sm) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-list {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-separator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
max-width: 200px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-link:hover {
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-link:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-current {
|
||||||
|
display: inline-block;
|
||||||
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
max-width: 200px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling */
|
||||||
|
.breadcrumb::-webkit-scrollbar {
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb::-webkit-scrollbar-track {
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-border);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.breadcrumb-link,
|
||||||
|
.breadcrumb-current {
|
||||||
|
max-width: 150px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
179
pmoapp/webapp/src/components/pmocontrol/ContainerItem.vue
Normal file
179
pmoapp/webapp/src/components/pmocontrol/ContainerItem.vue
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { ContainerEntry } from '@/services/pmocontrol/types'
|
||||||
|
import { Folder, Music } from 'lucide-vue-next'
|
||||||
|
import ActionMenu from './ActionMenu.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
entry: ContainerEntry
|
||||||
|
serverId: string
|
||||||
|
showActions?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
browse: [containerId: string]
|
||||||
|
playNow: [containerId: string, rendererId: string]
|
||||||
|
addToQueue: [containerId: string, rendererId: string]
|
||||||
|
attachPlaylist: [containerId: string, rendererId: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const iconComponent = computed(() => {
|
||||||
|
const cls = props.entry.class.toLowerCase()
|
||||||
|
if (cls.includes('playlist')) return Music
|
||||||
|
if (cls.includes('album')) return Music
|
||||||
|
return Folder
|
||||||
|
})
|
||||||
|
|
||||||
|
const containerType = computed(() => {
|
||||||
|
const cls = props.entry.class.toLowerCase()
|
||||||
|
if (cls.includes('playlist')) return 'Playlist'
|
||||||
|
if (cls.includes('album')) return 'Album'
|
||||||
|
if (cls.includes('artist')) return 'Artiste'
|
||||||
|
if (cls.includes('genre')) return 'Genre'
|
||||||
|
return 'Dossier'
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleBrowse() {
|
||||||
|
emit('browse', props.entry.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePlayNow(rendererId: string) {
|
||||||
|
emit('playNow', props.entry.id, rendererId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddToQueue(rendererId: string) {
|
||||||
|
emit('addToQueue', props.entry.id, rendererId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAttachPlaylist(rendererId: string) {
|
||||||
|
emit('attachPlaylist', props.entry.id, rendererId)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="container-item">
|
||||||
|
<!-- Main content (clickable) -->
|
||||||
|
<button class="container-content" @click="handleBrowse">
|
||||||
|
<div class="container-icon">
|
||||||
|
<component :is="iconComponent" :size="24" />
|
||||||
|
</div>
|
||||||
|
<div class="container-metadata">
|
||||||
|
<div class="container-title">{{ entry.title }}</div>
|
||||||
|
<div class="container-details">
|
||||||
|
<span class="container-type">{{ containerType }}</span>
|
||||||
|
<span v-if="entry.child_count !== null" class="container-count">
|
||||||
|
{{ entry.child_count }} élément{{ entry.child_count > 1 ? 's' : '' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Actions menu -->
|
||||||
|
<div class="container-actions">
|
||||||
|
<ActionMenu
|
||||||
|
type="container"
|
||||||
|
:entry-id="entry.id"
|
||||||
|
:server-id="serverId"
|
||||||
|
@play-now="handlePlayNow"
|
||||||
|
@add-to-queue="handleAddToQueue"
|
||||||
|
@attach-playlist="handleAttachPlaylist"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.container-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition: background-color var(--transition-fast);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-item:hover {
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: var(--color-bg-tertiary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-metadata {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-title {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-details {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-type {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-count::before {
|
||||||
|
content: '•';
|
||||||
|
margin-right: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-actions {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover {
|
||||||
|
background-color: var(--color-bg-tertiary);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
172
pmoapp/webapp/src/components/pmocontrol/CurrentTrack.vue
Normal file
172
pmoapp/webapp/src/components/pmocontrol/CurrentTrack.vue
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRenderersStore } from '@/stores/renderers'
|
||||||
|
import { usePlaybackStore } from '@/stores/playback'
|
||||||
|
import { Music } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
rendererId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const renderersStore = useRenderersStore()
|
||||||
|
const playbackStore = usePlaybackStore()
|
||||||
|
|
||||||
|
const state = computed(() => renderersStore.getStateById(props.rendererId))
|
||||||
|
const metadata = computed(() => playbackStore.getTrackMetadata(props.rendererId))
|
||||||
|
|
||||||
|
// Calcul du pourcentage de progression
|
||||||
|
const progressPercent = computed(() => {
|
||||||
|
const position = state.value?.position_ms
|
||||||
|
const duration = state.value?.duration_ms
|
||||||
|
if (position && duration && duration > 0) {
|
||||||
|
return (position / duration) * 100
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// Formater la durée en MM:SS
|
||||||
|
function formatTime(ms: number | null | undefined): string {
|
||||||
|
if (!ms) return '--:--'
|
||||||
|
const totalSeconds = Math.floor(ms / 1000)
|
||||||
|
const minutes = Math.floor(totalSeconds / 60)
|
||||||
|
const seconds = totalSeconds % 60
|
||||||
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentTime = computed(() => formatTime(state.value?.position_ms))
|
||||||
|
const totalTime = computed(() => formatTime(state.value?.duration_ms))
|
||||||
|
|
||||||
|
const hasCover = computed(() => !!metadata.value?.album_art_uri)
|
||||||
|
|
||||||
|
function handleImageError(event: Event) {
|
||||||
|
const img = event.target as HTMLImageElement
|
||||||
|
img.style.display = 'none'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="current-track">
|
||||||
|
<!-- Cover Art -->
|
||||||
|
<div class="cover-container">
|
||||||
|
<img
|
||||||
|
v-if="hasCover"
|
||||||
|
:src="metadata?.album_art_uri!"
|
||||||
|
:alt="metadata?.album || 'Album cover'"
|
||||||
|
class="cover-image"
|
||||||
|
loading="lazy"
|
||||||
|
@error="handleImageError"
|
||||||
|
/>
|
||||||
|
<div v-else class="cover-placeholder">
|
||||||
|
<Music :size="64" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Metadata -->
|
||||||
|
<div class="metadata">
|
||||||
|
<h2 class="title">{{ metadata?.title || 'Aucun titre' }}</h2>
|
||||||
|
<p class="artist">{{ metadata?.artist || 'Artiste inconnu' }}</p>
|
||||||
|
<p class="album" v-if="metadata?.album">{{ metadata.album }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress Bar -->
|
||||||
|
<div class="progress-section">
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div
|
||||||
|
class="progress-bar-fill"
|
||||||
|
:style="{ width: `${progressPercent}%` }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="time-display">
|
||||||
|
<span>{{ currentTime }}</span>
|
||||||
|
<span>{{ totalTime }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.current-track {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-container {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
max-width: 300px;
|
||||||
|
margin: 0 auto;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0 0 var(--spacing-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin: 0 0 var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.album {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-display {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.cover-container {
|
||||||
|
max-width: 250px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.cover-container {
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
268
pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue
Normal file
268
pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, watch } from 'vue'
|
||||||
|
import { useMediaServersStore } from '@/stores/mediaServers'
|
||||||
|
import { useRenderersStore } from '@/stores/renderers'
|
||||||
|
import { useUIStore } from '@/stores/ui'
|
||||||
|
import type { BreadcrumbItem } from './Breadcrumb.vue'
|
||||||
|
import Breadcrumb from './Breadcrumb.vue'
|
||||||
|
import ContainerItem from './ContainerItem.vue'
|
||||||
|
import MediaItem from './MediaItem.vue'
|
||||||
|
import { Loader2 } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
serverId: string
|
||||||
|
containerId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const mediaServersStore = useMediaServersStore()
|
||||||
|
const renderersStore = useRenderersStore()
|
||||||
|
const uiStore = useUIStore()
|
||||||
|
|
||||||
|
const browseData = computed(() =>
|
||||||
|
mediaServersStore.getBrowseCached(props.serverId, props.containerId)
|
||||||
|
)
|
||||||
|
|
||||||
|
const containers = computed(() =>
|
||||||
|
browseData.value?.entries.filter((e) => e.is_container) || []
|
||||||
|
)
|
||||||
|
|
||||||
|
const items = computed(() =>
|
||||||
|
browseData.value?.entries.filter((e) => !e.is_container) || []
|
||||||
|
)
|
||||||
|
|
||||||
|
const loading = computed(() => mediaServersStore.loading)
|
||||||
|
const error = computed(() => mediaServersStore.error)
|
||||||
|
|
||||||
|
const breadcrumbPath = computed<BreadcrumbItem[]>(() => mediaServersStore.currentPath)
|
||||||
|
|
||||||
|
// Charger le container au montage et quand containerId change
|
||||||
|
watch(
|
||||||
|
() => props.containerId,
|
||||||
|
async (newContainerId) => {
|
||||||
|
if (newContainerId) {
|
||||||
|
await mediaServersStore.browseContainer(props.serverId, newContainerId)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
navigate: [containerId: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function handleNavigate(containerId: string) {
|
||||||
|
emit('navigate', containerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBrowseContainer(containerId: string) {
|
||||||
|
emit('navigate', containerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions handlers
|
||||||
|
async function handlePlayNow(entryId: string, rendererId: string) {
|
||||||
|
try {
|
||||||
|
// TODO: Implémenter l'action "play now" quand l'API sera disponible
|
||||||
|
console.log('Play now:', entryId, 'on', rendererId)
|
||||||
|
uiStore.addNotification('info', 'Fonctionnalité "Lire maintenant" pas encore implémentée dans l\'API')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erreur play now:', err)
|
||||||
|
uiStore.addNotification('error', 'Erreur lors de la lecture')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddToQueue(entryId: string, rendererId: string) {
|
||||||
|
try {
|
||||||
|
// TODO: Implémenter l'action "add to queue" quand l'API sera disponible
|
||||||
|
console.log('Add to queue:', entryId, 'on', rendererId)
|
||||||
|
uiStore.addNotification('info', 'Fonctionnalité "Ajouter à la queue" pas encore implémentée dans l\'API')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erreur add to queue:', err)
|
||||||
|
uiStore.addNotification('error', 'Erreur lors de l\'ajout à la queue')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAttachPlaylist(containerId: string, rendererId: string) {
|
||||||
|
try {
|
||||||
|
await renderersStore.attachPlaylist(rendererId, props.serverId, containerId)
|
||||||
|
uiStore.addNotification('success', `Queue attachée à la playlist !`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erreur attach playlist:', err)
|
||||||
|
uiStore.addNotification('error', 'Erreur lors de l\'attachement')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="media-browser">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<Breadcrumb
|
||||||
|
:items="breadcrumbPath"
|
||||||
|
:serverId="serverId"
|
||||||
|
@navigate="handleNavigate"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div v-if="loading" class="browser-loading">
|
||||||
|
<Loader2 :size="32" class="spinner" />
|
||||||
|
<p>Chargement...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error state -->
|
||||||
|
<div v-else-if="error" class="browser-error">
|
||||||
|
<p class="error-message">{{ error }}</p>
|
||||||
|
<button class="btn btn-secondary" @click="mediaServersStore.browseContainer(serverId, containerId)">
|
||||||
|
Réessayer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div v-else class="browser-content">
|
||||||
|
<!-- Containers section -->
|
||||||
|
<div v-if="containers.length" class="browser-section">
|
||||||
|
<h3 class="section-title">Dossiers et playlists</h3>
|
||||||
|
<div class="entries-list">
|
||||||
|
<ContainerItem
|
||||||
|
v-for="container in containers"
|
||||||
|
:key="container.id"
|
||||||
|
:entry="container"
|
||||||
|
:server-id="serverId"
|
||||||
|
@browse="handleBrowseContainer"
|
||||||
|
@play-now="handlePlayNow"
|
||||||
|
@add-to-queue="handleAddToQueue"
|
||||||
|
@attach-playlist="handleAttachPlaylist"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Items section -->
|
||||||
|
<div v-if="items.length" class="browser-section">
|
||||||
|
<h3 class="section-title">Pistes</h3>
|
||||||
|
<div class="entries-list">
|
||||||
|
<MediaItem
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item.id"
|
||||||
|
:entry="item"
|
||||||
|
:server-id="serverId"
|
||||||
|
@play-now="handlePlayNow"
|
||||||
|
@add-to-queue="handleAddToQueue"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div v-if="!containers.length && !items.length" class="browser-empty">
|
||||||
|
<p>Ce dossier est vide</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.media-browser {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading */
|
||||||
|
.browser-loading {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error */
|
||||||
|
.browser-error {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
color: var(--status-offline);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content */
|
||||||
|
.browser-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xl);
|
||||||
|
padding-right: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0;
|
||||||
|
padding-bottom: var(--spacing-sm);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entries-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.browser-empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling */
|
||||||
|
.browser-content::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser-content::-webkit-scrollbar-track {
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser-content::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-border);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser-content::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
185
pmoapp/webapp/src/components/pmocontrol/MediaItem.vue
Normal file
185
pmoapp/webapp/src/components/pmocontrol/MediaItem.vue
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ContainerEntry } from '@/services/pmocontrol/types'
|
||||||
|
import { Music } from 'lucide-vue-next'
|
||||||
|
import ActionMenu from './ActionMenu.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
entry: ContainerEntry
|
||||||
|
serverId: string
|
||||||
|
showActions?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
playNow: [itemId: string, rendererId: string]
|
||||||
|
addToQueue: [itemId: string, rendererId: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function handleImageError(event: Event) {
|
||||||
|
const img = event.target as HTMLImageElement
|
||||||
|
img.style.display = 'none'
|
||||||
|
const placeholder = img.nextElementSibling
|
||||||
|
if (placeholder && placeholder instanceof HTMLElement) {
|
||||||
|
placeholder.style.display = 'flex'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePlayNow(rendererId: string) {
|
||||||
|
emit('playNow', props.entry.id, rendererId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddToQueue(rendererId: string) {
|
||||||
|
emit('addToQueue', props.entry.id, rendererId)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="media-item">
|
||||||
|
<!-- Cover miniature -->
|
||||||
|
<div class="media-cover">
|
||||||
|
<img
|
||||||
|
v-if="entry.album_art_uri"
|
||||||
|
:src="entry.album_art_uri"
|
||||||
|
:alt="entry.album || entry.title"
|
||||||
|
class="cover-image"
|
||||||
|
loading="lazy"
|
||||||
|
@error="handleImageError"
|
||||||
|
/>
|
||||||
|
<div class="cover-placeholder" :style="{ display: entry.album_art_uri ? 'none' : 'flex' }">
|
||||||
|
<Music :size="20" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Metadata -->
|
||||||
|
<div class="media-metadata">
|
||||||
|
<div class="media-title">{{ entry.title }}</div>
|
||||||
|
<div class="media-details">
|
||||||
|
<span v-if="entry.artist" class="media-artist">{{ entry.artist }}</span>
|
||||||
|
<span v-if="entry.album" class="media-album">{{ entry.album }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions menu -->
|
||||||
|
<div class="media-actions">
|
||||||
|
<ActionMenu
|
||||||
|
type="item"
|
||||||
|
:entry-id="entry.id"
|
||||||
|
:server-id="serverId"
|
||||||
|
@play-now="handlePlayNow"
|
||||||
|
@add-to-queue="handleAddToQueue"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.media-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition: background-color var(--transition-fast);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-item:hover {
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cover */
|
||||||
|
.media-cover {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: var(--color-bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-placeholder {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Metadata */
|
||||||
|
.media-metadata {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-title {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-details {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-artist {
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-album {
|
||||||
|
flex-shrink: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-album::before {
|
||||||
|
content: '•';
|
||||||
|
margin-right: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Actions */
|
||||||
|
.media-actions {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover {
|
||||||
|
background-color: var(--color-bg-tertiary);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
162
pmoapp/webapp/src/components/pmocontrol/MediaServerCard.vue
Normal file
162
pmoapp/webapp/src/components/pmocontrol/MediaServerCard.vue
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import type { MediaServerSummary } from '@/services/pmocontrol/types'
|
||||||
|
import { Server, Circle } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
server: MediaServerSummary
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const statusClass = computed(() => props.server.online ? 'online' : 'offline')
|
||||||
|
const statusLabel = computed(() => props.server.online ? 'En ligne' : 'Hors ligne')
|
||||||
|
|
||||||
|
function goToServer() {
|
||||||
|
if (props.server.online) {
|
||||||
|
router.push(`/server/${props.server.id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="['media-server-card', { offline: !server.online }]">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="server-icon">
|
||||||
|
<Server :size="40" />
|
||||||
|
</div>
|
||||||
|
<div class="header-content">
|
||||||
|
<h3 class="server-name">{{ server.friendly_name }}</h3>
|
||||||
|
<p class="server-model">{{ server.model_name }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status -->
|
||||||
|
<div class="card-status">
|
||||||
|
<Circle :size="12" :class="['status-indicator', statusClass]" fill="currentColor" />
|
||||||
|
<span :class="['status-label', statusClass]">{{ statusLabel }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Browse Button -->
|
||||||
|
<button
|
||||||
|
class="btn btn-primary card-browse-btn"
|
||||||
|
@click="goToServer"
|
||||||
|
:disabled="!server.online"
|
||||||
|
>
|
||||||
|
Parcourir
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.media-server-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
transition: all var(--transition-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-server-card:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-server-card.offline {
|
||||||
|
opacity: 0.6;
|
||||||
|
filter: grayscale(0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: var(--color-bg-tertiary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-name {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0 0 var(--spacing-xs);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-model {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status */
|
||||||
|
.card-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.online {
|
||||||
|
color: var(--status-playing);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.offline {
|
||||||
|
color: var(--status-offline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-label {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-label.online {
|
||||||
|
color: var(--status-playing);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-label.offline {
|
||||||
|
color: var(--status-offline);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Browse Button */
|
||||||
|
.card-browse-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-browse-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
180
pmoapp/webapp/src/components/pmocontrol/PlaylistBindingPanel.vue
Normal file
180
pmoapp/webapp/src/components/pmocontrol/PlaylistBindingPanel.vue
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRenderersStore } from '@/stores/renderers'
|
||||||
|
import { Link, Unlink } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
rendererId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const renderersStore = useRenderersStore()
|
||||||
|
|
||||||
|
const binding = computed(() => renderersStore.getBindingById(props.rendererId))
|
||||||
|
const isAttached = computed(() => !!binding.value)
|
||||||
|
|
||||||
|
async function handleDetach() {
|
||||||
|
try {
|
||||||
|
await renderersStore.detachPlaylist(props.rendererId)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur detach:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="playlist-binding-panel">
|
||||||
|
<h4 class="panel-title">Synchronisation Playlist</h4>
|
||||||
|
|
||||||
|
<!-- État attaché -->
|
||||||
|
<div v-if="isAttached" class="binding-status attached">
|
||||||
|
<div class="status-info">
|
||||||
|
<Link :size="20" />
|
||||||
|
<div class="status-text">
|
||||||
|
<p class="status-label">Attachée à une playlist</p>
|
||||||
|
<p class="status-details">
|
||||||
|
<span class="detail-label">Serveur:</span>
|
||||||
|
{{ binding?.server_id }}
|
||||||
|
</p>
|
||||||
|
<p class="status-details">
|
||||||
|
<span class="detail-label">Container:</span>
|
||||||
|
{{ binding?.container_id }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn" @click="handleDetach">
|
||||||
|
<Unlink :size="16" />
|
||||||
|
Détacher
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- État détaché -->
|
||||||
|
<div v-else class="binding-status detached">
|
||||||
|
<div class="status-info">
|
||||||
|
<Unlink :size="20" />
|
||||||
|
<div class="status-text">
|
||||||
|
<p class="status-label">Non attachée</p>
|
||||||
|
<p class="status-description">
|
||||||
|
La file d'attente n'est pas synchronisée avec une playlist.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-section">
|
||||||
|
<p class="help-text">
|
||||||
|
<strong>📌 Playlist Dynamique</strong><br>
|
||||||
|
Attachez cette queue à une playlist/album du serveur média.
|
||||||
|
La queue se synchronisera automatiquement : les pistes ajoutées ou
|
||||||
|
retirées sur le serveur apparaîtront ici en temps réel.
|
||||||
|
</p>
|
||||||
|
<p class="help-text help-text-warning">
|
||||||
|
⚠️ <em>Fonctionnalité en cours de développement</em> :
|
||||||
|
Les actions "Attacher" ne sont pas encore disponibles dans le navigateur de médias.
|
||||||
|
Pour l'instant, utilisez l'API REST directement.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.playlist-binding-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-status {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-info {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-info > svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attached .status-info > svg {
|
||||||
|
color: var(--status-playing);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detached .status-info > svg {
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-label {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0 0 var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-description {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-details {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin: var(--spacing-xs) 0 0;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border-left: 3px solid var(--status-transitioning);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text strong {
|
||||||
|
color: var(--color-text);
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text-warning {
|
||||||
|
border-left-color: var(--status-paused);
|
||||||
|
background-color: var(--status-paused-bg);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
114
pmoapp/webapp/src/components/pmocontrol/QueueItem.vue
Normal file
114
pmoapp/webapp/src/components/pmocontrol/QueueItem.vue
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Music, Play } from 'lucide-vue-next'
|
||||||
|
import type { QueueItem } from '@/services/pmocontrol/types'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
item: QueueItem
|
||||||
|
isCurrent: boolean
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="['queue-item', { current: isCurrent }]">
|
||||||
|
<!-- Indicateur piste en cours -->
|
||||||
|
<div class="current-indicator" v-if="isCurrent">
|
||||||
|
<Play :size="16" fill="currentColor" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Index (1-based pour l'affichage) -->
|
||||||
|
<span class="item-index">{{ item.index + 1 }}</span>
|
||||||
|
|
||||||
|
<!-- Cover miniature (placeholder pour l'instant, on ajoutera la cover plus tard) -->
|
||||||
|
<div class="item-cover">
|
||||||
|
<Music :size="20" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Métadonnées -->
|
||||||
|
<div class="item-metadata">
|
||||||
|
<div class="item-title">{{ item.title || 'Sans titre' }}</div>
|
||||||
|
<div class="item-artist">
|
||||||
|
{{ item.artist || 'Artiste inconnu' }}
|
||||||
|
<span v-if="item.album"> • {{ item.album }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.queue-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition: background-color var(--transition-fast);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item:hover {
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item.current {
|
||||||
|
background-color: var(--status-playing-bg);
|
||||||
|
border: 1px solid var(--status-playing);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-indicator {
|
||||||
|
position: absolute;
|
||||||
|
left: var(--spacing-xs);
|
||||||
|
color: var(--status-playing);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-index {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
min-width: 2rem;
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item.current .item-index {
|
||||||
|
margin-left: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-cover {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
background-color: var(--color-bg-tertiary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-metadata {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-title {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
color: var(--color-text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item.current .item-title {
|
||||||
|
color: var(--status-playing);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-artist {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
152
pmoapp/webapp/src/components/pmocontrol/QueueViewer.vue
Normal file
152
pmoapp/webapp/src/components/pmocontrol/QueueViewer.vue
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch, nextTick } from 'vue'
|
||||||
|
import { useRenderersStore } from '@/stores/renderers'
|
||||||
|
import QueueItem from './QueueItem.vue'
|
||||||
|
import { Link } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
rendererId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const renderersStore = useRenderersStore()
|
||||||
|
|
||||||
|
const queue = computed(() => renderersStore.getQueueById(props.rendererId))
|
||||||
|
const binding = computed(() => renderersStore.getBindingById(props.rendererId))
|
||||||
|
|
||||||
|
const isAttached = computed(() => !!binding.value)
|
||||||
|
|
||||||
|
const queueContainer = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
// Auto-scroll vers la piste courante lors de l'ouverture
|
||||||
|
watch(() => queue.value?.current_index, async (currentIndex) => {
|
||||||
|
if (currentIndex !== null && currentIndex !== undefined && queueContainer.value) {
|
||||||
|
await nextTick()
|
||||||
|
const currentItem = queueContainer.value.querySelector('.queue-item.current')
|
||||||
|
if (currentItem) {
|
||||||
|
currentItem.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="queue-viewer">
|
||||||
|
<!-- Header avec indication de binding -->
|
||||||
|
<div class="queue-header">
|
||||||
|
<h3 class="queue-title">
|
||||||
|
File d'attente
|
||||||
|
<span class="queue-count" v-if="queue?.items.length">
|
||||||
|
({{ queue.items.length }})
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- Indicateur playlist attachée -->
|
||||||
|
<div v-if="isAttached" class="binding-indicator">
|
||||||
|
<Link :size="16" />
|
||||||
|
<span class="binding-text">
|
||||||
|
Attachée à une playlist
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Liste des items -->
|
||||||
|
<div v-if="queue?.items.length" class="queue-list" ref="queueContainer">
|
||||||
|
<QueueItem
|
||||||
|
v-for="item in queue.items"
|
||||||
|
:key="item.index"
|
||||||
|
:item="item"
|
||||||
|
:is-current="item.index === queue.current_index"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- État vide -->
|
||||||
|
<div v-else class="queue-empty">
|
||||||
|
<p>Aucun élément dans la file d'attente</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.queue-viewer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-title {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-count {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-indicator {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
|
background-color: var(--status-playing-bg);
|
||||||
|
color: var(--status-playing);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
border: 1px solid var(--status-playing);
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-text {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
padding-right: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling */
|
||||||
|
.queue-list::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-list::-webkit-scrollbar-track {
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-list::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-border);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-list::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
294
pmoapp/webapp/src/components/pmocontrol/RendererCard.vue
Normal file
294
pmoapp/webapp/src/components/pmocontrol/RendererCard.vue
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { usePlaybackStore } from '@/stores/playback'
|
||||||
|
import type { RendererSummary, RendererState } from '@/services/pmocontrol/types'
|
||||||
|
import StatusBadge from './StatusBadge.vue'
|
||||||
|
import { Music, Volume2, VolumeX } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
renderer: RendererSummary
|
||||||
|
state: RendererState | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const playbackStore = usePlaybackStore()
|
||||||
|
|
||||||
|
const metadata = computed(() => playbackStore.getTrackMetadata(props.renderer.id))
|
||||||
|
|
||||||
|
const protocolLabel = computed(() => {
|
||||||
|
switch (props.renderer.protocol) {
|
||||||
|
case 'UpnpAvOnly':
|
||||||
|
return 'UPnP AV'
|
||||||
|
case 'OpenHomeOnly':
|
||||||
|
return 'OpenHome'
|
||||||
|
case 'Hybrid':
|
||||||
|
return 'Hybrid'
|
||||||
|
default:
|
||||||
|
return 'Unknown'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const protocolClass = computed(() => {
|
||||||
|
switch (props.renderer.protocol) {
|
||||||
|
case 'UpnpAvOnly':
|
||||||
|
return 'protocol-upnp'
|
||||||
|
case 'OpenHomeOnly':
|
||||||
|
return 'protocol-openhome'
|
||||||
|
case 'Hybrid':
|
||||||
|
return 'protocol-hybrid'
|
||||||
|
default:
|
||||||
|
return 'protocol-unknown'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasCover = computed(() => !!metadata.value?.album_art_uri)
|
||||||
|
|
||||||
|
function goToRenderer() {
|
||||||
|
router.push(`/renderer/${props.renderer.id}`)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="['renderer-card', { offline: !renderer.online }]">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="header-content">
|
||||||
|
<h3 class="renderer-name">{{ renderer.friendly_name }}</h3>
|
||||||
|
<p class="renderer-model">{{ renderer.model_name }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="badges">
|
||||||
|
<span :class="['protocol-badge', protocolClass]">
|
||||||
|
{{ protocolLabel }}
|
||||||
|
</span>
|
||||||
|
<StatusBadge v-if="state" :status="state.transport_state" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cover Art -->
|
||||||
|
<div class="card-cover">
|
||||||
|
<img
|
||||||
|
v-if="hasCover"
|
||||||
|
:src="metadata?.album_art_uri!"
|
||||||
|
:alt="metadata?.album || 'Album cover'"
|
||||||
|
class="cover-image"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div v-else class="cover-placeholder">
|
||||||
|
<Music :size="48" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Metadata (current track) -->
|
||||||
|
<div v-if="metadata" class="card-metadata">
|
||||||
|
<p class="track-title">{{ metadata.title || 'Sans titre' }}</p>
|
||||||
|
<p class="track-artist">{{ metadata.artist || 'Artiste inconnu' }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="card-metadata empty">
|
||||||
|
<p class="track-title">Aucun média</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Volume -->
|
||||||
|
<div v-if="state && state.volume !== null" class="card-volume">
|
||||||
|
<VolumeX v-if="state.mute" :size="16" class="volume-icon muted" />
|
||||||
|
<Volume2 v-else :size="16" class="volume-icon" />
|
||||||
|
<div class="volume-bar">
|
||||||
|
<div
|
||||||
|
class="volume-bar-fill"
|
||||||
|
:style="{ width: `${state.mute ? 0 : state.volume}%` }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<span class="volume-value">{{ state.volume }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Control Button -->
|
||||||
|
<button class="btn btn-primary card-control-btn" @click="goToRenderer">
|
||||||
|
Contrôler
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.renderer-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
transition: all var(--transition-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.renderer-card:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.renderer-card.offline {
|
||||||
|
opacity: 0.6;
|
||||||
|
filter: grayscale(0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.renderer-name {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0 0 var(--spacing-xs);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.renderer-model {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badges {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-badge {
|
||||||
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-upnp {
|
||||||
|
background-color: rgba(59, 130, 246, 0.1);
|
||||||
|
color: #3b82f6;
|
||||||
|
border: 1px solid #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-openhome {
|
||||||
|
background-color: rgba(139, 92, 246, 0.1);
|
||||||
|
color: #8b5cf6;
|
||||||
|
border: 1px solid #8b5cf6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-hybrid {
|
||||||
|
background-color: rgba(16, 185, 129, 0.1);
|
||||||
|
color: #10b981;
|
||||||
|
border: 1px solid #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cover */
|
||||||
|
.card-cover {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: var(--color-bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Metadata */
|
||||||
|
.card-metadata {
|
||||||
|
min-height: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-title {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0 0 var(--spacing-xs);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-artist {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-metadata.empty .track-title {
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Volume */
|
||||||
|
.card-volume {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-icon {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-icon.muted {
|
||||||
|
color: var(--status-offline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-bar {
|
||||||
|
flex: 1;
|
||||||
|
height: 4px;
|
||||||
|
background-color: var(--color-bg-tertiary);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
transition: width var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-value {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
min-width: 2rem;
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Control Button */
|
||||||
|
.card-control-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive grid handled by parent (DashboardView) */
|
||||||
|
</style>
|
||||||
52
pmoapp/webapp/src/components/pmocontrol/StatusBadge.vue
Normal file
52
pmoapp/webapp/src/components/pmocontrol/StatusBadge.vue
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
status: 'PLAYING' | 'PAUSED' | 'STOPPED' | 'TRANSITIONING' | 'NO_MEDIA' | 'UNKNOWN' | 'OFFLINE'
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function getStatusClass(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'PLAYING':
|
||||||
|
return 'playing'
|
||||||
|
case 'PAUSED':
|
||||||
|
return 'paused'
|
||||||
|
case 'STOPPED':
|
||||||
|
case 'NO_MEDIA':
|
||||||
|
return 'stopped'
|
||||||
|
case 'TRANSITIONING':
|
||||||
|
return 'transitioning'
|
||||||
|
case 'OFFLINE':
|
||||||
|
return 'offline'
|
||||||
|
default:
|
||||||
|
return 'stopped'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusLabel(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'PLAYING':
|
||||||
|
return 'En lecture'
|
||||||
|
case 'PAUSED':
|
||||||
|
return 'En pause'
|
||||||
|
case 'STOPPED':
|
||||||
|
return 'Arrêté'
|
||||||
|
case 'NO_MEDIA':
|
||||||
|
return 'Aucun média'
|
||||||
|
case 'TRANSITIONING':
|
||||||
|
return 'Transition...'
|
||||||
|
case 'OFFLINE':
|
||||||
|
return 'Hors ligne'
|
||||||
|
default:
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span :class="['status-badge', getStatusClass(status)]">
|
||||||
|
{{ getStatusLabel(status) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Les styles sont définis globalement dans pmocontrol.css */
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRenderersStore } from '@/stores/renderers'
|
||||||
|
import { Play, Pause, Square, SkipForward } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
rendererId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const renderersStore = useRenderersStore()
|
||||||
|
|
||||||
|
const state = computed(() => renderersStore.getStateById(props.rendererId))
|
||||||
|
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 renderersStore.play(props.rendererId)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur play:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePause() {
|
||||||
|
try {
|
||||||
|
await renderersStore.pause(props.rendererId)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur pause:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStop() {
|
||||||
|
try {
|
||||||
|
await renderersStore.stop(props.rendererId)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur stop:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleNext() {
|
||||||
|
try {
|
||||||
|
await renderersStore.next(props.rendererId)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur next:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<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="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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.transport-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
102
pmoapp/webapp/src/components/pmocontrol/VolumeControl.vue
Normal file
102
pmoapp/webapp/src/components/pmocontrol/VolumeControl.vue
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { useRenderersStore } from '@/stores/renderers'
|
||||||
|
import { Volume2, VolumeX } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
rendererId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const renderersStore = useRenderersStore()
|
||||||
|
|
||||||
|
const state = computed(() => renderersStore.getStateById(props.rendererId))
|
||||||
|
const localVolume = ref(state.value?.volume ?? 50)
|
||||||
|
|
||||||
|
// Synchroniser localVolume avec le state
|
||||||
|
watch(() => state.value?.volume, (newVolume) => {
|
||||||
|
if (newVolume !== undefined && newVolume !== null) {
|
||||||
|
localVolume.value = newVolume
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// Debounce pour le slider
|
||||||
|
let debounceTimer: number | null = null
|
||||||
|
function handleVolumeChange(event: Event) {
|
||||||
|
const target = event.target as HTMLInputElement
|
||||||
|
localVolume.value = parseInt(target.value, 10)
|
||||||
|
|
||||||
|
// Debounce: attendre 300ms avant d'envoyer à l'API
|
||||||
|
if (debounceTimer !== null) {
|
||||||
|
clearTimeout(debounceTimer)
|
||||||
|
}
|
||||||
|
|
||||||
|
debounceTimer = window.setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
await renderersStore.setVolume(props.rendererId, localVolume.value)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur set volume:', error)
|
||||||
|
}
|
||||||
|
debounceTimer = null
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleToggleMute() {
|
||||||
|
try {
|
||||||
|
await renderersStore.toggleMute(props.rendererId)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur toggle mute:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="volume-control">
|
||||||
|
<button
|
||||||
|
class="btn btn-icon"
|
||||||
|
@click="handleToggleMute"
|
||||||
|
:title="state?.mute ? 'Réactiver le son' : 'Couper le son'"
|
||||||
|
>
|
||||||
|
<VolumeX v-if="state?.mute" :size="20" />
|
||||||
|
<Volume2 v-else :size="20" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
:value="localVolume"
|
||||||
|
@input="handleVolumeChange"
|
||||||
|
class="volume-slider"
|
||||||
|
:disabled="state?.mute ?? false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span class="volume-value">{{ localVolume }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.volume-control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-value {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
min-width: 2.5rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,7 +1,60 @@
|
|||||||
import { createApp } from "vue";
|
import { createApp } from "vue";
|
||||||
|
import { createPinia } from "pinia";
|
||||||
import App from "./App.vue";
|
import App from "./App.vue";
|
||||||
import router from "./router";
|
import router from "./router";
|
||||||
|
|
||||||
import "./style.css";
|
// Stores
|
||||||
|
import { useRenderersStore } from "./stores/renderers";
|
||||||
|
import { useMediaServersStore } from "./stores/mediaServers";
|
||||||
|
import { usePlaybackStore } from "./stores/playback";
|
||||||
|
import { useUIStore } from "./stores/ui";
|
||||||
|
|
||||||
createApp(App).use(router).mount("#app");
|
// Service SSE
|
||||||
|
import { sse } from "./services/pmocontrol/sse";
|
||||||
|
|
||||||
|
// Styles
|
||||||
|
import "./style.css";
|
||||||
|
import "./assets/styles/variables.css";
|
||||||
|
import "./assets/styles/pmocontrol.css";
|
||||||
|
|
||||||
|
// Créer l'application Vue
|
||||||
|
const app = createApp(App);
|
||||||
|
|
||||||
|
// Créer et installer Pinia
|
||||||
|
const pinia = createPinia();
|
||||||
|
app.use(pinia);
|
||||||
|
app.use(router);
|
||||||
|
|
||||||
|
// Monter l'application
|
||||||
|
app.mount("#app");
|
||||||
|
|
||||||
|
// Après montage, initialiser SSE et connecter aux stores
|
||||||
|
const renderersStore = useRenderersStore();
|
||||||
|
const mediaServersStore = useMediaServersStore();
|
||||||
|
const playbackStore = usePlaybackStore();
|
||||||
|
const uiStore = useUIStore();
|
||||||
|
|
||||||
|
// Connecter SSE aux stores
|
||||||
|
sse.onRendererEvent((event) => {
|
||||||
|
renderersStore.updateFromSSE(event);
|
||||||
|
playbackStore.updateFromSSE(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
sse.onMediaServerEvent((event) => {
|
||||||
|
mediaServersStore.updateFromSSE(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
sse.onConnectionChange((connected) => {
|
||||||
|
uiStore.setSSEConnected(connected);
|
||||||
|
if (connected) {
|
||||||
|
console.log("[App] SSE connecté - Chargement des données initiales");
|
||||||
|
// Charger les données initiales
|
||||||
|
renderersStore.fetchRenderers();
|
||||||
|
mediaServersStore.fetchServers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Démarrer la connexion SSE
|
||||||
|
sse.connect();
|
||||||
|
|
||||||
|
console.log("[App] PMOControl initialisé");
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { createRouter, createWebHistory } from "vue-router";
|
import { createRouter, createWebHistory } from "vue-router";
|
||||||
|
|
||||||
|
// PMOControl Views (nouvelle home)
|
||||||
|
import DashboardView from "../views/DashboardView.vue";
|
||||||
|
import RendererView from "../views/RendererView.vue";
|
||||||
|
import MediaServerView from "../views/MediaServerView.vue";
|
||||||
|
|
||||||
|
// Debug Components (anciennes routes)
|
||||||
import GenericMusicPlayer from "../components/GenericMusicPlayer.vue";
|
import GenericMusicPlayer from "../components/GenericMusicPlayer.vue";
|
||||||
import LogView from "../components/LogView.vue";
|
import LogView from "../components/LogView.vue";
|
||||||
import CoverCacheManager from "../components/CoverCacheManager.vue";
|
import CoverCacheManager from "../components/CoverCacheManager.vue";
|
||||||
@@ -8,13 +15,59 @@ import APIDashboard from "../components/APIDashboard.vue";
|
|||||||
import RadioParadiseExplorer from "../components/RadioParadiseExplorer.vue";
|
import RadioParadiseExplorer from "../components/RadioParadiseExplorer.vue";
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{ path: "/", name: "home", component: GenericMusicPlayer },
|
// PMOControl (nouvelle home)
|
||||||
{ path: "/logs", name: "logs", component: LogView },
|
{
|
||||||
{ path: "/covers-cache", name: "covers-cache", component: CoverCacheManager },
|
path: "/",
|
||||||
{ path: "/audio-cache", name: "audio-cache", component: AudioCacheManager },
|
name: "Dashboard",
|
||||||
{ path: "/upnp", name: "upnp", component: UpnpExplorer },
|
component: DashboardView,
|
||||||
{ path: "/api-dashboard", name: "api-dashboard", component: APIDashboard },
|
},
|
||||||
{ path: "/radio-paradise", name: "radio-paradise", component: RadioParadiseExplorer },
|
{
|
||||||
|
path: "/renderer/:id",
|
||||||
|
name: "Renderer",
|
||||||
|
component: RendererView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/server/:serverId",
|
||||||
|
name: "MediaServer",
|
||||||
|
component: MediaServerView,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Debug menu (anciennes routes déplacées sous /debug)
|
||||||
|
{
|
||||||
|
path: "/debug/generic-player",
|
||||||
|
name: "GenericPlayer",
|
||||||
|
component: GenericMusicPlayer,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/debug/logs",
|
||||||
|
name: "Logs",
|
||||||
|
component: LogView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/debug/covers-cache",
|
||||||
|
name: "CoversCache",
|
||||||
|
component: CoverCacheManager,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/debug/audio-cache",
|
||||||
|
name: "AudioCache",
|
||||||
|
component: AudioCacheManager,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/debug/upnp",
|
||||||
|
name: "UpnpExplorer",
|
||||||
|
component: UpnpExplorer,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/debug/api-dashboard",
|
||||||
|
name: "APIDashboard",
|
||||||
|
component: APIDashboard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/debug/radio-paradise",
|
||||||
|
name: "RadioParadise",
|
||||||
|
component: RadioParadiseExplorer,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
|
|||||||
230
pmoapp/webapp/src/services/pmocontrol/api.ts
Normal file
230
pmoapp/webapp/src/services/pmocontrol/api.ts
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
// Client API REST pour PMOControl
|
||||||
|
// Communique avec /api/control/*
|
||||||
|
|
||||||
|
import type {
|
||||||
|
RendererSummary,
|
||||||
|
RendererState,
|
||||||
|
QueueSnapshot,
|
||||||
|
AttachedPlaylistInfo,
|
||||||
|
MediaServerSummary,
|
||||||
|
BrowseResponse,
|
||||||
|
VolumeSetRequest,
|
||||||
|
AttachPlaylistRequest,
|
||||||
|
SuccessResponse,
|
||||||
|
ErrorResponse
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client API REST pour le Control Point PMOMusic
|
||||||
|
*/
|
||||||
|
class PMOControlAPI {
|
||||||
|
private readonly baseURL = '/api/control'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effectue une requête HTTP générique
|
||||||
|
*/
|
||||||
|
private async request<T>(
|
||||||
|
path: string,
|
||||||
|
options: RequestInit = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const url = `${this.baseURL}${path}`
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error: ErrorResponse = await response.json().catch(() => ({
|
||||||
|
error: `HTTP ${response.status}: ${response.statusText}`,
|
||||||
|
}))
|
||||||
|
throw new Error(error.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// RENDERERS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste tous les renderers découverts
|
||||||
|
* GET /api/control/renderers
|
||||||
|
*/
|
||||||
|
async getRenderers(): Promise<RendererSummary[]> {
|
||||||
|
return this.request<RendererSummary[]>('/renderers')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère l'état détaillé d'un renderer
|
||||||
|
* GET /api/control/renderers/{id}
|
||||||
|
*/
|
||||||
|
async getRendererState(id: string): Promise<RendererState> {
|
||||||
|
return this.request<RendererState>(`/renderers/${encodeURIComponent(id)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère la queue d'un renderer (avec current_index)
|
||||||
|
* GET /api/control/renderers/{id}/queue
|
||||||
|
*/
|
||||||
|
async getQueue(id: string): Promise<QueueSnapshot> {
|
||||||
|
return this.request<QueueSnapshot>(`/renderers/${encodeURIComponent(id)}/queue`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère le binding playlist d'un renderer
|
||||||
|
* GET /api/control/renderers/{id}/binding
|
||||||
|
*/
|
||||||
|
async getBinding(id: string): Promise<AttachedPlaylistInfo | null> {
|
||||||
|
return this.request<AttachedPlaylistInfo | null>(`/renderers/${encodeURIComponent(id)}/binding`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CONTRÔLE TRANSPORT
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Démarre la lecture sur un renderer
|
||||||
|
* POST /api/control/renderers/{id}/play
|
||||||
|
*/
|
||||||
|
async play(id: string): Promise<SuccessResponse> {
|
||||||
|
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/play`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Met en pause la lecture sur un renderer
|
||||||
|
* POST /api/control/renderers/{id}/pause
|
||||||
|
*/
|
||||||
|
async pause(id: string): Promise<SuccessResponse> {
|
||||||
|
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/pause`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arrête la lecture sur un renderer
|
||||||
|
* POST /api/control/renderers/{id}/stop
|
||||||
|
*/
|
||||||
|
async stop(id: string): Promise<SuccessResponse> {
|
||||||
|
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/stop`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Passe au morceau suivant dans la queue
|
||||||
|
* POST /api/control/renderers/{id}/next
|
||||||
|
*/
|
||||||
|
async next(id: string): Promise<SuccessResponse> {
|
||||||
|
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/next`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CONTRÔLE VOLUME
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Définit le volume d'un renderer (0-100)
|
||||||
|
* POST /api/control/renderers/{id}/volume/set
|
||||||
|
*/
|
||||||
|
async setVolume(id: string, volume: number): Promise<SuccessResponse> {
|
||||||
|
const payload: VolumeSetRequest = { volume }
|
||||||
|
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/volume/set`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Augmente le volume de 5%
|
||||||
|
* POST /api/control/renderers/{id}/volume/up
|
||||||
|
*/
|
||||||
|
async volumeUp(id: string): Promise<SuccessResponse> {
|
||||||
|
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/volume/up`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diminue le volume de 5%
|
||||||
|
* POST /api/control/renderers/{id}/volume/down
|
||||||
|
*/
|
||||||
|
async volumeDown(id: string): Promise<SuccessResponse> {
|
||||||
|
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/volume/down`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bascule le mute d'un renderer
|
||||||
|
* POST /api/control/renderers/{id}/mute/toggle
|
||||||
|
*/
|
||||||
|
async toggleMute(id: string): Promise<SuccessResponse> {
|
||||||
|
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/mute/toggle`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PLAYLIST BINDING
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attache la queue d'un renderer à une playlist d'un serveur
|
||||||
|
* POST /api/control/renderers/{id}/binding/attach
|
||||||
|
*/
|
||||||
|
async attachPlaylist(
|
||||||
|
rendererId: string,
|
||||||
|
serverId: string,
|
||||||
|
containerId: string
|
||||||
|
): Promise<SuccessResponse> {
|
||||||
|
const payload: AttachPlaylistRequest = { server_id: serverId, container_id: containerId }
|
||||||
|
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(rendererId)}/binding/attach`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Détache la queue d'un renderer de sa playlist
|
||||||
|
* POST /api/control/renderers/{id}/binding/detach
|
||||||
|
*/
|
||||||
|
async detachPlaylist(rendererId: string): Promise<SuccessResponse> {
|
||||||
|
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(rendererId)}/binding/detach`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MEDIA SERVERS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste tous les serveurs de médias découverts
|
||||||
|
* GET /api/control/servers
|
||||||
|
*/
|
||||||
|
async getServers(): Promise<MediaServerSummary[]> {
|
||||||
|
return this.request<MediaServerSummary[]>('/servers')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Browse le contenu d'un container sur un serveur
|
||||||
|
* GET /api/control/servers/{serverId}/containers/{containerId}
|
||||||
|
*/
|
||||||
|
async browseContainer(serverId: string, containerId: string): Promise<BrowseResponse> {
|
||||||
|
return this.request<BrowseResponse>(
|
||||||
|
`/servers/${encodeURIComponent(serverId)}/containers/${encodeURIComponent(containerId)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton
|
||||||
|
export const api = new PMOControlAPI()
|
||||||
208
pmoapp/webapp/src/services/pmocontrol/sse.ts
Normal file
208
pmoapp/webapp/src/services/pmocontrol/sse.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
// Service SSE (Server-Sent Events) pour PMOControl
|
||||||
|
// Gère la connexion temps réel à /api/control/events
|
||||||
|
|
||||||
|
import type { RendererEventPayload, MediaServerEventPayload, UnifiedEventPayload } from './types'
|
||||||
|
|
||||||
|
type RendererEventCallback = (event: RendererEventPayload) => void
|
||||||
|
type MediaServerEventCallback = (event: MediaServerEventPayload) => void
|
||||||
|
type ConnectionCallback = (connected: boolean) => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service SSE pour recevoir les événements du Control Point en temps réel
|
||||||
|
*/
|
||||||
|
export class PMOControlSSE {
|
||||||
|
private eventSource: EventSource | null = null
|
||||||
|
private reconnectAttempts = 0
|
||||||
|
private maxReconnectDelay = 30000 // 30 secondes max
|
||||||
|
private reconnectTimer: number | null = null
|
||||||
|
|
||||||
|
private rendererCallbacks: Set<RendererEventCallback> = new Set()
|
||||||
|
private serverCallbacks: Set<MediaServerEventCallback> = new Set()
|
||||||
|
private connectionCallbacks: Set<ConnectionCallback> = new Set()
|
||||||
|
|
||||||
|
private isConnected = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connecte au flux SSE
|
||||||
|
*/
|
||||||
|
connect(): void {
|
||||||
|
if (this.eventSource) {
|
||||||
|
console.warn('[SSE] Connexion déjà active')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[SSE] Connexion à /api/control/events...')
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.eventSource = new EventSource('/api/control/events')
|
||||||
|
|
||||||
|
this.eventSource.onopen = () => {
|
||||||
|
console.log('[SSE] Connexion établie')
|
||||||
|
this.reconnectAttempts = 0
|
||||||
|
this.isConnected = true
|
||||||
|
this.notifyConnectionCallbacks(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.eventSource.addEventListener('control', (e: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
const event: UnifiedEventPayload = JSON.parse(e.data)
|
||||||
|
this.handleEvent(event)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SSE] Erreur parsing événement:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.eventSource.onerror = () => {
|
||||||
|
console.error('[SSE] Erreur de connexion')
|
||||||
|
this.isConnected = false
|
||||||
|
this.notifyConnectionCallbacks(false)
|
||||||
|
this.disconnect()
|
||||||
|
this.scheduleReconnect()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SSE] Erreur création EventSource:', error)
|
||||||
|
this.scheduleReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Déconnecte du flux SSE
|
||||||
|
*/
|
||||||
|
disconnect(): void {
|
||||||
|
if (this.reconnectTimer !== null) {
|
||||||
|
clearTimeout(this.reconnectTimer)
|
||||||
|
this.reconnectTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.eventSource) {
|
||||||
|
console.log('[SSE] Déconnexion')
|
||||||
|
this.eventSource.close()
|
||||||
|
this.eventSource = null
|
||||||
|
this.isConnected = false
|
||||||
|
this.notifyConnectionCallbacks(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Programme une reconnexion avec backoff exponentiel
|
||||||
|
*/
|
||||||
|
private scheduleReconnect(): void {
|
||||||
|
if (this.reconnectTimer !== null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.reconnectAttempts++
|
||||||
|
|
||||||
|
// Backoff exponentiel: 1s, 2s, 4s, 8s, 16s, 30s (max)
|
||||||
|
const delay = Math.min(
|
||||||
|
1000 * Math.pow(2, this.reconnectAttempts - 1),
|
||||||
|
this.maxReconnectDelay
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log(`[SSE] Reconnexion dans ${delay / 1000}s (tentative ${this.reconnectAttempts})`)
|
||||||
|
|
||||||
|
this.reconnectTimer = window.setTimeout(() => {
|
||||||
|
this.reconnectTimer = null
|
||||||
|
this.connect()
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch un événement aux callbacks appropriés
|
||||||
|
*/
|
||||||
|
private handleEvent(event: UnifiedEventPayload): void {
|
||||||
|
if (event.category === 'renderer') {
|
||||||
|
// Extraire le payload renderer (sans le champ category)
|
||||||
|
const { category, ...rendererEvent } = event
|
||||||
|
this.notifyRendererCallbacks(rendererEvent as RendererEventPayload)
|
||||||
|
} else if (event.category === 'media_server') {
|
||||||
|
// Extraire le payload server (sans le champ category)
|
||||||
|
const { category, ...serverEvent } = event
|
||||||
|
this.notifyServerCallbacks(serverEvent as MediaServerEventPayload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enregistre un callback pour les événements renderer
|
||||||
|
*/
|
||||||
|
onRendererEvent(callback: RendererEventCallback): () => void {
|
||||||
|
this.rendererCallbacks.add(callback)
|
||||||
|
// Retourne une fonction de cleanup
|
||||||
|
return () => {
|
||||||
|
this.rendererCallbacks.delete(callback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enregistre un callback pour les événements media server
|
||||||
|
*/
|
||||||
|
onMediaServerEvent(callback: MediaServerEventCallback): () => void {
|
||||||
|
this.serverCallbacks.add(callback)
|
||||||
|
// Retourne une fonction de cleanup
|
||||||
|
return () => {
|
||||||
|
this.serverCallbacks.delete(callback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enregistre un callback pour les changements de connexion
|
||||||
|
*/
|
||||||
|
onConnectionChange(callback: ConnectionCallback): () => void {
|
||||||
|
this.connectionCallbacks.add(callback)
|
||||||
|
// Appeler immédiatement avec l'état actuel
|
||||||
|
callback(this.isConnected)
|
||||||
|
// Retourne une fonction de cleanup
|
||||||
|
return () => {
|
||||||
|
this.connectionCallbacks.delete(callback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifie tous les callbacks renderer
|
||||||
|
*/
|
||||||
|
private notifyRendererCallbacks(event: RendererEventPayload): void {
|
||||||
|
this.rendererCallbacks.forEach(callback => {
|
||||||
|
try {
|
||||||
|
callback(event)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SSE] Erreur dans callback renderer:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifie tous les callbacks server
|
||||||
|
*/
|
||||||
|
private notifyServerCallbacks(event: MediaServerEventPayload): void {
|
||||||
|
this.serverCallbacks.forEach(callback => {
|
||||||
|
try {
|
||||||
|
callback(event)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SSE] Erreur dans callback server:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifie tous les callbacks de connexion
|
||||||
|
*/
|
||||||
|
private notifyConnectionCallbacks(connected: boolean): void {
|
||||||
|
this.connectionCallbacks.forEach(callback => {
|
||||||
|
try {
|
||||||
|
callback(connected)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SSE] Erreur dans callback connexion:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne l'état de connexion actuel
|
||||||
|
*/
|
||||||
|
isConnectedState(): boolean {
|
||||||
|
return this.isConnected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton
|
||||||
|
export const sse = new PMOControlSSE()
|
||||||
138
pmoapp/webapp/src/services/pmocontrol/types.ts
Normal file
138
pmoapp/webapp/src/services/pmocontrol/types.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
// Types TypeScript pour l'API PMOControl
|
||||||
|
// Synchronisés avec pmocontrol/src/openapi.rs
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// RENDERERS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface RendererSummary {
|
||||||
|
id: string
|
||||||
|
friendly_name: string
|
||||||
|
model_name: string
|
||||||
|
protocol: 'UpnpAvOnly' | 'OpenHomeOnly' | 'Hybrid'
|
||||||
|
online: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RendererState {
|
||||||
|
id: string
|
||||||
|
friendly_name: string
|
||||||
|
transport_state: 'PLAYING' | 'PAUSED' | 'STOPPED' | 'TRANSITIONING' | 'NO_MEDIA' | 'UNKNOWN'
|
||||||
|
position_ms: number | null
|
||||||
|
duration_ms: number | null
|
||||||
|
volume: number | null // 0-100
|
||||||
|
mute: boolean | null
|
||||||
|
queue_len: number
|
||||||
|
attached_playlist: AttachedPlaylistInfo | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttachedPlaylistInfo {
|
||||||
|
server_id: string
|
||||||
|
container_id: string
|
||||||
|
has_seen_update: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// QUEUE (avec current_index)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface QueueItem {
|
||||||
|
index: number // 0-based
|
||||||
|
uri: string
|
||||||
|
title: string | null
|
||||||
|
artist: string | null
|
||||||
|
album: string | null
|
||||||
|
server_id: string | null
|
||||||
|
object_id: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueueSnapshot {
|
||||||
|
renderer_id: string
|
||||||
|
items: QueueItem[]
|
||||||
|
current_index: number | null // Index de la piste en cours (null si rien en lecture)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MEDIA SERVERS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface MediaServerSummary {
|
||||||
|
id: string
|
||||||
|
friendly_name: string
|
||||||
|
model_name: string
|
||||||
|
online: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContainerEntry {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
class: string // UPnP class
|
||||||
|
is_container: boolean
|
||||||
|
child_count: number | null
|
||||||
|
artist: string | null
|
||||||
|
album: string | null
|
||||||
|
album_art_uri: string | null // ⚠️ Nom exact: album_art_uri
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrowseResponse {
|
||||||
|
container_id: string
|
||||||
|
entries: ContainerEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// COMMANDES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface VolumeSetRequest {
|
||||||
|
volume: number // 0-100
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttachPlaylistRequest {
|
||||||
|
server_id: string
|
||||||
|
container_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SuccessResponse {
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorResponse {
|
||||||
|
error: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ÉVÉNEMENTS SSE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type RendererEventPayload =
|
||||||
|
| { type: 'state_changed'; renderer_id: string; state: string; timestamp: string }
|
||||||
|
| { type: 'position_changed'; renderer_id: string; track: number | null; rel_time: string | null; track_duration: string | null; timestamp: string }
|
||||||
|
| { type: 'volume_changed'; renderer_id: string; volume: number; timestamp: string }
|
||||||
|
| { type: 'mute_changed'; renderer_id: string; mute: boolean; timestamp: string }
|
||||||
|
| { type: 'metadata_changed'; renderer_id: string; title: string | null; artist: string | null; album: string | null; album_art_uri: string | null; timestamp: string }
|
||||||
|
| { type: 'queue_updated'; renderer_id: string; queue_length: number; timestamp: string }
|
||||||
|
|
||||||
|
export type MediaServerEventPayload =
|
||||||
|
| { type: 'global_updated'; server_id: string; system_update_id: number | null; timestamp: string }
|
||||||
|
| { type: 'containers_updated'; server_id: string; container_ids: string[]; timestamp: string }
|
||||||
|
|
||||||
|
export type UnifiedEventPayload =
|
||||||
|
| { category: 'renderer' } & RendererEventPayload
|
||||||
|
| { category: 'media_server' } & MediaServerEventPayload
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MÉTADONNÉES PISTE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface TrackMetadata {
|
||||||
|
title: string | null
|
||||||
|
artist: string | null
|
||||||
|
album: string | null
|
||||||
|
album_art_uri: string | null
|
||||||
|
duration_ms: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PositionInfo {
|
||||||
|
track: number | null
|
||||||
|
rel_time: string | null // Format HH:MM:SS
|
||||||
|
track_duration: string | null // Format HH:MM:SS
|
||||||
|
}
|
||||||
144
pmoapp/webapp/src/stores/mediaServers.ts
Normal file
144
pmoapp/webapp/src/stores/mediaServers.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
// Store Pinia pour les media servers
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { api } from '../services/pmocontrol/api'
|
||||||
|
import type {
|
||||||
|
MediaServerSummary,
|
||||||
|
BrowseResponse,
|
||||||
|
MediaServerEventPayload
|
||||||
|
} from '../services/pmocontrol/types'
|
||||||
|
|
||||||
|
export interface BreadcrumbItem {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useMediaServersStore = defineStore('mediaServers', () => {
|
||||||
|
// État
|
||||||
|
const servers = ref<Map<string, MediaServerSummary>>(new Map())
|
||||||
|
const browseCache = ref<Map<string, BrowseResponse>>(new Map())
|
||||||
|
const currentPath = ref<BreadcrumbItem[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
const onlineServers = computed(() => {
|
||||||
|
return Array.from(servers.value.values()).filter(s => s.online)
|
||||||
|
})
|
||||||
|
|
||||||
|
const getServerById = (id: string) => {
|
||||||
|
return servers.value.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getBrowseCached = (serverId: string, containerId: string) => {
|
||||||
|
const key = `${serverId}/${containerId}`
|
||||||
|
return browseCache.value.get(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
async function fetchServers() {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
const data = await api.getServers()
|
||||||
|
|
||||||
|
// Mettre à jour le Map
|
||||||
|
servers.value.clear()
|
||||||
|
data.forEach(s => servers.value.set(s.id, s))
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Erreur fetch servers'
|
||||||
|
console.error('[Store MediaServers] Erreur fetch:', e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function browseContainer(serverId: string, containerId: string) {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
const data = await api.browseContainer(serverId, containerId)
|
||||||
|
|
||||||
|
// Mettre en cache
|
||||||
|
const key = `${serverId}/${containerId}`
|
||||||
|
browseCache.value.set(key, data)
|
||||||
|
|
||||||
|
return data
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Erreur browse container'
|
||||||
|
console.error(`[Store MediaServers] Erreur browse ${serverId}/${containerId}:`, e)
|
||||||
|
throw e
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPath(path: BreadcrumbItem[]) {
|
||||||
|
currentPath.value = path
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPath() {
|
||||||
|
currentPath.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateCache(serverId: string, containerId?: string) {
|
||||||
|
if (containerId) {
|
||||||
|
// Invalider un container spécifique
|
||||||
|
const key = `${serverId}/${containerId}`
|
||||||
|
browseCache.value.delete(key)
|
||||||
|
} else {
|
||||||
|
// Invalider tous les containers d'un serveur
|
||||||
|
const keysToDelete: string[] = []
|
||||||
|
browseCache.value.forEach((_, key) => {
|
||||||
|
if (key.startsWith(serverId + '/')) {
|
||||||
|
keysToDelete.push(key)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
keysToDelete.forEach(key => browseCache.value.delete(key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSE event handling
|
||||||
|
function updateFromSSE(event: MediaServerEventPayload) {
|
||||||
|
const serverId = event.server_id
|
||||||
|
|
||||||
|
switch (event.type) {
|
||||||
|
case 'global_updated': {
|
||||||
|
// Invalider tout le cache de ce serveur
|
||||||
|
console.log(`[Store MediaServers] GlobalUpdated pour ${serverId}`)
|
||||||
|
invalidateCache(serverId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'containers_updated': {
|
||||||
|
// Invalider les containers spécifiques
|
||||||
|
console.log(`[Store MediaServers] ContainersUpdated pour ${serverId}:`, event.container_ids)
|
||||||
|
event.container_ids.forEach(containerId => {
|
||||||
|
invalidateCache(serverId, containerId)
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// État
|
||||||
|
servers,
|
||||||
|
browseCache,
|
||||||
|
currentPath,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
// Getters
|
||||||
|
onlineServers,
|
||||||
|
getServerById,
|
||||||
|
getBrowseCached,
|
||||||
|
// Actions
|
||||||
|
fetchServers,
|
||||||
|
browseContainer,
|
||||||
|
setPath,
|
||||||
|
clearPath,
|
||||||
|
invalidateCache,
|
||||||
|
updateFromSSE,
|
||||||
|
}
|
||||||
|
})
|
||||||
92
pmoapp/webapp/src/stores/playback.ts
Normal file
92
pmoapp/webapp/src/stores/playback.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
// Store Pinia pour le playback (métadonnées et positions)
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import type { TrackMetadata, PositionInfo, RendererEventPayload } from '../services/pmocontrol/types'
|
||||||
|
|
||||||
|
export const usePlaybackStore = defineStore('playback', () => {
|
||||||
|
// État
|
||||||
|
// Map de renderer_id → métadonnées de la piste courante
|
||||||
|
const currentTracks = ref<Map<string, TrackMetadata>>(new Map())
|
||||||
|
|
||||||
|
// Map de renderer_id → position actuelle
|
||||||
|
const positions = ref<Map<string, PositionInfo>>(new Map())
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
const getTrackMetadata = (rendererId: string) => {
|
||||||
|
return currentTracks.value.get(rendererId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPosition = (rendererId: string) => {
|
||||||
|
return positions.value.get(rendererId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
function updateMetadata(rendererId: string, metadata: TrackMetadata) {
|
||||||
|
currentTracks.value.set(rendererId, metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePosition(rendererId: string, position: PositionInfo) {
|
||||||
|
positions.value.set(rendererId, position)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearMetadata(rendererId: string) {
|
||||||
|
currentTracks.value.delete(rendererId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPosition(rendererId: string) {
|
||||||
|
positions.value.delete(rendererId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSE event handling
|
||||||
|
function updateFromSSE(event: RendererEventPayload) {
|
||||||
|
const rendererId = event.renderer_id
|
||||||
|
|
||||||
|
switch (event.type) {
|
||||||
|
case 'metadata_changed': {
|
||||||
|
const metadata: TrackMetadata = {
|
||||||
|
title: event.title,
|
||||||
|
artist: event.artist,
|
||||||
|
album: event.album,
|
||||||
|
album_art_uri: event.album_art_uri,
|
||||||
|
duration_ms: null,
|
||||||
|
}
|
||||||
|
updateMetadata(rendererId, metadata)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'position_changed': {
|
||||||
|
const position: PositionInfo = {
|
||||||
|
track: event.track,
|
||||||
|
rel_time: event.rel_time,
|
||||||
|
track_duration: event.track_duration,
|
||||||
|
}
|
||||||
|
updatePosition(rendererId, position)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'state_changed': {
|
||||||
|
// Si stopped, on peut clear les métadonnées
|
||||||
|
if (event.state === 'STOPPED' || event.state === 'NO_MEDIA') {
|
||||||
|
clearMetadata(rendererId)
|
||||||
|
clearPosition(rendererId)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// État
|
||||||
|
currentTracks,
|
||||||
|
positions,
|
||||||
|
// Getters
|
||||||
|
getTrackMetadata,
|
||||||
|
getPosition,
|
||||||
|
// Actions
|
||||||
|
updateMetadata,
|
||||||
|
updatePosition,
|
||||||
|
clearMetadata,
|
||||||
|
clearPosition,
|
||||||
|
updateFromSSE,
|
||||||
|
}
|
||||||
|
})
|
||||||
301
pmoapp/webapp/src/stores/renderers.ts
Normal file
301
pmoapp/webapp/src/stores/renderers.ts
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
// Store Pinia pour les renderers
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { api } from '../services/pmocontrol/api'
|
||||||
|
import type {
|
||||||
|
RendererSummary,
|
||||||
|
RendererState,
|
||||||
|
QueueSnapshot,
|
||||||
|
AttachedPlaylistInfo,
|
||||||
|
RendererEventPayload
|
||||||
|
} from '../services/pmocontrol/types'
|
||||||
|
|
||||||
|
export const useRenderersStore = defineStore('renderers', () => {
|
||||||
|
// État
|
||||||
|
const renderers = ref<Map<string, RendererSummary>>(new Map())
|
||||||
|
const states = ref<Map<string, RendererState>>(new Map())
|
||||||
|
const queues = ref<Map<string, QueueSnapshot>>(new Map())
|
||||||
|
const bindings = ref<Map<string, AttachedPlaylistInfo | null>>(new Map())
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
const onlineRenderers = computed(() => {
|
||||||
|
return Array.from(renderers.value.values()).filter(r => r.online)
|
||||||
|
})
|
||||||
|
|
||||||
|
const playingRenderers = computed(() => {
|
||||||
|
return Array.from(states.value.values()).filter(
|
||||||
|
s => s.transport_state === 'PLAYING'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const getRendererById = (id: string) => {
|
||||||
|
return renderers.value.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStateById = (id: string) => {
|
||||||
|
return states.value.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getQueueById = (id: string) => {
|
||||||
|
return queues.value.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getBindingById = (id: string) => {
|
||||||
|
return bindings.value.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions - Fetch data
|
||||||
|
async function fetchRenderers() {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
const data = await api.getRenderers()
|
||||||
|
|
||||||
|
// Mettre à jour le Map
|
||||||
|
renderers.value.clear()
|
||||||
|
data.forEach(r => renderers.value.set(r.id, r))
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Erreur fetch renderers'
|
||||||
|
console.error('[Store Renderers] Erreur fetch:', e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRendererState(id: string) {
|
||||||
|
try {
|
||||||
|
const state = await api.getRendererState(id)
|
||||||
|
states.value.set(id, state)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[Store Renderers] Erreur fetch state ${id}:`, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchQueue(id: string) {
|
||||||
|
try {
|
||||||
|
const queue = await api.getQueue(id)
|
||||||
|
queues.value.set(id, queue)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[Store Renderers] Erreur fetch queue ${id}:`, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchBinding(id: string) {
|
||||||
|
try {
|
||||||
|
const binding = await api.getBinding(id)
|
||||||
|
bindings.value.set(id, binding)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[Store Renderers] Erreur fetch binding ${id}:`, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions - Transport controls
|
||||||
|
async function play(id: string) {
|
||||||
|
try {
|
||||||
|
await api.play(id)
|
||||||
|
// L'état sera mis à jour via SSE
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Erreur play'
|
||||||
|
console.error(`[Store Renderers] Erreur play ${id}:`, e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pause(id: string) {
|
||||||
|
try {
|
||||||
|
await api.pause(id)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Erreur pause'
|
||||||
|
console.error(`[Store Renderers] Erreur pause ${id}:`, e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stop(id: string) {
|
||||||
|
try {
|
||||||
|
await api.stop(id)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Erreur stop'
|
||||||
|
console.error(`[Store Renderers] Erreur stop ${id}:`, e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function next(id: string) {
|
||||||
|
try {
|
||||||
|
await api.next(id)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Erreur next'
|
||||||
|
console.error(`[Store Renderers] Erreur next ${id}:`, e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions - Volume controls
|
||||||
|
async function setVolume(id: string, volume: number) {
|
||||||
|
try {
|
||||||
|
await api.setVolume(id, volume)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Erreur set volume'
|
||||||
|
console.error(`[Store Renderers] Erreur set volume ${id}:`, e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function volumeUp(id: string) {
|
||||||
|
try {
|
||||||
|
await api.volumeUp(id)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Erreur volume up'
|
||||||
|
console.error(`[Store Renderers] Erreur volume up ${id}:`, e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function volumeDown(id: string) {
|
||||||
|
try {
|
||||||
|
await api.volumeDown(id)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Erreur volume down'
|
||||||
|
console.error(`[Store Renderers] Erreur volume down ${id}:`, e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleMute(id: string) {
|
||||||
|
try {
|
||||||
|
await api.toggleMute(id)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Erreur toggle mute'
|
||||||
|
console.error(`[Store Renderers] Erreur toggle mute ${id}:`, e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions - Playlist binding
|
||||||
|
async function attachPlaylist(rendererId: string, serverId: string, containerId: string) {
|
||||||
|
try {
|
||||||
|
await api.attachPlaylist(rendererId, serverId, containerId)
|
||||||
|
// Rafraîchir le binding
|
||||||
|
await fetchBinding(rendererId)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Erreur attach playlist'
|
||||||
|
console.error(`[Store Renderers] Erreur attach playlist ${rendererId}:`, e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detachPlaylist(rendererId: string) {
|
||||||
|
try {
|
||||||
|
await api.detachPlaylist(rendererId)
|
||||||
|
bindings.value.set(rendererId, null)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Erreur detach playlist'
|
||||||
|
console.error(`[Store Renderers] Erreur detach playlist ${rendererId}:`, e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSE event handling
|
||||||
|
function updateFromSSE(event: RendererEventPayload) {
|
||||||
|
const rendererId = event.renderer_id
|
||||||
|
|
||||||
|
switch (event.type) {
|
||||||
|
case 'state_changed': {
|
||||||
|
const state = states.value.get(rendererId)
|
||||||
|
if (state) {
|
||||||
|
state.transport_state = event.state as any
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'position_changed': {
|
||||||
|
const state = states.value.get(rendererId)
|
||||||
|
if (state && event.rel_time && event.track_duration) {
|
||||||
|
// Convertir HH:MM:SS en millisecondes
|
||||||
|
state.position_ms = parseHMStoMs(event.rel_time)
|
||||||
|
state.duration_ms = parseHMStoMs(event.track_duration)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'volume_changed': {
|
||||||
|
const state = states.value.get(rendererId)
|
||||||
|
if (state) {
|
||||||
|
state.volume = event.volume
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'mute_changed': {
|
||||||
|
const state = states.value.get(rendererId)
|
||||||
|
if (state) {
|
||||||
|
state.mute = event.mute
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'queue_updated': {
|
||||||
|
const state = states.value.get(rendererId)
|
||||||
|
if (state) {
|
||||||
|
state.queue_len = event.queue_length
|
||||||
|
}
|
||||||
|
// Rafraîchir la queue complète
|
||||||
|
fetchQueue(rendererId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'metadata_changed': {
|
||||||
|
// Les métadonnées sont gérées par le store playback
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utilitaire: convertit HH:MM:SS en millisecondes
|
||||||
|
function parseHMStoMs(hms: string): number {
|
||||||
|
const parts = hms.split(':').map(Number)
|
||||||
|
if (parts.length === 3 && parts.every(p => !isNaN(p))) {
|
||||||
|
const hours = parts[0]!
|
||||||
|
const minutes = parts[1]!
|
||||||
|
const seconds = parts[2]!
|
||||||
|
return (hours * 3600 + minutes * 60 + seconds) * 1000
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// État
|
||||||
|
renderers,
|
||||||
|
states,
|
||||||
|
queues,
|
||||||
|
bindings,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
// Getters
|
||||||
|
onlineRenderers,
|
||||||
|
playingRenderers,
|
||||||
|
getRendererById,
|
||||||
|
getStateById,
|
||||||
|
getQueueById,
|
||||||
|
getBindingById,
|
||||||
|
// Actions
|
||||||
|
fetchRenderers,
|
||||||
|
fetchRendererState,
|
||||||
|
fetchQueue,
|
||||||
|
fetchBinding,
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
stop,
|
||||||
|
next,
|
||||||
|
setVolume,
|
||||||
|
volumeUp,
|
||||||
|
volumeDown,
|
||||||
|
toggleMute,
|
||||||
|
attachPlaylist,
|
||||||
|
detachPlaylist,
|
||||||
|
updateFromSSE,
|
||||||
|
}
|
||||||
|
})
|
||||||
111
pmoapp/webapp/src/stores/ui.ts
Normal file
111
pmoapp/webapp/src/stores/ui.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
// Store Pinia pour l'état UI global
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
export interface Notification {
|
||||||
|
id: string
|
||||||
|
type: 'info' | 'success' | 'warning' | 'error'
|
||||||
|
message: string
|
||||||
|
duration?: number // ms, undefined = permanent
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUIStore = defineStore('ui', () => {
|
||||||
|
// État
|
||||||
|
const selectedRendererId = ref<string | null>(null)
|
||||||
|
const selectedServerId = ref<string | null>(null)
|
||||||
|
const showEventLog = ref(false)
|
||||||
|
const sseConnected = ref(false)
|
||||||
|
const notifications = ref<Notification[]>([])
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
function selectRenderer(id: string | null) {
|
||||||
|
selectedRendererId.value = id
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectServer(id: string | null) {
|
||||||
|
selectedServerId.value = id
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleEventLog() {
|
||||||
|
showEventLog.value = !showEventLog.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSSEConnected(connected: boolean) {
|
||||||
|
sseConnected.value = connected
|
||||||
|
}
|
||||||
|
|
||||||
|
function addNotification(
|
||||||
|
type: Notification['type'],
|
||||||
|
message: string,
|
||||||
|
duration?: number
|
||||||
|
) {
|
||||||
|
const id = `notif-${Date.now()}-${Math.random()}`
|
||||||
|
const notification: Notification = {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
message,
|
||||||
|
duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
notifications.value.push(notification)
|
||||||
|
|
||||||
|
// Auto-remove après duration (défaut: 5s)
|
||||||
|
const timeout = duration !== undefined ? duration : 5000
|
||||||
|
if (timeout > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
removeNotification(id)
|
||||||
|
}, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeNotification(id: string) {
|
||||||
|
const index = notifications.value.findIndex(n => n.id === id)
|
||||||
|
if (index !== -1) {
|
||||||
|
notifications.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearNotifications() {
|
||||||
|
notifications.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raccourcis pour les types de notifications
|
||||||
|
function notifySuccess(message: string, duration?: number) {
|
||||||
|
return addNotification('success', message, duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyError(message: string, duration?: number) {
|
||||||
|
return addNotification('error', message, duration || 7000) // 7s pour erreurs
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyWarning(message: string, duration?: number) {
|
||||||
|
return addNotification('warning', message, duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyInfo(message: string, duration?: number) {
|
||||||
|
return addNotification('info', message, duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// État
|
||||||
|
selectedRendererId,
|
||||||
|
selectedServerId,
|
||||||
|
showEventLog,
|
||||||
|
sseConnected,
|
||||||
|
notifications,
|
||||||
|
// Actions
|
||||||
|
selectRenderer,
|
||||||
|
selectServer,
|
||||||
|
toggleEventLog,
|
||||||
|
setSSEConnected,
|
||||||
|
addNotification,
|
||||||
|
removeNotification,
|
||||||
|
clearNotifications,
|
||||||
|
notifySuccess,
|
||||||
|
notifyError,
|
||||||
|
notifyWarning,
|
||||||
|
notifyInfo,
|
||||||
|
}
|
||||||
|
})
|
||||||
233
pmoapp/webapp/src/views/DashboardView.vue
Normal file
233
pmoapp/webapp/src/views/DashboardView.vue
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted } from 'vue'
|
||||||
|
import { useRenderersStore } from '@/stores/renderers'
|
||||||
|
import { useMediaServersStore } from '@/stores/mediaServers'
|
||||||
|
import RendererCard from '@/components/pmocontrol/RendererCard.vue'
|
||||||
|
import MediaServerCard from '@/components/pmocontrol/MediaServerCard.vue'
|
||||||
|
import { Radio, Server } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const renderersStore = useRenderersStore()
|
||||||
|
const mediaServersStore = useMediaServersStore()
|
||||||
|
|
||||||
|
const renderers = computed(() => Array.from(renderersStore.renderers.values()))
|
||||||
|
const onlineRenderers = computed(() => renderersStore.onlineRenderers)
|
||||||
|
const mediaServers = computed(() => Array.from(mediaServersStore.servers.values()))
|
||||||
|
const onlineServers = computed(() => mediaServersStore.onlineServers)
|
||||||
|
|
||||||
|
// Charger les données au montage (si pas encore chargées par SSE)
|
||||||
|
onMounted(async () => {
|
||||||
|
if (renderers.value.length === 0) {
|
||||||
|
await renderersStore.fetchRenderers()
|
||||||
|
}
|
||||||
|
if (mediaServers.value.length === 0) {
|
||||||
|
await mediaServersStore.fetchServers()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="dashboard-view">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="dashboard-header">
|
||||||
|
<h1 class="dashboard-title">PMOControl Dashboard</h1>
|
||||||
|
<div class="dashboard-stats">
|
||||||
|
<div class="stat">
|
||||||
|
<Radio :size="20" />
|
||||||
|
<span>{{ onlineRenderers.length }} / {{ renderers.length }} renderers</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<Server :size="20" />
|
||||||
|
<span>{{ onlineServers.length }} / {{ mediaServers.length }} serveurs</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Renderers Section -->
|
||||||
|
<section class="dashboard-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 class="section-title">
|
||||||
|
<Radio :size="24" />
|
||||||
|
<span>Renderers Audio</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="renderers.length" class="renderers-grid">
|
||||||
|
<RendererCard
|
||||||
|
v-for="renderer in renderers"
|
||||||
|
:key="renderer.id"
|
||||||
|
:renderer="renderer"
|
||||||
|
:state="renderersStore.getStateById(renderer.id) ?? null"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="empty-state">
|
||||||
|
<p>Aucun renderer découvert</p>
|
||||||
|
<button class="btn btn-secondary" @click="renderersStore.fetchRenderers()">
|
||||||
|
Actualiser
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Media Servers Section -->
|
||||||
|
<section class="dashboard-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 class="section-title">
|
||||||
|
<Server :size="24" />
|
||||||
|
<span>Serveurs de Médias</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="mediaServers.length" class="servers-grid">
|
||||||
|
<MediaServerCard
|
||||||
|
v-for="server in mediaServers"
|
||||||
|
:key="server.id"
|
||||||
|
:server="server"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="empty-state">
|
||||||
|
<p>Aucun serveur de médias découvert</p>
|
||||||
|
<button class="btn btn-secondary" @click="mediaServersStore.fetchServers()">
|
||||||
|
Actualiser
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xl);
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.dashboard-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-title {
|
||||||
|
font-size: var(--text-3xl);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat svg {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sections */
|
||||||
|
.dashboard-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title svg {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grids */
|
||||||
|
.renderers-grid,
|
||||||
|
.servers-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 2px dashed var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.dashboard-view {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-title {
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.renderers-grid,
|
||||||
|
.servers-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) and (max-width: 1024px) {
|
||||||
|
.renderers-grid,
|
||||||
|
.servers-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.renderers-grid,
|
||||||
|
.servers-grid {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
237
pmoapp/webapp/src/views/MediaServerView.vue
Normal file
237
pmoapp/webapp/src/views/MediaServerView.vue
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, watch, ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useMediaServersStore } from '@/stores/mediaServers'
|
||||||
|
import MediaBrowser from '@/components/pmocontrol/MediaBrowser.vue'
|
||||||
|
import { ArrowLeft, Server } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const mediaServersStore = useMediaServersStore()
|
||||||
|
|
||||||
|
const serverId = computed(() => route.params.serverId as string)
|
||||||
|
const containerId = ref(route.query.container as string || '0')
|
||||||
|
|
||||||
|
const server = computed(() => mediaServersStore.getServerById(serverId.value))
|
||||||
|
|
||||||
|
// Watcher sur query.container pour mettre à jour containerId
|
||||||
|
watch(
|
||||||
|
() => route.query.container,
|
||||||
|
(newContainer) => {
|
||||||
|
containerId.value = (newContainer as string) || '0'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNavigate(newContainerId: string) {
|
||||||
|
router.push({
|
||||||
|
name: 'MediaServer',
|
||||||
|
params: { serverId: serverId.value },
|
||||||
|
query: { container: newContainerId }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="media-server-view">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="server-header">
|
||||||
|
<button class="btn-back" @click="goBack" title="Retour au dashboard">
|
||||||
|
<ArrowLeft :size="20" />
|
||||||
|
</button>
|
||||||
|
<div class="header-content">
|
||||||
|
<div class="server-info">
|
||||||
|
<Server :size="24" class="server-icon" />
|
||||||
|
<div class="server-details">
|
||||||
|
<h1 class="server-name">{{ server?.friendly_name || 'Chargement...' }}</h1>
|
||||||
|
<p class="server-model">{{ server?.model_name }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="server" class="server-status" :class="{ online: server.online, offline: !server.online }">
|
||||||
|
<span class="status-dot"></span>
|
||||||
|
<span class="status-label">{{ server.online ? 'En ligne' : 'Hors ligne' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div v-if="!server" class="loading-state">
|
||||||
|
<p>Chargement du serveur...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Offline state -->
|
||||||
|
<div v-else-if="!server.online" class="offline-state">
|
||||||
|
<p>Ce serveur est actuellement hors ligne</p>
|
||||||
|
<button class="btn btn-secondary" @click="goBack">
|
||||||
|
Retour au dashboard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<div v-else class="server-content">
|
||||||
|
<MediaBrowser
|
||||||
|
:serverId="serverId"
|
||||||
|
:containerId="containerId"
|
||||||
|
@navigate="handleNavigate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.media-server-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.server-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-back {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-back:hover {
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-icon {
|
||||||
|
color: var(--color-primary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-name {
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-model {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-status.online .status-dot {
|
||||||
|
background-color: var(--status-playing);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-status.online .status-label {
|
||||||
|
color: var(--status-playing);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-status.offline .status-dot {
|
||||||
|
background-color: var(--status-offline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-status.offline .status-label {
|
||||||
|
color: var(--status-offline);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading & Offline states */
|
||||||
|
.loading-state,
|
||||||
|
.offline-state {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-state p {
|
||||||
|
color: var(--status-offline);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content */
|
||||||
|
.server-content {
|
||||||
|
flex: 1;
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive - Mobile */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.media-server-view {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-name {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-info {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
283
pmoapp/webapp/src/views/RendererView.vue
Normal file
283
pmoapp/webapp/src/views/RendererView.vue
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useRenderersStore } from '@/stores/renderers'
|
||||||
|
import CurrentTrack from '@/components/pmocontrol/CurrentTrack.vue'
|
||||||
|
import TransportControls from '@/components/pmocontrol/TransportControls.vue'
|
||||||
|
import VolumeControl from '@/components/pmocontrol/VolumeControl.vue'
|
||||||
|
import QueueViewer from '@/components/pmocontrol/QueueViewer.vue'
|
||||||
|
import PlaylistBindingPanel from '@/components/pmocontrol/PlaylistBindingPanel.vue'
|
||||||
|
import StatusBadge from '@/components/pmocontrol/StatusBadge.vue'
|
||||||
|
import { ArrowLeft, Radio } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const renderersStore = useRenderersStore()
|
||||||
|
|
||||||
|
const rendererId = computed(() => route.params.id as string)
|
||||||
|
const renderer = computed(() => renderersStore.getRendererById(rendererId.value))
|
||||||
|
const state = computed(() => renderersStore.getStateById(rendererId.value))
|
||||||
|
|
||||||
|
// Charger les données au montage si nécessaire
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!renderer.value) {
|
||||||
|
await renderersStore.fetchRenderers()
|
||||||
|
}
|
||||||
|
if (!state.value) {
|
||||||
|
await renderersStore.fetchRendererState(rendererId.value)
|
||||||
|
}
|
||||||
|
await renderersStore.fetchQueue(rendererId.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocolLabel = computed(() => {
|
||||||
|
if (!renderer.value) return ''
|
||||||
|
switch (renderer.value.protocol) {
|
||||||
|
case 'UpnpAvOnly':
|
||||||
|
return 'UPnP AV'
|
||||||
|
case 'OpenHomeOnly':
|
||||||
|
return 'OpenHome'
|
||||||
|
case 'Hybrid':
|
||||||
|
return 'Hybrid'
|
||||||
|
default:
|
||||||
|
return 'Unknown'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="renderer-view">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="renderer-header">
|
||||||
|
<button class="btn-back" @click="goBack" title="Retour au dashboard">
|
||||||
|
<ArrowLeft :size="20" />
|
||||||
|
</button>
|
||||||
|
<div class="header-content">
|
||||||
|
<div class="renderer-info">
|
||||||
|
<Radio :size="24" class="renderer-icon" />
|
||||||
|
<div class="renderer-details">
|
||||||
|
<h1 class="renderer-name">{{ renderer?.friendly_name || 'Chargement...' }}</h1>
|
||||||
|
<p class="renderer-model">{{ renderer?.model_name }} • {{ protocolLabel }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StatusBadge v-if="state" :status="state.transport_state" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div v-if="!renderer || !state" class="loading-state">
|
||||||
|
<p>Chargement du renderer...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<div v-else class="renderer-content">
|
||||||
|
<!-- Left column (Desktop) / Top (Mobile) -->
|
||||||
|
<div class="left-column">
|
||||||
|
<!-- Current Track -->
|
||||||
|
<section class="content-section">
|
||||||
|
<CurrentTrack :rendererId="rendererId" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Transport Controls -->
|
||||||
|
<section class="content-section">
|
||||||
|
<TransportControls :rendererId="rendererId" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Volume Control -->
|
||||||
|
<section class="content-section">
|
||||||
|
<h3 class="section-subtitle">Volume</h3>
|
||||||
|
<VolumeControl :rendererId="rendererId" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Playlist Binding -->
|
||||||
|
<section class="content-section">
|
||||||
|
<PlaylistBindingPanel :rendererId="rendererId" />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right column (Desktop) / Bottom (Mobile) -->
|
||||||
|
<div class="right-column">
|
||||||
|
<section class="content-section queue-section">
|
||||||
|
<QueueViewer :rendererId="rendererId" />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.renderer-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.renderer-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-back {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-back:hover {
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.renderer-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.renderer-icon {
|
||||||
|
color: var(--color-primary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.renderer-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.renderer-name {
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.renderer-model {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading */
|
||||||
|
.loading-state {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: var(--text-base);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content */
|
||||||
|
.renderer-content {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
gap: var(--spacing-xl);
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-column,
|
||||||
|
.right-column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-section {
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-section {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 400px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-subtitle {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0 0 var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive - Desktop */
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.renderer-content {
|
||||||
|
grid-template-columns: 400px 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-section {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive - Tablet */
|
||||||
|
@media (min-width: 768px) and (max-width: 1023px) {
|
||||||
|
.renderer-content {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-column {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-section {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive - Mobile */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.renderer-view {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.renderer-name {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.renderer-info {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-section {
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,6 +3,10 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
"types": ["vite/client"],
|
"types": ["vite/client"],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
base: '/app/', // Base path pour le déploiement
|
base: '/app/', // Base path pour le déploiement
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
|
}
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
|
|||||||
Reference in New Issue
Block a user