diff --git a/README.md b/README.md
index cea9692..d7e13c7 100644
--- a/README.md
+++ b/README.md
@@ -94,7 +94,7 @@ When the page loads, the script binds every `[data-vr-web-launcher]` on the page
- Control icons are embedded from [Lucide](https://lucide.dev/) SVG definitions, so no PNG icon assets are required.
## Demo
-Run `npm run build`, then open `test-pages/index.html` through a local web server. The test hub is a gallery with launchers for flat 3D image, VR180 3D image, image carousels, flat 3D video, and VR180 3D video.
+Run `npm run build`, then open `test-pages/index.html` through a local web server. The test hub is a gallery with launchers for flat 3D image, VR180 3D image, image carousels, flat 3D video, VR180 3D video, and a local file picker for browser-selected image/video media.
For local experimentation, run:
diff --git a/test-pages/demo.css b/test-pages/demo.css
index 6efa4c9..52134de 100644
--- a/test-pages/demo.css
+++ b/test-pages/demo.css
@@ -204,6 +204,88 @@ a {
font-weight: 650;
}
+.demo-local-panel {
+ display: grid;
+ gap: 16px;
+ width: min(100%, 760px);
+ padding: 18px;
+ border: 1px solid #d5d5cf;
+ border-radius: 8px;
+ background: #fff;
+}
+
+.demo-field {
+ display: grid;
+ gap: 6px;
+ font-weight: 650;
+}
+
+.demo-field input,
+.demo-field select {
+ width: 100%;
+ min-height: 42px;
+ border: 1px solid #c7c7c0;
+ border-radius: 6px;
+ background: #fff;
+ color: inherit;
+ font: inherit;
+}
+
+.demo-field input {
+ padding: 8px;
+}
+
+.demo-field select {
+ padding: 0 10px;
+}
+
+.demo-local-name {
+ margin: 0;
+ color: #606058;
+ font-size: 0.95rem;
+}
+
+.demo-local-preview {
+ display: grid;
+ place-items: center;
+ width: 100%;
+ aspect-ratio: 16 / 9;
+ border-radius: 6px;
+ background: #111;
+ color: #d8d8d8;
+ overflow: hidden;
+}
+
+.demo-local-preview img,
+.demo-local-preview video {
+ display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+}
+
+.demo-local-preview p {
+ margin: 0;
+ padding: 16px;
+ text-align: center;
+}
+
+.demo-local-launch {
+ min-height: 44px;
+ border: 0;
+ border-radius: 6px;
+ background: #151515;
+ color: #fff;
+ font: inherit;
+ font-weight: 750;
+ cursor: pointer;
+}
+
+.demo-local-launch:disabled {
+ background: #9a9a92;
+ cursor: not-allowed;
+}
+
@media (max-width: 640px) {
.demo-page {
padding: 20px;
diff --git a/test-pages/index.html b/test-pages/index.html
index 1d5b9d6..6018799 100644
--- a/test-pages/index.html
+++ b/test-pages/index.html
@@ -111,6 +111,7 @@
VR180 image carousel
3D video
VR180 video
+ Local media
Image tests use files in ../media/. Video tests expect ../media/sbs-video.mp4.
diff --git a/test-pages/local-media-picker.js b/test-pages/local-media-picker.js
new file mode 100644
index 0000000..2485f68
--- /dev/null
+++ b/test-pages/local-media-picker.js
@@ -0,0 +1,140 @@
+const fileInput = document.querySelector('[data-local-media-file]');
+const launchButton = document.querySelector('[data-local-media-launch]');
+const projectionSelect = document.querySelector('[data-local-media-projection]');
+const preview = document.querySelector('[data-local-media-preview]');
+const fileName = document.querySelector('[data-local-media-name]');
+
+let activeObjectUrl = '';
+
+fileInput?.addEventListener('change', () => {
+ const file = fileInput.files?.[0];
+ if (!file) {
+ clearLocalMedia();
+ return;
+ }
+
+ const mediaType = getMediaType(file);
+ if (!mediaType) {
+ clearLocalMedia();
+ setPreviewMessage('Choose an image or video file.');
+ return;
+ }
+
+ if (activeObjectUrl) {
+ URL.revokeObjectURL(activeObjectUrl);
+ }
+
+ activeObjectUrl = URL.createObjectURL(file);
+ updateLauncher(file, mediaType, activeObjectUrl);
+ renderPreview(file, mediaType, activeObjectUrl);
+});
+
+projectionSelect?.addEventListener('change', () => {
+ if (launchButton && projectionSelect) {
+ launchButton.dataset.projection = projectionSelect.value;
+ }
+});
+
+window.addEventListener('pagehide', () => {
+ if (activeObjectUrl) {
+ URL.revokeObjectURL(activeObjectUrl);
+ }
+});
+
+function clearLocalMedia() {
+ if (activeObjectUrl) {
+ URL.revokeObjectURL(activeObjectUrl);
+ activeObjectUrl = '';
+ }
+
+ if (launchButton) {
+ launchButton.disabled = true;
+ delete launchButton.dataset.src;
+ delete launchButton.dataset.mediaType;
+ delete launchButton.dataset.type;
+ delete launchButton.dataset.title;
+ }
+
+ if (fileName) {
+ fileName.textContent = 'No file selected';
+ }
+
+ setPreviewMessage('Preview will appear here.');
+}
+
+function getMediaType(file) {
+ if (file.type.startsWith('image/')) {
+ return 'image';
+ }
+
+ if (file.type.startsWith('video/')) {
+ return 'video';
+ }
+
+ const extension = file.name.split('.').pop()?.toLowerCase();
+ if (['avif', 'gif', 'jpeg', 'jpg', 'png', 'webp'].includes(extension)) {
+ return 'image';
+ }
+
+ if (['m4v', 'mov', 'mp4', 'ogv', 'webm'].includes(extension)) {
+ return 'video';
+ }
+
+ return '';
+}
+
+function updateLauncher(file, mediaType, objectUrl) {
+ if (!launchButton || !projectionSelect) {
+ return;
+ }
+
+ launchButton.disabled = false;
+ launchButton.dataset.src = objectUrl;
+ launchButton.dataset.mediaType = mediaType;
+ launchButton.dataset.projection = projectionSelect.value;
+ launchButton.dataset.title = file.name;
+
+ if (file.type) {
+ launchButton.dataset.type = file.type;
+ } else {
+ delete launchButton.dataset.type;
+ }
+
+ if (fileName) {
+ fileName.textContent = `${file.name} (${mediaType})`;
+ }
+}
+
+function renderPreview(file, mediaType, objectUrl) {
+ if (!preview) {
+ return;
+ }
+
+ preview.replaceChildren();
+
+ if (mediaType === 'image') {
+ const image = document.createElement('img');
+ image.src = objectUrl;
+ image.alt = file.name;
+ preview.appendChild(image);
+ return;
+ }
+
+ const video = document.createElement('video');
+ video.src = objectUrl;
+ video.controls = true;
+ video.muted = true;
+ video.playsInline = true;
+ video.preload = 'metadata';
+ preview.appendChild(video);
+}
+
+function setPreviewMessage(message) {
+ if (!preview) {
+ return;
+ }
+
+ const placeholder = document.createElement('p');
+ placeholder.textContent = message;
+ preview.replaceChildren(placeholder);
+}
diff --git a/test-pages/test-local-media.html b/test-pages/test-local-media.html
new file mode 100644
index 0000000..bf340b6
--- /dev/null
+++ b/test-pages/test-local-media.html
@@ -0,0 +1,58 @@
+
+
+
+
+
+ Local Media Test
+
+
+
+
+
+
+
+
Checking immersive WebXR support...
+
+
+
+
+
+
+ No file selected
+
+
+
Preview will appear here.
+
+
+
+
+
+
+
+
+
+
+