première version de la web UI du control point

This commit is contained in:
2025-12-04 22:43:01 +01:00
parent 75878be564
commit b38d2593b3
34 changed files with 5713 additions and 20 deletions

View 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?

View File

@@ -9,9 +9,12 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"dompurify": "^3.2.7", "dompurify": "^3.2.7",
"lucide-vue-next": "^0.555.0",
"marked": "^16.3.0", "marked": "^16.3.0",
"pinia": "^3.0.4",
"vue": "^3.5.21", "vue": "^3.5.21",
"vue-router": "^4.5.1" "vue-router": "^4.5.1",
"vue-virtual-scroller": "^2.0.0-beta.8"
}, },
"devDependencies": { "devDependencies": {
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
@@ -957,6 +960,36 @@
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vue/devtools-kit": {
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz",
"integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==",
"license": "MIT",
"dependencies": {
"@vue/devtools-shared": "^7.7.9",
"birpc": "^2.3.0",
"hookable": "^5.5.3",
"mitt": "^3.0.1",
"perfect-debounce": "^1.0.0",
"speakingurl": "^14.0.1",
"superjson": "^2.2.2"
}
},
"node_modules/@vue/devtools-kit/node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"license": "MIT"
},
"node_modules/@vue/devtools-shared": {
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz",
"integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==",
"license": "MIT",
"dependencies": {
"rfdc": "^1.4.1"
}
},
"node_modules/@vue/language-core": { "node_modules/@vue/language-core": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.1.0.tgz", "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.1.0.tgz",
@@ -1057,6 +1090,30 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/birpc": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
"integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/copy-anything": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz",
"integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==",
"license": "MIT",
"dependencies": {
"is-what": "^5.2.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
@@ -1165,6 +1222,33 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/hookable": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
"license": "MIT"
},
"node_modules/is-what": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
"integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/lucide-vue-next": {
"version": "0.555.0",
"resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-0.555.0.tgz",
"integrity": "sha512-7hczPsiMD/y+VNLpal5Q5Wv09kQxlHS0l/cM1xagrd+MA3i5umMm+PUXqllvsbgwAl3PHv27fo59h4PN02GM5A==",
"license": "ISC",
"peerDependencies": {
"vue": ">=3.0.1"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.19", "version": "0.30.19",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz",
@@ -1186,6 +1270,12 @@
"node": ">= 20" "node": ">= 20"
} }
}, },
"node_modules/mitt": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-2.1.0.tgz",
"integrity": "sha512-ILj2TpLiysu2wkBbWjAmww7TkZb65aiQO+DkVdUTBpBXq+MHYiETENkKFMtsJZX1Lf4pe4QOrTSjIfUwN5lRdg==",
"license": "MIT"
},
"node_modules/muggle-string": { "node_modules/muggle-string": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
@@ -1218,6 +1308,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
"license": "MIT"
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1238,6 +1334,36 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/pinia": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^7.7.7"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"typescript": ">=4.5.0",
"vue": "^3.5.11"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/pinia/node_modules/@vue/devtools-api": {
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz",
"integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==",
"license": "MIT",
"dependencies": {
"@vue/devtools-kit": "^7.7.9"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.6", "version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@@ -1266,6 +1392,12 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT"
},
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.52.3", "version": "4.52.3",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.3.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.3.tgz",
@@ -1317,6 +1449,27 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/speakingurl": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
"integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/superjson": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz",
"integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==",
"license": "MIT",
"dependencies": {
"copy-anything": "^4"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.15", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -1454,6 +1607,24 @@
} }
} }
}, },
"node_modules/vue-observe-visibility": {
"version": "2.0.0-alpha.1",
"resolved": "https://registry.npmjs.org/vue-observe-visibility/-/vue-observe-visibility-2.0.0-alpha.1.tgz",
"integrity": "sha512-flFbp/gs9pZniXR6fans8smv1kDScJ8RS7rEpMjhVabiKeq7Qz3D9+eGsypncjfIyyU84saU88XZ0zjbD6Gq/g==",
"license": "MIT",
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/vue-resize": {
"version": "2.0.0-alpha.1",
"resolved": "https://registry.npmjs.org/vue-resize/-/vue-resize-2.0.0-alpha.1.tgz",
"integrity": "sha512-7+iqOueLU7uc9NrMfrzbG8hwMqchfVfSzpVlCMeJQe4pyibqyoifDNbKTZvwxZKDvGkB+PdFeKvnGZMoEb8esg==",
"license": "MIT",
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/vue-router": { "node_modules/vue-router": {
"version": "4.5.1", "version": "4.5.1",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz",
@@ -1485,6 +1656,20 @@
"peerDependencies": { "peerDependencies": {
"typescript": ">=5.0.0" "typescript": ">=5.0.0"
} }
},
"node_modules/vue-virtual-scroller": {
"version": "2.0.0-beta.8",
"resolved": "https://registry.npmjs.org/vue-virtual-scroller/-/vue-virtual-scroller-2.0.0-beta.8.tgz",
"integrity": "sha512-b8/f5NQ5nIEBRTNi6GcPItE4s7kxNHw2AIHLtDp+2QvqdTjVN0FgONwX9cr53jWRgnu+HRLPaWDOR2JPI5MTfQ==",
"license": "MIT",
"dependencies": {
"mitt": "^2.1.0",
"vue-observe-visibility": "^2.0.0-alpha.1",
"vue-resize": "^2.0.0-alpha.1"
},
"peerDependencies": {
"vue": "^3.2.0"
}
} }
} }
} }

