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",
|
||||
"dependencies": {
|
||||
"dompurify": "^3.2.7",
|
||||
"lucide-vue-next": "^0.555.0",
|
||||
"marked": "^16.3.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.21",
|
||||
"vue-router": "^4.5.1"
|
||||
"vue-router": "^4.5.1",
|
||||
"vue-virtual-scroller": "^2.0.0-beta.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
@@ -957,6 +960,36 @@
|
||||
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
|
||||
"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": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.1.0.tgz",
|
||||
@@ -1057,6 +1090,30 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "3.1.3",
|
||||
"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_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": {
|
||||
"version": "0.30.19",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz",
|
||||
@@ -1186,6 +1270,12 @@
|
||||
"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": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
|
||||
@@ -1218,6 +1308,12 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -1238,6 +1334,36 @@
|
||||
"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": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
@@ -1266,6 +1392,12 @@
|
||||
"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": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.3.tgz",
|
||||
@@ -1317,6 +1449,27 @@
|
||||
"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": {
|
||||
"version": "0.2.15",
|
||||
"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": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz",
|
||||
@@ -1485,6 +1656,20 @@
|
||||
"peerDependencies": {
|
||||
"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": {
|
||||
"dompurify": "^3.2.7",
|
||||
"lucide-vue-next": "^0.555.0",
|
||||
"marked": "^16.3.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.21",
|
||||
"vue-router": "^4.5.1"
|
||||
"vue-router": "^4.5.1",
|
||||
"vue-virtual-scroller": "^2.0.0-beta.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<nav class="main-nav">
|
||||
<router-link to="/">🏠 Accueil</router-link>
|
||||
<router-link to="/" class="nav-logo">PMOControl</router-link>
|
||||
|
||||
<!-- Menu déroulant Debug -->
|
||||
<div class="dropdown" @mouseenter="showDebugMenu = true" @mouseleave="showDebugMenu = false">
|
||||
@@ -10,14 +10,15 @@
|
||||
<span class="arrow">{{ showDebugMenu ? '▼' : '▶' }}</span>
|
||||
</button>
|
||||
<div v-show="showDebugMenu" class="dropdown-menu">
|
||||
<router-link to="/logs" @click="showDebugMenu = false">📋 Logs</router-link>
|
||||
<router-link to="/upnp" @click="showDebugMenu = false">🎵 UPnP Explorer</router-link>
|
||||
<router-link to="/covers-cache" @click="showDebugMenu = false">🎨 Cover Cache</router-link>
|
||||
<router-link to="/audio-cache" @click="showDebugMenu = false">🎵 Audio Cache</router-link>
|
||||
<router-link to="/api-dashboard" @click="showDebugMenu = false">🚀 API Dashboard</router-link>
|
||||
<router-link to="/debug/generic-player" @click="showDebugMenu = false">🎵 Generic Player</router-link>
|
||||
<router-link to="/debug/logs" @click="showDebugMenu = false">📋 Logs</router-link>
|
||||
<router-link to="/debug/upnp" @click="showDebugMenu = false">🎵 UPnP Explorer</router-link>
|
||||
<router-link to="/debug/covers-cache" @click="showDebugMenu = false">🎨 Cover Cache</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>
|
||||
<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>
|
||||
</nav>
|
||||
@@ -35,7 +36,7 @@ const showDebugMenu = ref(false)
|
||||
const route = useRoute()
|
||||
|
||||
const isDebugRoute = computed(() => {
|
||||
return ['/logs', '/upnp', '/covers-cache', '/audio-cache', '/api-dashboard', '/radio-paradise'].includes(route.path)
|
||||
return route.path.startsWith('/debug/')
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -63,6 +64,25 @@ const isDebugRoute = computed(() => {
|
||||
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 {
|
||||
color: #eee;
|
||||
padding: 0.5rem 1rem;
|
||||
@@ -77,7 +97,7 @@ const isDebugRoute = computed(() => {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.main-nav a.router-link-active {
|
||||
.main-nav a.router-link-active:not(.nav-logo) {
|
||||
background: #569cd6;
|
||||
color: #fff;
|
||||
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 { createPinia } from "pinia";
|
||||
import App from "./App.vue";
|
||||
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";
|
||||
|
||||
// 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 LogView from "../components/LogView.vue";
|
||||
import CoverCacheManager from "../components/CoverCacheManager.vue";
|
||||
@@ -8,13 +15,59 @@ import APIDashboard from "../components/APIDashboard.vue";
|
||||
import RadioParadiseExplorer from "../components/RadioParadiseExplorer.vue";
|
||||
|
||||
const routes = [
|
||||
{ path: "/", name: "home", component: GenericMusicPlayer },
|
||||
{ path: "/logs", name: "logs", component: LogView },
|
||||
{ path: "/covers-cache", name: "covers-cache", component: CoverCacheManager },
|
||||
{ path: "/audio-cache", name: "audio-cache", component: AudioCacheManager },
|
||||
{ path: "/upnp", name: "upnp", component: UpnpExplorer },
|
||||
{ path: "/api-dashboard", name: "api-dashboard", component: APIDashboard },
|
||||
{ path: "/radio-paradise", name: "radio-paradise", component: RadioParadiseExplorer },
|
||||
// PMOControl (nouvelle home)
|
||||
{
|
||||
path: "/",
|
||||
name: "Dashboard",
|
||||
component: DashboardView,
|
||||
},
|
||||
{
|
||||
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({
|
||||
|
||||
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": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
base: '/app/', // Base path pour le déploiement
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
|
||||
Reference in New Issue
Block a user