@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 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
+ [![npm version](https://img.shields.io/npm/v/@magic-spells/responsive-video.svg)](https://www.npmjs.com/package/@magic-spells/responsive-video)
4
+ [![npm bundle size](https://img.shields.io/bundlephobia/minzip/@magic-spells/responsive-video)](https://bundlephobia.com/package/@magic-spells/responsive-video)
5
+ [![license](https://img.shields.io/npm/l/@magic-spells/responsive-video.svg)](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":"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"}
@@ -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":"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"}
@@ -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
+ }