View File

@@ -10,9 +10,12 @@
}, },
"dependencies": { "dependencies": {
"dompurify": "^3.2.7", "dompurify": "^3.2.7",
"lucide-vue-next": "^0.555.0",
"marked": "^16.3.0", "marked": "^16.3.0",
"pinia": "^3.0.4",
"vue": "^3.5.21", "vue": "^3.5.21",
"vue-router": "^4.5.1" "vue-router": "^4.5.1",
"vue-virtual-scroller": "^2.0.0-beta.8"
}, },
"devDependencies": { "devDependencies": {
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="app-container"> <div class="app-container">
<nav class="main-nav"> <nav class="main-nav">
<router-link to="/">🏠 Accueil</router-link> <router-link to="/" class="nav-logo">PMOControl</router-link>
<!-- Menu déroulant Debug --> <!-- Menu déroulant Debug -->
<div class="dropdown" @mouseenter="showDebugMenu = true" @mouseleave="showDebugMenu = false"> <div class="dropdown" @mouseenter="showDebugMenu = true" @mouseleave="showDebugMenu = false">
@@ -10,14 +10,15 @@
<span class="arrow">{{ showDebugMenu ? '▼' : '▶' }}</span> <span class="arrow">{{ showDebugMenu ? '▼' : '▶' }}</span>
</button> </button>
<div v-show="showDebugMenu" class="dropdown-menu"> <div v-show="showDebugMenu" class="dropdown-menu">
<router-link to="/logs" @click="showDebugMenu = false">📋 Logs</router-link> <router-link to="/debug/generic-player" @click="showDebugMenu = false">🎵 Generic Player</router-link>
<router-link to="/upnp" @click="showDebugMenu = false">🎵 UPnP Explorer</router-link> <router-link to="/debug/logs" @click="showDebugMenu = false">📋 Logs</router-link>
<router-link to="/covers-cache" @click="showDebugMenu = false">🎨 Cover Cache</router-link> <router-link to="/debug/upnp" @click="showDebugMenu = false">🎵 UPnP Explorer</router-link>
<router-link to="/audio-cache" @click="showDebugMenu = false">🎵 Audio Cache</router-link> <router-link to="/debug/covers-cache" @click="showDebugMenu = false">🎨 Cover Cache</router-link>
<router-link to="/api-dashboard" @click="showDebugMenu = false">🚀 API Dashboard</router-link> <router-link to="/debug/audio-cache" @click="showDebugMenu = false">🎵 Audio Cache</router-link>
<router-link to="/debug/api-dashboard" @click="showDebugMenu = false">🚀 API Dashboard</router-link>
<div class="submenu-divider">Sources</div> <div class="submenu-divider">Sources</div>
<router-link to="/radio-paradise" @click="showDebugMenu = false">📻 Radio Paradise</router-link> <router-link to="/debug/radio-paradise" @click="showDebugMenu = false">📻 Radio Paradise</router-link>
</div> </div>
</div> </div>
</nav> </nav>
@@ -35,7 +36,7 @@ const showDebugMenu = ref(false)
const route = useRoute() const route = useRoute()
const isDebugRoute = computed(() => { const isDebugRoute = computed(() => {
return ['/logs', '/upnp', '/covers-cache', '/audio-cache', '/api-dashboard', '/radio-paradise'].includes(route.path) return route.path.startsWith('/debug/')
}) })
</script> </script>
@@ -63,6 +64,25 @@ const isDebugRoute = computed(() => {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
} }
.nav-logo {
font-size: 1.25rem;
font-weight: 700;
color: #fff !important;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 0.5rem 1rem !important;
border-radius: 6px;
text-decoration: none;
white-space: nowrap;
transition: all 0.2s;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.nav-logo:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
}
.main-nav a { .main-nav a {
color: #eee; color: #eee;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
@@ -77,7 +97,7 @@ const isDebugRoute = computed(() => {
color: #fff; color: #fff;
} }
.main-nav a.router-link-active { .main-nav a.router-link-active:not(.nav-logo) {
background: #569cd6; background: #569cd6;
color: #fff; color: #fff;
font-weight: bold; font-weight: bold;

View 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;
}

View 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;
}
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View 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>

View File

@@ -1,7 +1,60 @@
import { createApp } from "vue"; import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue"; import App from "./App.vue";
import router from "./router"; import router from "./router";
import "./style.css"; // Stores
import { useRenderersStore } from "./stores/renderers";
import { useMediaServersStore } from "./stores/mediaServers";
import { usePlaybackStore } from "./stores/playback";
import { useUIStore } from "./stores/ui";
createApp(App).use(router).mount("#app"); // Service SSE
import { sse } from "./services/pmocontrol/sse";
// Styles
import "./style.css";
import "./assets/styles/variables.css";
import "./assets/styles/pmocontrol.css";
// Créer l'application Vue
const app = createApp(App);
// Créer et installer Pinia
const pinia = createPinia();
app.use(pinia);
app.use(router);
// Monter l'application
app.mount("#app");
// Après montage, initialiser SSE et connecter aux stores
const renderersStore = useRenderersStore();
const mediaServersStore = useMediaServersStore();
const playbackStore = usePlaybackStore();
const uiStore = useUIStore();
// Connecter SSE aux stores
sse.onRendererEvent((event) => {
renderersStore.updateFromSSE(event);
playbackStore.updateFromSSE(event);
});
sse.onMediaServerEvent((event) => {
mediaServersStore.updateFromSSE(event);
});
sse.onConnectionChange((connected) => {
uiStore.setSSEConnected(connected);
if (connected) {
console.log("[App] SSE connecté - Chargement des données initiales");
// Charger les données initiales
renderersStore.fetchRenderers();
mediaServersStore.fetchServers();
}
});
// Démarrer la connexion SSE
sse.connect();
console.log("[App] PMOControl initialisé");

View File

@@ -1,4 +1,11 @@
import { createRouter, createWebHistory } from "vue-router"; import { createRouter, createWebHistory } from "vue-router";
// PMOControl Views (nouvelle home)
import DashboardView from "../views/DashboardView.vue";
import RendererView from "../views/RendererView.vue";
import MediaServerView from "../views/MediaServerView.vue";
// Debug Components (anciennes routes)
import GenericMusicPlayer from "../components/GenericMusicPlayer.vue"; import GenericMusicPlayer from "../components/GenericMusicPlayer.vue";
import LogView from "../components/LogView.vue"; import LogView from "../components/LogView.vue";
import CoverCacheManager from "../components/CoverCacheManager.vue"; import CoverCacheManager from "../components/CoverCacheManager.vue";
@@ -8,13 +15,59 @@ import APIDashboard from "../components/APIDashboard.vue";
import RadioParadiseExplorer from "../components/RadioParadiseExplorer.vue"; import RadioParadiseExplorer from "../components/RadioParadiseExplorer.vue";
const routes = [ const routes = [
{ path: "/", name: "home", component: GenericMusicPlayer }, // PMOControl (nouvelle home)
{ path: "/logs", name: "logs", component: LogView }, {
{ path: "/covers-cache", name: "covers-cache", component: CoverCacheManager }, path: "/",
{ path: "/audio-cache", name: "audio-cache", component: AudioCacheManager }, name: "Dashboard",
{ path: "/upnp", name: "upnp", component: UpnpExplorer }, component: DashboardView,
{ path: "/api-dashboard", name: "api-dashboard", component: APIDashboard }, },
{ path: "/radio-paradise", name: "radio-paradise", component: RadioParadiseExplorer }, {
path: "/renderer/:id",
name: "Renderer",
component: RendererView,
},
{
path: "/server/:serverId",
name: "MediaServer",
component: MediaServerView,
},
// Debug menu (anciennes routes déplacées sous /debug)
{
path: "/debug/generic-player",
name: "GenericPlayer",
component: GenericMusicPlayer,
},
{
path: "/debug/logs",
name: "Logs",
component: LogView,
},
{
path: "/debug/covers-cache",
name: "CoversCache",
component: CoverCacheManager,
},
{
path: "/debug/audio-cache",
name: "AudioCache",
component: AudioCacheManager,
},
{
path: "/debug/upnp",
name: "UpnpExplorer",
component: UpnpExplorer,
},
{
path: "/debug/api-dashboard",
name: "APIDashboard",
component: APIDashboard,
},
{
path: "/debug/radio-paradise",
name: "RadioParadise",
component: RadioParadiseExplorer,
},
]; ];
const router = createRouter({ const router = createRouter({

View 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()

View 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()

View 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
}

View 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,
}
})

View 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,
}
})

View 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,
}
})

View 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,
}
})

View 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>

View 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>

View 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>

View File

@@ -3,6 +3,10 @@
"compilerOptions": { "compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"], "types": ["vite/client"],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
/* Linting */ /* Linting */
"strict": true, "strict": true,

View File

@@ -1,10 +1,16 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [vue()],
base: '/app/', // Base path pour le déploiement base: '/app/', // Base path pour le déploiement
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: { server: {
proxy: { proxy: {
'/api': { '/api': {