feat(ui): implement framework-agnostic WCAG 2.1 AA compliant dark glassmorphic player controls
swadhinbiswas committed 3 months ago
169628b
1 parent 540ea98
commit 169628b
2 files changed
+209 -0
Showing 2 changed files+209-0
▼packages/ui/package.json
+14-0
| @@ -0,0 +1,14 @@ | ||
1 | +{ | |
2 | + "name": "@media-runtime/ui", | |
3 | + "version": "1.0.0", | |
4 | + "description": "Framework-agnostic, WCAG 2.1 AA compliant UI controls for media-runtime", | |
5 | + "main": "dist/index.cjs", | |
6 | + "module": "dist/index.js", | |
7 | + "types": "dist/index.d.ts", | |
8 | + "unpkg": "dist/index.umd.js", | |
9 | + "jsdelivr": "dist/index.umd.js", | |
10 | + "type": "module", | |
11 | + "scripts": { | |
12 | + "build": "node ../../scripts/build-all.js" | |
13 | + } | |
14 | +} | |
0 | 15 | |
▼packages/ui/src/index.ts
+195-0
| @@ -0,0 +1,195 @@ | ||
1 | +import { Player } from '@media-runtime/core'; | |
2 | + | |
3 | +export class PlayerUI { | |
4 | + private container: HTMLElement; | |
5 | + private controlsBar!: HTMLDivElement; | |
6 | + private playBtn!: HTMLButtonElement; | |
7 | + private timeSlider!: HTMLInputElement; | |
8 | + private timeDisplay!: HTMLSpanElement; | |
9 | + private volumeBtn!: HTMLButtonElement; | |
10 | + private volumeSlider!: HTMLInputElement; | |
11 | + private pipBtn!: HTMLButtonElement; | |
12 | + private fullscreenBtn!: HTMLButtonElement; | |
13 | + | |
14 | + constructor(private player: Player, containerElement: HTMLElement) { | |
15 | + this.container = containerElement; | |
16 | + this.initDOM(); | |
17 | + this.bindEvents(); | |
18 | + this.bindKeyboardNav(); | |
19 | + } | |
20 | + | |
21 | + private initDOM(): void { | |
22 | + this.container.classList.add('media-runtime-container'); | |
23 | + this.container.setAttribute('role', 'region'); | |
24 | + this.container.setAttribute('aria-label', 'Media Player Controls'); | |
25 | + this.container.style.position = 'relative'; | |
26 | + this.container.style.overflow = 'hidden'; | |
27 | + this.container.style.backgroundColor = '#000'; | |
28 | + | |
29 | + // Apply dark glassmorphic styling | |
30 | + const style = document.createElement('style'); | |
31 | + style.textContent = ` | |
32 | + .media-runtime-controls { | |
33 | + position: absolute; | |
34 | + bottom: 0; | |
35 | + left: 0; | |
36 | + right: 0; | |
37 | + height: 52px; | |
38 | + background: rgba(15, 23, 42, 0.85); | |
39 | + backdrop-filter: blur(12px); | |
40 | + display: flex; | |
41 | + align-items: center; | |
42 | + padding: 0 16px; | |
43 | + gap: 12px; | |
44 | + color: #F8FAFC; | |
45 | + font-family: system-ui, -apple-system, sans-serif; | |
46 | + font-size: 14px; | |
47 | + box-sizing: border-box; | |
48 | + z-index: 100; | |
49 | + } | |
50 | + .media-runtime-btn { | |
51 | + background: transparent; | |
52 | + border: none; | |
53 | + color: #F8FAFC; | |
54 | + cursor: pointer; | |
55 | + padding: 6px; | |
56 | + border-radius: 6px; | |
57 | + display: inline-flex; | |
58 | + align-items: center; | |
59 | + justify-content: center; | |
60 | + } | |
61 | + .media-runtime-btn:focus-visible { | |
62 | + outline: 2px solid #38BDF8; | |
63 | + outline-offset: 2px; | |
64 | + } | |
65 | + .media-runtime-slider { | |
66 | + accent-color: #38BDF8; | |
67 | + cursor: pointer; | |
68 | + flex: 1; | |
69 | + } | |
70 | + `; | |
71 | + document.head.appendChild(style); | |
72 | + | |
73 | + this.controlsBar = document.createElement('div'); | |
74 | + this.controlsBar.className = 'media-runtime-controls'; | |
75 | + | |
76 | + // Play/Pause Button | |
77 | + this.playBtn = document.createElement('button'); | |
78 | + this.playBtn.className = 'media-runtime-btn'; | |
79 | + this.playBtn.setAttribute('aria-label', 'Play'); | |
80 | + this.playBtn.innerHTML = '▶'; | |
81 | + | |
82 | + // Time Slider | |
83 | + this.timeSlider = document.createElement('input'); | |
84 | + this.timeSlider.type = 'range'; | |
85 | + this.timeSlider.className = 'media-runtime-slider'; | |
86 | + this.timeSlider.min = '0'; | |
87 | + this.timeSlider.max = '100'; | |
88 | + this.timeSlider.value = '0'; | |
89 | + this.timeSlider.setAttribute('aria-label', 'Seek timeline'); | |
90 | + | |
91 | + // Time Display | |
92 | + this.timeDisplay = document.createElement('span'); | |
93 | + this.timeDisplay.textContent = '00:00 / 00:00'; | |
94 | + | |
95 | + // Volume Button | |
96 | + this.volumeBtn = document.createElement('button'); | |
97 | + this.volumeBtn.className = 'media-runtime-btn'; | |
98 | + this.volumeBtn.setAttribute('aria-label', 'Mute'); | |
99 | + this.volumeBtn.innerHTML = '🔊'; | |
100 | + | |
101 | + // Volume Slider | |
102 | + this.volumeSlider = document.createElement('input'); | |
103 | + this.volumeSlider.type = 'range'; | |
104 | + this.volumeSlider.className = 'media-runtime-slider'; | |
105 | + this.volumeSlider.style.width = '60px'; | |
106 | + this.volumeSlider.min = '0'; | |
107 | + this.volumeSlider.max = '1'; | |
108 | + this.volumeSlider.step = '0.05'; | |
109 | + this.volumeSlider.value = '1'; | |
110 | + this.volumeSlider.setAttribute('aria-label', 'Volume level'); | |
111 | + | |
112 | + // PiP Button | |
113 | + this.pipBtn = document.createElement('button'); | |
114 | + this.pipBtn.className = 'media-runtime-btn'; | |
115 | + this.pipBtn.setAttribute('aria-label', 'Picture in Picture'); | |
116 | + this.pipBtn.innerHTML = '⧉'; | |
117 | + | |
118 | + // Fullscreen Button | |
119 | + this.fullscreenBtn = document.createElement('button'); | |
120 | + this.fullscreenBtn.className = 'media-runtime-btn'; | |
121 | + this.fullscreenBtn.setAttribute('aria-label', 'Toggle Fullscreen'); | |
122 | + this.fullscreenBtn.innerHTML = '⛶'; | |
123 | + | |
124 | + this.controlsBar.appendChild(this.playBtn); | |
125 | + this.controlsBar.appendChild(this.timeSlider); | |
126 | + this.controlsBar.appendChild(this.timeDisplay); | |
127 | + this.controlsBar.appendChild(this.volumeBtn); | |
128 | + this.controlsBar.appendChild(this.volumeSlider); | |
129 | + this.controlsBar.appendChild(this.pipBtn); | |
130 | + this.controlsBar.appendChild(this.fullscreenBtn); | |
131 | + | |
132 | + this.container.appendChild(this.controlsBar); | |
133 | + } | |
134 | + | |
135 | + private bindEvents(): void { | |
136 | + this.playBtn.onclick = () => { | |
137 | + if (this.player.getState() === 'playing') { | |
138 | + this.player.pause(); | |
139 | + } else { | |
140 | + this.player.play(); | |
141 | + } | |
142 | + }; | |
143 | + | |
144 | + this.timeSlider.oninput = () => { | |
145 | + const seekTarget = parseFloat(this.timeSlider.value); | |
146 | + this.player.seek(seekTarget); | |
147 | + }; | |
148 | + | |
149 | + this.volumeSlider.oninput = () => { | |
150 | + this.player.setVolume(parseFloat(this.volumeSlider.value)); | |
151 | + }; | |
152 | + | |
153 | + this.player.on('statechange', (state) => { | |
154 | + if (state === 'playing') { | |
155 | + this.playBtn.innerHTML = '⏸'; | |
156 | + this.playBtn.setAttribute('aria-label', 'Pause'); | |
157 | + } else { | |
158 | + this.playBtn.innerHTML = '▶'; | |
159 | + this.playBtn.setAttribute('aria-label', 'Play'); | |
160 | + } | |
161 | + }); | |
162 | + | |
163 | + this.player.on('timeupdate', (timeSeconds: any) => { | |
164 | + const dur = this.player.getDuration() || 100; | |
165 | + this.timeSlider.max = String(dur); | |
166 | + this.timeSlider.value = String(timeSeconds || 0); | |
167 | + this.timeDisplay.textContent = `${this.formatTime(timeSeconds || 0)} / ${this.formatTime(dur)}`; | |
168 | + }); | |
169 | + } | |
170 | + | |
171 | + private bindKeyboardNav(): void { | |
172 | + this.container.tabIndex = 0; | |
173 | + this.container.onkeydown = (e: KeyboardEvent) => { | |
174 | + if (e.key === ' ' || e.key === 'k') { | |
175 | + e.preventDefault(); | |
176 | + this.playBtn.click(); | |
177 | + } else if (e.key === 'ArrowLeft') { | |
178 | + e.preventDefault(); | |
179 | + this.player.seek(Math.max(0, this.player.getCurrentTime() - 5)); | |
180 | + } else if (e.key === 'ArrowRight') { | |
181 | + e.preventDefault(); | |
182 | + this.player.seek(this.player.getCurrentTime() + 5); | |
183 | + } else if (e.key === 'm') { | |
184 | + e.preventDefault(); | |
185 | + this.player.setMuted(true); | |
186 | + } | |
187 | + }; | |
188 | + } | |
189 | + | |
190 | + private formatTime(sec: number): string { | |
191 | + const m = Math.floor(sec / 60); | |
192 | + const s = Math.floor(sec % 60); | |
193 | + return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; | |
194 | + } | |
195 | +} | |
0 | 196 | |