├── .nvmrc ├── .gitignore ├── packages ├── app │ ├── .env │ ├── .eslintignore │ ├── src │ │ ├── react-app-env.d.ts │ │ ├── polyfills.ts │ │ ├── global.d.ts │ │ ├── webxr-polyfill.d.ts │ │ ├── components │ │ │ ├── ui │ │ │ │ ├── GroupControl.tsx │ │ │ │ ├── Control.tsx │ │ │ │ ├── GroupControlElement.tsx │ │ │ │ └── UI.tsx │ │ │ ├── util │ │ │ │ └── ConditionalWrapper.tsx │ │ │ ├── VrPlayer.tsx │ │ │ ├── DebugPlayer.tsx │ │ │ └── App.tsx │ │ ├── setupTests.ts │ │ ├── util │ │ │ └── debounce.ts │ │ ├── atoms │ │ │ └── controls.ts │ │ ├── worker │ │ │ └── videoRecognition.worker │ │ │ │ ├── index.ts │ │ │ │ ├── videoRecognition.spec.ts │ │ │ │ └── videoRecognition.ts │ │ ├── index.css │ │ ├── helper │ │ │ ├── util.ts │ │ │ └── getImageFrames.ts │ │ ├── index.tsx │ │ ├── hooks │ │ │ └── useXRSession.ts │ │ ├── logo.svg │ │ ├── service-worker.ts │ │ └── serviceWorkerRegistration.ts │ ├── public │ │ ├── robots.txt │ │ ├── favicon.ico │ │ ├── thumbnail.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── mstile-150x150.png │ │ ├── apple-touch-icon.png │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── browserconfig.xml │ │ ├── manifest.json │ │ ├── index.html │ │ └── safari-pinned-tab.svg │ ├── postcss.config.js │ ├── tailwind.config.js │ ├── .gitignore │ ├── .eslintrc │ ├── tsconfig.json │ ├── package.json │ └── README.md └── player │ ├── .eslintignore │ ├── .gitignore │ ├── src │ ├── primitive │ │ ├── index.ts │ │ ├── primitive.ts │ │ ├── square.ts │ │ └── sphere.ts │ ├── types.ts │ ├── index.ts │ └── renderer │ │ ├── renderProps.ts │ │ ├── debugRenderer.ts │ │ ├── vrRenderer.ts │ │ └── renderer.ts │ ├── .eslintrc │ ├── package.json │ └── tsconfig.json ├── .npmrc ├── .prettierrc ├── .ncurc.js ├── README.md ├── .editorconfig ├── .vscode ├── settings.json └── launch.json ├── package.json ├── LICENCE ├── .eslintrc ├── .gitattributes └── org └── icon.svg /.nvmrc: -------------------------------------------------------------------------------- 1 | 16.13 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /packages/app/.env: -------------------------------------------------------------------------------- 1 | BROWSER = 'none' 2 | -------------------------------------------------------------------------------- /packages/player/.eslintignore: -------------------------------------------------------------------------------- 1 | lib 2 | -------------------------------------------------------------------------------- /packages/player/.gitignore: -------------------------------------------------------------------------------- 1 | lib 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | legacy-peer-deps=true 3 | -------------------------------------------------------------------------------- /packages/app/.eslintignore: -------------------------------------------------------------------------------- 1 | build 2 | tailwind.config.js 3 | postcss.config.js 4 | -------------------------------------------------------------------------------- /packages/app/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /packages/player/src/primitive/index.ts: -------------------------------------------------------------------------------- 1 | export * from './primitive'; 2 | export * from './sphere'; 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "proseWrap": "always" 5 | } 6 | -------------------------------------------------------------------------------- /packages/app/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /packages/app/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimoWilhelm/vr-player/HEAD/packages/app/public/favicon.ico -------------------------------------------------------------------------------- /packages/app/public/thumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimoWilhelm/vr-player/HEAD/packages/app/public/thumbnail.png -------------------------------------------------------------------------------- /packages/app/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimoWilhelm/vr-player/HEAD/packages/app/public/favicon-16x16.png -------------------------------------------------------------------------------- /packages/app/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimoWilhelm/vr-player/HEAD/packages/app/public/favicon-32x32.png -------------------------------------------------------------------------------- /packages/app/public/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimoWilhelm/vr-player/HEAD/packages/app/public/mstile-150x150.png -------------------------------------------------------------------------------- /packages/app/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /packages/app/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimoWilhelm/vr-player/HEAD/packages/app/public/apple-touch-icon.png -------------------------------------------------------------------------------- /packages/app/src/polyfills.ts: -------------------------------------------------------------------------------- 1 | import WebXRPolyfill from 'webxr-polyfill'; 2 | 3 | // eslint-disable-next-line no-new 4 | new WebXRPolyfill(); 5 | -------------------------------------------------------------------------------- /packages/app/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimoWilhelm/vr-player/HEAD/packages/app/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /packages/app/public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimoWilhelm/vr-player/HEAD/packages/app/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /packages/app/src/global.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | interface Navigator extends Navigator { 3 | xr: XRSystem; 4 | } 5 | } 6 | 7 | export {}; 8 | -------------------------------------------------------------------------------- /packages/player/src/types.ts: -------------------------------------------------------------------------------- 1 | export type Format = 'screen' | '180' | '360'; 2 | export type Layout = 'mono' | 'stereoTopBottom' | 'stereoLeftRight'; 3 | -------------------------------------------------------------------------------- /packages/app/src/webxr-polyfill.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'webxr-polyfill' { 2 | declare class WebXRPolyfill { 3 | xr: XRSystem; 4 | } 5 | export = WebXRPolyfill; 6 | } 7 | 8 | export {}; 9 | -------------------------------------------------------------------------------- /packages/player/src/index.ts: -------------------------------------------------------------------------------- 1 | export { VrRenderer } from './renderer/vrRenderer'; 2 | export { DebugRenderer } from './renderer/debugRenderer'; 3 | export type { Format, Layout } from './types'; 4 | -------------------------------------------------------------------------------- /.ncurc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | format: 'group', 3 | workspaces: true, 4 | root: true, 5 | upgrade: true, 6 | reject: [ 7 | // breaking 8 | 'typescript', 9 | 'eslint', 10 | ], 11 | }; 12 | -------------------------------------------------------------------------------- /packages/player/src/primitive/primitive.ts: -------------------------------------------------------------------------------- 1 | import type { vec2, vec3 } from 'gl-matrix'; 2 | 3 | export type Primitive = { 4 | positions: vec3[]; 5 | indices: vec3[]; 6 | uvs: vec2[]; 7 | normals: vec3[]; 8 | }; 9 | -------------------------------------------------------------------------------- /packages/player/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "../../.eslintrc" 4 | ], 5 | "parserOptions": { 6 | "sourceType": "module", 7 | "project": [ 8 | "./tsconfig.json" 9 | ] 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/app/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: ['./src/**/*.{js,jsx,ts,tsx}'], 3 | theme: { 4 | extend: { 5 | aria: { 6 | current: 'current="true"', 7 | }, 8 | }, 9 | }, 10 | plugins: [], 11 | }; 12 | -------------------------------------------------------------------------------- /packages/app/src/components/ui/GroupControl.tsx: -------------------------------------------------------------------------------- 1 | export function GroupControl({ children }: { children: React.ReactNode }) { 2 | return ( 3 |
4 | {children} 5 |
6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # VR Player 2 | 3 | Play any 180° or 360° videos on your VR HMD using the WebXR APIs. Most common VR headsets should be supported. 4 | 5 | Supports mono or stereo video playback with top-bottom or left-right layouts. 6 | 7 | --- 8 | 9 | Icons by [unDraw](https://undraw.co/) 10 | -------------------------------------------------------------------------------- /packages/app/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | import 'jest-canvas-mock'; 7 | -------------------------------------------------------------------------------- /packages/app/public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #da532c 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /packages/app/src/components/util/ConditionalWrapper.tsx: -------------------------------------------------------------------------------- 1 | export const ConditionalWrapper = ({ 2 | condition, 3 | wrapper, 4 | children, 5 | }: { 6 | condition: boolean; 7 | wrapper: (children: React.ReactElement) => JSX.Element; 8 | children: React.ReactElement; 9 | }) => (condition ? wrapper(children) : children); 10 | -------------------------------------------------------------------------------- /packages/app/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /packages/player/src/renderer/renderProps.ts: -------------------------------------------------------------------------------- 1 | import type { Texture2D } from 'regl'; 2 | import type { mat4 } from 'gl-matrix'; 3 | 4 | export type RenderProps = { 5 | model: mat4; 6 | view: mat4 | Float32Array; 7 | projection: mat4 | Float32Array; 8 | texture: Texture2D; 9 | texCoordScaleOffset: Float32Array; 10 | viewport: { 11 | x: GLint; 12 | y: GLint; 13 | width: GLsizei; 14 | height: GLsizei; 15 | }; 16 | }; 17 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | indent_style = space 9 | indent_size = 2 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [*.{md,mdx}] 14 | max_line_length = 120 15 | trim_trailing_whitespace = false 16 | 17 | [*.{ts,tsx,js,jsx,mjs}] 18 | quote_type = single 19 | 20 | [*.{yaml,yml}] 21 | quote_type = single 22 | -------------------------------------------------------------------------------- /packages/app/src/util/debounce.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 2 | export function debounceAnimationFrame void>( 3 | fn: T, 4 | ): T { 5 | let handle: number | undefined; 6 | 7 | return ((...args: unknown[]) => { 8 | if (handle !== undefined) { 9 | window.cancelAnimationFrame(handle); 10 | } 11 | 12 | handle = window.requestAnimationFrame(() => { 13 | fn(...args); 14 | }); 15 | }) as T; 16 | } 17 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.associations": { 3 | "*.css": "tailwindcss" 4 | }, 5 | "editor.quickSuggestions": { 6 | "strings": true 7 | }, 8 | "debug.internalConsoleOptions": "neverOpen", 9 | "eslint.validate": [ 10 | "javascript", 11 | "javascriptreact", 12 | "typescript", 13 | "typescriptreact", 14 | ], 15 | "eslint.workingDirectories": [ 16 | { 17 | "mode": "auto" 18 | } 19 | ], 20 | "editor.codeActionsOnSave": { 21 | "source.fixAll": "explicit" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "App:Chrome", 9 | "type": "chrome", 10 | "request": "launch", 11 | "url": "http://localhost:3000", 12 | "webRoot": "${workspaceFolder}/packages/app", 13 | "sourceMaps": true 14 | }, 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /packages/app/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "jest": true, 4 | }, 5 | "plugins": ["react"], 6 | "extends": [ 7 | "plugin:react/recommended", 8 | "plugin:react/jsx-runtime", 9 | "../../.eslintrc", 10 | ], 11 | "parserOptions": { 12 | "sourceType": "module", 13 | "project": ["./tsconfig.json"], 14 | }, 15 | "settings": { 16 | "react": { 17 | "version": "detect", 18 | }, 19 | "import/resolver": { 20 | "node": { 21 | "paths": ["src"], 22 | }, 23 | }, 24 | }, 25 | } 26 | -------------------------------------------------------------------------------- /packages/app/src/atoms/controls.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'jotai'; 2 | import type { Format, Layout } from '@vr-viewer/player'; 3 | 4 | export const autoPlayAtom = atom(false); 5 | export const autoDetectAtom = atom(true); 6 | export const detectingAtom = atom(false); 7 | 8 | export const layoutAtom = atom('mono'); 9 | export const flipLayoutAtom = atom(false); 10 | 11 | export const formatAtom = atom('screen'); 12 | 13 | export const debugAtom = atom(false); 14 | 15 | export const videoUrlAtom = atom(null); 16 | -------------------------------------------------------------------------------- /packages/app/src/worker/videoRecognition.worker/index.ts: -------------------------------------------------------------------------------- 1 | import { detectFormat, detectLayout } from './videoRecognition'; 2 | import { expose } from 'comlink'; 3 | import type { Format, Layout } from '@vr-viewer/player'; 4 | 5 | export function recognizeVideo(frames: ImageData[]): [Layout?, Format?] { 6 | const layout = detectLayout(frames); 7 | const format = detectFormat(frames); 8 | 9 | return [layout, format]; 10 | } 11 | 12 | const module = { recognizeVideo }; 13 | 14 | expose(module); 15 | 16 | export type VideoRecognitionWorker = typeof module; 17 | -------------------------------------------------------------------------------- /packages/app/src/components/ui/Control.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | 3 | export function Control( 4 | props: Omit, 'type' | 'className'>, 5 | ) { 6 | return ( 7 | 83 | 84 | setAutoPlay(!autoPlay)}> 85 | Autoplay 86 | 87 | 88 |
89 | {detecting && ( 90 | 91 | Loading... 92 | 96 | 100 | 101 | )} 102 | setAutoDetect(!autoDetect)} 105 | > 106 | Detect Video Settings 107 | 108 |
109 | 110 |
111 | 112 | setLayout('mono')} 115 | > 116 | Mono 117 | 118 | setLayout('stereoLeftRight')} 121 | > 122 | Left | Right 123 | 124 | setLayout('stereoTopBottom')} 127 | > 128 | Top | Bottom 129 | 130 | 131 | 132 |
139 | 152 |
153 |
154 | 155 | 156 | setFormat('screen')} 159 | > 160 | Screen 161 | 162 | setFormat('180')} 165 | > 166 | 180° 167 | 168 | setFormat('360')} 171 | > 172 | 360° 173 | 174 | 175 | 176 | setDebug(!debug)}> 177 | Preview 178 | 179 | 180 | 187 | 194 | GitHub 195 | 196 | 197 | 198 | 199 | ); 200 | } 201 | -------------------------------------------------------------------------------- /packages/app/src/components/App.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowDownTrayIcon, XMarkIcon } from '@heroicons/react/24/solid'; 2 | import { DebugPlayer } from 'components/DebugPlayer'; 3 | import { Toaster, toast } from 'react-hot-toast'; 4 | import { UI } from './ui/UI'; 5 | import { VrPlayer } from 'components/VrPlayer'; 6 | import { 7 | autoDetectAtom, 8 | autoPlayAtom, 9 | debugAtom, 10 | detectingAtom, 11 | flipLayoutAtom, 12 | formatAtom, 13 | layoutAtom, 14 | videoUrlAtom, 15 | } from 'atoms/controls'; 16 | import { getImageFrames } from 'helper/getImageFrames'; 17 | import { transfer, wrap } from 'comlink'; 18 | import { useAtom, useSetAtom } from 'jotai'; 19 | import { useDropzone } from 'react-dropzone'; 20 | import { useEffect, useRef, useState } from 'react'; 21 | import { useXRSession } from 'hooks/useXRSession'; 22 | import clsx from 'clsx'; 23 | import type { VideoRecognitionWorker } from 'worker/videoRecognition.worker'; 24 | 25 | const worker = wrap( 26 | new Worker(new URL('worker/videoRecognition.worker', import.meta.url)), 27 | ); 28 | 29 | export function App() { 30 | const videoRef = useRef(null); 31 | const canvasRef = useRef(null); 32 | const urlInputRef = useRef(null); 33 | 34 | const [layout, setLayout] = useAtom(layoutAtom); 35 | const [flipLayout] = useAtom(flipLayoutAtom); 36 | const [format, setFormat] = useAtom(formatAtom); 37 | 38 | const [debug, setDebug] = useAtom(debugAtom); 39 | 40 | const [autoPlay] = useAtom(autoPlayAtom); 41 | const [autoDetect] = useAtom(autoDetectAtom); 42 | 43 | const [file, setFile] = useState(null); 44 | const [videoUrl, setVideoUrl] = useAtom(videoUrlAtom); 45 | const [ready, setReady] = useState(false); 46 | 47 | const [, xrSession] = useXRSession(); 48 | 49 | const setDetecting = useSetAtom(detectingAtom); 50 | 51 | useEffect(() => { 52 | if (ready && videoRef.current && autoDetect) { 53 | setDetecting(true); 54 | 55 | void (async (video) => { 56 | const frames = await getImageFrames(video); 57 | 58 | const [detectedLayout, detectedFormat] = await worker.recognizeVideo( 59 | transfer(frames, [...frames.map((frame) => frame.data.buffer)]), 60 | ); 61 | 62 | if (detectedLayout) setLayout(detectedLayout); 63 | if (detectedFormat) setFormat(detectedFormat); 64 | 65 | setDetecting(false); 66 | })(videoRef.current); 67 | } 68 | }, [autoDetect, ready, setDetecting, setFormat, setLayout]); 69 | 70 | useEffect(() => { 71 | let objectUrl = ''; 72 | 73 | if (file) { 74 | objectUrl = URL.createObjectURL(file); 75 | setVideoUrl(objectUrl); 76 | } 77 | 78 | return () => { 79 | URL.revokeObjectURL(objectUrl); 80 | }; 81 | }, [file, setVideoUrl]); 82 | 83 | useEffect(() => { 84 | if (ready && urlInputRef.current) { 85 | urlInputRef.current.value = ''; 86 | } 87 | }, [ready]); 88 | 89 | useEffect(() => { 90 | if (videoRef.current) { 91 | setReady(false); 92 | videoRef.current.src = videoUrl ?? ''; 93 | } 94 | }, [videoUrl]); 95 | 96 | const { getRootProps, getInputProps, isDragActive } = useDropzone({ 97 | noClick: true, 98 | multiple: false, 99 | accept: { 100 | 'video/*': [], 101 | }, 102 | onDropAccepted: (acceptedFiles) => { 103 | setFile(acceptedFiles[0]); 104 | }, 105 | onDropRejected: (rejection) => { 106 | toast.error(rejection[0].errors[0].message); 107 | }, 108 | }); 109 | 110 | return ( 111 |
115 | 116 | {videoRef.current && canvasRef.current && ready && debug && ( 117 | 124 | )} 125 | {videoRef.current && canvasRef.current && ready && xrSession && ( 126 | 134 | )} 135 |
136 | 137 |
138 |
139 |
197 |
205 | 206 | 216 |
217 |
225 |
226 |
227 | 228 |
229 |
230 |
231 | ); 232 | } 233 | -------------------------------------------------------------------------------- /org/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 22 | 24 | 47 | 49 | 50 | 52 | image/svg+xml 53 | 55 | 56 | 57 | 58 | 59 | 64 | 71 | 76 | 81 | 86 | 91 | 96 | 101 | 108 | 113 | 118 | 123 | 128 | 133 | 138 | 139 | 140 | --------------------------------------------------------------------------------