├── .yarnrc.yml ├── packages ├── web │ ├── src │ │ ├── global.d.ts │ │ ├── routes │ │ │ ├── index.tsx │ │ │ ├── __root.tsx │ │ │ └── dir │ │ │ │ ├── $dirId │ │ │ │ ├── file.module.css │ │ │ │ └── file.tsx │ │ │ │ └── $dirId.tsx │ │ ├── index.tsx │ │ ├── index.css │ │ ├── util.ts │ │ ├── routeTree.gen.ts │ │ └── Uploader.tsx │ ├── public │ │ └── robots.txt │ ├── tsconfig.json │ ├── index.html │ ├── vite.config.ts │ └── package.json ├── electron │ ├── vite.main.config.ts │ ├── vite.renderer.config.ts │ ├── index.html │ ├── src │ │ ├── index.css │ │ ├── renderer.ts │ │ └── main.ts │ ├── package.json │ └── forge.config.js ├── cli │ ├── tsconfig.json │ ├── package.json │ └── src │ │ └── index.ts └── lib │ ├── tsconfig.json │ ├── src │ ├── ffmpeg.ts │ ├── args.ts │ └── index.ts │ └── package.json ├── logo.png ├── .github ├── FUNDING.yml └── workflows │ ├── test.yml │ └── publish.yml ├── screenshot.png ├── .eslintignore ├── tsconfig.json ├── .eslintrc.cjs ├── LICENSE ├── package.json ├── .gitignore └── README.md /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /packages/web/src/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.module.css'; 2 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mifi/ezshare/HEAD/logo.png -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: mifi 2 | custom: https://mifi.no/thanks 3 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mifi/ezshare/HEAD/screenshot.png -------------------------------------------------------------------------------- /packages/web/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /packages/electron/out 2 | /packages/cli/dist 3 | /packages/lib/dist 4 | /packages/web/dist 5 | 6 | /packages/web/src/routeTree.gen.ts 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "references": [ 3 | { "path": "packages/cli" }, 4 | { "path": "packages/lib" }, 5 | { "path": "packages/web" }, 6 | ], 7 | "files": [] 8 | } 9 | -------------------------------------------------------------------------------- /packages/electron/vite.main.config.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-extraneous-dependencies 2 | import { defineConfig } from 'vite'; 3 | 4 | // https://vitejs.dev/config 5 | export default defineConfig({}); 6 | -------------------------------------------------------------------------------- /packages/electron/vite.renderer.config.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-extraneous-dependencies 2 | import { defineConfig } from 'vite'; 3 | 4 | // https://vitejs.dev/config 5 | export default defineConfig({}); 6 | -------------------------------------------------------------------------------- /packages/cli/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@tsconfig/strictest", "@tsconfig/node18/tsconfig.json"], 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "declaration": true, 6 | }, 7 | "include": [ 8 | "src/**/*", 9 | ], 10 | } 11 | -------------------------------------------------------------------------------- /packages/lib/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@tsconfig/strictest", "@tsconfig/node18/tsconfig.json"], 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "declaration": true, 6 | }, 7 | "include": [ 8 | "src/**/*", 9 | ], 10 | } 11 | -------------------------------------------------------------------------------- /packages/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@tsconfig/strictest", "@tsconfig/vite-react/tsconfig.json"], 3 | "compilerOptions": { 4 | "declaration": true, 5 | "types": ["vite-plugin-svgr/client"], 6 | }, 7 | "include": [ 8 | "src/**/*", 9 | ], 10 | } -------------------------------------------------------------------------------- /packages/web/src/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute, redirect } from '@tanstack/react-router'; 2 | 3 | 4 | // eslint-disable-next-line import/prefer-default-export 5 | export const Route = createFileRoute('/')({ 6 | beforeLoad: () => { 7 | throw redirect({ to: '/dir/$dirId', params: { dirId: '/' } }); 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /packages/electron/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ezshare 6 | 7 | 8 | 9 | 10 |
11 |
12 | ezshare HTTP server is running. 13 | 14 |
15 |
16 |
17 | 18 | 19 | -------------------------------------------------------------------------------- /packages/web/src/routes/__root.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet, createRootRoute } from '@tanstack/react-router'; 2 | import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'; 3 | 4 | 5 | // eslint-disable-next-line import/prefer-default-export 6 | export const Route = createRootRoute({ 7 | // eslint-disable-next-line no-use-before-define 8 | component: Root, 9 | }); 10 | 11 | function Root() { 12 | return ( 13 | <> 14 | 15 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['mifi'], 3 | 4 | overrides: [ 5 | { 6 | files: ['./packages/web/src/**/*.{js,mjs,cjs,mjs,jsx,ts,mts,tsx}'], 7 | rules: { 8 | 'import/no-extraneous-dependencies': 'off', 9 | }, 10 | env: { 11 | node: false, 12 | browser: true, 13 | }, 14 | }, 15 | { 16 | files: ['./packages/web/src/routes/**/*.{js,mjs,cjs,mjs,jsx,ts,mts,tsx}'], 17 | rules: { 18 | 'unicorn/filename-case': 'off', 19 | }, 20 | }, 21 | ], 22 | }; 23 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | env: 9 | # https://github.com/actions/setup-node/issues/531 10 | SKIP_YARN_COREPACK_CHECK: true 11 | steps: 12 | - uses: actions/checkout@v5 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: 22 16 | - run: corepack enable 17 | - run: yarn --immutable 18 | - run: yarn dedupe --check 19 | - run: yarn build 20 | - run: yarn test 21 | - run: yarn lint 22 | -------------------------------------------------------------------------------- /packages/electron/src/index.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | align-items: center; 4 | justify-content: center; 5 | width: 100vw; 6 | height: 100vh; 7 | font-family: sans-serif; 8 | } 9 | 10 | .dynamic { 11 | display: flex; 12 | flex-direction: column; 13 | gap: 1em; 14 | flex-wrap: wrap; 15 | } 16 | 17 | .url { 18 | margin: .5em; 19 | width: 14em; 20 | overflow: hidden; 21 | } 22 | 23 | .url canvas { 24 | width: 100% !important; 25 | height: auto !important; 26 | } 27 | 28 | .url .text { 29 | margin-top: .5em; 30 | text-align: center; 31 | overflow: hidden; 32 | text-overflow: ellipsis; 33 | } 34 | -------------------------------------------------------------------------------- /packages/web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | ezshare 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /packages/web/vite.config.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-extraneous-dependencies 2 | import { defineConfig } from 'vite'; 3 | // eslint-disable-next-line import/no-extraneous-dependencies 4 | import react from '@vitejs/plugin-react'; 5 | import { tanstackRouter } from '@tanstack/router-plugin/vite'; 6 | 7 | 8 | export default defineConfig({ 9 | clearScreen: false, 10 | plugins: [ 11 | // Please make sure that '@tanstack/router-plugin' is passed before '@vitejs/plugin-react' 12 | tanstackRouter({ 13 | target: 'react', 14 | autoCodeSplitting: true, 15 | }), 16 | react(), 17 | ], 18 | server: { 19 | open: false, 20 | port: 3000, 21 | }, 22 | }); 23 | -------------------------------------------------------------------------------- /packages/cli/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ezshare/cli", 3 | "version": "2.0.1", 4 | "type": "module", 5 | "bin": { 6 | "ezshare": "dist/index.js" 7 | }, 8 | "scripts": { 9 | "build": "tsc --preserveWatchOutput", 10 | "start": "node --enable-source-maps --watch dist --dev-mode", 11 | "dev": "yarn build && concurrently -k \"yarn build --watch\" \"yarn start\"", 12 | "prepack": "yarn build" 13 | }, 14 | "files": [ 15 | "/dist/**" 16 | ], 17 | "publishConfig": { 18 | "access": "public" 19 | }, 20 | "dependencies": { 21 | "@ezshare/lib": "workspace:^", 22 | "concurrently": "*", 23 | "qrcode-terminal": "^0.12.0" 24 | }, 25 | "devDependencies": { 26 | "@tsconfig/node18": "^18.2.4", 27 | "@tsconfig/strictest": "^2.0.5", 28 | "typescript": "*" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | workflow_dispatch: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | build: 10 | strategy: 11 | matrix: 12 | os: [macos-latest, ubuntu-latest, windows-latest] 13 | runs-on: ${{ matrix.os }} 14 | timeout-minutes: 60 15 | permissions: 16 | contents: write 17 | env: 18 | # https://github.com/actions/setup-node/issues/531 19 | SKIP_YARN_COREPACK_CHECK: true 20 | steps: 21 | - uses: actions/checkout@v5 22 | - uses: actions/setup-node@v4 23 | with: 24 | node-version: 22 25 | - run: corepack enable 26 | - run: yarn --immutable 27 | - run: yarn build 28 | - run: yarn workspace @ezshare/electron publish 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | -------------------------------------------------------------------------------- /packages/electron/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ezshare/electron", 3 | "productName": "ezshare", 4 | "version": "2.0.0", 5 | "private": true, 6 | "type": "commonjs", 7 | "main": ".vite/build/main.js", 8 | "dependencies": { 9 | "@ezshare/lib": "workspace:^" 10 | }, 11 | "scripts": { 12 | "dev": "electron-forge start", 13 | "package": "electron-forge package", 14 | "make": "electron-forge make", 15 | "publish": "electron-forge publish" 16 | }, 17 | "packageManager": "yarn@4.9.4", 18 | "devDependencies": { 19 | "@electron-forge/cli": "^7.8.3", 20 | "@electron-forge/maker-zip": "^7.8.3", 21 | "@electron-forge/plugin-vite": "^7.8.3", 22 | "@electron-forge/publisher-github": "^7.8.3", 23 | "@types/qrcode": "^1", 24 | "electron": "^37.3.1", 25 | "qrcode": "^1.5.4", 26 | "vite": "5" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/web/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import { RouterProvider, createRouter } from '@tanstack/react-router'; 4 | 5 | import 'sweetalert2/src/sweetalert2.scss'; 6 | import 'fontsource-roboto'; 7 | 8 | import './index.css'; 9 | 10 | // Import the generated route tree 11 | import { routeTree } from './routeTree.gen'; 12 | 13 | 14 | // Create a new router instance 15 | const router = createRouter({ routeTree }); 16 | 17 | // Register the router instance for type safety 18 | declare module '@tanstack/react-router' { 19 | interface Register { 20 | router: typeof router 21 | } 22 | } 23 | 24 | const container = document.getElementById('root'); 25 | const root = createRoot(container!); 26 | root.render( 27 | 28 | 29 | , 30 | ); 31 | -------------------------------------------------------------------------------- /packages/lib/src/ffmpeg.ts: -------------------------------------------------------------------------------- 1 | import { execa } from 'execa'; 2 | 3 | 4 | export default function Ffmpeg({ ffmpegPath }: { ffmpegPath: string }) { 5 | let hasFfmpeg = false; 6 | 7 | async function runStartupCheck() { 8 | // will throw if exit code != 0 9 | await execa(ffmpegPath, ['-hide_banner', '-f', 'lavfi', '-i', 'nullsrc=s=256x256:d=1', '-f', 'null', '-']); 10 | hasFfmpeg = true; 11 | } 12 | 13 | async function renderThumbnail(filePath: string) { 14 | const { stdout } = await execa(ffmpegPath, [ // todo stream instead 15 | // '-ss', String(timestamp), 16 | '-i', filePath, 17 | '-vf', 'scale=-2:200', 18 | '-vframes', '1', 19 | '-q:v', '10', 20 | '-c:v', 'mjpeg', 21 | '-update', '1', 22 | '-f', 'image2', 23 | '-', 24 | ], { encoding: 'buffer' }); 25 | 26 | return stdout; 27 | } 28 | 29 | return { 30 | runStartupCheck, 31 | renderThumbnail, 32 | hasFfmpeg: () => hasFfmpeg, 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Mikael Finstad 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. 22 | -------------------------------------------------------------------------------- /packages/cli/src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import assert from 'node:assert'; 4 | import { dirname } from 'node:path'; 5 | import qrcode from 'qrcode-terminal'; 6 | import { fileURLToPath } from 'node:url'; 7 | 8 | import App, { parseArgs } from '@ezshare/lib'; 9 | 10 | 11 | const args = parseArgs(process.argv.slice(2)); 12 | 13 | const indexUrl = await import.meta.resolve?.('@ezshare/web/dist/index.html'); 14 | assert(indexUrl); 15 | const webPath = dirname(fileURLToPath(indexUrl)); 16 | 17 | const { start, runStartupCheck, getUrls, sharedPath } = App({ ...args, webPath }); 18 | 19 | const urls = getUrls(); 20 | if (urls.length === 0) { 21 | console.warn('No network interfaces detected.'); 22 | } else { 23 | await runStartupCheck(); 24 | 25 | await start(); 26 | 27 | console.log('Server listening:'); 28 | urls.forEach((url) => { 29 | console.log(); 30 | console.log(`Scan this QR code on your phone or enter ${url}`); 31 | console.log(); 32 | qrcode.generate(url); 33 | }); 34 | if (urls.length > 1) { 35 | console.log('Note that there are multiple QR codes above, one for each network interface. (scroll up)'); 36 | } 37 | 38 | console.log(`Sharing path ${sharedPath}`); 39 | } 40 | -------------------------------------------------------------------------------- /packages/web/src/index.css: -------------------------------------------------------------------------------- 1 | /* https://colorhunt.co/palette/175167 */ 2 | 3 | body { 4 | margin: 0; 5 | font-family: 'Roboto', sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | 9 | background: #f9f9f9; 10 | color: rgba(0,0,0,0.8); 11 | overflow: hidden; 12 | } 13 | 14 | h1, h2, h3 { 15 | text-align: center; 16 | margin-top: 0px; 17 | margin-left: 8px; 18 | margin-right: 8px; 19 | color: rgba(0,0,0,0.6); 20 | font-weight: 400; 21 | text-transform: uppercase; 22 | } 23 | 24 | h2 { 25 | margin-bottom: 0.3em; 26 | } 27 | 28 | .icon-spin { 29 | -webkit-animation: icon-spin 2s infinite linear; 30 | animation: icon-spin 2s infinite linear; 31 | } 32 | 33 | @-webkit-keyframes icon-spin { 34 | 0% { 35 | -webkit-transform: rotate(0deg); 36 | transform: rotate(0deg); 37 | } 38 | 100% { 39 | -webkit-transform: rotate(359deg); 40 | transform: rotate(359deg); 41 | } 42 | } 43 | 44 | @keyframes icon-spin { 45 | 0% { 46 | -webkit-transform: rotate(0deg); 47 | transform: rotate(0deg); 48 | } 49 | 100% { 50 | -webkit-transform: rotate(359deg); 51 | transform: rotate(359deg); 52 | } 53 | } -------------------------------------------------------------------------------- /packages/electron/forge.config.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-var-requires 2 | const { dirname } = require('node:path'); 3 | 4 | 5 | module.exports = { 6 | packagerConfig: { 7 | asar: true, 8 | extraResource: [ 9 | dirname(require.resolve('@ezshare/web/dist/index.html')), 10 | ], 11 | }, 12 | rebuildConfig: {}, 13 | makers: [ 14 | { 15 | name: '@electron-forge/maker-zip', 16 | platforms: ['darwin', 'win32', 'linux'], 17 | config: {}, 18 | }, 19 | ], 20 | plugins: [ 21 | { 22 | name: '@electron-forge/plugin-vite', 23 | config: { 24 | build: [ 25 | { 26 | entry: 'src/main.ts', 27 | config: 'vite.main.config.ts', 28 | target: 'main', 29 | }, 30 | ], 31 | renderer: [ 32 | { 33 | name: 'main_window', 34 | config: 'vite.renderer.config.ts', 35 | }, 36 | ], 37 | }, 38 | }, 39 | ], 40 | publishers: [ 41 | { 42 | name: '@electron-forge/publisher-github', 43 | config: { 44 | repository: { 45 | owner: 'mifi', 46 | name: 'ezshare', 47 | }, 48 | }, 49 | }, 50 | ], 51 | }; 52 | -------------------------------------------------------------------------------- /packages/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ezshare/web", 3 | "version": "0.0.2", 4 | "type": "module", 5 | "files": [ 6 | "/dist" 7 | ], 8 | "publishConfig": { 9 | "access": "public" 10 | }, 11 | "scripts": { 12 | "build": "vite build", 13 | "dev": "vite" 14 | }, 15 | "devDependencies": { 16 | "@tanstack/react-router": "^1.131.27", 17 | "@tanstack/react-router-devtools": "^1.131.27", 18 | "@tanstack/router-plugin": "^1.131.27", 19 | "@tsconfig/strictest": "^2.0.5", 20 | "@tsconfig/vite-react": "^3.0.2", 21 | "@types/css-modules": "^1.0.5", 22 | "@types/react": "^18.3.12", 23 | "@types/react-dom": "^18.3.1", 24 | "@vitejs/plugin-react": "^4.2.1", 25 | "axios": "^0.28.0", 26 | "fontsource-roboto": "^3.0.3", 27 | "framer-motion": "^11.11.10", 28 | "react": "^18.3.1", 29 | "react-circular-progressbar": "^2.1.0", 30 | "react-clipboard.js": "^2.0.16", 31 | "react-dom": "^18.3.1", 32 | "react-dropzone": "^10.2.2", 33 | "react-icons": "^3.9.0", 34 | "sass-embedded": "^1.80.3", 35 | "sweetalert2": "^11.22.5", 36 | "tiny-invariant": "^1.3.3", 37 | "vite": "^7.1.3", 38 | "vite-plugin-svgr": "^4.3.0", 39 | "zod": "^4.0.17" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/electron/src/renderer.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-extraneous-dependencies 2 | import QRCode from 'qrcode'; 3 | 4 | import './index.css'; 5 | 6 | const { ipcRenderer } = window.require('electron'); 7 | 8 | async function fetchData() { 9 | const container = document.getElementById('dynamic'); 10 | if (!container) throw new Error('No container found'); 11 | 12 | const { urls }: { urls: string[] } = await ipcRenderer.invoke('getData'); 13 | 14 | container.replaceChildren(); 15 | 16 | await Promise.all(urls.map(async (url) => { 17 | const canvas = document.createElement('canvas'); 18 | canvas.width = 300; 19 | canvas.height = 300; 20 | 21 | const node = document.createElement('div'); 22 | node.className = 'url'; 23 | 24 | const textNode = document.createElement('div'); 25 | textNode.textContent = url; 26 | textNode.className = 'text'; 27 | 28 | node.append(canvas); 29 | node.append(textNode); 30 | container.append(node); 31 | 32 | await new Promise((resolve, reject) => QRCode.toCanvas(canvas, url, { margin: 0 }, (error) => { 33 | if (error) reject(error); 34 | else resolve(); 35 | })); 36 | })); 37 | } 38 | 39 | document.addEventListener('DOMContentLoaded', fetchData); 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ezshare", 3 | "version": "1.12.0", 4 | "description": "Easily share and receive files over a local network", 5 | "main": "dist/index.js", 6 | "repository": "https://github.com/mifi/ezshare", 7 | "author": "Mikael Finstad ", 8 | "license": "MIT", 9 | "engines": { 10 | "node": ">=18" 11 | }, 12 | "workspaces": [ 13 | "packages/*" 14 | ], 15 | "type": "module", 16 | "scripts": { 17 | "test": "exit 0", 18 | "lint": "eslint .", 19 | "build": "yarn workspaces foreach -Apt run build", 20 | "clean": "git clean -Xfd packages -e '!node_modules' -e '!**/node_modules/**/*'", 21 | "dev:cli": "yarn build && yarn workspaces foreach -Rpi --from @ezshare/cli run dev", 22 | "dev:electron": "yarn build && yarn workspaces foreach -Rp -i --from @ezshare/electron run dev" 23 | }, 24 | "devDependencies": { 25 | "@typescript-eslint/eslint-plugin": "^6.12.0", 26 | "@typescript-eslint/parser": "^6.12.0", 27 | "concurrently": "^5.1.0", 28 | "eslint": "^8.2.0", 29 | "eslint-config-mifi": "^0.0.6", 30 | "eslint-plugin-import": "^2.25.3", 31 | "eslint-plugin-jsx-a11y": "^6.5.1", 32 | "eslint-plugin-react": "^7.28.0", 33 | "eslint-plugin-react-hooks": "^4.3.0", 34 | "eslint-plugin-unicorn": "^51.0.1", 35 | "typescript": "^5.6.3" 36 | }, 37 | "packageManager": "yarn@4.9.4" 38 | } 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | .pnp.* 58 | .yarn/* 59 | !.yarn/patches 60 | !.yarn/plugins 61 | !.yarn/releases 62 | !.yarn/sdks 63 | !.yarn/versions 64 | 65 | # dotenv environment variables file 66 | .env 67 | 68 | .vite 69 | 70 | tsconfig.tsbuildinfo 71 | 72 | /packages/electron/out 73 | /packages/cli/dist 74 | /packages/lib/dist 75 | /packages/web/dist 76 | -------------------------------------------------------------------------------- /packages/web/src/routes/dir/$dirId/file.module.css: -------------------------------------------------------------------------------- 1 | .dialog { 2 | --duration: 0.2s; 3 | 4 | border: .1em solid var(--black-a5); 5 | background: var(--white-a8); 6 | color: var(--gray-12); 7 | backdrop-filter: blur(2em); 8 | border-radius: .5em; 9 | padding: 1.7em; 10 | box-shadow: 0 0 1em .3em var(--black-a1); 11 | transform-origin: center; 12 | transition: 13 | translate var(--duration) ease-out, 14 | scale var(--duration) ease-out, 15 | opacity var(--duration) ease-out, 16 | display var(--duration) ease-out allow-discrete; 17 | 18 | &[open] { 19 | /* Post-Entry (Normal) State */ 20 | scale: 1; 21 | opacity: 1; 22 | 23 | /* Pre-Entry State */ 24 | @starting-style { 25 | scale: 0.6; 26 | opacity: 0; 27 | } 28 | } 29 | 30 | &::backdrop { 31 | background-color: var(--black-a7); 32 | animation: overlayShow 600ms cubic-bezier(0.16, 1, 0.3, 1); 33 | } 34 | 35 | h1 { 36 | text-transform: uppercase; 37 | font-size: 1.3em; 38 | margin-top: 0; 39 | } 40 | } 41 | 42 | .dialog * { 43 | -webkit-user-select: none; 44 | user-select: none; 45 | -webkit-touch-callout:none; 46 | touch-action: pan-x pan-y; 47 | } 48 | 49 | :global(.dark-theme) .dialog { 50 | border: .1em solid var(--white-a3); 51 | background: var(--black-a4); 52 | box-shadow: 0 0 1em .3em var(--black-a2); 53 | } 54 | 55 | @keyframes overlayShow { 56 | from { 57 | opacity: 0; 58 | } 59 | to { 60 | opacity: 1; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /packages/lib/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ezshare/lib", 3 | "version": "0.0.2", 4 | "type": "module", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "build": "tsc --preserveWatchOutput", 8 | "dev": "yarn build --watch" 9 | }, 10 | "files": [ 11 | "/dist/**" 12 | ], 13 | "publishConfig": { 14 | "access": "public" 15 | }, 16 | "types": "dist/index.d.ts", 17 | "dependencies": { 18 | "@ezshare/web": "workspace:^", 19 | "archiver": "^3.1.1", 20 | "basic-auth": "^2.0.1", 21 | "body-parser": "^1.19.0", 22 | "clipboardy": "^2.3.0", 23 | "content-disposition": "^0.5.3", 24 | "execa": "^9.6.0", 25 | "express": "^4.17.1", 26 | "express-async-handler": "^1.1.4", 27 | "filenamify": "^4.2.0", 28 | "formidable": "^3.5.2", 29 | "http-proxy-middleware": "^3.0.3", 30 | "is-path-inside": "^4.0.0", 31 | "lodash": "^4.17.15", 32 | "morgan": "^1.9.1", 33 | "p-map": "^4.0.0", 34 | "range-parser": "^1.2.1", 35 | "yargs": "^18.0.0" 36 | }, 37 | "devDependencies": { 38 | "@tsconfig/node18": "^18.2.4", 39 | "@tsconfig/strictest": "^2.0.5", 40 | "@types/archiver": "3", 41 | "@types/basic-auth": "^1.1.8", 42 | "@types/content-disposition": "^0.5.8", 43 | "@types/express": "4", 44 | "@types/formidable": "3", 45 | "@types/lodash": "^4.17.12", 46 | "@types/morgan": "^1.9.9", 47 | "@types/node": "18", 48 | "@types/qrcode-terminal": "^0.12.2", 49 | "@types/yargs": "^17.0.33", 50 | "typescript": "*" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /packages/web/src/util.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Swal from 'sweetalert2'; 3 | import invariant from 'tiny-invariant'; 4 | 5 | export interface EzshareFile { 6 | path: string, 7 | fileName: string 8 | isDir: boolean, 9 | } 10 | 11 | export interface CurrentDir { 12 | sharedPath?: string, cwd: string, files: EzshareFile[] 13 | } 14 | 15 | export const Toast = Swal.mixin({ 16 | toast: true, 17 | showConfirmButton: false, 18 | timer: 3000, 19 | position: 'top', 20 | }); 21 | 22 | export const rootPath = '/'; 23 | 24 | export const colorLink = '#db6400'; 25 | export const colorLink2 = '#ffa62b'; 26 | 27 | export const boxBackgroundColor = '#fff'; 28 | export const headingBackgroundColor = '#16697a'; 29 | export const iconColor = '#ffa62b'; // 'rgba(0,0,0,0.3)' 30 | 31 | export const mightBeVideo = ({ isDir, fileName }: { isDir?: boolean, fileName: string }) => !isDir && /\.(mp4|m4v|mov|qt|webm|mkv|avi|flv|vob|ogg|ogv|mpe?g|m2v|mp2|mpv)$/i.test(fileName); 32 | export const mightBeImage = ({ isDir, fileName }: { isDir?: boolean, fileName: string }) => !isDir && /\.(jpe?g|png|gif|webp)$/i.test(fileName); 33 | 34 | export const getDownloadUrl = (path: string, cacheBuster?: boolean, forceDownload?: boolean) => `/api/download?f=${encodeURIComponent(path)}&forceDownload=${forceDownload ? 'true' : 'false'}&_=${cacheBuster ? Date.now() : 0}`; 35 | export const getThumbUrl = (path: string) => `/api/thumbnail?f=${encodeURIComponent(path)}`; 36 | 37 | export interface MainContext { 38 | currentPath: string, 39 | currentDir: CurrentDir, 40 | loadDir: (path: string) => Promise, 41 | } 42 | 43 | export const Context = React.createContext(undefined); 44 | 45 | export function useContext() { 46 | const context = React.useContext(Context); 47 | invariant(context); 48 | return context; 49 | } 50 | -------------------------------------------------------------------------------- /packages/lib/src/args.ts: -------------------------------------------------------------------------------- 1 | import yargs from 'yargs/yargs'; 2 | import assert from 'node:assert'; 3 | 4 | 5 | export default function parse(args: string[]) { 6 | const usage = ` 7 | Usage 8 | $ ezshare [shared_path] 9 | 10 | Options 11 | [shared_path] If omitted, will use current directory 12 | --port Port to listen (default 8080) 13 | --max-upload-size Max upload file size (default 16 GB) 14 | --zip-compression-level ZIP compression level (default 0, no compression - faster) 15 | --username Username for basic authentication 16 | --password Password for basic authentication 17 | --ffmpeg-path Path to ffmpeg executable (default 'ffmpeg') 18 | 19 | Examples 20 | $ ezshare 21 | Shares all files and folders under the current directory (cd) 22 | 23 | $ ezshare /Users/me 24 | Shares all files and folders under /Users/me 25 | `; 26 | 27 | const { port, maxUploadSize, zipCompressionLevel, devMode, username, password, ffmpegPath, _ } = yargs(args).usage(usage).options({ 28 | devMode: { type: 'boolean', default: false }, 29 | port: { type: 'number', default: 8080 }, 30 | maxUploadSize: { type: 'number', default: 16 * 1024 * 1024 * 1024 }, 31 | zipCompressionLevel: { type: 'number', default: 0 }, 32 | username: { type: 'string' }, 33 | password: { type: 'string' }, 34 | ffmpegPath: { type: 'string', default: 'ffmpeg' }, 35 | }).parseSync(); 36 | 37 | if (zipCompressionLevel != null) { 38 | assert(zipCompressionLevel <= 9 && zipCompressionLevel >= 0, 'zip-compression-level must be between 0 and 9'); 39 | } 40 | 41 | assert((username == null && password == null) || (username != null && password != null), 'If username is provided, password must also be provided'); 42 | const auth = username && password ? { username, password } : undefined; 43 | 44 | const sharedPath = _[0]; 45 | assert(typeof sharedPath === 'string' || sharedPath == null); 46 | 47 | return { 48 | sharedPath, 49 | port, 50 | maxUploadSize, 51 | zipCompressionLevel, 52 | devMode, 53 | auth, 54 | ffmpegPath, 55 | }; 56 | } 57 | -------------------------------------------------------------------------------- /packages/electron/src/main.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-extraneous-dependencies 2 | import { app, BrowserWindow, ipcMain } from 'electron'; 3 | import { join } from 'node:path'; 4 | 5 | import Ezshare, { parseArgs } from '@ezshare/lib'; 6 | 7 | 8 | declare global { 9 | const MAIN_WINDOW_VITE_DEV_SERVER_URL: string | undefined; 10 | const MAIN_WINDOW_VITE_NAME: string; 11 | } 12 | 13 | function createWindow() { 14 | const mainWindow = new BrowserWindow({ 15 | width: 800, 16 | height: 600, 17 | webPreferences: { 18 | contextIsolation: false, 19 | nodeIntegration: true, 20 | }, 21 | }); 22 | 23 | if (MAIN_WINDOW_VITE_DEV_SERVER_URL) { 24 | mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL); 25 | } else { 26 | mainWindow.loadFile(join(__dirname, '..', 'renderer', MAIN_WINDOW_VITE_NAME, 'index.html')); 27 | } 28 | } 29 | 30 | app.on('window-all-closed', () => { 31 | if (process.platform !== 'darwin') app.quit(); 32 | }); 33 | 34 | app.on('activate', () => { 35 | if (BrowserWindow.getAllWindows().length === 0) { 36 | createWindow(); 37 | } 38 | }); 39 | 40 | (async () => { 41 | await app.whenReady(); 42 | 43 | const ignoreFirstArgs = process.defaultApp ? 2 : 1; 44 | // production: First arg is the LosslessCut executable 45 | // dev: First 2 args are electron and the index.js 46 | const argsWithoutAppName = process.argv.length > ignoreFirstArgs ? process.argv.slice(ignoreFirstArgs) : []; 47 | 48 | const args = parseArgs(argsWithoutAppName); 49 | const webPath = app.isPackaged ? join(process.resourcesPath, 'dist') : join(__dirname, '..', '..', '..', 'web', 'dist'); 50 | const { start, runStartupCheck, getUrls } = Ezshare({ ...args, webPath }); 51 | 52 | const urls = getUrls(); 53 | if (urls.length === 0) { 54 | console.warn('No network interfaces detected.'); 55 | } else { 56 | await runStartupCheck(); 57 | 58 | await start(); 59 | 60 | console.log('Server listening'); 61 | 62 | ipcMain.handle('getData', async () => ({ 63 | urls, 64 | })); 65 | 66 | createWindow(); 67 | } 68 | // eslint-disable-next-line unicorn/prefer-top-level-await 69 | })().catch((err) => console.error(err)); 70 | -------------------------------------------------------------------------------- /packages/web/src/routeTree.gen.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | // @ts-nocheck 4 | 5 | // noinspection JSUnusedGlobalSymbols 6 | 7 | // This file was automatically generated by TanStack Router. 8 | // You should NOT make any changes in this file as it will be overwritten. 9 | // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. 10 | 11 | import { Route as rootRouteImport } from './routes/__root' 12 | import { Route as IndexRouteImport } from './routes/index' 13 | import { Route as DirDirIdRouteImport } from './routes/dir/$dirId' 14 | import { Route as DirDirIdFileRouteImport } from './routes/dir/$dirId/file' 15 | 16 | const IndexRoute = IndexRouteImport.update({ 17 | id: '/', 18 | path: '/', 19 | getParentRoute: () => rootRouteImport, 20 | } as any) 21 | const DirDirIdRoute = DirDirIdRouteImport.update({ 22 | id: '/dir/$dirId', 23 | path: '/dir/$dirId', 24 | getParentRoute: () => rootRouteImport, 25 | } as any) 26 | const DirDirIdFileRoute = DirDirIdFileRouteImport.update({ 27 | id: '/file', 28 | path: '/file', 29 | getParentRoute: () => DirDirIdRoute, 30 | } as any) 31 | 32 | export interface FileRoutesByFullPath { 33 | '/': typeof IndexRoute 34 | '/dir/$dirId': typeof DirDirIdRouteWithChildren 35 | '/dir/$dirId/file': typeof DirDirIdFileRoute 36 | } 37 | export interface FileRoutesByTo { 38 | '/': typeof IndexRoute 39 | '/dir/$dirId': typeof DirDirIdRouteWithChildren 40 | '/dir/$dirId/file': typeof DirDirIdFileRoute 41 | } 42 | export interface FileRoutesById { 43 | __root__: typeof rootRouteImport 44 | '/': typeof IndexRoute 45 | '/dir/$dirId': typeof DirDirIdRouteWithChildren 46 | '/dir/$dirId/file': typeof DirDirIdFileRoute 47 | } 48 | export interface FileRouteTypes { 49 | fileRoutesByFullPath: FileRoutesByFullPath 50 | fullPaths: '/' | '/dir/$dirId' | '/dir/$dirId/file' 51 | fileRoutesByTo: FileRoutesByTo 52 | to: '/' | '/dir/$dirId' | '/dir/$dirId/file' 53 | id: '__root__' | '/' | '/dir/$dirId' | '/dir/$dirId/file' 54 | fileRoutesById: FileRoutesById 55 | } 56 | export interface RootRouteChildren { 57 | IndexRoute: typeof IndexRoute 58 | DirDirIdRoute: typeof DirDirIdRouteWithChildren 59 | } 60 | 61 | declare module '@tanstack/react-router' { 62 | interface FileRoutesByPath { 63 | '/': { 64 | id: '/' 65 | path: '/' 66 | fullPath: '/' 67 | preLoaderRoute: typeof IndexRouteImport 68 | parentRoute: typeof rootRouteImport 69 | } 70 | '/dir/$dirId': { 71 | id: '/dir/$dirId' 72 | path: '/dir/$dirId' 73 | fullPath: '/dir/$dirId' 74 | preLoaderRoute: typeof DirDirIdRouteImport 75 | parentRoute: typeof rootRouteImport 76 | } 77 | '/dir/$dirId/file': { 78 | id: '/dir/$dirId/file' 79 | path: '/file' 80 | fullPath: '/dir/$dirId/file' 81 | preLoaderRoute: typeof DirDirIdFileRouteImport 82 | parentRoute: typeof DirDirIdRoute 83 | } 84 | } 85 | } 86 | 87 | interface DirDirIdRouteChildren { 88 | DirDirIdFileRoute: typeof DirDirIdFileRoute 89 | } 90 | 91 | const DirDirIdRouteChildren: DirDirIdRouteChildren = { 92 | DirDirIdFileRoute: DirDirIdFileRoute, 93 | } 94 | 95 | const DirDirIdRouteWithChildren = DirDirIdRoute._addFileChildren( 96 | DirDirIdRouteChildren, 97 | ) 98 | 99 | const rootRouteChildren: RootRouteChildren = { 100 | IndexRoute: IndexRoute, 101 | DirDirIdRoute: DirDirIdRouteWithChildren, 102 | } 103 | export const routeTree = rootRouteImport 104 | ._addFileChildren(rootRouteChildren) 105 | ._addFileTypes() 106 | -------------------------------------------------------------------------------- /packages/web/src/Uploader.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useCallback } from 'react'; 2 | import axios, { AxiosError } from 'axios'; 3 | 4 | import { FaFileUpload } from 'react-icons/fa'; 5 | import { useDropzone } from 'react-dropzone'; 6 | 7 | import { CircularProgressbar } from 'react-circular-progressbar'; 8 | import 'react-circular-progressbar/dist/styles.css'; 9 | import { boxBackgroundColor, iconColor, Toast } from './util'; 10 | 11 | 12 | export default function Uploader({ onUploadSuccess, cwd }: { 13 | onUploadSuccess: () => void, 14 | cwd: string, 15 | }) { 16 | const [uploadProgress, setUploadProgress] = useState(); 17 | const [uploadSpeed, setUploadSpeed] = useState(); 18 | 19 | const onDrop = useCallback((acceptedFiles: File[], rejectedFiles: File[]) => { 20 | // console.log(acceptedFiles); 21 | 22 | if (rejectedFiles && rejectedFiles.length > 0) { 23 | Toast.fire({ icon: 'warning', title: 'Some file was not accepted' }); 24 | } 25 | 26 | async function upload() { 27 | let dataTotal: number; 28 | let dataLoaded: number; 29 | let startTime: number; 30 | 31 | try { 32 | // Toast.fire({ title: `${acceptedFiles.length} ${rejectedFiles.length}` }); 33 | setUploadProgress(0); 34 | const data = new FormData(); 35 | acceptedFiles.forEach((file) => data.append('files', file)); 36 | 37 | const onUploadProgress = (progressEvent: ProgressEvent) => { 38 | dataTotal = progressEvent.total; 39 | dataLoaded = progressEvent.loaded; 40 | if (!startTime && dataLoaded) startTime = Date.now(); 41 | setUploadProgress(dataLoaded / dataTotal); 42 | if (dataLoaded && startTime) setUploadSpeed(dataLoaded / ((Date.now() - startTime) / 1000)); 43 | }; 44 | 45 | await axios.post(`/api/upload?path=${encodeURIComponent(cwd)}`, data, { onUploadProgress }); 46 | 47 | Toast.fire({ icon: 'success', title: 'File(s) uploaded successfully' }); 48 | onUploadSuccess(); 49 | } catch (err) { 50 | console.error('Upload failed', err); 51 | const message = (err instanceof AxiosError && err.response?.data.error.message) || (err as Error).message; 52 | Toast.fire({ icon: 'error', title: `Upload failed: ${message}` }); 53 | } finally { 54 | setUploadProgress(undefined); 55 | setUploadSpeed(undefined); 56 | } 57 | } 58 | 59 | upload(); 60 | }, [cwd, onUploadSuccess]); 61 | 62 | const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop }); 63 | 64 | if (uploadProgress != null) { 65 | const percentage = Math.round(uploadProgress * 100); 66 | return ( 67 |
68 |
69 | 70 |
71 | {uploadSpeed &&
{(uploadSpeed / 1e6).toFixed(2)}MB/s
} 72 |
73 | ); 74 | } 75 | 76 | return ( 77 | // eslint-disable-next-line react/jsx-props-no-spreading 78 |
79 | {/* eslint-disable-next-line react/jsx-props-no-spreading */} 80 | 81 | 82 | 83 | 84 |
85 | {isDragActive ? 'Drop files here to upload' : 'Drag \'n drop some files here, or press to select files to upload'} 86 |
87 |
88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![](logo.png) 2 | 3 | A simple **file server** that lets you easily share many big files like photos and videos with friends (or between your devices) over a local network without requiring an internet connection. It starts an HTTP server that lists all files and directories in the directory where it is started from. Then anyone can then connect to the server and download files or automatically generated ZIP of whole directories, kind of like a self hosted Google Drive. The client can also upload files to the server via their browser, and clipboard card be shared both ways. A QR code is generated for convenience. 4 | 5 | ![Demo](screenshot.png) 6 | 7 | ## Features 8 | - Local two-way transfers without going through the internet 9 | - Send full quality photos and videos to others without needing a fast internet connection 10 | - Handles really big files and directories 11 | - Handles a LOT of files 12 | - Auto ZIPs directories on-the-fly 13 | - Two-way sharing of clipboard 14 | - The web client works on all major platforms, including iOS and Android (however the server must run on a Mac/Windows/Linux computer) 15 | - Video/image playback and slideshows 16 | 17 | ## Install (with Node.js / npm) 18 | 19 | - Install [Node.js](https://nodejs.org) and open a terminal: 20 | 21 | ```bash 22 | npm install -g ezshare 23 | ``` 24 | 25 | ## Install (standalone) 26 | 27 | If you don't want to install Node.js, you can download Electron based executable of `ezshare` from Releases. 28 | 29 | ## Migrate from v1 to v2 30 | 31 | ```bash 32 | npm uninstall -g ezshare 33 | npm i -g @ezshare/cli 34 | ``` 35 | 36 | ## Usage 37 | 38 | - Open a terminal and run: 39 | - `cd /path/to/your/shared/folder` 40 | - `ezshare` 41 | - Open the browser in the other end to the printed URL 42 | - Start to upload or download files to/from this folder! 43 | - **Note** that the two devices need to be on the same WiFi (or possibly personal hotspot) 44 | 45 | **Alternatively** you can pass it the path you want to share: 46 | ```bash 47 | ezshare /your/shared/folder 48 | ``` 49 | 50 | For more info run `ezshare --help` 51 | 52 | ## Supported platforms 53 | - The web client with all operating systems that have a modern browser. iOS, Android, Mac, Windows, ++ 54 | - The command line application works on all major desktop OS (Mac, Windows, Linux) 55 | 56 | ## Share over internet without NAT 57 | 58 | If you want to share a file over internet, you can use a service like [Ngrok](https://ngrok.com/) or [Cloudflare tunnels](https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/). 59 | 60 | Ngrok: 61 | ```bash 62 | ngrok http http://ip.of.ezshare:8080/ 63 | ``` 64 | 65 | Cloudflare tunnel: 66 | ```bash 67 | cloudflared tunnel --url http://ip.of.ezshare:8080/ --no-autoupdate 68 | ``` 69 | 70 | Then just share the URL you are given from one of these services. 71 | 72 | More alternatives: 73 | - https://github.com/anderspitman/awesome-tunneling 74 | 75 | ## Development 76 | 77 | ### Running CLI locally 78 | 79 | ```bash 80 | yarn dev:cli 81 | ``` 82 | 83 | ### Running Electron locally 84 | 85 | ```bash 86 | yarn dev:electron 87 | ``` 88 | 89 | ### Package locally 90 | 91 | ```bash 92 | yarn build && yarn workspace @ezshare/electron package 93 | ``` 94 | 95 | ## Release 96 | 97 | First push and wait for green GitHub Actions. 98 | 99 | Version whichever packages are changed: 100 | 101 | ```bash 102 | yarn workspace @ezshare/web version patch 103 | yarn workspace @ezshare/lib version patch 104 | yarn workspace @ezshare/cli version patch 105 | yarn workspace @ezshare/electron version patch 106 | 107 | git add packages/*/package.json 108 | git commit -m Release 109 | ``` 110 | 111 | Now build and publish to npm: 112 | 113 | ```bash 114 | yarn build 115 | yarn workspace @ezshare/web npm publish 116 | yarn workspace @ezshare/lib npm publish 117 | yarn workspace @ezshare/cli npm publish 118 | ``` 119 | 120 | - `git push` 121 | - Now trigger [workflow dispatch](https://github.com/mifi/ezshare/actions/workflows/publish.yml) to build the Electron version. 122 | - Wait for GitHub Actions run 123 | - Edit draft to add release notes, and check that artifacts got added. 124 | 125 | ## Credits 126 | - Icon made by [Freepik](https://www.flaticon.com/authors/freepik) from [www.flaticon.com](https://www.flaticon.com/) 127 | 128 | ## See also 129 | - https://github.com/claudiodangelis/qr-filetransfer 130 | - https://github.com/shivensinha4/qr-fileshare 131 | 132 | --- 133 | 134 | Made with ❤️ in 🇳🇴 135 | 136 | [More apps by mifi.no](https://mifi.no/) 137 | 138 | Follow me on [GitHub](https://github.com/mifi/), [YouTube](https://www.youtube.com/channel/UC6XlvVH63g0H54HSJubURQA), [IG](https://www.instagram.com/mifi.no/), [Twitter](https://twitter.com/mifi_no) for more awesome content! 139 | -------------------------------------------------------------------------------- /packages/lib/src/index.ts: -------------------------------------------------------------------------------- 1 | import express, { Response } from 'express'; 2 | import Formidable from 'formidable'; 3 | import { createReadStream } from 'node:fs'; 4 | import { join, basename, relative, resolve as resolvePath } from 'node:path'; 5 | import assert from 'node:assert'; 6 | import * as fs from 'node:fs/promises'; 7 | import morgan from 'morgan'; 8 | import asyncHandler from 'express-async-handler'; 9 | import archiver from 'archiver'; 10 | import pMap from 'p-map'; 11 | import os from 'node:os'; 12 | import contentDisposition from 'content-disposition'; 13 | import { createProxyMiddleware } from 'http-proxy-middleware'; 14 | import clipboardy from 'clipboardy'; 15 | import bodyParser from 'body-parser'; 16 | import filenamify from 'filenamify'; 17 | import stream from 'node:stream/promises'; 18 | import parseRange from 'range-parser'; 19 | import isPathInside from 'is-path-inside'; 20 | import basicAuth from 'basic-auth'; 21 | import { timingSafeEqual } from 'node:crypto'; 22 | 23 | import Ffmpeg from './ffmpeg.js'; 24 | 25 | export { default as parseArgs } from './args.js'; 26 | 27 | 28 | const maxFields = 1000; 29 | const debug = false; 30 | 31 | const pathExists = (path: string) => fs.access(path, fs.constants.F_OK).then(() => true).catch(() => false); 32 | 33 | export default ({ sharedPath: sharedPathIn, port, maxUploadSize, zipCompressionLevel, devMode, auth, ffmpegPath, webPath }: { 34 | sharedPath: string | undefined, 35 | port: number, 36 | maxUploadSize: number, 37 | zipCompressionLevel: number, 38 | devMode: boolean, 39 | auth?: { username: string, password: string } | undefined, 40 | ffmpegPath: string, 41 | webPath: string, 42 | }) => { 43 | const ffmpeg = Ffmpeg({ ffmpegPath }); 44 | 45 | // console.log({ sharedPath: sharedPathIn, port, maxUploadSize, zipCompressionLevel }); 46 | const sharedPath = sharedPathIn ? resolvePath(sharedPathIn) : process.cwd(); 47 | 48 | function arePathsEqual(path1: string, path2: string) { 49 | return relative(path1, path2) === ''; 50 | } 51 | 52 | async function getFileAbsPath(relPath: string | undefined) { 53 | if (relPath == null) return sharedPath; 54 | const absPath = join(sharedPath, join('/', relPath)); 55 | const realPath = await fs.realpath(absPath); 56 | assert(isPathInside(realPath, sharedPath) || arePathsEqual(realPath, sharedPath), `Path must be within shared path ${realPath} ${sharedPath}`); 57 | return realPath; 58 | } 59 | 60 | const app = express(); 61 | 62 | app.use((req, res, next) => { 63 | if (auth != null) { 64 | const authRes = basicAuth(req); 65 | try { 66 | assert(authRes); 67 | assert(timingSafeEqual(Buffer.from(authRes.name, 'utf8'), Buffer.from(auth.username, 'utf8')) 68 | && timingSafeEqual(Buffer.from(authRes.pass, 'utf8'), Buffer.from(auth.password, 'utf8'))); 69 | } catch { 70 | res.set('WWW-Authenticate', 'Basic realm="ezshare"'); 71 | res.status(401).send('Authentication required.'); 72 | return; 73 | } 74 | } 75 | 76 | next(); 77 | }); 78 | 79 | if (debug) app.use(morgan('dev')); 80 | 81 | // NOTE: Must support non latin characters 82 | app.post('/api/upload', bodyParser.json(), asyncHandler(async (req, res) => { 83 | // console.log(req.headers) 84 | const uploadDirPathIn = req.query['path'] || '/'; 85 | assert(typeof uploadDirPathIn === 'string'); 86 | 87 | const uploadDirPath = await getFileAbsPath(uploadDirPathIn); 88 | 89 | // parse a file upload 90 | const form = Formidable({ 91 | keepExtensions: true, 92 | uploadDir: uploadDirPath, 93 | maxFileSize: maxUploadSize, 94 | maxFields, 95 | }); 96 | 97 | form.parse(req, async (err, _fields, { files: filesIn }) => { 98 | if (err) { 99 | console.error('Upload failed', err); 100 | res.status(400).send({ error: { message: err.message } }); 101 | return; 102 | } 103 | 104 | if (filesIn) { 105 | const files = Array.isArray(filesIn) ? filesIn : [filesIn]; 106 | 107 | // console.log(JSON.stringify({ fields, files }, null, 2)); 108 | console.log('Uploaded files to', uploadDirPath); 109 | files.forEach((f) => console.log(f.originalFilename, `(${f.size} bytes)`)); 110 | 111 | await pMap(files, async (file) => { 112 | try { 113 | const targetPath = join(uploadDirPath, filenamify(file.originalFilename ?? 'file', { maxLength: 255 })); 114 | if (!(await pathExists(targetPath))) await fs.rename(file.filepath, targetPath); // to prevent overwrites 115 | } catch (err2) { 116 | console.error(`Failed to rename ${file.originalFilename}`, err2); 117 | } 118 | }, { concurrency: 10 }); 119 | } 120 | res.end(); 121 | }); 122 | })); 123 | 124 | // NOTE: Must support non latin characters 125 | app.post('/api/paste', bodyParser.urlencoded({ extended: false }), asyncHandler(async (req, res) => { 126 | // eslint-disable-next-line unicorn/prefer-ternary 127 | if (req.body.saveAsFile === 'true') { 128 | await fs.writeFile(join(sharedPath, `client-clipboard-${Date.now()}.txt`), req.body.clipboard); 129 | } else { 130 | await clipboardy.write(req.body.clipboard); 131 | } 132 | res.end(); 133 | })); 134 | 135 | // NOTE: Must support non latin characters 136 | app.post('/api/copy', asyncHandler(async (_req, res) => { 137 | res.send(await clipboardy.read()); 138 | })); 139 | 140 | async function serveDirZip(filePath: string, res: Response) { 141 | const archive = archiver('zip', { 142 | zlib: { level: zipCompressionLevel }, 143 | }); 144 | 145 | res.writeHead(200, { 146 | 'Content-Type': 'application/zip', 147 | // NOTE: Must support non latin characters 148 | 'Content-disposition': contentDisposition(`${basename(filePath)}.zip`), 149 | }); 150 | 151 | const promise = stream.pipeline(archive, res); 152 | 153 | archive.directory(filePath, basename(filePath)); 154 | archive.finalize(); 155 | 156 | await promise; 157 | } 158 | 159 | async function serveResumableFileDownload({ filePath, range, res, forceDownload }: { 160 | filePath: string, 161 | range: string | undefined, 162 | res: Response, 163 | forceDownload: boolean, 164 | }) { 165 | if (forceDownload) { 166 | // Set the filename in the Content-disposition header 167 | res.set('Content-disposition', contentDisposition(basename(filePath))); 168 | } 169 | 170 | const { size: fileSize } = await fs.stat(filePath); 171 | 172 | if (range) { 173 | const subranges = parseRange(fileSize, range); 174 | assert(typeof subranges !== 'number'); 175 | if (subranges.type !== 'bytes') throw new Error(`Invalid range type ${subranges.type}`); 176 | 177 | if (subranges.length !== 1) throw new Error('Only a single range is supported'); 178 | const { start, end } = subranges[0]!; 179 | 180 | const contentLength = (end - start) + 1; 181 | 182 | // Set headers for resumable download 183 | res.status(206).set({ 184 | 'Content-Range': `bytes ${start}-${end}/${fileSize}`, 185 | 'Accept-Ranges': 'bytes', 186 | 'Content-Length': contentLength, 187 | 'Content-Type': 'application/octet-stream', 188 | }); 189 | 190 | await stream.pipeline(createReadStream(filePath, { start, end }), res); 191 | } else { 192 | // Standard download without resuming 193 | res.set({ 194 | // 'Content-Type': 'application/octet-stream', 195 | 'Content-Length': fileSize, 196 | }); 197 | 198 | await stream.pipeline(createReadStream(filePath), res); 199 | } 200 | } 201 | 202 | app.get('/api/download', asyncHandler(async (req, res) => { 203 | const { f } = req.query; 204 | assert(typeof f === 'string'); 205 | const filePath = await getFileAbsPath(f); 206 | const forceDownload = req.query['forceDownload'] === 'true'; 207 | 208 | const lstat = await fs.lstat(filePath); 209 | if (lstat.isDirectory()) { 210 | await serveDirZip(filePath, res); 211 | } else { 212 | const { range } = req.headers; 213 | await serveResumableFileDownload({ filePath, range, res, forceDownload }); 214 | } 215 | })); 216 | 217 | app.get('/api/thumbnail', asyncHandler(async (req, res) => { 218 | const { f } = req.query; 219 | assert(typeof f === 'string'); 220 | const filePath = await getFileAbsPath(f); 221 | 222 | // todo limit concurrency? 223 | if (!ffmpeg.hasFfmpeg()) { 224 | res.status(500).end(); 225 | return; 226 | } 227 | const thumbnail = await ffmpeg.renderThumbnail(filePath); 228 | res.set('Cache-Control', 'private, max-age=300'); 229 | res.set('Content-Type', 'image/jpeg'); 230 | res.send(Buffer.from(thumbnail)); 231 | })); 232 | 233 | app.get('/api/browse', asyncHandler(async (req, res) => { 234 | const browseRelPath = req.query['p'] || '/'; 235 | assert(typeof browseRelPath === 'string'); 236 | const browseAbsPath = await getFileAbsPath(browseRelPath); 237 | 238 | let readdirEntries = await fs.readdir(browseAbsPath, { withFileTypes: true }); 239 | readdirEntries = readdirEntries.sort(({ name: a }, { name: b }) => new Intl.Collator(undefined, { numeric: true }).compare(a, b)); 240 | 241 | const entries = (await pMap(readdirEntries, async (entry) => { 242 | try { 243 | // TODO what if a file called ".." 244 | const entryRelPath = join(browseRelPath, entry.name); 245 | const entryAbsPath = join(browseAbsPath, entry.name); 246 | const entryRealPath = await fs.realpath(entryAbsPath); 247 | 248 | if (!entryRealPath.startsWith(sharedPath)) { 249 | console.warn('Ignoring symlink pointing outside shared path', entryRealPath); 250 | return []; 251 | } 252 | 253 | const stat = await fs.lstat(entryRealPath); 254 | const isDir = stat.isDirectory(); 255 | 256 | return [{ 257 | path: entryRelPath, 258 | isDir, 259 | fileName: entry.name, 260 | }]; 261 | } catch (err) { 262 | console.warn((err as Error).message); 263 | // https://github.com/mifi/ezshare/issues/29 264 | return []; 265 | } 266 | }, { concurrency: 10 })).flat(); 267 | 268 | res.send({ 269 | files: [ 270 | { path: join(browseRelPath, '..'), fileName: '..', isDir: true }, 271 | ...entries, 272 | ], 273 | cwd: browseRelPath, 274 | sharedPath, 275 | }); 276 | })); 277 | 278 | 279 | app.get('/api/zip-files', asyncHandler(async (req, res) => { 280 | const zipFileName = `${new Date().toISOString().replace(/^(\d+-\d+-\d+)T(\d+):(\d+):(\d+).*$/, '$1 $2.$3.$3')}.zip`; 281 | const { files: filesJson } = req.query; 282 | assert(typeof filesJson === 'string'); 283 | 284 | const files = JSON.parse(filesJson) as unknown; 285 | assert(Array.isArray(files)); 286 | 287 | const archive = archiver('zip', { zlib: { level: zipCompressionLevel } }); 288 | 289 | res.writeHead(200, { 290 | 'Content-Type': 'application/zip', 291 | // NOTE: Must support non latin characters 292 | 'Content-Disposition': contentDisposition(zipFileName), 293 | }); 294 | 295 | const promise = stream.pipeline(archive, res); 296 | 297 | await pMap(files, async (file: unknown) => { 298 | assert(typeof file === 'string'); 299 | const absPath = await getFileAbsPath(file); 300 | // Add each file to the archive: 301 | archive.file(absPath, { name: file }); 302 | }, { concurrency: 1 }); 303 | 304 | archive.finalize(); 305 | 306 | await promise; 307 | })); 308 | 309 | function getUrls() { 310 | const interfaces = os.networkInterfaces(); 311 | return Object.values(interfaces).flatMap((addresses) => (addresses != null ? addresses : [])).filter(({ family, address }) => family === 'IPv4' && address !== '127.0.0.1').map(({ address }) => `http://${address}:${port}/`); 312 | } 313 | 314 | let started = false; 315 | 316 | async function startServer() { 317 | assert(!started, 'Server already started'); 318 | started = true; 319 | 320 | // Serving the frontend depending on dev/production 321 | if (devMode) { 322 | app.use('/', createProxyMiddleware({ target: 'http://localhost:3000', ws: true })); 323 | } else { 324 | app.use('/', express.static(webPath)); 325 | 326 | // Default fallback to index.html because it's a SPA (so user can open any deep link) 327 | app.use('*', (_req, res) => res.sendFile(join(webPath, 'index.html'))); 328 | } 329 | 330 | return new Promise((resolve) => { 331 | app.listen(port, resolve); 332 | }); 333 | } 334 | 335 | return { 336 | runStartupCheck: ffmpeg.runStartupCheck, 337 | getUrls, 338 | start: startServer, 339 | sharedPath, 340 | }; 341 | }; 342 | -------------------------------------------------------------------------------- /packages/web/src/routes/dir/$dirId/file.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute, useNavigate, useRouter, useSearch } from '@tanstack/react-router'; 2 | import z from 'zod'; 3 | import { useState, useEffect, useCallback, CSSProperties, useRef, KeyboardEventHandler, WheelEventHandler, MouseEventHandler, ReactEventHandler, useMemo } from 'react'; 4 | import { FaList, FaTimes, FaVolumeMute, FaVolumeUp } from 'react-icons/fa'; 5 | 6 | import { getDownloadUrl, mightBeImage, mightBeVideo, useContext } from '../../../util'; 7 | import styles from './file.module.css'; 8 | 9 | // eslint-disable-next-line import/prefer-default-export 10 | export const Route = createFileRoute('/dir/$dirId/file')({ 11 | // eslint-disable-next-line no-use-before-define 12 | component: ViewingFile, 13 | validateSearch: z.object({ 14 | p: z.string(), 15 | }).parse, 16 | }); 17 | 18 | const buttonStyle: CSSProperties = { all: 'unset', padding: '.1em .3em', cursor: 'pointer', fontSize: '2em' }; 19 | 20 | function ViewingFile() { 21 | const { currentDir } = useContext(); 22 | const playableFiles = useMemo(() => currentDir.files.filter((f) => !f.isDir && (mightBeVideo(f) || mightBeImage(f))), [currentDir.files]); 23 | const { p: path } = useSearch({ from: Route.fullPath }); 24 | const navigate = useNavigate({ from: Route.fullPath }); 25 | 26 | const viewingFile = useMemo(() => (currentDir.files.find((f) => f.path === path) ?? currentDir.files[0]), [path, currentDir.files]); 27 | 28 | const dialogRef = useRef(null); 29 | const pointerStartX = useRef(); 30 | const lastWheel = useRef(0); 31 | const [showControls, setShowControls] = useState(false); 32 | const [playlistMode, setPlaylistMode] = useState(false); 33 | const [muted, setMuted] = useState(true); 34 | const [progress, setProgress] = useState(0); 35 | const [canPlayVideo, setCanPlayVideo] = useState(false); 36 | const [videoError, setVideoError] = useState(null); 37 | 38 | const setRelViewingFile = useCallback((rel: number) => { 39 | const navigatePath = (f: { path: string } | undefined) => { 40 | if (f == null) return; 41 | navigate({ search: { p: f.path }, replace: true }); 42 | }; 43 | 44 | if (viewingFile == null) { 45 | navigatePath(playableFiles[0]); 46 | } else { 47 | const currentIndex = playableFiles.findIndex((f) => f.path === viewingFile?.path); 48 | const nextIndex = (currentIndex + rel + playableFiles.length) % playableFiles.length; 49 | navigatePath(playableFiles[nextIndex]); 50 | } 51 | }, [navigate, playableFiles, viewingFile]); 52 | 53 | const { history } = useRouter(); 54 | 55 | const handleClose = useCallback(() => history.go(-1), [history]); 56 | const handleNext = useCallback(() => setRelViewingFile(1), [setRelViewingFile]); 57 | const handlePrev = useCallback(() => setRelViewingFile(-1), [setRelViewingFile]); 58 | 59 | const mediaRef = useRef(null); 60 | 61 | const isVideo = useMemo(() => viewingFile != null && mightBeVideo(viewingFile), [viewingFile]); 62 | const isImage = useMemo(() => viewingFile != null && mightBeImage(viewingFile), [viewingFile]); 63 | 64 | useEffect(() => { 65 | if (mediaRef.current) { 66 | mediaRef.current.focus({ preventScroll: true }); 67 | } 68 | setShowControls(false); 69 | setCanPlayVideo(false); 70 | setProgress(0); 71 | setVideoError(null); 72 | 73 | if (isImage && playlistMode) { 74 | const slideTime = 5000; 75 | const startTime = Date.now(); 76 | 77 | let t: number | undefined; 78 | 79 | // ken burns zoom 80 | const animation = mediaRef.current?.animate([ 81 | { transform: 'scale(1)', offset: 0 }, 82 | { transform: 'scale(1.05)', offset: 1 }, 83 | ], { 84 | duration: slideTime, 85 | fill: 'none', 86 | }); 87 | 88 | const tick = () => { 89 | t = setTimeout(() => { 90 | const now = Date.now(); 91 | 92 | const p = Math.max(0, Math.min(1, (now - startTime) / slideTime)); 93 | setProgress(p); 94 | 95 | if (now - startTime >= slideTime) { 96 | handleNext(); 97 | return; 98 | } 99 | tick(); 100 | }, 40); 101 | }; 102 | 103 | tick(); 104 | 105 | return () => { 106 | if (t != null) clearTimeout(t); 107 | animation?.cancel(); 108 | }; 109 | } 110 | 111 | return undefined; 112 | }, [handleNext, isImage, playlistMode, viewingFile]); 113 | 114 | const handleKeyDown = useCallback>((e) => { 115 | if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; 116 | 117 | // eslint-disable-next-line unicorn/prefer-switch 118 | if (e.key === 'ArrowLeft') { 119 | e.preventDefault(); 120 | e.stopPropagation(); 121 | handlePrev(); 122 | } else if (e.key === 'ArrowRight') { 123 | e.preventDefault(); 124 | e.stopPropagation(); 125 | handleNext(); 126 | } else if (e.key === 'Escape') { 127 | e.preventDefault(); 128 | e.stopPropagation(); 129 | handleClose(); 130 | } 131 | }, [handleClose, handleNext, handlePrev]); 132 | 133 | const handlePointerDown = useCallback>((e) => { 134 | pointerStartX.current = e.clientX; 135 | }, []); 136 | 137 | const handlePointerUp = useCallback>((e) => { 138 | if (pointerStartX.current == null) return; 139 | 140 | const diff = pointerStartX.current - e.clientX; 141 | if (Math.abs(diff) > 50) { 142 | if (diff > 0) handleNext(); 143 | else handlePrev(); 144 | } 145 | 146 | pointerStartX.current = undefined; 147 | }, [handleNext, handlePrev]); 148 | 149 | const handleWheel = useCallback>((e) => { 150 | // Trackpad horizontal swipes come as wheel events 151 | if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) { 152 | if (Date.now() - lastWheel.current < 500) return; // ignore if too fast 153 | lastWheel.current = Date.now(); 154 | e.preventDefault(); 155 | 156 | if (e.deltaX > 0) handleNext(); 157 | else handlePrev(); 158 | } 159 | }, [handleNext, handlePrev]); 160 | 161 | const handleClick = useCallback>((e) => { 162 | e.stopPropagation(); 163 | if (mediaRef.current != null && 'play' in mediaRef.current) { 164 | if (mediaRef.current.paused) { 165 | mediaRef.current.play(); 166 | } else { 167 | mediaRef.current.pause(); 168 | } 169 | } 170 | }, []); 171 | 172 | const handlePrevClick = useCallback>((e) => { 173 | e.preventDefault(); 174 | e.stopPropagation(); 175 | handlePrev(); 176 | }, [handlePrev]); 177 | 178 | const handleNextClick = useCallback>((e) => { 179 | e.preventDefault(); 180 | e.stopPropagation(); 181 | handleNext(); 182 | }, [handleNext]); 183 | 184 | const handlePlaylistModeClick = useCallback>((e) => { 185 | e.stopPropagation(); 186 | setPlaylistMode((v) => !v); 187 | }, []); 188 | 189 | const handleMuteClick = useCallback>((e) => { 190 | e.stopPropagation(); 191 | setMuted((v) => !v); 192 | }, []); 193 | 194 | const handleVideoEnded = useCallback>(() => { 195 | if (playlistMode) { 196 | handleNext(); 197 | } 198 | }, [handleNext, playlistMode]); 199 | 200 | const scrubbingRef = useRef(false); 201 | const handleScrubDown = useCallback>((e) => { 202 | e.preventDefault(); 203 | e.stopPropagation(); 204 | scrubbingRef.current = true; 205 | }, []); 206 | const handleScrubUp = useCallback>((e) => { 207 | e.preventDefault(); 208 | e.stopPropagation(); 209 | scrubbingRef.current = false; 210 | }, []); 211 | const handleScrub = useCallback>((e) => { 212 | e.preventDefault(); 213 | e.stopPropagation(); 214 | if (!scrubbingRef.current) return; 215 | const target = e.target as HTMLDivElement; 216 | const p = e.clientX / target.clientWidth; 217 | if (mediaRef.current && mediaRef.current instanceof HTMLVideoElement) { 218 | mediaRef.current.currentTime = mediaRef.current.duration * p; 219 | } 220 | }, []); 221 | 222 | const handleVideoTimeUpdate = useCallback>((e) => { 223 | if (e.currentTarget instanceof HTMLVideoElement) { 224 | setProgress(e.currentTarget.currentTime / e.currentTarget.duration); 225 | } 226 | }, []); 227 | 228 | const handleVideoError = useCallback>((e) => { 229 | if (e.target instanceof HTMLVideoElement) { 230 | setVideoError(e.target.error); 231 | } 232 | 233 | if (playlistMode) { 234 | setTimeout(() => { 235 | handleNext(); 236 | }, 300); 237 | } 238 | }, [handleNext, playlistMode]); 239 | 240 | function renderPreview() { 241 | if (viewingFile == null) { 242 | return null; 243 | } 244 | 245 | if (isVideo) { 246 | return ( 247 | <> 248 | {/* eslint-disable-next-line jsx-a11y/media-has-caption */} 249 |