> ## Documentation Index
> Fetch the complete documentation index at: https://docs.researchanddesire.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Funscript Player

> Play funscript files synced with video directly to your OSSM via Bluetooth

export const OssmFunscriptPlayer = () => {
  const OSSM_SERVICE_UUID = '522b443a-4f53-534d-0001-420badbabe69';
  const OSSM_COMMAND_CHARACTERISTIC_UUID = '522b443a-4f53-534d-1000-420badbabe69';
  const OSSM_SPEED_KNOB_CHARACTERISTIC_UUID = '522b443a-4f53-534d-1010-420badbabe69';
  const OSSM_LATENCY_COMPENSATION_CHARACTERISTIC_UUID = '522b443a-4f53-534d-1030-420badbabe69';
  const OSSM_STATE_CHARACTERISTIC_UUID = '522b443a-4f53-534d-2000-420badbabe69';
  const isWebBluetoothSupported = () => {
    return typeof navigator !== 'undefined' && ('bluetooth' in navigator);
  };
  const [connectionStatus, setConnectionStatus] = useState('disconnected');
  const [device, setDevice] = useState(null);
  const [error, setError] = useState(null);
  const [isSupported, setIsSupported] = useState(true);
  const [videoFile, setVideoFile] = useState(null);
  const [videoUrl, setVideoUrl] = useState(null);
  const [funscriptFile, setFunscriptFile] = useState(null);
  const [funscriptActions, setFunscriptActions] = useState([]);
  const [funscriptSimpleActions, setFunscriptSimpleActions] = useState([]);
  const [isSimple, setIsSimple] = useState(true);
  const [isReverse, setIsReverse] = useState(false);
  const [isPlaying, setIsPlaying] = useState(false);
  const [currentPosition, setCurrentPosition] = useState(0);
  const [currentTime, setCurrentTime] = useState(0);
  const [commandsSent, setCommandsSent] = useState(0);
  const [timeOffset, setTimeOffset] = useState(5);
  const [buffer, setBuffer] = useState(0);
  const [speed, setSpeed] = useState(0);
  const [stroke, setStroke] = useState(0);
  const [depth, setDepth] = useState(0);
  const [sensation, setSensation] = useState(0);
  const [logs, setLogs] = useState([]);
  const [logsExpanded, setLogsExpanded] = useState(false);
  const commandCharacteristicRef = useRef(null);
  const speedKnobCharacteristicRef = useRef(null);
  const latencyCompensationCharacteristicRef = useRef(null);
  const serverRef = useRef(null);
  const videoRef = useRef(null);
  const logsContainerRef = useRef(null);
  const currentActionIndexRef = useRef(0);
  const lastSentTimeRef = useRef(0);
  const syncIntervalRef = useRef(null);
  const commandsSentRef = useRef(0);
  useEffect(() => {
    setIsSupported(isWebBluetoothSupported());
  }, []);
  useEffect(() => {
    if (logsExpanded && logsContainerRef.current) {
      logsContainerRef.current.scrollTop = logsContainerRef.current.scrollHeight;
    }
  }, [logs, logsExpanded]);
  useEffect(() => {
    return () => {
      if (syncIntervalRef.current) {
        clearInterval(syncIntervalRef.current);
      }
    };
  }, []);
  useEffect(() => {
    if (videoFile) {
      const url = URL.createObjectURL(videoFile);
      setVideoUrl(url);
      return () => {
        URL.revokeObjectURL(url);
      };
    } else {
      setVideoUrl(null);
    }
  }, [videoFile]);
  const addLog = useCallback((direction, data) => {
    const entry = {
      id: Date.now() + Math.random(),
      timestamp: new Date().toISOString().split('T')[1].slice(0, 12),
      direction,
      data
    };
    setLogs(prev => [...prev.slice(-999), entry]);
  }, []);
  const sendCommand = useCallback(async command => {
    if (!commandCharacteristicRef.current) {
      return false;
    }
    try {
      const encoder = new TextEncoder();
      await commandCharacteristicRef.current.writeValueWithoutResponse(encoder.encode(command));
      addLog('TX', command);
      return true;
    } catch (err) {
      addLog('ERR', `Send failed: ${err.message}`);
      return false;
    }
  }, [addLog]);
  const sendStreamPosition = useCallback(async (position, timeMs) => {
    const clampedPos = Math.max(0, Math.min(100, Math.round(position)));
    const clampedTime = Math.max(0, Math.min(10000, Math.round(timeMs)));
    const command = `stream:${clampedPos}:${clampedTime}`;
    const success = await sendCommand(command);
    if (success) {
      commandsSentRef.current += 1;
      setCommandsSent(commandsSentRef.current);
      setCurrentPosition(clampedPos);
    }
    return success;
  }, [sendCommand]);
  const handleBufferChange = useCallback(value => {
    sendCommand(`set:buffer:${value / 2}`);
  }, [sendCommand]);
  const handleSpeedChange = useCallback(value => {
    sendCommand(`set:speed:${value}`);
  }, [sendCommand]);
  const handleStrokeChange = useCallback(value => {
    sendCommand(`set:stroke:${value}`);
    console.log(!isPlaying);
    if (!isPlaying) {
      sendStreamPosition(100, 1000);
    }
  }, [sendCommand]);
  const handleSensationChange = useCallback(value => {
    sendCommand(`set:sensation:${value}`);
  }, [sendCommand]);
  const handleDepthChange = useCallback(value => {
    sendCommand(`set:depth:${value}`);
    if (!isPlaying) {
      sendStreamPosition(0, 1000);
    }
  }, [sendCommand]);
  const handleStateChange = useCallback(value => {
    const stateRaw = new TextDecoder().decode(value);
    addLog('RX', `state: ${stateRaw}`);
    const state = JSON.parse(stateRaw);
    if (state.speed !== undefined) {
      setSpeed(state.speed);
    }
    if (state.depth !== undefined) {
      setDepth(state.depth);
    }
    if (state.stroke !== undefined) {
      setStroke(state.stroke);
    }
    if (state.sensation !== undefined) {
      setSensation(state.sensation);
    }
    if (state.buffer !== undefined) {
      setBuffer(state.buffer * 2);
    }
  });
  const handleSimpleToggle = useCallback(async () => {
    setIsSimple(!isSimple);
    if (videoRef.current) {
      videoRef.current.pause();
    }
    stopSync();
  }, [isSimple]);
  const handleReverseToggle = useCallback(async () => {
    setIsReverse(!isReverse);
    if (videoRef.current) {
      videoRef.current.pause();
    }
    stopSync();
  }, [isReverse]);
  const handleConnect = async () => {
    setError(null);
    setConnectionStatus('connecting');
    try {
      addLog('INFO', 'Requesting OSSM device...');
      const bleDevice = await navigator.bluetooth.requestDevice({
        filters: [{
          services: [OSSM_SERVICE_UUID]
        }],
        optionalServices: [OSSM_SERVICE_UUID]
      });
      bleDevice.addEventListener('gattserverdisconnected', handleDisconnect);
      addLog('INFO', `Found device: ${bleDevice.name}`);
      const server = await bleDevice.gatt.connect();
      serverRef.current = server;
      addLog('INFO', 'Connected to GATT server');
      const service = await server.getPrimaryService(OSSM_SERVICE_UUID);
      addLog('INFO', 'Got OSSM service');
      commandCharacteristicRef.current = await service.getCharacteristic(OSSM_COMMAND_CHARACTERISTIC_UUID);
      addLog('INFO', 'Got command characteristic');
      addLog('INFO', 'Entering streaming mode...');
      await sendCommand('go:streaming');
      speedKnobCharacteristicRef.current = await service.getCharacteristic(OSSM_SPEED_KNOB_CHARACTERISTIC_UUID);
      addLog('INFO', 'Got speed knob characteristic');
      latencyCompensationCharacteristicRef.current = await service.getCharacteristic(OSSM_LATENCY_COMPENSATION_CHARACTERISTIC_UUID);
      addLog('INFO', 'Got latency compensation characteristic');
      try {
        const stateChar = await service.getCharacteristic(OSSM_STATE_CHARACTERISTIC_UUID);
        const stateValue = await stateChar.readValue();
        handleStateChange(stateValue);
        await stateChar.startNotifications();
        stateChar.addEventListener('characteristicvaluechanged', event => {
          handleStateChange(event.target.value);
        });
        addLog('INFO', 'Subscribed to state notifications');
      } catch (e) {
        addLog('INFO', 'State notifications not available');
      }
      const encoder = new TextEncoder();
      await speedKnobCharacteristicRef.current.writeValue(encoder.encode('false'));
      addLog('INFO', 'Disable speed knob as override');
      await latencyCompensationCharacteristicRef.current.writeValue(encoder.encode('true'));
      addLog('INFO', 'Enable latency compensation');
      setDevice(bleDevice);
      setConnectionStatus('connected');
      addLog('INFO', 'Ready for funscript playback');
    } catch (err) {
      addLog('ERR', `Connection failed: ${err.message}`);
      setError(err.message);
      setConnectionStatus('disconnected');
    }
  };
  const handleDisconnect = useCallback(() => {
    addLog('INFO', 'Device disconnected');
    setConnectionStatus('disconnected');
    setDevice(null);
    serverRef.current = null;
    commandCharacteristicRef.current = null;
    stopSync();
  }, [addLog]);
  const handleDisconnectClick = async () => {
    if (videoRef.current) {
      videoRef.current.pause();
    }
    stopSync();
    try {
      await sendCommand('set:speed:0');
    } catch (e) {}
    if (serverRef.current) {
      serverRef.current.disconnect();
    }
    handleDisconnect();
  };
  const parseFunscript = useCallback(content => {
    var data = {};
    try {
      data = JSON.parse(content);
    } catch (err) {
      addLog('ERR', `Failed to parse funscript as JSON: ${err.message}`);
    }
    if (!data.actions) {
      try {
        var csv = content.split("\n");
        if (csv.length > 0) {
          data.actions = csv.map(item => ({
            pos: Number(item.split(",")[1]),
            at: Number(item.split(",")[0])
          }));
        }
      } catch (err) {
        addLog('ERR', `Failed to parse funscript as CSV: ${err.message}`);
      }
    }
    if (!data.actions || !Array.isArray(data.actions)) {
      setError(`Failed to parse funscript`);
      return false;
    }
    var lastDirection = 0;
    data.simpleActions = [];
    data.actions.forEach(function (value, index) {
      var nextValue = data.actions[index + 1];
      if (nextValue) {
        var direction = (value.pos - nextValue.pos) / Math.abs(value.pos - nextValue.pos);
        if (direction != lastDirection) {
          data.simpleActions.push(data.actions[index]);
        }
        lastDirection = direction;
      }
    });
    data.simpleActions;
    setFunscriptActions(data.actions);
    setFunscriptSimpleActions(data.simpleActions);
    addLog('INFO', `Loaded ${data.actions.length} actions`);
    if (data.version) addLog('INFO', `Funscript version: ${data.version}`);
    if (data.inverted) addLog('INFO', 'Script is inverted');
    if (data.range) addLog('INFO', `Range: ${data.range}`);
    return true;
  }, [addLog]);
  const formatTime = seconds => {
    const mins = Math.floor(seconds / 60);
    const secs = Math.floor(seconds % 60);
    return `${mins}:${secs.toString().padStart(2, '0')}`;
  };
  const syncFunscript = useCallback(() => {
    var actions = funscriptActions;
    if (isSimple) {
      actions = funscriptSimpleActions;
    }
    if (!videoRef.current || actions.length === 0) return;
    const currentTimeMs = videoRef.current.currentTime * 1000 + timeOffset + buffer;
    setCurrentTime(videoRef.current.currentTime);
    while (currentActionIndexRef.current < actions.length) {
      const action = actions[currentActionIndexRef.current];
      if (action.at > currentTimeMs) {
        break;
      }
      let timeToNext = 100;
      if (currentActionIndexRef.current < actions.length - 1) {
        const nextAction = actions[currentActionIndexRef.current + 1];
        timeToNext = nextAction.at - action.at;
        if (action.at > lastSentTimeRef.current) {
          if (isReverse) {
            sendStreamPosition(100 - nextAction.pos, timeToNext);
          } else {
            sendStreamPosition(nextAction.pos, timeToNext);
          }
          lastSentTimeRef.current = action.at;
        }
      }
      currentActionIndexRef.current++;
    }
  }, [funscriptActions, funscriptSimpleActions, timeOffset, buffer, sendStreamPosition, isReverse, isSimple]);
  const startSync = useCallback(() => {
    if (syncIntervalRef.current) return;
    setIsPlaying(true);
    syncIntervalRef.current = setInterval(syncFunscript, 2);
    addLog('INFO', 'Started sync');
  }, [syncFunscript, addLog]);
  const stopSync = useCallback(() => {
    if (syncIntervalRef.current) {
      clearInterval(syncIntervalRef.current);
      syncIntervalRef.current = null;
    }
    setIsPlaying(false);
  }, []);
  const resetPlayback = useCallback(() => {
    currentActionIndexRef.current = 0;
    lastSentTimeRef.current = 0;
    commandsSentRef.current = 0;
    setCommandsSent(0);
    stopSync();
  }, [stopSync]);
  const handleVideoSelect = e => {
    const file = e.target.files[0];
    if (file) {
      setVideoFile(file);
      addLog('INFO', `Loaded video: ${file.name}`);
    }
  };
  const handleFunscriptSelect = e => {
    const file = e.target.files[0];
    if (file) {
      setFunscriptFile(file);
      const reader = new FileReader();
      reader.onload = event => {
        if (parseFunscript(event.target.result)) {
          resetPlayback();
        }
      };
      reader.readAsText(file);
    }
  };
  const handleVideoPlay = () => {
    addLog('INFO', 'Video playing');
    startSync();
  };
  const handleVideoPause = () => {
    addLog('INFO', 'Video paused');
    stopSync();
  };
  const handleVideoSeeked = () => {
    if (!videoRef.current) return;
    const currentTimeMs = videoRef.current.currentTime * 1000;
    var actions = funscriptActions;
    if (isSimple) {
      actions = funscriptSimpleActions;
    }
    currentActionIndexRef.current = actions.findIndex(a => a.at > currentTimeMs);
    if (currentActionIndexRef.current === -1) currentActionIndexRef.current = actions.length;
    lastSentTimeRef.current = currentTimeMs - 1;
    addLog('INFO', `Seeked to ${formatTime(videoRef.current.currentTime)}`);
  };
  const handleVideoEnded = () => {
    addLog('INFO', 'Video ended');
    stopSync();
  };
  if (!isSupported) {
    return <div className="not-prose mx-auto max-w-2xl rounded-xl border border-amber-300 bg-amber-50 p-6 dark:border-amber-700 dark:bg-amber-900/20">
        <p className="font-medium text-amber-800 dark:text-amber-200">
          Web Bluetooth is not supported in this browser.
        </p>
        <p className="mt-2 text-sm text-amber-700 dark:text-amber-300">
          Please use Chrome, Edge, or Opera on desktop or Android to use this player.
        </p>
      </div>;
  }
  const isConnected = connectionStatus === 'connected';
  const isConnecting = connectionStatus === 'connecting';
  return <div className="not-prose mx-auto max-w-2xl space-y-4">
      {}
      {error && <div className="rounded-xl border border-red-300 bg-red-50 p-4 dark:border-red-700 dark:bg-red-900/20">
          <div className="flex items-start gap-2">
            <svg className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
            </svg>
            <div className="flex-1">
              <p className="font-medium text-red-800 dark:text-red-300">Error</p>
              <p className="text-sm text-red-700 dark:text-red-400">{error}</p>
            </div>
            <button onClick={() => setError(null)} className="text-red-500 hover:text-red-700 dark:hover:text-red-300">
              <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
              </svg>
            </button>
          </div>
        </div>}

      {}
      <div className="rounded-xl border border-zinc-200 bg-white p-6 dark:border-zinc-700 dark:bg-zinc-900">
        <div className="flex items-center justify-between">
          <div className="flex items-center gap-3">
            <div className={`h-3 w-3 rounded-full ${isConnected ? 'bg-green-500' : isConnecting ? 'bg-amber-500 animate-pulse' : 'bg-zinc-300 dark:bg-zinc-600'}`} />
            <span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">
              {isConnected ? `Connected to ${device?.name || 'OSSM'}` : isConnecting ? 'Connecting...' : 'Disconnected'}
            </span>
          </div>

          {!isConnected ? <button onClick={handleConnect} disabled={isConnecting} className="rounded-full bg-violet-500 px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-violet-600 disabled:opacity-50 disabled:cursor-not-allowed">
              {isConnecting ? 'Connecting...' : 'Connect to OSSM'}
            </button> : <button onClick={handleDisconnectClick} className="rounded-full bg-zinc-200 px-6 py-2 text-sm font-medium text-zinc-700 transition-colors hover:bg-zinc-300 dark:bg-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-600">
              Disconnect
            </button>}
        </div>
      </div>

      {}

      <div className="rounded-xl border border-zinc-200 bg-white p-6 dark:border-zinc-700 dark:bg-zinc-900">
        <h3 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100 mb-4">Video & Funscript</h3>

        {}
        <div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
          <div>
            <label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
              Video File
            </label>
            <label className="block cursor-pointer">
              <input type="file" accept="video/*" onChange={handleVideoSelect} className="hidden" />
              <div className={`px-4 py-3 rounded-lg border-2 border-dashed text-center text-sm transition-colors ${videoFile ? 'border-green-500 text-green-600 dark:text-green-400' : 'border-zinc-300 text-zinc-500 hover:border-violet-500 hover:text-violet-600 dark:border-zinc-600 dark:hover:border-violet-500'}`}>
                {videoFile ? videoFile.name : 'Click to select video'}
              </div>
            </label>
          </div>

          <div>
            <label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
              Funscript File
            </label>
            <label className="block cursor-pointer">
              <input type="file" accept=".funscript,.json,.csv" onChange={handleFunscriptSelect} className="hidden" />
              <div className={`px-4 py-3 rounded-lg border-2 border-dashed text-center text-sm transition-colors ${funscriptFile ? 'border-green-500 text-green-600 dark:text-green-400' : 'border-zinc-300 text-zinc-500 hover:border-violet-500 hover:text-violet-600 dark:border-zinc-600 dark:hover:border-violet-500'}`}>
                {funscriptFile ? funscriptFile.name : 'Click to select .funscript'}
              </div>
            </label>
          </div>
        </div>

        {}
        <div className="rounded-lg overflow-hidden bg-black mb-4">
          {videoUrl ? <video loop ref={videoRef} src={videoUrl} controls className="w-full max-h-96" onPlay={handleVideoPlay} onPause={handleVideoPause} onSeeked={handleVideoSeeked} onEnded={handleVideoEnded} /> : <div className="flex flex-col items-center justify-center h-48 text-zinc-500">
              <svg className="w-12 h-12 mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z" />
              </svg>
              <span className="text-sm">Load a video file to begin</span>
            </div>}
        </div>

        {}
        <div className="h-2 bg-zinc-200 dark:bg-zinc-700 rounded-full overflow-hidden mb-4">
          <div className="h-full bg-violet-500 transition-all duration-75" style={{
    width: `${currentPosition}%`
  }} />
        </div>

        {}
        <div className="grid grid-cols-2 sm:grid-cols-6 gap-3">
          <div className="bg-zinc-50 dark:bg-zinc-800 rounded-lg p-3 text-center">
            <div className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">{formatTime(currentTime)}</div>
            <div className="text-xs text-zinc-500 dark:text-zinc-400">Time</div>
          </div>
          <div className="bg-zinc-50 dark:bg-zinc-800 rounded-lg p-3 text-center">
            <div className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">{currentPosition}</div>
            <div className="text-xs text-zinc-500 dark:text-zinc-400">Position</div>
          </div>
          <div className="bg-zinc-50 dark:bg-zinc-800 rounded-lg p-3 text-center">
            <div className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">{isSimple ? funscriptSimpleActions.length : funscriptActions.length}</div>
            <div className="text-xs text-zinc-500 dark:text-zinc-400">Actions</div>
          </div>
          <div className="bg-zinc-50 dark:bg-zinc-800 rounded-lg p-3 text-center">
            <div className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">{commandsSent}</div>
            <div className="text-xs text-zinc-500 dark:text-zinc-400">Sent</div>
          </div>
          <button onClick={handleReverseToggle} className={`w-full rounded-lg text-lg font-bold transition-colors bg-zinc-50 dark:bg-zinc-800 rounded-lg p-3 text-center`}>
            <div className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
              {isReverse ? 'ON' : 'OFF'}
            </div>
            <div className="text-xs text-zinc-500 dark:text-zinc-400">Reverse</div>
          </button>
          <button onClick={handleSimpleToggle} className={`w-full rounded-lg text-lg font-bold transition-colors bg-zinc-50 dark:bg-zinc-800 rounded-lg p-3 text-center`}>
            <div className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
              {isSimple ? 'ON' : 'OFF'}
            </div>
            <div className="text-xs text-zinc-500 dark:text-zinc-400">Simplified</div>
          </button>
        </div>
      </div>

      {isConnected && <>
          {}
          <div className="rounded-xl border border-zinc-200 bg-white p-6 dark:border-zinc-700 dark:bg-zinc-900">
            <h3 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100 mb-4">Playback Settings</h3>

            <div className="space-y-4">
              <div>
                <div className="flex justify-between items-center mb-2">
                  <label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Max Speed</label>
                  <span className="text-sm font-mono text-zinc-500 dark:text-zinc-400">{speed}</span>
                </div>
                <input type="range" min="0" max="100" value={speed} onChange={e => setSpeed(parseInt(e.target.value))} onMouseUp={e => handleSpeedChange(parseInt(e.target.value))} onTouchEnd={e => handleSpeedChange(parseInt(e.target.value))} className="w-full h-2 rounded-lg appearance-none cursor-pointer bg-zinc-200 dark:bg-zinc-700 accent-violet-500" />
              </div>
              <div>
                <div className="flex justify-between items-center mb-2">
                  <label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Max Stroke Length</label>
                  <span className="text-sm font-mono text-zinc-500 dark:text-zinc-400">{stroke}</span>
                </div>
                <input type="range" min="0" max="100" value={stroke} onChange={e => setStroke(parseInt(e.target.value))} onMouseUp={e => handleStrokeChange(parseInt(e.target.value))} onTouchEnd={e => handleStrokeChange(parseInt(e.target.value))} className="w-full h-2 rounded-lg appearance-none cursor-pointer bg-zinc-200 dark:bg-zinc-700 accent-violet-500" />
              </div>
              <div>
                <div className="flex justify-between items-center mb-2">
                  <label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Max Depth</label>
                  <span className="text-sm font-mono text-zinc-500 dark:text-zinc-400">{depth}</span>
                </div>
                <input type="range" min="0" max="100" value={depth} onChange={e => setDepth(parseInt(e.target.value))} onMouseUp={e => handleDepthChange(parseInt(e.target.value))} onTouchEnd={e => handleDepthChange(parseInt(e.target.value))} className="w-full h-2 rounded-lg appearance-none cursor-pointer bg-zinc-200 dark:bg-zinc-700 accent-violet-500" />
              </div>
              <div>
                <div className="flex justify-between items-center mb-2">
                  <label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Max Accelration</label>
                  <span className="text-sm font-mono text-zinc-500 dark:text-zinc-400">{sensation}</span>
                </div>
                <input type="range" min="0" max="100" value={sensation} onChange={e => setSensation(parseInt(e.target.value))} onMouseUp={e => handleSensationChange(parseInt(e.target.value))} onTouchEnd={e => handleSensationChange(parseInt(e.target.value))} className="w-full h-2 rounded-lg appearance-none cursor-pointer bg-zinc-200 dark:bg-zinc-700 accent-violet-500" />
              </div>
              <div>
                <div className="flex justify-between items-center mb-2">
                  <label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Device Buffer</label>
                  <span className="text-sm font-mono text-zinc-500 dark:text-zinc-400">{buffer}ms</span>
                </div>
                <input type="range" min="0" max="200" value={buffer} onChange={e => setBuffer(parseInt(e.target.value))} onMouseUp={e => handleBufferChange(parseInt(e.target.value))} onTouchEnd={e => handleBufferChange(parseInt(e.target.value))} className="w-full h-2 rounded-lg appearance-none cursor-pointer bg-zinc-200 dark:bg-zinc-700 accent-violet-500" />
              </div>
              <div>
                <div className="flex justify-between items-center mb-2">
                  <label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Time Offset</label>
                  <span className="text-sm font-mono text-zinc-500 dark:text-zinc-400">{timeOffset}ms</span>
                </div>
                <input type="range" min="-50" max="50" value={timeOffset} onChange={e => setTimeOffset(parseInt(e.target.value))} className="w-full h-2 rounded-lg appearance-none cursor-pointer bg-zinc-200 dark:bg-zinc-700 accent-violet-500" />
              </div>
            </div>
          </div>
        </>}
      {}
      <div className="rounded-xl border border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900 overflow-hidden">
        <button onClick={() => setLogsExpanded(!logsExpanded)} className="w-full flex items-center justify-between px-6 py-4 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors">
          <span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">
            Debug Logs ({logs.length})
          </span>
          <svg className={`h-4 w-4 text-zinc-500 transition-transform ${logsExpanded ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
          </svg>
        </button>

        {logsExpanded && <>
            <div ref={logsContainerRef} className="max-h-48 overflow-y-auto bg-zinc-900 p-3">
              {logs.length === 0 ? <p className="text-xs text-zinc-500 font-mono">No logs yet...</p> : <div className="space-y-0.5">
                  {logs.map(log => <div key={log.id} className="text-xs font-mono flex gap-2">
                      <span className="text-zinc-500 shrink-0">{log.timestamp}</span>
                      <span className={`shrink-0 font-semibold ${log.direction === 'TX' ? 'text-green-400' : log.direction === 'RX' ? 'text-blue-400' : log.direction === 'INFO' ? 'text-amber-400' : 'text-red-400'}`}>
                        {log.direction}
                      </span>
                      <span className="text-zinc-300 break-all">{log.data}</span>
                    </div>)}
                </div>}
            </div>

            {logs.length > 0 && <button onClick={() => setLogs([])} className="w-full px-3 py-2 text-xs text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 bg-zinc-50 dark:bg-zinc-800 border-t border-zinc-200 dark:border-zinc-700">
                Clear Logs
              </button>}
          </>}
      </div>
    </div>;
};

<Warning>
  **Experimental Feature**: The Funscript Player uses streaming mode, which is experimental and not recommended for general play. The protocol and behavior may change in future firmware updates. Use at your own risk and always test at low intensity first.
</Warning>

Sync your OSSM with funscript files for video-synchronized playback. Load a video and matching `.funscript` or `.csv` file, connect via Bluetooth, and enjoy automated motion control.

<OssmFunscriptPlayer />

## Requirements

* **OSSM firmware**: Version 3.0 or later (with BLE streaming support)
* **Compatible browser**: Chrome, Edge, or Opera on desktop/Android
* **Bluetooth**: Enabled on your device
* **Funscript file**: A `.funscript` or `.csv` file matching your video

<Note>
  Web Bluetooth is **not supported** on iOS devices (iPhone/iPad) or in Safari/Firefox browsers. For these platforms, consider using third-party funscript players with [Buttplug.io](https://buttplug.io) integration.
</Note>

## How to Use

<Steps>
  <Step title="Load your media files">
    Click the file pickers to select your video file and matching `.funscript` or `.csv` file. The funscript filename should match the video filename (e.g., `my-video.mp4` and `my-video.funscript`).
  </Step>

  <Step title="Connect to your OSSM">
    Click **Connect to OSSM** and select your device from the Bluetooth list. The OSSM will automatically enter streaming mode.
  </Step>

  <Step title="Play the video">
    Press play on the video player. Position commands are sent in real-time as the video plays, keeping your OSSM synchronized with the content.
  </Step>
</Steps>

## What is Funscript?

Funscript is a JSON-based file format used to synchronize toy movements with video content. Each funscript contains a series of position/time pairs that define where the device should move at specific moments during playback.

```json Example funscript structure theme={null}
{
  "version": "1.0",
  "actions": [
    { "pos": 0, "at": 1000 },
    { "pos": 99, "at": 2000 },
    { "pos": 0, "at": 3000 }
  ]
}
```

* **pos**: Position from 0 (fully extended) to 100 (fully retracted). Yes, this seems backwards, but it's how funscripts are implemented.
* **at**: Timestamp in milliseconds

<Info>
  Funscripts can be created using tools like [OpenFunscripter](https://github.com/OpenFunscripter/OpenFunscripter) or purchased from marketplaces like [RealSync](https://realsync.us/).
</Info>

## Alternative CSV format?

A CSV file can be used in place of a funscript. The player expects a pair of values. The time value first and the position value second. Each new line of the file defines where the device should move at specific moments during playback.

```csv Example CSV structure theme={null}
0,100
25294,100
27567,100
28167,50
29067,90
29667,40
31100,100
```

* Timestamp in milliseconds
* Position from 0 (fully extended) to 100 (fully retracted). Yes, this seems backwards, but it's how funscripts are implemented.

## Playback Settings

| Setting               | Description                                                                                                                                              |
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Max Speed**         | Set the max speed of your device. If a move is unable to be completed in the alotted time it will be shortened automatically at the expense of accuracy. |
| **Max Stroke Length** | Sets the length of the rail that will be used. If the depth is less then this value the entire depth will be used.                                       |
| **Max Depth**         | Sets the maximum depth to be used by the funscript.                                                                                                      |
| **Max Acceleration**  | Sets how quickly the OSSM is allowed to reach a particular speed. Reduce this value to limit jerkiness at the expense of accuracy on fast moves.         |
| **Device Buffer**     | Attempts to smooth out latency by adding a small amount of buffer to all commands. Automatically included in time offset to keep video in sync.          |
| **Time Offset**       | Adjust sync timing (-50ms to +50ms). Use positive values if the OSSM is behind the video, negative if ahead.                                             |

## Safety Features

* **Auto-stop on disconnect**: If Bluetooth connection is lost, the OSSM ramps down safely
* **Return to menu on disconnect**: When you disconnect, the OSSM returns to the main menu
* **Manual control**: You can pause the video at any time to stop motion
* **Max Speed and Acceleration**: These settings will slow down the motion to reduce intensity.
* **Dynamic Stroke Length**: OSSM will reduce stroke length as necessary if a move is beyond the capabilities of the machine or user limits, at the expense of positional accuracy.

<Warning>
  Always ensure you have easy access to pause the video or disconnect from the OSSM. Start with slower content to verify sync timing before using faster scripts.
</Warning>

## Troubleshooting

<AccordionGroup>
  <Accordion title="OSSM not responding to commands">
    * Ensure the OSSM shows "streaming" mode on its display after connecting
    * Check the debug logs for any error messages
    * Try disconnecting and reconnecting
    * Verify your OSSM firmware supports streaming mode (v3.0+)
  </Accordion>

  <Accordion title="Motion is out of sync with video">
    * Adjust the **Time Offset** slider to compensate for delays
    * Ensure your device isn't experiencing Bluetooth interference
  </Accordion>

  <Accordion title="Funscript file not loading">
    * Verify the file has a `.funscript` or `.json` extension
    * Check that the file contains valid JSON with an `actions` array
    * Look for parsing errors in the debug logs
  </Accordion>

  <Accordion title="Choppy or stuttering motion">
    * Increase buffer setting
    * Close other Bluetooth-connected devices to reduce interference
    * Stay within 10 meters of your OSSM
    * Try reducing video quality if your device is struggling
    * Use a funscript with fewer per stroke. Funscripts with commands only when the machine changes directions are smoothest.
  </Accordion>
</AccordionGroup>

## Technical Details

The funscript player uses the OSSM's native streaming protocol via BLE:

* **Command format**: `stream:position:time` where position is 0-100 and time is milliseconds to reach the target
* **Update rate**: Commands are sent every 2ms when new actions are due
* **BLE characteristic**: Uses the primary OSSM command characteristic (`522b443a-4f53-534d-1000-420badbabe69`)

<Info>
  Position streaming is experimental. The firmware calculates the required speed to reach each target position within the specified time, using maximum acceleration for responsive motion. If the BLE connection is lost, the OSSM automatically ramps down and stops over 2 seconds.
</Info>

<CardGroup cols={2}>
  <Card title="BLE Protocol Documentation" icon="bluetooth" href="/ossm/Software/communication/ble">
    Full streaming command reference and protocol details.
  </Card>

  <Card title="Operating Modes" icon="sliders" href="/ossm/Software/getting-started/operating-modes#streaming-mode-experimental">
    Learn about streaming mode and other operating modes.
  </Card>
</CardGroup>
