@magic-spells/responsive-video 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +245 -0
- package/dist/responsive-video.cjs.js +163 -0
- package/dist/responsive-video.cjs.js.map +1 -0
- package/dist/responsive-video.esm.js +161 -0
- package/dist/responsive-video.esm.js.map +1 -0
- package/dist/responsive-video.js +169 -0
- package/dist/responsive-video.js.map +1 -0
- package/dist/responsive-video.min.js +1 -0
- package/package.json +79 -0
- package/src/responsive-video.js +158 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Cory Schulz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# @magic-spells/responsive-video
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@magic-spells/responsive-video)
|
|
4
|
+
[](https://bundlephobia.com/package/@magic-spells/responsive-video)
|
|
5
|
+
[](https://github.com/magic-spells/responsive-video/blob/main/LICENSE)
|
|
6
|
+
|
|
7
|
+
A lightweight, zero-dependency web component that intelligently swaps video sources and poster images based on viewport width. Built for performance-critical hero sections and modern web applications where mobile users should never download desktop assets (and vice versa).
|
|
8
|
+
|
|
9
|
+
**[Live Demo](https://magic-spells.github.io/responsive-video/demo/)**
|
|
10
|
+
|
|
11
|
+
## Why This Exists
|
|
12
|
+
|
|
13
|
+
Traditional responsive video implementations using `<source>` tags with media queries load multiple sources, wasting bandwidth and degrading performance on mobile devices. This component solves that by:
|
|
14
|
+
|
|
15
|
+
- **Loading only what's needed**: Mobile users download only mobile videos and posters, desktop users get desktop assets
|
|
16
|
+
- **Automatic switching**: Responds to viewport changes and updates the active source seamlessly
|
|
17
|
+
- **Performance-first**: Uses requestAnimationFrame for resize throttling and passive event listeners
|
|
18
|
+
- **Framework-agnostic**: Pure web component that works with any framework or no framework at all
|
|
19
|
+
- **Tiny footprint**: Single class, no dependencies, ~2KB minified
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install @magic-spells/responsive-video
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Or use directly from a CDN:
|
|
28
|
+
|
|
29
|
+
```html
|
|
30
|
+
<script type="module" src="https://unpkg.com/@magic-spells/responsive-video"></script>
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Quick Start
|
|
34
|
+
|
|
35
|
+
```html
|
|
36
|
+
<responsive-video
|
|
37
|
+
mobile-video="https://cdn.example.com/video-portrait.mp4"
|
|
38
|
+
desktop-video="https://cdn.example.com/video-landscape.mp4"
|
|
39
|
+
mobile-poster="https://cdn.example.com/poster-portrait.jpg"
|
|
40
|
+
desktop-poster="https://cdn.example.com/poster-landscape.jpg"
|
|
41
|
+
breakpoint="900"
|
|
42
|
+
>
|
|
43
|
+
<video autoplay muted playsinline loop></video>
|
|
44
|
+
</responsive-video>
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The component automatically:
|
|
48
|
+
1. Detects the viewport width
|
|
49
|
+
2. Loads the appropriate video source and poster image
|
|
50
|
+
3. Applies the `src` and `poster` to the child `<video>` element
|
|
51
|
+
4. Re-evaluates when the window resizes
|
|
52
|
+
5. Sets `data-active-mode` to "mobile" or "desktop" for styling hooks
|
|
53
|
+
|
|
54
|
+
## API
|
|
55
|
+
|
|
56
|
+
### Attributes
|
|
57
|
+
|
|
58
|
+
| Attribute | Type | Default | Description |
|
|
59
|
+
|-----------|------|---------|-------------|
|
|
60
|
+
| `mobile-video` | String | — | Video source URL for viewports narrower than the breakpoint |
|
|
61
|
+
| `desktop-video` | String | — | Video source URL for viewports equal to or wider than the breakpoint |
|
|
62
|
+
| `mobile-poster` | String | — | Poster image URL for viewports narrower than the breakpoint (optional) |
|
|
63
|
+
| `desktop-poster` | String | — | Poster image URL for viewports equal to or wider than the breakpoint (optional) |
|
|
64
|
+
| `breakpoint` | Number | `768` | Viewport width (in pixels) where the switch between mobile and desktop occurs |
|
|
65
|
+
|
|
66
|
+
### Behavior
|
|
67
|
+
|
|
68
|
+
- If viewport width ≥ breakpoint → loads `desktop-video` and `desktop-poster`
|
|
69
|
+
- If viewport width < breakpoint → loads `mobile-video` and `mobile-poster`
|
|
70
|
+
- If `mobile-video` is missing → falls back to `desktop-video` on all screen sizes
|
|
71
|
+
- Poster attributes are optional—if omitted, no poster is set
|
|
72
|
+
- If no matching video exists → component does nothing
|
|
73
|
+
- The component looks for the first `<video>` element in its light DOM
|
|
74
|
+
|
|
75
|
+
### Data Attributes
|
|
76
|
+
|
|
77
|
+
The component sets `data-active-mode` on itself to indicate which source is currently active:
|
|
78
|
+
|
|
79
|
+
```html
|
|
80
|
+
<responsive-video data-active-mode="mobile">...</responsive-video>
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
You can use this for conditional styling:
|
|
84
|
+
|
|
85
|
+
```css
|
|
86
|
+
responsive-video[data-active-mode="mobile"] {
|
|
87
|
+
/* Mobile-specific styles */
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
responsive-video[data-active-mode="desktop"] {
|
|
91
|
+
aspect-ratio: 16/9;
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## How It Works
|
|
96
|
+
|
|
97
|
+
The `ResponsiveVideo` class extends `HTMLElement` and implements the Custom Elements API with these lifecycle hooks:
|
|
98
|
+
|
|
99
|
+
### Lifecycle
|
|
100
|
+
|
|
101
|
+
1. **`connectedCallback()`**: Queries for the child `<video>` element, attaches resize listeners, and performs initial source evaluation
|
|
102
|
+
2. **`disconnectedCallback()`**: Cleans up event listeners and cancels pending animation frames
|
|
103
|
+
|
|
104
|
+
The component does not use `observedAttributes` or `attributeChangedCallback`—attributes are read dynamically on connect and during resize events.
|
|
105
|
+
|
|
106
|
+
### Resize Handling
|
|
107
|
+
|
|
108
|
+
Window resize events are throttled using `requestAnimationFrame` to prevent excessive recalculation. The component only updates the video source when it detects an actual change (e.g., crossing the breakpoint threshold or when the video URL differs from the currently loaded source).
|
|
109
|
+
|
|
110
|
+
### Source Swapping
|
|
111
|
+
|
|
112
|
+
When the source needs to change:
|
|
113
|
+
1. The component updates the `<video>` element's `src` attribute
|
|
114
|
+
2. Updates the `poster` attribute (if a poster URL is provided for the active mode)
|
|
115
|
+
3. Calls `video.load()` to initiate loading
|
|
116
|
+
4. Attempts to auto-play if `video.autoplay` is true (catches and ignores errors for muted autoplay requirements)
|
|
117
|
+
5. Updates `data-active-mode` to reflect the active source ("mobile" or "desktop")
|
|
118
|
+
|
|
119
|
+
### Private Implementation Details
|
|
120
|
+
|
|
121
|
+
The component uses private class fields (denoted by `#`) to encapsulate state:
|
|
122
|
+
- `#videoEl`: Reference to the child video element
|
|
123
|
+
- `#currentSrc`: Currently loaded video URL to prevent redundant updates
|
|
124
|
+
- `#resizeRaf`: requestAnimationFrame ID for resize throttling
|
|
125
|
+
- `#boundResize`: Cached resize handler reference
|
|
126
|
+
|
|
127
|
+
## Use Cases
|
|
128
|
+
|
|
129
|
+
### E-commerce Hero Sections
|
|
130
|
+
|
|
131
|
+
```html
|
|
132
|
+
<responsive-video
|
|
133
|
+
mobile-video="/assets/videos/hero-mobile.mp4"
|
|
134
|
+
desktop-video="/assets/videos/hero-desktop.mp4"
|
|
135
|
+
mobile-poster="/assets/images/hero-mobile-poster.jpg"
|
|
136
|
+
desktop-poster="/assets/images/hero-desktop-poster.jpg"
|
|
137
|
+
breakpoint="768"
|
|
138
|
+
>
|
|
139
|
+
<video autoplay muted playsinline loop></video>
|
|
140
|
+
</responsive-video>
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Progressive Enhancement
|
|
144
|
+
|
|
145
|
+
```html
|
|
146
|
+
<responsive-video
|
|
147
|
+
mobile-video="/videos/hero-mobile.mp4"
|
|
148
|
+
desktop-video="/videos/hero-desktop.mp4"
|
|
149
|
+
mobile-poster="/images/hero-mobile-poster.jpg"
|
|
150
|
+
desktop-poster="/images/hero-desktop-poster.jpg"
|
|
151
|
+
>
|
|
152
|
+
<video autoplay muted playsinline loop>
|
|
153
|
+
<!-- Fallback for browsers without custom element support -->
|
|
154
|
+
<source src="/videos/hero-desktop.mp4" type="video/mp4">
|
|
155
|
+
</video>
|
|
156
|
+
</responsive-video>
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Development
|
|
160
|
+
|
|
161
|
+
Install dependencies:
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
npm install
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Start the dev server with live reload:
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
npm run dev
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
This launches a local server on port 3006 with hot module replacement. The server serves both `dist/` and `demo/` directories. The demo uses CDN-hosted videos to avoid committing large files to the repository.
|
|
174
|
+
|
|
175
|
+
### Project Structure
|
|
176
|
+
|
|
177
|
+
```
|
|
178
|
+
responsive-video/
|
|
179
|
+
├── src/
|
|
180
|
+
│ └── responsive-video.js # Source code (ES class)
|
|
181
|
+
├── demo/
|
|
182
|
+
│ └── index.html # Demo page (uses CDN video URLs)
|
|
183
|
+
├── dist/ # Build outputs (generated)
|
|
184
|
+
│ ├── responsive-video.esm.js # ES Module
|
|
185
|
+
│ ├── responsive-video.cjs.js # CommonJS
|
|
186
|
+
│ ├── responsive-video.js # UMD
|
|
187
|
+
│ └── responsive-video.min.js # Minified UMD
|
|
188
|
+
├── rollup.config.mjs # Rollup bundler config
|
|
189
|
+
└── package.json
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### Build Commands
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
# Production build (creates all distribution formats)
|
|
196
|
+
npm run build
|
|
197
|
+
|
|
198
|
+
# Lint source code
|
|
199
|
+
npm run lint
|
|
200
|
+
|
|
201
|
+
# Format code with Prettier
|
|
202
|
+
npm run format
|
|
203
|
+
|
|
204
|
+
# Start dev server
|
|
205
|
+
npm run dev
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### Build Outputs
|
|
209
|
+
|
|
210
|
+
The build process generates four distribution formats:
|
|
211
|
+
|
|
212
|
+
1. **ESM** (`responsive-video.esm.js`) — For modern bundlers and `<script type="module">`
|
|
213
|
+
2. **CommonJS** (`responsive-video.cjs.js`) — For Node.js and older bundlers
|
|
214
|
+
3. **UMD** (`responsive-video.js`) — Universal module for direct browser usage
|
|
215
|
+
4. **Minified UMD** (`responsive-video.min.js`) — Production-ready minified bundle
|
|
216
|
+
|
|
217
|
+
All builds include sourcemaps except the minified version.
|
|
218
|
+
|
|
219
|
+
## Browser Support
|
|
220
|
+
|
|
221
|
+
Supports all modern browsers that implement [Custom Elements v1](https://caniuse.com/custom-elementsv1):
|
|
222
|
+
|
|
223
|
+
- Chrome/Edge 67+
|
|
224
|
+
- Firefox 63+
|
|
225
|
+
- Safari 10.1+
|
|
226
|
+
- iOS Safari 10.3+
|
|
227
|
+
|
|
228
|
+
For older browsers, use a [Custom Elements polyfill](https://github.com/webcomponents/polyfills/tree/master/packages/custom-elements).
|
|
229
|
+
|
|
230
|
+
## Performance Considerations
|
|
231
|
+
|
|
232
|
+
- Component registers with the Custom Elements registry only once
|
|
233
|
+
- Resize events are throttled via `requestAnimationFrame`
|
|
234
|
+
- Source updates are skipped when no actual change is detected
|
|
235
|
+
- Event listeners use `{ passive: true }` for better scroll performance
|
|
236
|
+
- Video element is cached to avoid repeated DOM queries
|
|
237
|
+
- Cleanup happens automatically on disconnect
|
|
238
|
+
|
|
239
|
+
## License
|
|
240
|
+
|
|
241
|
+
MIT © [Cory Schulz](https://github.com/magic-spells)
|
|
242
|
+
|
|
243
|
+
## Contributing
|
|
244
|
+
|
|
245
|
+
Issues and pull requests are welcome at [github.com/magic-spells/responsive-video](https://github.com/magic-spells/responsive-video).
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Responsive video loader that swaps sources based on viewport width.
|
|
5
|
+
* Only the matching source is applied so mobile users never download the desktop asset (and vice versa).
|
|
6
|
+
*/
|
|
7
|
+
class ResponsiveVideo extends HTMLElement {
|
|
8
|
+
#videoEl = null;
|
|
9
|
+
#currentSrc = null;
|
|
10
|
+
#resizeRaf = 0;
|
|
11
|
+
#boundResize = null;
|
|
12
|
+
|
|
13
|
+
connectedCallback() {
|
|
14
|
+
this.#videoEl = this.querySelector("video");
|
|
15
|
+
if (!this.#videoEl) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
this.attachEvents();
|
|
19
|
+
this.updateSource();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
disconnectedCallback() {
|
|
23
|
+
this.detachEvents();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Sets up listeners that keep the video source aligned with the viewport.
|
|
28
|
+
*/
|
|
29
|
+
attachEvents() {
|
|
30
|
+
if (typeof window === "undefined" || this.#boundResize) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
this.#boundResize = () => {
|
|
35
|
+
if (this.#resizeRaf) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
this.#resizeRaf = requestAnimationFrame(() => {
|
|
39
|
+
this.#resizeRaf = 0;
|
|
40
|
+
this.updateSource();
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
window.addEventListener("resize", this.#boundResize, { passive: true });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Removes listeners and cancels pending work.
|
|
49
|
+
*/
|
|
50
|
+
detachEvents() {
|
|
51
|
+
if (typeof window !== "undefined" && this.#boundResize) {
|
|
52
|
+
window.removeEventListener("resize", this.#boundResize);
|
|
53
|
+
}
|
|
54
|
+
if (this.#resizeRaf) {
|
|
55
|
+
cancelAnimationFrame(this.#resizeRaf);
|
|
56
|
+
this.#resizeRaf = 0;
|
|
57
|
+
}
|
|
58
|
+
this.#boundResize = null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Calculates which source should be active and updates the video if needed.
|
|
63
|
+
*/
|
|
64
|
+
updateSource() {
|
|
65
|
+
if (!this.#videoEl) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (typeof window === "undefined") {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const breakpoint = this.#getBreakpoint();
|
|
73
|
+
const mobileVideo = this.getAttribute("mobile-video")?.trim() || "";
|
|
74
|
+
const desktopVideo = this.getAttribute("desktop-video")?.trim() || "";
|
|
75
|
+
const mobilePoster = this.getAttribute("mobile-poster")?.trim() || "";
|
|
76
|
+
const desktopPoster = this.getAttribute("desktop-poster")?.trim() || "";
|
|
77
|
+
const viewportWidth = window.innerWidth || 0;
|
|
78
|
+
|
|
79
|
+
let nextMode = null;
|
|
80
|
+
let nextVideo = "";
|
|
81
|
+
let nextPoster = "";
|
|
82
|
+
|
|
83
|
+
if (viewportWidth >= breakpoint && desktopVideo) {
|
|
84
|
+
nextMode = "desktop";
|
|
85
|
+
nextVideo = desktopVideo;
|
|
86
|
+
nextPoster = desktopPoster;
|
|
87
|
+
} else if (mobileVideo) {
|
|
88
|
+
nextMode = "mobile";
|
|
89
|
+
nextVideo = mobileVideo;
|
|
90
|
+
nextPoster = mobilePoster;
|
|
91
|
+
} else if (desktopVideo) {
|
|
92
|
+
// Fallback to desktop when mobile video is missing.
|
|
93
|
+
nextMode = "desktop";
|
|
94
|
+
nextVideo = desktopVideo;
|
|
95
|
+
nextPoster = desktopPoster;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!nextVideo) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (nextVideo === this.#currentSrc) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
this.#applySource(nextVideo, nextPoster, nextMode);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
#getBreakpoint() {
|
|
110
|
+
const attr = this.getAttribute("breakpoint");
|
|
111
|
+
const parsed = parseInt(attr ?? "", 10);
|
|
112
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 768;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
#applySource(videoUrl, posterUrl, mode) {
|
|
116
|
+
const video = this.#videoEl;
|
|
117
|
+
if (!video) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const currentSrc = video.getAttribute("src");
|
|
122
|
+
if (currentSrc === videoUrl) {
|
|
123
|
+
this.#currentSrc = videoUrl;
|
|
124
|
+
if (mode) {
|
|
125
|
+
this.dataset.activeMode = mode;
|
|
126
|
+
}
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
video.setAttribute("src", videoUrl);
|
|
131
|
+
|
|
132
|
+
if (posterUrl) {
|
|
133
|
+
video.setAttribute("poster", posterUrl);
|
|
134
|
+
} else {
|
|
135
|
+
video.removeAttribute("poster");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
video.load();
|
|
139
|
+
|
|
140
|
+
if (video.autoplay && typeof video.play === "function") {
|
|
141
|
+
const playPromise = video.play();
|
|
142
|
+
if (playPromise && typeof playPromise.catch === "function") {
|
|
143
|
+
playPromise.catch(() => {
|
|
144
|
+
// Most browsers require muted autoplay; ignore failures quietly.
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
this.#currentSrc = videoUrl;
|
|
150
|
+
if (mode) {
|
|
151
|
+
this.dataset.activeMode = mode;
|
|
152
|
+
} else {
|
|
153
|
+
delete this.dataset.activeMode;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (!customElements.get("responsive-video")) {
|
|
159
|
+
customElements.define("responsive-video", ResponsiveVideo);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
exports.ResponsiveVideo = ResponsiveVideo;
|
|
163
|
+
//# sourceMappingURL=responsive-video.cjs.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"responsive-video.cjs.js","sources":["../src/responsive-video.js"],"sourcesContent":["/**\n * Responsive video loader that swaps sources based on viewport width.\n * Only the matching source is applied so mobile users never download the desktop asset (and vice versa).\n */\nexport class ResponsiveVideo extends HTMLElement {\n #videoEl = null;\n #currentSrc = null;\n #resizeRaf = 0;\n #boundResize = null;\n\n connectedCallback() {\n this.#videoEl = this.querySelector(\"video\");\n if (!this.#videoEl) {\n return;\n }\n this.attachEvents();\n this.updateSource();\n }\n\n disconnectedCallback() {\n this.detachEvents();\n }\n\n /**\n * Sets up listeners that keep the video source aligned with the viewport.\n */\n attachEvents() {\n if (typeof window === \"undefined\" || this.#boundResize) {\n return;\n }\n\n this.#boundResize = () => {\n if (this.#resizeRaf) {\n return;\n }\n this.#resizeRaf = requestAnimationFrame(() => {\n this.#resizeRaf = 0;\n this.updateSource();\n });\n };\n\n window.addEventListener(\"resize\", this.#boundResize, { passive: true });\n }\n\n /**\n * Removes listeners and cancels pending work.\n */\n detachEvents() {\n if (typeof window !== \"undefined\" && this.#boundResize) {\n window.removeEventListener(\"resize\", this.#boundResize);\n }\n if (this.#resizeRaf) {\n cancelAnimationFrame(this.#resizeRaf);\n this.#resizeRaf = 0;\n }\n this.#boundResize = null;\n }\n\n /**\n * Calculates which source should be active and updates the video if needed.\n */\n updateSource() {\n if (!this.#videoEl) {\n return;\n }\n if (typeof window === \"undefined\") {\n return;\n }\n\n const breakpoint = this.#getBreakpoint();\n const mobileVideo = this.getAttribute(\"mobile-video\")?.trim() || \"\";\n const desktopVideo = this.getAttribute(\"desktop-video\")?.trim() || \"\";\n const mobilePoster = this.getAttribute(\"mobile-poster\")?.trim() || \"\";\n const desktopPoster = this.getAttribute(\"desktop-poster\")?.trim() || \"\";\n const viewportWidth = window.innerWidth || 0;\n\n let nextMode = null;\n let nextVideo = \"\";\n let nextPoster = \"\";\n\n if (viewportWidth >= breakpoint && desktopVideo) {\n nextMode = \"desktop\";\n nextVideo = desktopVideo;\n nextPoster = desktopPoster;\n } else if (mobileVideo) {\n nextMode = \"mobile\";\n nextVideo = mobileVideo;\n nextPoster = mobilePoster;\n } else if (desktopVideo) {\n // Fallback to desktop when mobile video is missing.\n nextMode = \"desktop\";\n nextVideo = desktopVideo;\n nextPoster = desktopPoster;\n }\n\n if (!nextVideo) {\n return;\n }\n\n if (nextVideo === this.#currentSrc) {\n return;\n }\n\n this.#applySource(nextVideo, nextPoster, nextMode);\n }\n\n #getBreakpoint() {\n const attr = this.getAttribute(\"breakpoint\");\n const parsed = parseInt(attr ?? \"\", 10);\n return Number.isFinite(parsed) && parsed > 0 ? parsed : 768;\n }\n\n #applySource(videoUrl, posterUrl, mode) {\n const video = this.#videoEl;\n if (!video) {\n return;\n }\n\n const currentSrc = video.getAttribute(\"src\");\n if (currentSrc === videoUrl) {\n this.#currentSrc = videoUrl;\n if (mode) {\n this.dataset.activeMode = mode;\n }\n return;\n }\n\n video.setAttribute(\"src\", videoUrl);\n\n if (posterUrl) {\n video.setAttribute(\"poster\", posterUrl);\n } else {\n video.removeAttribute(\"poster\");\n }\n\n video.load();\n\n if (video.autoplay && typeof video.play === \"function\") {\n const playPromise = video.play();\n if (playPromise && typeof playPromise.catch === \"function\") {\n playPromise.catch(() => {\n // Most browsers require muted autoplay; ignore failures quietly.\n });\n }\n }\n\n this.#currentSrc = videoUrl;\n if (mode) {\n this.dataset.activeMode = mode;\n } else {\n delete this.dataset.activeMode;\n }\n }\n}\n\nif (!customElements.get(\"responsive-video\")) {\n customElements.define(\"responsive-video\", ResponsiveVideo);\n}\n"],"names":[],"mappings":";;AAAA;AACA;AACA;AACA;AACO,MAAM,eAAe,SAAS,WAAW,CAAC;AACjD,EAAE,QAAQ,GAAG,IAAI,CAAC;AAClB,EAAE,WAAW,GAAG,IAAI,CAAC;AACrB,EAAE,UAAU,GAAG,CAAC,CAAC;AACjB,EAAE,YAAY,GAAG,IAAI,CAAC;AACtB;AACA,EAAE,iBAAiB,GAAG;AACtB,IAAI,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC,aAAa,CAAC,OAAO,CAAC,CAAC;AAChD,IAAI,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE;AACxB,MAAM,OAAO;AACb,KAAK;AACL,IAAI,IAAI,CAAC,YAAY,EAAE,CAAC;AACxB,IAAI,IAAI,CAAC,YAAY,EAAE,CAAC;AACxB,GAAG;AACH;AACA,EAAE,oBAAoB,GAAG;AACzB,IAAI,IAAI,CAAC,YAAY,EAAE,CAAC;AACxB,GAAG;AACH;AACA;AACA;AACA;AACA,EAAE,YAAY,GAAG;AACjB,IAAI,IAAI,OAAO,MAAM,KAAK,WAAW,IAAI,IAAI,CAAC,YAAY,EAAE;AAC5D,MAAM,OAAO;AACb,KAAK;AACL;AACA,IAAI,IAAI,CAAC,YAAY,GAAG,MAAM;AAC9B,MAAM,IAAI,IAAI,CAAC,UAAU,EAAE;AAC3B,QAAQ,OAAO;AACf,OAAO;AACP,MAAM,IAAI,CAAC,UAAU,GAAG,qBAAqB,CAAC,MAAM;AACpD,QAAQ,IAAI,CAAC,UAAU,GAAG,CAAC,CAAC;AAC5B,QAAQ,IAAI,CAAC,YAAY,EAAE,CAAC;AAC5B,OAAO,CAAC,CAAC;AACT,KAAK,CAAC;AACN;AACA,IAAI,MAAM,CAAC,gBAAgB,CAAC,QAAQ,EAAE,IAAI,CAAC,YAAY,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC;AAC5E,GAAG;AACH;AACA;AACA;AACA;AACA,EAAE,YAAY,GAAG;AACjB,IAAI,IAAI,OAAO,MAAM,KAAK,WAAW,IAAI,IAAI,CAAC,YAAY,EAAE;AAC5D,MAAM,MAAM,CAAC,mBAAmB,CAAC,QAAQ,EAAE,IAAI,CAAC,YAAY,CAAC,CAAC;AAC9D,KAAK;AACL,IAAI,IAAI,IAAI,CAAC,UAAU,EAAE;AACzB,MAAM,oBAAoB,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;AAC5C,MAAM,IAAI,CAAC,UAAU,GAAG,CAAC,CAAC;AAC1B,KAAK;AACL,IAAI,IAAI,CAAC,YAAY,GAAG,IAAI,CAAC;AAC7B,GAAG;AACH;AACA;AACA;AACA;AACA,EAAE,YAAY,GAAG;AACjB,IAAI,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE;AACxB,MAAM,OAAO;AACb,KAAK;AACL,IAAI,IAAI,OAAO,MAAM,KAAK,WAAW,EAAE;AACvC,MAAM,OAAO;AACb,KAAK;AACL;AACA,IAAI,MAAM,UAAU,GAAG,IAAI,CAAC,cAAc,EAAE,CAAC;AAC7C,IAAI,MAAM,WAAW,GAAG,IAAI,CAAC,YAAY,CAAC,cAAc,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;AACxE,IAAI,MAAM,YAAY,GAAG,IAAI,CAAC,YAAY,CAAC,eAAe,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;AAC1E,IAAI,MAAM,YAAY,GAAG,IAAI,CAAC,YAAY,CAAC,eAAe,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;AAC1E,IAAI,MAAM,aAAa,GAAG,IAAI,CAAC,YAAY,CAAC,gBAAgB,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;AAC5E,IAAI,MAAM,aAAa,GAAG,MAAM,CAAC,UAAU,IAAI,CAAC,CAAC;AACjD;AACA,IAAI,IAAI,QAAQ,GAAG,IAAI,CAAC;AACxB,IAAI,IAAI,SAAS,GAAG,EAAE,CAAC;AACvB,IAAI,IAAI,UAAU,GAAG,EAAE,CAAC;AACxB;AACA,IAAI,IAAI,aAAa,IAAI,UAAU,IAAI,YAAY,EAAE;AACrD,MAAM,QAAQ,GAAG,SAAS,CAAC;AAC3B,MAAM,SAAS,GAAG,YAAY,CAAC;AAC/B,MAAM,UAAU,GAAG,aAAa,CAAC;AACjC,KAAK,MAAM,IAAI,WAAW,EAAE;AAC5B,MAAM,QAAQ,GAAG,QAAQ,CAAC;AAC1B,MAAM,SAAS,GAAG,WAAW,CAAC;AAC9B,MAAM,UAAU,GAAG,YAAY,CAAC;AAChC,KAAK,MAAM,IAAI,YAAY,EAAE;AAC7B;AACA,MAAM,QAAQ,GAAG,SAAS,CAAC;AAC3B,MAAM,SAAS,GAAG,YAAY,CAAC;AAC/B,MAAM,UAAU,GAAG,aAAa,CAAC;AACjC,KAAK;AACL;AACA,IAAI,IAAI,CAAC,SAAS,EAAE;AACpB,MAAM,OAAO;AACb,KAAK;AACL;AACA,IAAI,IAAI,SAAS,KAAK,IAAI,CAAC,WAAW,EAAE;AACxC,MAAM,OAAO;AACb,KAAK;AACL;AACA,IAAI,IAAI,CAAC,YAAY,CAAC,SAAS,EAAE,UAAU,EAAE,QAAQ,CAAC,CAAC;AACvD,GAAG;AACH;AACA,EAAE,cAAc,GAAG;AACnB,IAAI,MAAM,IAAI,GAAG,IAAI,CAAC,YAAY,CAAC,YAAY,CAAC,CAAC;AACjD,IAAI,MAAM,MAAM,GAAG,QAAQ,CAAC,IAAI,IAAI,EAAE,EAAE,EAAE,CAAC,CAAC;AAC5C,IAAI,OAAO,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,IAAI,MAAM,GAAG,CAAC,GAAG,MAAM,GAAG,GAAG,CAAC;AAChE,GAAG;AACH;AACA,EAAE,YAAY,CAAC,QAAQ,EAAE,SAAS,EAAE,IAAI,EAAE;AAC1C,IAAI,MAAM,KAAK,GAAG,IAAI,CAAC,QAAQ,CAAC;AAChC,IAAI,IAAI,CAAC,KAAK,EAAE;AAChB,MAAM,OAAO;AACb,KAAK;AACL;AACA,IAAI,MAAM,UAAU,GAAG,KAAK,CAAC,YAAY,CAAC,KAAK,CAAC,CAAC;AACjD,IAAI,IAAI,UAAU,KAAK,QAAQ,EAAE;AACjC,MAAM,IAAI,CAAC,WAAW,GAAG,QAAQ,CAAC;AAClC,MAAM,IAAI,IAAI,EAAE;AAChB,QAAQ,IAAI,CAAC,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;AACvC,OAAO;AACP,MAAM,OAAO;AACb,KAAK;AACL;AACA,IAAI,KAAK,CAAC,YAAY,CAAC,KAAK,EAAE,QAAQ,CAAC,CAAC;AACxC;AACA,IAAI,IAAI,SAAS,EAAE;AACnB,MAAM,KAAK,CAAC,YAAY,CAAC,QAAQ,EAAE,SAAS,CAAC,CAAC;AAC9C,KAAK,MAAM;AACX,MAAM,KAAK,CAAC,eAAe,CAAC,QAAQ,CAAC,CAAC;AACtC,KAAK;AACL;AACA,IAAI,KAAK,CAAC,IAAI,EAAE,CAAC;AACjB;AACA,IAAI,IAAI,KAAK,CAAC,QAAQ,IAAI,OAAO,KAAK,CAAC,IAAI,KAAK,UAAU,EAAE;AAC5D,MAAM,MAAM,WAAW,GAAG,KAAK,CAAC,IAAI,EAAE,CAAC;AACvC,MAAM,IAAI,WAAW,IAAI,OAAO,WAAW,CAAC,KAAK,KAAK,UAAU,EAAE;AAClE,QAAQ,WAAW,CAAC,KAAK,CAAC,MAAM;AAChC;AACA,SAAS,CAAC,CAAC;AACX,OAAO;AACP,KAAK;AACL;AACA,IAAI,IAAI,CAAC,WAAW,GAAG,QAAQ,CAAC;AAChC,IAAI,IAAI,IAAI,EAAE;AACd,MAAM,IAAI,CAAC,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;AACrC,KAAK,MAAM;AACX,MAAM,OAAO,IAAI,CAAC,OAAO,CAAC,UAAU,CAAC;AACrC,KAAK;AACL,GAAG;AACH,CAAC;AACD;AACA,IAAI,CAAC,cAAc,CAAC,GAAG,CAAC,kBAAkB,CAAC,EAAE;AAC7C,EAAE,cAAc,CAAC,MAAM,CAAC,kBAAkB,EAAE,eAAe,CAAC,CAAC;AAC7D;;;;"}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Responsive video loader that swaps sources based on viewport width.
|
|
3
|
+
* Only the matching source is applied so mobile users never download the desktop asset (and vice versa).
|
|
4
|
+
*/
|
|
5
|
+
class ResponsiveVideo extends HTMLElement {
|
|
6
|
+
#videoEl = null;
|
|
7
|
+
#currentSrc = null;
|
|
8
|
+
#resizeRaf = 0;
|
|
9
|
+
#boundResize = null;
|
|
10
|
+
|
|
11
|
+
connectedCallback() {
|
|
12
|
+
this.#videoEl = this.querySelector("video");
|
|
13
|
+
if (!this.#videoEl) {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
this.attachEvents();
|
|
17
|
+
this.updateSource();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
disconnectedCallback() {
|
|
21
|
+
this.detachEvents();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Sets up listeners that keep the video source aligned with the viewport.
|
|
26
|
+
*/
|
|
27
|
+
attachEvents() {
|
|
28
|
+
if (typeof window === "undefined" || this.#boundResize) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
this.#boundResize = () => {
|
|
33
|
+
if (this.#resizeRaf) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
this.#resizeRaf = requestAnimationFrame(() => {
|
|
37
|
+
this.#resizeRaf = 0;
|
|
38
|
+
this.updateSource();
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
window.addEventListener("resize", this.#boundResize, { passive: true });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Removes listeners and cancels pending work.
|
|
47
|
+
*/
|
|
48
|
+
detachEvents() {
|
|
49
|
+
if (typeof window !== "undefined" && this.#boundResize) {
|
|
50
|
+
window.removeEventListener("resize", this.#boundResize);
|
|
51
|
+
}
|
|
52
|
+
if (this.#resizeRaf) {
|
|
53
|
+
cancelAnimationFrame(this.#resizeRaf);
|
|
54
|
+
this.#resizeRaf = 0;
|
|
55
|
+
}
|
|
56
|
+
this.#boundResize = null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Calculates which source should be active and updates the video if needed.
|
|
61
|
+
*/
|
|
62
|
+
updateSource() {
|
|
63
|
+
if (!this.#videoEl) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (typeof window === "undefined") {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const breakpoint = this.#getBreakpoint();
|
|
71
|
+
const mobileVideo = this.getAttribute("mobile-video")?.trim() || "";
|
|
72
|
+
const desktopVideo = this.getAttribute("desktop-video")?.trim() || "";
|
|
73
|
+
const mobilePoster = this.getAttribute("mobile-poster")?.trim() || "";
|
|
74
|
+
const desktopPoster = this.getAttribute("desktop-poster")?.trim() || "";
|
|
75
|
+
const viewportWidth = window.innerWidth || 0;
|
|
76
|
+
|
|
77
|
+
let nextMode = null;
|
|
78
|
+
let nextVideo = "";
|
|
79
|
+
let nextPoster = "";
|
|
80
|
+
|
|
81
|
+
if (viewportWidth >= breakpoint && desktopVideo) {
|
|
82
|
+
nextMode = "desktop";
|
|
83
|
+
nextVideo = desktopVideo;
|
|
84
|
+
nextPoster = desktopPoster;
|
|
85
|
+
} else if (mobileVideo) {
|
|
86
|
+
nextMode = "mobile";
|
|
87
|
+
nextVideo = mobileVideo;
|
|
88
|
+
nextPoster = mobilePoster;
|
|
89
|
+
} else if (desktopVideo) {
|
|
90
|
+
// Fallback to desktop when mobile video is missing.
|
|
91
|
+
nextMode = "desktop";
|
|
92
|
+
nextVideo = desktopVideo;
|
|
93
|
+
nextPoster = desktopPoster;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!nextVideo) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (nextVideo === this.#currentSrc) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
this.#applySource(nextVideo, nextPoster, nextMode);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
#getBreakpoint() {
|
|
108
|
+
const attr = this.getAttribute("breakpoint");
|
|
109
|
+
const parsed = parseInt(attr ?? "", 10);
|
|
110
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 768;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#applySource(videoUrl, posterUrl, mode) {
|
|
114
|
+
const video = this.#videoEl;
|
|
115
|
+
if (!video) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const currentSrc = video.getAttribute("src");
|
|
120
|
+
if (currentSrc === videoUrl) {
|
|
121
|
+
this.#currentSrc = videoUrl;
|
|
122
|
+
if (mode) {
|
|
123
|
+
this.dataset.activeMode = mode;
|
|
124
|
+
}
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
video.setAttribute("src", videoUrl);
|
|
129
|
+
|
|
130
|
+
if (posterUrl) {
|
|
131
|
+
video.setAttribute("poster", posterUrl);
|
|
132
|
+
} else {
|
|
133
|
+
video.removeAttribute("poster");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
video.load();
|
|
137
|
+
|
|
138
|
+
if (video.autoplay && typeof video.play === "function") {
|
|
139
|
+
const playPromise = video.play();
|
|
140
|
+
if (playPromise && typeof playPromise.catch === "function") {
|
|
141
|
+
playPromise.catch(() => {
|
|
142
|
+
// Most browsers require muted autoplay; ignore failures quietly.
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
this.#currentSrc = videoUrl;
|
|
148
|
+
if (mode) {
|
|
149
|
+
this.dataset.activeMode = mode;
|
|
150
|
+
} else {
|
|
151
|
+
delete this.dataset.activeMode;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!customElements.get("responsive-video")) {
|
|
157
|
+
customElements.define("responsive-video", ResponsiveVideo);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export { ResponsiveVideo };
|
|
161
|
+
//# sourceMappingURL=responsive-video.esm.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"responsive-video.esm.js","sources":["../src/responsive-video.js"],"sourcesContent":["/**\n * Responsive video loader that swaps sources based on viewport width.\n * Only the matching source is applied so mobile users never download the desktop asset (and vice versa).\n */\nexport class ResponsiveVideo extends HTMLElement {\n #videoEl = null;\n #currentSrc = null;\n #resizeRaf = 0;\n #boundResize = null;\n\n connectedCallback() {\n this.#videoEl = this.querySelector(\"video\");\n if (!this.#videoEl) {\n return;\n }\n this.attachEvents();\n this.updateSource();\n }\n\n disconnectedCallback() {\n this.detachEvents();\n }\n\n /**\n * Sets up listeners that keep the video source aligned with the viewport.\n */\n attachEvents() {\n if (typeof window === \"undefined\" || this.#boundResize) {\n return;\n }\n\n this.#boundResize = () => {\n if (this.#resizeRaf) {\n return;\n }\n this.#resizeRaf = requestAnimationFrame(() => {\n this.#resizeRaf = 0;\n this.updateSource();\n });\n };\n\n window.addEventListener(\"resize\", this.#boundResize, { passive: true });\n }\n\n /**\n * Removes listeners and cancels pending work.\n */\n detachEvents() {\n if (typeof window !== \"undefined\" && this.#boundResize) {\n window.removeEventListener(\"resize\", this.#boundResize);\n }\n if (this.#resizeRaf) {\n cancelAnimationFrame(this.#resizeRaf);\n this.#resizeRaf = 0;\n }\n this.#boundResize = null;\n }\n\n /**\n * Calculates which source should be active and updates the video if needed.\n */\n updateSource() {\n if (!this.#videoEl) {\n return;\n }\n if (typeof window === \"undefined\") {\n return;\n }\n\n const breakpoint = this.#getBreakpoint();\n const mobileVideo = this.getAttribute(\"mobile-video\")?.trim() || \"\";\n const desktopVideo = this.getAttribute(\"desktop-video\")?.trim() || \"\";\n const mobilePoster = this.getAttribute(\"mobile-poster\")?.trim() || \"\";\n const desktopPoster = this.getAttribute(\"desktop-poster\")?.trim() || \"\";\n const viewportWidth = window.innerWidth || 0;\n\n let nextMode = null;\n let nextVideo = \"\";\n let nextPoster = \"\";\n\n if (viewportWidth >= breakpoint && desktopVideo) {\n nextMode = \"desktop\";\n nextVideo = desktopVideo;\n nextPoster = desktopPoster;\n } else if (mobileVideo) {\n nextMode = \"mobile\";\n nextVideo = mobileVideo;\n nextPoster = mobilePoster;\n } else if (desktopVideo) {\n // Fallback to desktop when mobile video is missing.\n nextMode = \"desktop\";\n nextVideo = desktopVideo;\n nextPoster = desktopPoster;\n }\n\n if (!nextVideo) {\n return;\n }\n\n if (nextVideo === this.#currentSrc) {\n return;\n }\n\n this.#applySource(nextVideo, nextPoster, nextMode);\n }\n\n #getBreakpoint() {\n const attr = this.getAttribute(\"breakpoint\");\n const parsed = parseInt(attr ?? \"\", 10);\n return Number.isFinite(parsed) && parsed > 0 ? parsed : 768;\n }\n\n #applySource(videoUrl, posterUrl, mode) {\n const video = this.#videoEl;\n if (!video) {\n return;\n }\n\n const currentSrc = video.getAttribute(\"src\");\n if (currentSrc === videoUrl) {\n this.#currentSrc = videoUrl;\n if (mode) {\n this.dataset.activeMode = mode;\n }\n return;\n }\n\n video.setAttribute(\"src\", videoUrl);\n\n if (posterUrl) {\n video.setAttribute(\"poster\", posterUrl);\n } else {\n video.removeAttribute(\"poster\");\n }\n\n video.load();\n\n if (video.autoplay && typeof video.play === \"function\") {\n const playPromise = video.play();\n if (playPromise && typeof playPromise.catch === \"function\") {\n playPromise.catch(() => {\n // Most browsers require muted autoplay; ignore failures quietly.\n });\n }\n }\n\n this.#currentSrc = videoUrl;\n if (mode) {\n this.dataset.activeMode = mode;\n } else {\n delete this.dataset.activeMode;\n }\n }\n}\n\nif (!customElements.get(\"responsive-video\")) {\n customElements.define(\"responsive-video\", ResponsiveVideo);\n}\n"],"names":[],"mappings":"AAAA;AACA;AACA;AACA;AACO,MAAM,eAAe,SAAS,WAAW,CAAC;AACjD,EAAE,QAAQ,GAAG,IAAI,CAAC;AAClB,EAAE,WAAW,GAAG,IAAI,CAAC;AACrB,EAAE,UAAU,GAAG,CAAC,CAAC;AACjB,EAAE,YAAY,GAAG,IAAI,CAAC;AACtB;AACA,EAAE,iBAAiB,GAAG;AACtB,IAAI,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC,aAAa,CAAC,OAAO,CAAC,CAAC;AAChD,IAAI,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE;AACxB,MAAM,OAAO;AACb,KAAK;AACL,IAAI,IAAI,CAAC,YAAY,EAAE,CAAC;AACxB,IAAI,IAAI,CAAC,YAAY,EAAE,CAAC;AACxB,GAAG;AACH;AACA,EAAE,oBAAoB,GAAG;AACzB,IAAI,IAAI,CAAC,YAAY,EAAE,CAAC;AACxB,GAAG;AACH;AACA;AACA;AACA;AACA,EAAE,YAAY,GAAG;AACjB,IAAI,IAAI,OAAO,MAAM,KAAK,WAAW,IAAI,IAAI,CAAC,YAAY,EAAE;AAC5D,MAAM,OAAO;AACb,KAAK;AACL;AACA,IAAI,IAAI,CAAC,YAAY,GAAG,MAAM;AAC9B,MAAM,IAAI,IAAI,CAAC,UAAU,EAAE;AAC3B,QAAQ,OAAO;AACf,OAAO;AACP,MAAM,IAAI,CAAC,UAAU,GAAG,qBAAqB,CAAC,MAAM;AACpD,QAAQ,IAAI,CAAC,UAAU,GAAG,CAAC,CAAC;AAC5B,QAAQ,IAAI,CAAC,YAAY,EAAE,CAAC;AAC5B,OAAO,CAAC,CAAC;AACT,KAAK,CAAC;AACN;AACA,IAAI,MAAM,CAAC,gBAAgB,CAAC,QAAQ,EAAE,IAAI,CAAC,YAAY,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC;AAC5E,GAAG;AACH;AACA;AACA;AACA;AACA,EAAE,YAAY,GAAG;AACjB,IAAI,IAAI,OAAO,MAAM,KAAK,WAAW,IAAI,IAAI,CAAC,YAAY,EAAE;AAC5D,MAAM,MAAM,CAAC,mBAAmB,CAAC,QAAQ,EAAE,IAAI,CAAC,YAAY,CAAC,CAAC;AAC9D,KAAK;AACL,IAAI,IAAI,IAAI,CAAC,UAAU,EAAE;AACzB,MAAM,oBAAoB,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;AAC5C,MAAM,IAAI,CAAC,UAAU,GAAG,CAAC,CAAC;AAC1B,KAAK;AACL,IAAI,IAAI,CAAC,YAAY,GAAG,IAAI,CAAC;AAC7B,GAAG;AACH;AACA;AACA;AACA;AACA,EAAE,YAAY,GAAG;AACjB,IAAI,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE;AACxB,MAAM,OAAO;AACb,KAAK;AACL,IAAI,IAAI,OAAO,MAAM,KAAK,WAAW,EAAE;AACvC,MAAM,OAAO;AACb,KAAK;AACL;AACA,IAAI,MAAM,UAAU,GAAG,IAAI,CAAC,cAAc,EAAE,CAAC;AAC7C,IAAI,MAAM,WAAW,GAAG,IAAI,CAAC,YAAY,CAAC,cAAc,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;AACxE,IAAI,MAAM,YAAY,GAAG,IAAI,CAAC,YAAY,CAAC,eAAe,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;AAC1E,IAAI,MAAM,YAAY,GAAG,IAAI,CAAC,YAAY,CAAC,eAAe,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;AAC1E,IAAI,MAAM,aAAa,GAAG,IAAI,CAAC,YAAY,CAAC,gBAAgB,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;AAC5E,IAAI,MAAM,aAAa,GAAG,MAAM,CAAC,UAAU,IAAI,CAAC,CAAC;AACjD;AACA,IAAI,IAAI,QAAQ,GAAG,IAAI,CAAC;AACxB,IAAI,IAAI,SAAS,GAAG,EAAE,CAAC;AACvB,IAAI,IAAI,UAAU,GAAG,EAAE,CAAC;AACxB;AACA,IAAI,IAAI,aAAa,IAAI,UAAU,IAAI,YAAY,EAAE;AACrD,MAAM,QAAQ,GAAG,SAAS,CAAC;AAC3B,MAAM,SAAS,GAAG,YAAY,CAAC;AAC/B,MAAM,UAAU,GAAG,aAAa,CAAC;AACjC,KAAK,MAAM,IAAI,WAAW,EAAE;AAC5B,MAAM,QAAQ,GAAG,QAAQ,CAAC;AAC1B,MAAM,SAAS,GAAG,WAAW,CAAC;AAC9B,MAAM,UAAU,GAAG,YAAY,CAAC;AAChC,KAAK,MAAM,IAAI,YAAY,EAAE;AAC7B;AACA,MAAM,QAAQ,GAAG,SAAS,CAAC;AAC3B,MAAM,SAAS,GAAG,YAAY,CAAC;AAC/B,MAAM,UAAU,GAAG,aAAa,CAAC;AACjC,KAAK;AACL;AACA,IAAI,IAAI,CAAC,SAAS,EAAE;AACpB,MAAM,OAAO;AACb,KAAK;AACL;AACA,IAAI,IAAI,SAAS,KAAK,IAAI,CAAC,WAAW,EAAE;AACxC,MAAM,OAAO;AACb,KAAK;AACL;AACA,IAAI,IAAI,CAAC,YAAY,CAAC,SAAS,EAAE,UAAU,EAAE,QAAQ,CAAC,CAAC;AACvD,GAAG;AACH;AACA,EAAE,cAAc,GAAG;AACnB,IAAI,MAAM,IAAI,GAAG,IAAI,CAAC,YAAY,CAAC,YAAY,CAAC,CAAC;AACjD,IAAI,MAAM,MAAM,GAAG,QAAQ,CAAC,IAAI,IAAI,EAAE,EAAE,EAAE,CAAC,CAAC;AAC5C,IAAI,OAAO,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,IAAI,MAAM,GAAG,CAAC,GAAG,MAAM,GAAG,GAAG,CAAC;AAChE,GAAG;AACH;AACA,EAAE,YAAY,CAAC,QAAQ,EAAE,SAAS,EAAE,IAAI,EAAE;AAC1C,IAAI,MAAM,KAAK,GAAG,IAAI,CAAC,QAAQ,CAAC;AAChC,IAAI,IAAI,CAAC,KAAK,EAAE;AAChB,MAAM,OAAO;AACb,KAAK;AACL;AACA,IAAI,MAAM,UAAU,GAAG,KAAK,CAAC,YAAY,CAAC,KAAK,CAAC,CAAC;AACjD,IAAI,IAAI,UAAU,KAAK,QAAQ,EAAE;AACjC,MAAM,IAAI,CAAC,WAAW,GAAG,QAAQ,CAAC;AAClC,MAAM,IAAI,IAAI,EAAE;AAChB,QAAQ,IAAI,CAAC,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;AACvC,OAAO;AACP,MAAM,OAAO;AACb,KAAK;AACL;AACA,IAAI,KAAK,CAAC,YAAY,CAAC,KAAK,EAAE,QAAQ,CAAC,CAAC;AACxC;AACA,IAAI,IAAI,SAAS,EAAE;AACnB,MAAM,KAAK,CAAC,YAAY,CAAC,QAAQ,EAAE,SAAS,CAAC,CAAC;AAC9C,KAAK,MAAM;AACX,MAAM,KAAK,CAAC,eAAe,CAAC,QAAQ,CAAC,CAAC;AACtC,KAAK;AACL;AACA,IAAI,KAAK,CAAC,IAAI,EAAE,CAAC;AACjB;AACA,IAAI,IAAI,KAAK,CAAC,QAAQ,IAAI,OAAO,KAAK,CAAC,IAAI,KAAK,UAAU,EAAE;AAC5D,MAAM,MAAM,WAAW,GAAG,KAAK,CAAC,IAAI,EAAE,CAAC;AACvC,MAAM,IAAI,WAAW,IAAI,OAAO,WAAW,CAAC,KAAK,KAAK,UAAU,EAAE;AAClE,QAAQ,WAAW,CAAC,KAAK,CAAC,MAAM;AAChC;AACA,SAAS,CAAC,CAAC;AACX,OAAO;AACP,KAAK;AACL;AACA,IAAI,IAAI,CAAC,WAAW,GAAG,QAAQ,CAAC;AAChC,IAAI,IAAI,IAAI,EAAE;AACd,MAAM,IAAI,CAAC,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;AACrC,KAAK,MAAM;AACX,MAAM,OAAO,IAAI,CAAC,OAAO,CAAC,UAAU,CAAC;AACrC,KAAK;AACL,GAAG;AACH,CAAC;AACD;AACA,IAAI,CAAC,cAAc,CAAC,GAAG,CAAC,kBAAkB,CAAC,EAAE;AAC7C,EAAE,cAAc,CAAC,MAAM,CAAC,kBAAkB,EAAE,eAAe,CAAC,CAAC;AAC7D;;;;"}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
(function (global, factory) {
|
|
2
|
+
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
|
|
3
|
+
typeof define === 'function' && define.amd ? define(['exports'], factory) :
|
|
4
|
+
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.ResponsiveVideo = {}));
|
|
5
|
+
})(this, (function (exports) { 'use strict';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Responsive video loader that swaps sources based on viewport width.
|
|
9
|
+
* Only the matching source is applied so mobile users never download the desktop asset (and vice versa).
|
|
10
|
+
*/
|
|
11
|
+
class ResponsiveVideo extends HTMLElement {
|
|
12
|
+
#videoEl = null;
|
|
13
|
+
#currentSrc = null;
|
|
14
|
+
#resizeRaf = 0;
|
|
15
|
+
#boundResize = null;
|
|
16
|
+
|
|
17
|
+
connectedCallback() {
|
|
18
|
+
this.#videoEl = this.querySelector("video");
|
|
19
|
+
if (!this.#videoEl) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
this.attachEvents();
|
|
23
|
+
this.updateSource();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
disconnectedCallback() {
|
|
27
|
+
this.detachEvents();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Sets up listeners that keep the video source aligned with the viewport.
|
|
32
|
+
*/
|
|
33
|
+
attachEvents() {
|
|
34
|
+
if (typeof window === "undefined" || this.#boundResize) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
this.#boundResize = () => {
|
|
39
|
+
if (this.#resizeRaf) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
this.#resizeRaf = requestAnimationFrame(() => {
|
|
43
|
+
this.#resizeRaf = 0;
|
|
44
|
+
this.updateSource();
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
window.addEventListener("resize", this.#boundResize, { passive: true });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Removes listeners and cancels pending work.
|
|
53
|
+
*/
|
|
54
|
+
detachEvents() {
|
|
55
|
+
if (typeof window !== "undefined" && this.#boundResize) {
|
|
56
|
+
window.removeEventListener("resize", this.#boundResize);
|
|
57
|
+
}
|
|
58
|
+
if (this.#resizeRaf) {
|
|
59
|
+
cancelAnimationFrame(this.#resizeRaf);
|
|
60
|
+
this.#resizeRaf = 0;
|
|
61
|
+
}
|
|
62
|
+
this.#boundResize = null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Calculates which source should be active and updates the video if needed.
|
|
67
|
+
*/
|
|
68
|
+
updateSource() {
|
|
69
|
+
if (!this.#videoEl) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (typeof window === "undefined") {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const breakpoint = this.#getBreakpoint();
|
|
77
|
+
const mobileVideo = this.getAttribute("mobile-video")?.trim() || "";
|
|
78
|
+
const desktopVideo = this.getAttribute("desktop-video")?.trim() || "";
|
|
79
|
+
const mobilePoster = this.getAttribute("mobile-poster")?.trim() || "";
|
|
80
|
+
const desktopPoster = this.getAttribute("desktop-poster")?.trim() || "";
|
|
81
|
+
const viewportWidth = window.innerWidth || 0;
|
|
82
|
+
|
|
83
|
+
let nextMode = null;
|
|
84
|
+
let nextVideo = "";
|
|
85
|
+
let nextPoster = "";
|
|
86
|
+
|
|
87
|
+
if (viewportWidth >= breakpoint && desktopVideo) {
|
|
88
|
+
nextMode = "desktop";
|
|
89
|
+
nextVideo = desktopVideo;
|
|
90
|
+
nextPoster = desktopPoster;
|
|
91
|
+
} else if (mobileVideo) {
|
|
92
|
+
nextMode = "mobile";
|
|
93
|
+
nextVideo = mobileVideo;
|
|
94
|
+
nextPoster = mobilePoster;
|
|
95
|
+
} else if (desktopVideo) {
|
|
96
|
+
// Fallback to desktop when mobile video is missing.
|
|
97
|
+
nextMode = "desktop";
|
|
98
|
+
nextVideo = desktopVideo;
|
|
99
|
+
nextPoster = desktopPoster;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!nextVideo) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (nextVideo === this.#currentSrc) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
this.#applySource(nextVideo, nextPoster, nextMode);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#getBreakpoint() {
|
|
114
|
+
const attr = this.getAttribute("breakpoint");
|
|
115
|
+
const parsed = parseInt(attr ?? "", 10);
|
|
116
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 768;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
#applySource(videoUrl, posterUrl, mode) {
|
|
120
|
+
const video = this.#videoEl;
|
|
121
|
+
if (!video) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const currentSrc = video.getAttribute("src");
|
|
126
|
+
if (currentSrc === videoUrl) {
|
|
127
|
+
this.#currentSrc = videoUrl;
|
|
128
|
+
if (mode) {
|
|
129
|
+
this.dataset.activeMode = mode;
|
|
130
|
+
}
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
video.setAttribute("src", videoUrl);
|
|
135
|
+
|
|
136
|
+
if (posterUrl) {
|
|
137
|
+
video.setAttribute("poster", posterUrl);
|
|
138
|
+
} else {
|
|
139
|
+
video.removeAttribute("poster");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
video.load();
|
|
143
|
+
|
|
144
|
+
if (video.autoplay && typeof video.play === "function") {
|
|
145
|
+
const playPromise = video.play();
|
|
146
|
+
if (playPromise && typeof playPromise.catch === "function") {
|
|
147
|
+
playPromise.catch(() => {
|
|
148
|
+
// Most browsers require muted autoplay; ignore failures quietly.
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
this.#currentSrc = videoUrl;
|
|
154
|
+
if (mode) {
|
|
155
|
+
this.dataset.activeMode = mode;
|
|
156
|
+
} else {
|
|
157
|
+
delete this.dataset.activeMode;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (!customElements.get("responsive-video")) {
|
|
163
|
+
customElements.define("responsive-video", ResponsiveVideo);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
exports.ResponsiveVideo = ResponsiveVideo;
|
|
167
|
+
|
|
168
|
+
}));
|
|
169
|
+
//# sourceMappingURL=responsive-video.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"responsive-video.js","sources":["../src/responsive-video.js"],"sourcesContent":["/**\n * Responsive video loader that swaps sources based on viewport width.\n * Only the matching source is applied so mobile users never download the desktop asset (and vice versa).\n */\nexport class ResponsiveVideo extends HTMLElement {\n #videoEl = null;\n #currentSrc = null;\n #resizeRaf = 0;\n #boundResize = null;\n\n connectedCallback() {\n this.#videoEl = this.querySelector(\"video\");\n if (!this.#videoEl) {\n return;\n }\n this.attachEvents();\n this.updateSource();\n }\n\n disconnectedCallback() {\n this.detachEvents();\n }\n\n /**\n * Sets up listeners that keep the video source aligned with the viewport.\n */\n attachEvents() {\n if (typeof window === \"undefined\" || this.#boundResize) {\n return;\n }\n\n this.#boundResize = () => {\n if (this.#resizeRaf) {\n return;\n }\n this.#resizeRaf = requestAnimationFrame(() => {\n this.#resizeRaf = 0;\n this.updateSource();\n });\n };\n\n window.addEventListener(\"resize\", this.#boundResize, { passive: true });\n }\n\n /**\n * Removes listeners and cancels pending work.\n */\n detachEvents() {\n if (typeof window !== \"undefined\" && this.#boundResize) {\n window.removeEventListener(\"resize\", this.#boundResize);\n }\n if (this.#resizeRaf) {\n cancelAnimationFrame(this.#resizeRaf);\n this.#resizeRaf = 0;\n }\n this.#boundResize = null;\n }\n\n /**\n * Calculates which source should be active and updates the video if needed.\n */\n updateSource() {\n if (!this.#videoEl) {\n return;\n }\n if (typeof window === \"undefined\") {\n return;\n }\n\n const breakpoint = this.#getBreakpoint();\n const mobileVideo = this.getAttribute(\"mobile-video\")?.trim() || \"\";\n const desktopVideo = this.getAttribute(\"desktop-video\")?.trim() || \"\";\n const mobilePoster = this.getAttribute(\"mobile-poster\")?.trim() || \"\";\n const desktopPoster = this.getAttribute(\"desktop-poster\")?.trim() || \"\";\n const viewportWidth = window.innerWidth || 0;\n\n let nextMode = null;\n let nextVideo = \"\";\n let nextPoster = \"\";\n\n if (viewportWidth >= breakpoint && desktopVideo) {\n nextMode = \"desktop\";\n nextVideo = desktopVideo;\n nextPoster = desktopPoster;\n } else if (mobileVideo) {\n nextMode = \"mobile\";\n nextVideo = mobileVideo;\n nextPoster = mobilePoster;\n } else if (desktopVideo) {\n // Fallback to desktop when mobile video is missing.\n nextMode = \"desktop\";\n nextVideo = desktopVideo;\n nextPoster = desktopPoster;\n }\n\n if (!nextVideo) {\n return;\n }\n\n if (nextVideo === this.#currentSrc) {\n return;\n }\n\n this.#applySource(nextVideo, nextPoster, nextMode);\n }\n\n #getBreakpoint() {\n const attr = this.getAttribute(\"breakpoint\");\n const parsed = parseInt(attr ?? \"\", 10);\n return Number.isFinite(parsed) && parsed > 0 ? parsed : 768;\n }\n\n #applySource(videoUrl, posterUrl, mode) {\n const video = this.#videoEl;\n if (!video) {\n return;\n }\n\n const currentSrc = video.getAttribute(\"src\");\n if (currentSrc === videoUrl) {\n this.#currentSrc = videoUrl;\n if (mode) {\n this.dataset.activeMode = mode;\n }\n return;\n }\n\n video.setAttribute(\"src\", videoUrl);\n\n if (posterUrl) {\n video.setAttribute(\"poster\", posterUrl);\n } else {\n video.removeAttribute(\"poster\");\n }\n\n video.load();\n\n if (video.autoplay && typeof video.play === \"function\") {\n const playPromise = video.play();\n if (playPromise && typeof playPromise.catch === \"function\") {\n playPromise.catch(() => {\n // Most browsers require muted autoplay; ignore failures quietly.\n });\n }\n }\n\n this.#currentSrc = videoUrl;\n if (mode) {\n this.dataset.activeMode = mode;\n } else {\n delete this.dataset.activeMode;\n }\n }\n}\n\nif (!customElements.get(\"responsive-video\")) {\n customElements.define(\"responsive-video\", ResponsiveVideo);\n}\n"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).ResponsiveVideo={})}(this,function(e){"use strict";class ResponsiveVideo extends HTMLElement{#e=null;#t=null;#i=0;#s=null;connectedCallback(){this.#e=this.querySelector("video"),this.#e&&(this.attachEvents(),this.updateSource())}disconnectedCallback(){this.detachEvents()}attachEvents(){"undefined"==typeof window||this.#s||(this.#s=()=>{this.#i||(this.#i=requestAnimationFrame(()=>{this.#i=0,this.updateSource()}))},window.addEventListener("resize",this.#s,{passive:!0}))}detachEvents(){"undefined"!=typeof window&&this.#s&&window.removeEventListener("resize",this.#s),this.#i&&(cancelAnimationFrame(this.#i),this.#i=0),this.#s=null}updateSource(){if(!this.#e)return;if("undefined"==typeof window)return;const e=this.#o(),t=this.getAttribute("mobile-video")?.trim()||"",i=this.getAttribute("desktop-video")?.trim()||"",s=this.getAttribute("mobile-poster")?.trim()||"",o=this.getAttribute("desktop-poster")?.trim()||"";let n=null,r="",d="";(window.innerWidth||0)>=e&&i?(n="desktop",r=i,d=o):t?(n="mobile",r=t,d=s):i&&(n="desktop",r=i,d=o),r&&r!==this.#t&&this.#n(r,d,n)}#o(){const e=this.getAttribute("breakpoint"),t=parseInt(e??"",10);return Number.isFinite(t)&&t>0?t:768}#n(e,t,i){const s=this.#e;if(!s)return;if(s.getAttribute("src")===e)return this.#t=e,void(i&&(this.dataset.activeMode=i));if(s.setAttribute("src",e),t?s.setAttribute("poster",t):s.removeAttribute("poster"),s.load(),s.autoplay&&"function"==typeof s.play){const e=s.play();e&&"function"==typeof e.catch&&e.catch(()=>{})}this.#t=e,i?this.dataset.activeMode=i:delete this.dataset.activeMode}}customElements.get("responsive-video")||customElements.define("responsive-video",ResponsiveVideo),e.ResponsiveVideo=ResponsiveVideo});
|
package/package.json
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@magic-spells/responsive-video",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Responsive video and poster image swapping web component.",
|
|
5
|
+
"author": "Cory Schulz",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "dist/responsive-video.cjs.js",
|
|
9
|
+
"module": "dist/responsive-video.esm.js",
|
|
10
|
+
"unpkg": "dist/responsive-video.min.js",
|
|
11
|
+
"types": "src/responsive-video.d.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./src/responsive-video.d.ts",
|
|
15
|
+
"import": "./dist/responsive-video.esm.js",
|
|
16
|
+
"require": "./dist/responsive-video.cjs.js",
|
|
17
|
+
"default": "./dist/responsive-video.esm.js"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"sideEffects": true,
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "https://github.com/magic-spells/responsive-video"
|
|
24
|
+
},
|
|
25
|
+
"homepage": "https://github.com/magic-spells/responsive-video#readme",
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/magic-spells/responsive-video/issues"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"responsive-video",
|
|
31
|
+
"web-components",
|
|
32
|
+
"custom-elements",
|
|
33
|
+
"hero-video",
|
|
34
|
+
"video-optimization",
|
|
35
|
+
"responsive-images",
|
|
36
|
+
"poster-images",
|
|
37
|
+
"mobile-optimization",
|
|
38
|
+
"bandwidth-optimization",
|
|
39
|
+
"performance",
|
|
40
|
+
"viewport",
|
|
41
|
+
"breakpoint",
|
|
42
|
+
"no-dependencies",
|
|
43
|
+
"vanilla-js"
|
|
44
|
+
],
|
|
45
|
+
"files": [
|
|
46
|
+
"dist/",
|
|
47
|
+
"src/",
|
|
48
|
+
"LICENSE",
|
|
49
|
+
"README.md"
|
|
50
|
+
],
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "rollup -c",
|
|
53
|
+
"lint": "eslint src/ rollup.config.mjs",
|
|
54
|
+
"format": "prettier --write .",
|
|
55
|
+
"prepublishOnly": "npm run build",
|
|
56
|
+
"serve": "rollup -c --watch",
|
|
57
|
+
"dev": "rollup -c --watch"
|
|
58
|
+
},
|
|
59
|
+
"publishConfig": {
|
|
60
|
+
"access": "public",
|
|
61
|
+
"registry": "https://registry.npmjs.org/"
|
|
62
|
+
},
|
|
63
|
+
"browserslist": [
|
|
64
|
+
"last 2 versions",
|
|
65
|
+
"not dead",
|
|
66
|
+
"not ie <= 11"
|
|
67
|
+
],
|
|
68
|
+
"devDependencies": {
|
|
69
|
+
"@eslint/js": "^9.38.0",
|
|
70
|
+
"@rollup/plugin-node-resolve": "^15.2.3",
|
|
71
|
+
"@rollup/plugin-terser": "^0.4.4",
|
|
72
|
+
"eslint": "^9.38.0",
|
|
73
|
+
"globals": "^15.15.0",
|
|
74
|
+
"prettier": "^3.3.3",
|
|
75
|
+
"rollup": "^3.0.0",
|
|
76
|
+
"rollup-plugin-copy": "^3.5.0",
|
|
77
|
+
"rollup-plugin-serve": "^1.1.1"
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Responsive video loader that swaps sources based on viewport width.
|
|
3
|
+
* Only the matching source is applied so mobile users never download the desktop asset (and vice versa).
|
|
4
|
+
*/
|
|
5
|
+
export class ResponsiveVideo extends HTMLElement {
|
|
6
|
+
#videoEl = null;
|
|
7
|
+
#currentSrc = null;
|
|
8
|
+
#resizeRaf = 0;
|
|
9
|
+
#boundResize = null;
|
|
10
|
+
|
|
11
|
+
connectedCallback() {
|
|
12
|
+
this.#videoEl = this.querySelector("video");
|
|
13
|
+
if (!this.#videoEl) {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
this.attachEvents();
|
|
17
|
+
this.updateSource();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
disconnectedCallback() {
|
|
21
|
+
this.detachEvents();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Sets up listeners that keep the video source aligned with the viewport.
|
|
26
|
+
*/
|
|
27
|
+
attachEvents() {
|
|
28
|
+
if (typeof window === "undefined" || this.#boundResize) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
this.#boundResize = () => {
|
|
33
|
+
if (this.#resizeRaf) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
this.#resizeRaf = requestAnimationFrame(() => {
|
|
37
|
+
this.#resizeRaf = 0;
|
|
38
|
+
this.updateSource();
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
window.addEventListener("resize", this.#boundResize, { passive: true });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Removes listeners and cancels pending work.
|
|
47
|
+
*/
|
|
48
|
+
detachEvents() {
|
|
49
|
+
if (typeof window !== "undefined" && this.#boundResize) {
|
|
50
|
+
window.removeEventListener("resize", this.#boundResize);
|
|
51
|
+
}
|
|
52
|
+
if (this.#resizeRaf) {
|
|
53
|
+
cancelAnimationFrame(this.#resizeRaf);
|
|
54
|
+
this.#resizeRaf = 0;
|
|
55
|
+
}
|
|
56
|
+
this.#boundResize = null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Calculates which source should be active and updates the video if needed.
|
|
61
|
+
*/
|
|
62
|
+
updateSource() {
|
|
63
|
+
if (!this.#videoEl) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (typeof window === "undefined") {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const breakpoint = this.#getBreakpoint();
|
|
71
|
+
const mobileVideo = this.getAttribute("mobile-video")?.trim() || "";
|
|
72
|
+
const desktopVideo = this.getAttribute("desktop-video")?.trim() || "";
|
|
73
|
+
const mobilePoster = this.getAttribute("mobile-poster")?.trim() || "";
|
|
74
|
+
const desktopPoster = this.getAttribute("desktop-poster")?.trim() || "";
|
|
75
|
+
const viewportWidth = window.innerWidth || 0;
|
|
76
|
+
|
|
77
|
+
let nextMode = null;
|
|
78
|
+
let nextVideo = "";
|
|
79
|
+
let nextPoster = "";
|
|
80
|
+
|
|
81
|
+
if (viewportWidth >= breakpoint && desktopVideo) {
|
|
82
|
+
nextMode = "desktop";
|
|
83
|
+
nextVideo = desktopVideo;
|
|
84
|
+
nextPoster = desktopPoster;
|
|
85
|
+
} else if (mobileVideo) {
|
|
86
|
+
nextMode = "mobile";
|
|
87
|
+
nextVideo = mobileVideo;
|
|
88
|
+
nextPoster = mobilePoster;
|
|
89
|
+
} else if (desktopVideo) {
|
|
90
|
+
// Fallback to desktop when mobile video is missing.
|
|
91
|
+
nextMode = "desktop";
|
|
92
|
+
nextVideo = desktopVideo;
|
|
93
|
+
nextPoster = desktopPoster;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!nextVideo) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (nextVideo === this.#currentSrc) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
this.#applySource(nextVideo, nextPoster, nextMode);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
#getBreakpoint() {
|
|
108
|
+
const attr = this.getAttribute("breakpoint");
|
|
109
|
+
const parsed = parseInt(attr ?? "", 10);
|
|
110
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 768;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#applySource(videoUrl, posterUrl, mode) {
|
|
114
|
+
const video = this.#videoEl;
|
|
115
|
+
if (!video) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const currentSrc = video.getAttribute("src");
|
|
120
|
+
if (currentSrc === videoUrl) {
|
|
121
|
+
this.#currentSrc = videoUrl;
|
|
122
|
+
if (mode) {
|
|
123
|
+
this.dataset.activeMode = mode;
|
|
124
|
+
}
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
video.setAttribute("src", videoUrl);
|
|
129
|
+
|
|
130
|
+
if (posterUrl) {
|
|
131
|
+
video.setAttribute("poster", posterUrl);
|
|
132
|
+
} else {
|
|
133
|
+
video.removeAttribute("poster");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
video.load();
|
|
137
|
+
|
|
138
|
+
if (video.autoplay && typeof video.play === "function") {
|
|
139
|
+
const playPromise = video.play();
|
|
140
|
+
if (playPromise && typeof playPromise.catch === "function") {
|
|
141
|
+
playPromise.catch(() => {
|
|
142
|
+
// Most browsers require muted autoplay; ignore failures quietly.
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
this.#currentSrc = videoUrl;
|
|
148
|
+
if (mode) {
|
|
149
|
+
this.dataset.activeMode = mode;
|
|
150
|
+
} else {
|
|
151
|
+
delete this.dataset.activeMode;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!customElements.get("responsive-video")) {
|
|
157
|
+
customElements.define("responsive-video", ResponsiveVideo);
|
|
158
|
+
}
|