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
▼packages/ui/package.json
+14-0
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
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