{"_id":"@dkukushkin/video-scrubber","name":"@dkukushkin/video-scrubber","dist-tags":{"latest":"0.1.0"},"versions":{"0.1.0":{"name":"@dkukushkin/video-scrubber","version":"0.1.0","description":"Drag-to-scrub HTML video: pointer and touch scrubbing with infinite wrap, optional autoplay, a framework-agnostic core and a React adapter.","keywords":["drag","pointer-events","react","scrub","scrubber","swipe","video"],"homepage":"https://github.com/DKukushkin91/video-scrubber#readme","bugs":{"url":"https://github.com/DKukushkin91/video-scrubber/issues"},"license":"MIT","author":{"name":"Dmitrii Kukushkin"},"repository":{"type":"git","url":"git+https://github.com/DKukushkin91/video-scrubber.git"},"type":"module","sideEffects":false,"exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","default":"./dist/index.js"},"./react":{"types":"./dist/react.d.ts","import":"./dist/react.js","default":"./dist/react.js"},"./package.json":"./package.json"},"publishConfig":{"access":"public"},"scripts":{"build":"tsdown","build:watch":"tsdown --watch","typecheck":"tsc --noEmit","lint":"oxlint --type-aware","format":"oxfmt","format:check":"oxfmt --check","check:comments":"node scripts/check-comments.mjs","test:unit":"node --test scripts/*.unit.mjs","check:package":"publint && attw --pack . --profile esm-only","check":"pnpm format:check && pnpm lint && pnpm check:comments && pnpm typecheck && pnpm build && pnpm test:unit && pnpm check:package && pnpm check:examples","dev":"pnpm --filter ./examples/playground dev","prepack":"pnpm build","check:examples":"pnpm --filter ./examples/playground typecheck && pnpm --filter ./examples/playground build"},"devDependencies":{"@arethetypeswrong/cli":"^0.18.5","@types/react":"^19.3.0","@types/react-dom":"^19.3.0","oxc-parser":"^0.149.0","oxfmt":"^0.67.0","oxlint":"^1.82.0","oxlint-tsgolint":"^7.0.2001","publint":"^0.3.24","react":"^19.3.0","react-dom":"^19.3.0","tsdown":"^0.23.0","typescript":"^7.0.2"},"peerDependencies":{"react":">=18"},"peerDependenciesMeta":{"react":{"optional":true}},"engines":{"node":">=24"},"packageManager":"pnpm@11.5.3","gitHead":"2168cc3cefee58100f94ad4832c02512cccdf48d","_id":"@dkukushkin/video-scrubber@0.1.0","_nodeVersion":"24.14.0","_npmVersion":"11.9.0","dist":{"integrity":"sha512-VadGdGF932hD9TDT/dLqmsjv7Qb9BNnGUySeNyjw9bHXhCM4STd9fZzVYb/4dgFqEJ8S8brLTWsoliA/9y/fIA==","shasum":"bd083f6e0818da347cda6e0ee436cfaee44d2ed8","tarball":"https://registry.npmjs.org/@dkukushkin/video-scrubber/-/video-scrubber-0.1.0.tgz","fileCount":16,"unpackedSize":95022,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIQCtRmp8EOIVlLJ0W07AacdaGsAuXOeyAHs9RnDC+RuZ3QIgHV1v/1vYfwXZ43kvXhtusfca/aaRJWBV6eogqo1tSiE="}]},"_npmUser":{"name":"dkukushkin","email":"Kukushkinds91@gmail.com"},"directories":{},"maintainers":[{"name":"dkukushkin","email":"Kukushkinds91@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/video-scrubber_0.1.0_1789124052402_0.5817005339004497"},"_hasShrinkwrap":false}},"time":{"created":"2026-09-11T10:54:12.240Z","0.1.0":"2026-09-11T10:54:12.540Z","modified":"2026-09-11T10:54:12.764Z"},"maintainers":[{"name":"dkukushkin","email":"Kukushkinds91@gmail.com"}],"description":"Drag-to-scrub HTML video: pointer and touch scrubbing with infinite wrap, optional autoplay, a framework-agnostic core and a React adapter.","homepage":"https://github.com/DKukushkin91/video-scrubber#readme","keywords":["drag","pointer-events","react","scrub","scrubber","swipe","video"],"repository":{"type":"git","url":"git+https://github.com/DKukushkin91/video-scrubber.git"},"author":{"name":"Dmitrii Kukushkin"},"bugs":{"url":"https://github.com/DKukushkin91/video-scrubber/issues"},"license":"MIT","readme":"# @dkukushkin/video-scrubber\n\nDrag a `<video>` like an object: press and move right to play it forward, move left to play it backward, cross either end and it wraps around — an endless loop in both directions. On touch devices the same works with a horizontal swipe while vertical scrolling and pinch-zoom keep working. Release the pointer and the frame stays; with `play` enabled the clip continues from that frame.\n\n- Framework-agnostic core (`@dkukushkin/video-scrubber`) — no dependencies.\n- Optional React adapter (`@dkukushkin/video-scrubber/react`) — `useVideoScrubber` and `<ScrubVideo>`.\n- Safe to import on the server: nothing touches the DOM until a controller is created.\n- Keyboard support and slider ARIA out of the box.\n\n## Install\n\n```bash\npnpm add @dkukushkin/video-scrubber\n```\n\nReact is an optional peer dependency (`>=18`) — only needed for the `/react` entry.\n\n## React\n\n```tsx\nimport { ScrubVideo } from '@dkukushkin/video-scrubber/react';\n\nexport const PlanPreview = ({ isHovered }: { isHovered: boolean }) => (\n  <ScrubVideo\n    src=\"/videos/plan.mp4\"\n    poster=\"/videos/plan.jpg\"\n    label=\"Apartment walkthrough\"\n    play={isHovered}\n    className=\"preview\"\n    videoClassName=\"preview-video\"\n  />\n);\n```\n\n`ScrubVideo` renders a focusable `role=\"slider\"` wrapper around a muted, inline, `preload=\"metadata\"` video. Styling is yours: pass `className` for the wrapper and `videoClassName` for the video (keep `pointer-events: none` on the video so the wrapper receives the gesture).\n\nNeed your own markup, or the gesture on a larger surface than the slider itself? Use the hook:\n\n```tsx\nimport { useCallback, useRef } from 'react';\nimport { useVideoScrubber } from '@dkukushkin/video-scrubber/react';\n\nexport const Card = () => {\n  const cardRef = useRef<HTMLElement | null>(null);\n  const { videoRef, controlRef, snapshot, seekBy } = useVideoScrubber({\n    play: false,\n    pointerTargetRef: cardRef,\n  });\n  const handleStepForward = useCallback(() => {\n    seekBy(1);\n  }, [seekBy]);\n\n  return (\n    <article ref={cardRef}>\n      <div\n        ref={controlRef}\n        role=\"slider\"\n        tabIndex={0}\n        aria-label=\"Walkthrough\"\n        aria-valuemin={0}\n        aria-valuemax={0}\n        aria-valuenow={0}\n        aria-disabled\n      >\n        <video ref={videoRef} src=\"/videos/plan.mp4\" muted playsInline preload=\"metadata\" aria-hidden />\n      </div>\n      <p>{snapshot.currentTime.toFixed(1)} s</p>\n      <button type=\"button\" onClick={handleStepForward}>\n        +1 s\n      </button>\n      <a href=\"/details\">Details</a>\n    </article>\n  );\n};\n```\n\nThe pointer target (`pointerTargetRef`, the whole card here) receives the drag; the control target (`controlRef`) receives keyboard events and ARIA updates. Links, buttons, inputs and anything marked `data-scrub-ignore` inside the pointer target keep working — pressing on them never starts a gesture, and a click without movement is never swallowed.\n\nBoth refs are callback refs, so conditional rendering and node replacement are handled. Changing `play`, `loop`, `sensitivity` or the other options updates the running controller instead of recreating it.\n\n## Vanilla\n\n```ts\nimport { createVideoScrubber } from '@dkukushkin/video-scrubber';\n\nconst video = document.querySelector('video')!;\nconst control = video.parentElement!;\n\nconst scrubber = createVideoScrubber(video, { play: true, loop: true });\nscrubber.attach({ pointerTarget: control });\n\nscrubber.subscribe(() => {\n  const { currentTime, duration, isDragging, play } = scrubber.getSnapshot();\n\n  readout.textContent = `${currentTime.toFixed(1)} / ${String(duration ?? '?')}${isDragging ? ' (dragging)' : ''}${play ? '' : ' (paused)'}`;\n});\n\nscrubber.update({ play: false });\nscrubber.destroy();\n```\n\n`attach({ pointerTarget, controlTarget })` takes two elements: the pointer target receives the drag; the control target receives keyboard events and the slider values (`aria-valuemin`, `aria-valuemax`, `aria-valuenow`, `aria-valuetext`, plus `aria-disabled` until the duration is known — all restored on detach). Omit `controlTarget` to use the pointer target for both; pass `null` to turn keyboard and ARIA off — the right choice when the pointer target is a whole card with links and no slider role of its own.\n\nThe controller exposes `attach(targets)`, `detach()`, `destroy()` (terminal — later calls are ignored), `update(partialOptions)`, `seekTo(seconds)` and `seekBy(seconds)` (both clamp to the clip), `getSnapshot()` → `{ currentTime, duration, isDragging, play }` and `subscribe(listener)`. The `.` entry also exports the helpers `wrapTime(time, duration)` (wraps into `[0, duration)`), `clampTime(time, duration)` (clamps into `[0, duration]`) and `formatTimeText(currentTime, duration)` (the default `aria-valuetext` formatter).\n\nThe core never sets `muted`, `preload` or ARIA roles on your elements — give the video `muted` and `playsinline` if you want autoplay, `preload=\"metadata\"` so the duration is known before the first gesture, and the control target `role=\"slider\"` with `tabindex=\"0\"` if you want keyboard access.\n\n## Options\n\n| Option                | Default       | Meaning                                                                                                 |\n| --------------------- | ------------- | ------------------------------------------------------------------------------------------------------- |\n| `play`                | `true`        | Keep the clip playing; a drag pauses it and it resumes from the released frame.                         |\n| `loop`                | `true`        | Sets `video.loop` for playback. Dragging always wraps regardless.                                       |\n| `sensitivity`         | `1`           | How many loops one full drag across the pointer target makes. `2` — half the width is a full loop.      |\n| `invertDirection`     | `false`       | Dragging or swiping right rewinds and left plays forward. Keyboard keys keep their usual meaning.       |\n| `dragThresholdPx`     | `4`           | Horizontal movement before a press becomes a drag. Movement must also be more horizontal than vertical. |\n| `keyboardStepSeconds` | `1`           | Arrow keys step.                                                                                        |\n| `pageStepSeconds`     | `10`          | PageUp / PageDown step.                                                                                 |\n| `getTrackWidth`       | —             | Custom track width instead of the pointer target's bounding width.                                      |\n| `formatValueText`     | `m:ss / m:ss` | `aria-valuetext` formatter.                                                                             |\n| `onPlaybackError`     | —             | Called when `video.play()` rejects for a reason other than an interrupted play.                         |\n\nNumeric options are validated; an invalid value throws a `RangeError`.\n\n## Behaviour details\n\n- Drag position is computed from the total offset since the press, so the gesture is O(1) per event and wraps naturally (`8 s + 5 s` on a 10 s clip is `3 s`).\n- At most one `currentTime` assignment per animation frame; the last requested position wins and is flushed before playback resumes.\n- Keyboard (`←` `→` `↑` `↓` `PageUp` `PageDown` `Home` `End`), `seekTo` and `seekBy` clamp to `[0, duration]` — slider semantics — while dragging wraps.\n- `touch-action: pan-y pinch-zoom` is applied to the pointer target while attached; `user-select: none` only for the duration of a gesture. Both are restored on detach.\n- If the browser has not loaded metadata yet (iOS ignores `preload` until a gesture), the first press calls `video.load()` and the accumulated drag is applied as soon as the duration is known.\n\n## Accessibility\n\nThe control target gets `aria-valuemin`, `aria-valuemax`, `aria-valuenow` and `aria-valuetext` kept in sync with the clip, and `aria-disabled=\"true\"` until metadata is available. Give it an accessible name (`aria-label` or `aria-labelledby`); `ScrubVideo` requires `label` for that reason. Original attributes are restored on detach.\n\n## SSR and Next.js\n\nThe `/react` entry is marked `'use client'`. `ScrubVideo` renders identical markup on the server and the client — behaviour props never change the HTML — so there are no hydration mismatches. Importing either entry in Node is safe.\n\n## Video encoding\n\nScrubbing seeks to arbitrary frames, so keyframe spacing decides how smooth it feels. Encode with dense keyframes and no B-frames, for example:\n\n```bash\nffmpeg -i in.mp4 -an -c:v libx264 -profile:v main -preset slow -crf 22 -pix_fmt yuv420p \\\n  -g 6 -keyint_min 6 -sc_threshold 0 -bf 0 -movflags +faststart out.mp4\n```\n\n## Development\n\n```bash\npnpm install\npnpm check          # format, lint (oxlint, type-aware), comment policy, tsc, build, contract checks, publint/attw, playground\npnpm build:watch    # in one terminal\npnpm dev            # playground on http://localhost:5173 (React) and /vanilla.html\n```\n\nBuilt with TypeScript 7, tsdown, oxlint and oxfmt. Contract checks run with `node --test` against the built output. The library's internal gesture math is exported from a non-public `internal` entry for those checks only; the public types intentionally expose no enums, so projects with `isolatedModules` are unaffected.\n\n## License\n\nMIT\n","readmeFilename":"README.md","_rev":"1-546b45c53cf822f0bf08966fa24dbdf2"}