Skip to content Mux LogoToggle Mux Brand Popover BlogBlog Search CopiedCopyShareCopiedCopyShare Back to Blog HomeArrow Right SearchSearch Sign upSign up Get a demoGet a demoLog inLog in Published on May 13, 2025 (4 days ago)

有史以来最令人恼火的视频播放器

Dave Kiss By Dave Kiss • 8 min read • Engineering

那是 1995 年。 游戏厅是喧嚣和霓虹灯的大教堂。 我再次举起双手,难以置信; Area 51 突袭的不公平让我非常恼火。 我紧紧抓住一枚硬币,心跳加速,眼睛盯着“继续?”屏幕。 十秒。 九。 八。 世界缩小为一个问题:我能继续玩吗? 我应该吗? 妈妈应该什么时候来这里?

那种付费才能玩的机制,虽然超级令人沮丧,但也超级有吸引力,它成为了我在规划 Web Dev Challenge 时最新任务的主要灵感:使用 Media Chrome,我们的开源视频播放器工具包,来构建我能梦见的最糟糕的视频播放器。

继续阅读,看看我想出的播放器,它是如何构建的,以及你如何与我争夺有史以来最令人恼火的视频播放器的桂冠。

Link一个会反击的视频播放器

现代视频播放器通常感觉就像同一个灰色矩形。 播放、暂停、拖动、重复。 是的,我知道,这存在是有充分理由的:一致性、可访问性、实用性……但尽管如此,我还是想知道,我们还能想出什么? 网络是一个了不起的无限画布。 将我们的创作限制在同质化的范围内,真是太可惜了。

我访问过的一些我最喜欢的网站有一个共同点:它们都使用运动、交互、视频、3D – 艺术家工具,在网络上创造栩栩如生的体验和艺术。 自然地,Three.js 及其 react-three-fiber 对应物感觉是开始探索的完美场所。

当我开始这个项目时,我想知道如何在不真正使用我们习惯看到的任何传统按钮的情况下控制视频播放器。 我希望播放器更像一台可以与之交互的机器,而不是一个可以点击的表单。

我决定视频播放器的目标是捡起一枚硬币并将其投入投币口。 如果你放进去了,这枚硬币会给你正好三秒的视频播放时间。 不多也不少。

考虑到机器的机制,我首先创建了一个新的 Three.js 场景并构建了一个投币箱。

Link投币箱

投币箱是一个简单的立方体,上面切了一个投币口。 这个立方体成为了我的播放器交互的核心; 它是用 3D 建模的,具有真实的物理约束。 我使用了一个名为 three-bvh-csg 的库,使用 constructive solid geometry 切割立方体的几何形状,从而创建一个逼真、实用的投币口。 投币口内还隐藏着一些红色的点光源,散发出诱人的光芒。

一个复古风格的街机投币口机器显示“25¢ INSERT COIN TO PLAY”的红色发光文字。金币堆放在顶部,散落在底部周围,底部位于色彩缤纷的 90 年代图案地板上。 Mux 徽标刻在底部附近。Fullscreen 投币口 CopiedCopyCopyCopiedCopyCopy``` useEffect(() => { if (!boxRef.current || !faceplateRef.current) return; // Create main box brush const mainBox = new Brush(new BoxGeometry(0.4, 0.6, 0.4)); mainBox.updateMatrixWorld(); // Create slot hole brush const slotHole = new Brush(new BoxGeometry(0.05, 0.3, 0.4)); slotHole.position.x = -0.05; slotHole.updateMatrixWorld(); // Create evaluator and subtract hole from box const evaluator = new Evaluator(); const result = evaluator.evaluate(mainBox, slotHole, SUBTRACTION); // Update the box mesh geometry boxRef.current.geometry = result.geometry; // Create faceplate brush const faceplate = new Brush(new BoxGeometry(0.25, 0.40, 0.01)); faceplate.updateMatrixWorld(); // Subtract slot from faceplate const faceplateResult = evaluator.evaluate(faceplate, slotHole, SUBTRACTION); // Update the faceplate mesh geometry faceplateRef.current.geometry = faceplateResult.geometry; }, []);


这个投币箱还有很多其他的几何图形——一个红色的面板,“INSERT COIN TO PLAY”文本,一根从侧面伸出的红色电线管,一个磨损的、低绒头的街机地毯地面——但它们主要都是为了展示。

![一个发光的街机投币口的特写角度视图显示了红色照明的内部和粗体的白色文本“25¢ INSERT COIN TO PLAY”。 金币散落在底部,Mux 徽标巧妙地刻在插槽下方纹理的黑色表面上,所有这些都以充满活力的复古图案地板为背景。](https://cdn.sanity.io/images/2ejqxsnu/production/a7bea967abc5b3b772a5567f0ee9f1593a4d81d7-1084x1190.png?w=3840&q=75&fit=clip&auto=format)Fullscreen
虽然它 _看起来_ 像一个真正的投币口,但实际上这个盒子什么也没做。
在典型的 Three.js 场景中,对象默认情况下没有碰撞检测。 换句话说,它们是假的。 场景中的对象以 3D 渲染,但如果两个对象相交,它们会直接穿过彼此。 投币口可能看起来很令人信服,但如果没有物理特性,它就无法阻挡或接受任何东西。 你可以把一枚硬币扔向它,它会像鬼魂一样直接穿过。

为了解决这个问题,我引入了 [react-three-rapier physics library](https://www.mux.com/blog/<https:/github.com/pmndrs/react-three-rapier>) 来处理重力和碰撞。 这时我开始添加碰撞器:与视觉几何形状相匹配的物理边界。 重要部分是立方体碰撞器,它可以阻止硬币从错误的位置进入。 这些碰撞器经过精心布置,与视觉插槽对齐,形成了一个硬币可以穿过的真实路径。

![一个黑色街机投币机制的特写镜头显示了发光的插槽和粗体的文本“25¢ INSERT COIN TO PLAY”。 黄色线框轮廓表示与插槽的网格组件对齐的碰撞器,形成定义硬币必须通过的路径的物理边界——最终将它们引导至确认成功插入的内部传感器。](https://cdn.sanity.io/images/2ejqxsnu/production/732e527222fdd888af7ca0a1b53d8c3864fb1729-1152x1322.png?w=3840&q=75&fit=clip&auto=format)Fullscreen
投币箱
CopiedCopyCopyCopiedCopyCopy```
<group>{/* Right wall */}<CuboidCollider
    args={[0.12, 0.3, 0.2]}
    position={[0.08, 0.00, 0.01]}
    restitution={30}
  />{/* Left wall */}<CuboidCollider
    args={[0.079, 0.18, 0.2]}
    position={[-0.139, 0.00, 0.01]}
    restitution={30}
  />{/* Top wall */}<CuboidCollider
    args={[0.09, 0.05, 0.2]}
    position={[-0.11, 0.25, 0.01]}
    restitution={30}
  />{/* Bottom wall */}<CuboidCollider
    args={[0.09, 0.05, 0.2]}
    position={[-0.11, -0.25, 0.01]}
    restitution={30}
  />{/* Visual meshes */}<mesh
    ref={boxRef}
    receiveShadow
    castShadow
  ><meshPhysicalMaterial
      color="#444444"
      roughness={0.8}
      clearcoat={0.8}
      clearcoatRoughness={0.2}
      metalness={0.2}
      normalMap={normalMap}
    /></mesh>{/* Wire tube coming out of coin box */}<mesh position={[-0.15, -0.25, -0.2]}><tubeGeometry
      args={[
        new THREE.CatmullRomCurve3([
          new THREE.Vector3(0, 0, 0),
          new THREE.Vector3(0, 0, -0.1),
          new THREE.Vector3(-0.1, 0, -0.2),
          new THREE.Vector3(-0.3, 0, -0.2),
          new THREE.Vector3(-0.5, 0, -0.1),
          new THREE.Vector3(-0.7, 0, 0)
        ]),
        64, // tubular segments
        0.02, // radius
        8, // radial segments
        false // closed
      ]}
    /><meshStandardMaterial
      color="#ff0000"
      roughness={0.3}
      metalness={0.7}
    /></mesh>{/* Mux logo */}<mesh position={[0.12, -0.27, 0.201]}><planeGeometry args={[0.1, 0.032]} /><meshPhysicalMaterial
      color="#ffffff"
      roughness={0.1}
      metalness={1}
      opacity={0.6}
      transparent
      iridescence={1}
      iridescenceIOR={2}
      clearcoat={1}
      transmission={0.5}
      map={new THREE.TextureLoader().load('/mux-logo.png')}
      emissive="#00ffff"
      emissiveIntensity={2}
    ></meshPhysicalMaterial></mesh>{/* Red faceplate */}<mesh
    ref={faceplateRef}
    position={[0, 0, 0.21]}
    receiveShadow
    castShadow
  ><MeshTransmissionMaterial
      color="#831313"
      background={new THREE.Color("#690F0F")}
      samples={10}
      thickness={0.1}
      transmission={1}
      roughness={0}
      resolution={2048}
      clearcoat={1}
      attenuationColor={"#fff"}
    /></mesh>{/* Multiple red glow lights behind faceplate */}<pointLight
    position={[-0.05, 0, 0.1]}
    color="#ff0000"
    intensity={lightIntensity}
    distance={0.3}
    decay={2}
  /><pointLight
    position={[0.05, 0.1, 0.1]}
    color="#ff0000"
    intensity={lightIntensity * 0.7}
    distance={0.25}
    decay={2}
  /><pointLight
    position={[0.05, -0.1, 0.1]}
    color="#ff0000"
    intensity={lightIntensity * 0.7}
    distance={0.25}
    decay={2}
  /><Text
    position={[0.04, 0.10, 0.22]}
    fontSize={0.075}
    fontWeight="bold"
    color="white"
    textAlign="center"
    anchorY="middle"
  >
    25
  </Text><Text
    position={[0.089, 0.083, 0.22]}
    fontSize={0.035}
    fontWeight="bold"
    color="white"
    textAlign="center"
    anchorY="middle"
  >
    ¢
  </Text><Text
    position={[0.04, -0.02, 0.22]}
    fontSize={0.025}
    color="white"
    fontWeight="bold"
    textAlign="center"
    anchorY="middle"
  >
    INSERT
  </Text><Text
    position={[0.04, -0.05, 0.22]}
    fontSize={0.022}
    fontWeight="bold"
    color="white"
    textAlign="center"
    anchorY="middle"
  >
    COIN TO
  </Text><Text
    position={[0.04, -0.082, 0.22]}
    fontSize={0.04}
    color="white"
    textAlign="center"
    fontWeight="bold"
    anchorY="middle"
  >
    PLAY
  </Text></group>

Link硬币

当然,没有硬币,投币箱就没用了。 让我们改变一下。

每个硬币都将使用其自身的视觉网格以及 CylinderCollider 对应物进行建模,使其成为场景中的“真实”对象——掉落一枚硬币,它会做出真实的反应,从边缘弹开,在地板上滚动,挑战你的精准度。

硬币 CopiedCopyCopyCopiedCopyCopy``` function Coin({ position }: { position: [number, number, number] }) { const rigidBody = useRef(null); const visualRef = useRef(null); return ( <RigidBody ref={rigidBody} position={position} userData={{ type: 'coin' }} colliders={false} ><CylinderCollider args={[0.01, 0.1]} friction={1} restitution={0.1} frictionCombineRule={3} // Use maximum friction when coins touch restitutionCombineRule={1} // Use minimum bounciness when coins touch /><cylinderGeometry args={[0.10, 0.10, 0.02, 16]} /> ); }


硬币的圆柱形碰撞器有点棘手。 我希望它们自然地相互作用——相互弹跳、堆叠和真实地滑动——但微调它们的摩擦力和恢复系数(弹性)被证明具有挑战性。 硬币经常摇晃,不断地争论如何对它们的物理环境做出反应,而不是平静地稳定下来。 最初,我认为这是一个错误,但我很快就将其视为一个特性。 摇晃的硬币生动地传达了场景是活跃的,鼓励你玩它。 这绝对与我不知道自己在做什么无关。 不。

对于拖放功能,我使用了 [@use-gesture/react](https://www.mux.com/blog/<https:/use-gesture.netlify.app/>),这是 [Poimandres](https://www.mux.com/blog/<https:/pmnd.rs/>) 多产团队的另一个交互式 JS 库。 根据我的规则集,一次只能拖动一个硬币,以确保玩家不能通过同时尝试多个硬币来作弊。 错误地投掷硬币? 太糟糕了,再试一次。

硬币拖动
CopiedCopyCopyCopiedCopyCopy```
const [isGrabbed, setIsGrabbed] = useState(false);
useFrame(({ pointer, camera, raycaster }) => {
  if (isGrabbed && rigidBody.current) {
    const coinPhysics = rigidBody.current;
    // Cast ray from pointer to get world position
    raycaster.setFromCamera(pointer, camera);
    // Use a vertical plane that faces the camera
    const intersectPlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 1);
    const targetPoint = new THREE.Vector3();
    raycaster.ray.intersectPlane(intersectPlane, targetPoint);
    const targetPos = {
      x: targetPoint.x,
      y: Math.max(targetPoint.y, 0.1),
      z: Math.max(targetPoint.z, -0.6)
    };
    coinPhysics.setNextKinematicTranslation(targetPos);
    const targetRotation = new Euler(Math.PI / 2, 0, Math.PI / 2);
    const targetQuat = new Quaternion().setFromEuler(targetRotation);
    const currentQuat = new Quaternion();
    const rot = coinPhysics.rotation();
    const quat = new Quaternion(rot.x, rot.y, rot.z, rot.w);
    currentQuat.setFromEuler(new Euler().setFromQuaternion(quat));
    currentQuat.slerp(targetQuat, 0.3);
    coinPhysics.setNextKinematicRotation(currentQuat);
  }
});
const bind = useDrag(({ down }) => {
  if (!rigidBody.current) return;
  const coinPhysics = rigidBody.current;
  if (down) {
    // Only allow grabbing if no other coin is being dragged
    if (!isAnyCoinBeingDragged || isGrabbed) {
      isAnyCoinBeingDragged = true;
      setIsGrabbed(true);
      document.body.style.cursor = 'grabbing';
      coinPhysics.setBodyType(2, true);
    }
  } else {
    if (isGrabbed) {
      isAnyCoinBeingDragged = false;
      setIsGrabbed(false);
      document.body.style.cursor = 'auto';
      coinPhysics.setBodyType(0, true);
      coinPhysics.applyImpulse({ x: 0, y: 0, z: -0.005 }, true);
    }
  }
});
return (
  <RigidBody
    ref={rigidBody}
    position={position}
    userData={{ type: 'coin' }}
    colliders={false}
    {...bind()}
  >{/*...*/}
)

Link传感器

正确插入一枚硬币,插槽闪烁,灯光跳舞,并且令人满意的声音确认你的成功。 错失机会,严厉的“game over”声音伴随着重置视频,将你送回起点,就像街机一样。

硬币通过位于可见插槽后方的不可见碰撞检测框来接受。 当一枚硬币通过这个阈值时,一个事件会触发视频恢复播放,并且计时器重置。

一个 3D 场景的广角视图显示了一个复古图案的地板、一个被金币包围的黑色投币机制,以及一个指向透明黄色框的大橙色箭头。 该框表示位于投币口后面的传感器碰撞器; 它检测硬币何时已完全滚过插槽以确认进入。Fullscreen 传感器 CopiedCopyCopyCopiedCopyCopy``` <CuboidCollider args={[0.2, 0.4, 0.2]} position={[-0.05, .1, -1.5]} sensor onIntersectionEnter={debounce(handleCoinInserted, 200)} /> // On sensor collision const handleCoinInserted = () => { onCoinInserted(); setIsFlickering(true); // Play coin sound const coinSound = new Audio('/coin.m4a'); coinSound.play(); // Reset flicker after 500ms setTimeout(() => { setIsFlickering(false); }, 500); }; // Animate the light flicker useFrame(() => { if (isFlickering) { setLightIntensity(Math.random() * 5 + 1); // Random intensity between 1 and 6 } else { setLightIntensity(2); // Default intensity } }); // ... later, update the light intensity {/* Multiple red glow lights behind faceplate */} <pointLight position={[-0.05, 0, 0.1]} color="#ff0000" intensity={lightIntensity} distance={0.3} decay={2} /> <pointLight position={[0.05, 0.1, 0.1]} color="#ff0000" intensity={lightIntensity * 0.7} distance={0.25} decay={2} /> <pointLight position={[0.05, -0.1, 0.1]} color="#ff0000" intensity={lightIntensity * 0.7} distance={0.25} decay={2} />


## [Link计时器](https://www.mux.com/blog/<#the-timer>)

计时器始终可见,始终具有威胁性,不断提醒你时间不多了(哇……深刻)。 当你快用完时,栏会呈红色闪烁。 压力是真实的。 如果倒计时达到零,视频会戛然而止。 可怕的“_CONTINUE?_”在屏幕上闪烁,重新浮现出街机的焦虑感。

计时器规则
CopiedCopyCopyCopiedCopyCopy```
const [timeRemaining, setTimeRemaining] = useState(0);
 const [maxTime, setMaxTime] = useState(0);
 const [isPlaying, setIsPlaying] = useState(false);
 const [continueCountdown, setContinueCountdown] = useState(0);
 const CONTINUE_TIME = 8; // 8 seconds to continue
 const WARNING_THRESHOLD = 3; // Start warning animation when 3 seconds or less remain
 const TIME_PER_COIN = 3; // 3 seconds per coin
 const handleCoinInserted = () => {
  const newTime = timeRemaining + TIME_PER_COIN; // Add seconds for each coin
  setTimeRemaining(newTime);
  setMaxTime(newTime); // Update max time when coins are added
 };

计时器 CopiedCopyCopyCopiedCopyCopy``` { timeRemaining > 0 && ( TIME<div className={h-full transition-all duration-1000 ease-linear ${timeRemaining <= WARNING_THRESHOLD ? 'animate-pulse' : ''}} style={{ width: ${Math.max(((timeRemaining - 1) / (maxTime - 1)) * 100, 0)}%, background: timeRemaining <= WARNING_THRESHOLD ? 'linear-gradient(90deg, #FF0000 0%, #FF6B00 100%)' : 'linear-gradient(90deg, #39FF14 0%, #00FF94 100%)', boxShadow: timeRemaining <= WARNING_THRESHOLD ? '0 0 10px rgba(255, 0, 0, 0.5)' : '0 0 10px rgba(57, 255, 20, 0.3)' }} /><div className={text-2xl font-vcr ml-4 ${timeRemaining <= WARNING_THRESHOLD ? 'text-red-500 animate-pulse' : 'text-[#39FF14]'}}>{timeRemaining} ) }


未能及时插入硬币的后果是毁灭性的。 你的视频进度丢失了,视频被重置,准备再次从绝对的开头开始播放。

重置
CopiedCopyCopyCopiedCopyCopy```
const videoRef = useRef<HTMLVideoElement>(null);
 useEffect(() => {
  if (videoRef.current) {
   if (isPlaying) {
    videoRef.current.play();
   } else {
    videoRef.current.pause();
    if (continueCountdown === 0 && timeRemaining === 0) {
     const gameOverSound = new Audio('/game-over.m4a');
     gameOverSound.play();
     videoRef.current.currentTime = 0; // Reset video to beginning when continue countdown ends
    }
   }
  }
 }, [isPlaying, continueCountdown, timeRemaining]);

这个播放器是无情的。 它奖励专注,惩罚分心,并且拒绝道歉。 它绝对不是性能最高或最实用的,但它 非常有趣。 亲自试用 https://worst.player.style 或浏览整个代码库 https://github.com/muxinc/worst.player.style – 并准备好玩。

Link付费游戏

在构建这个视频播放器时发生了一些奇怪的事情。 当我花更多的时间将硬币扔过投币口,看着它们翻滚并消失在虚无中,将投币口塞得太满以至于卡住,并疯狂地与时间赛跑以延长我的游戏时间时,我意识到发生了一些意想不到的事情。 它把我带到了某个地方。 我真的在观看——或者我在玩?——视频时玩得很开心。

是插入硬币的触觉机制吗? 在我制作的这个游戏化世界中赢得更多宝贵时间的刺激感? 还是怀旧,回忆起我童年时期投入街机游戏机的无数硬币?

无论是什么原因,有一件事是肯定的:我非常喜欢构建这个播放器。 我开始的目标是创建你能想象到的最糟糕的视频播放器,但在构建过程中,我可能构建了我最喜欢的播放器。 如果我的口袋里再多一些硬币就好了。

Link轮到你了:你能想到更糟糕的播放器 UX 吗?

让我们不要止步于我想出的东西。 我想看到你最糟糕的作品(并且有一些奖品可以颁发给你的提交作品 👀)

构建你自己的憎恶之物,提交它,你可能会赢得真正的街机代币、定制的 swag,或者只是让某人对着他们的屏幕大喊大叫的满足感。 在 https://worst.player.style 获取完整详细信息。 唯一的规则:没有无聊的视频播放器。

Written By

Dave Kiss

Dave Kiss – Senior Community Engineering Lead

Was: solo-developreneur. Now: developer community person. Happy to ride a bike, hike a hike, high-five a hand, and listen to spa music.

Leave your wallet where it is

No credit card required to get started. Sign upSign up

Read more like this

The llms.txt logo; a cursor with four petals around itPublished on April 8, 2025 • By Darius CepulisWe want your LLM to read our docsDave in a retro style video playerPublished on April 2, 2025 • By Dave KissHow to build a Windows 98-style video player in 2025Cats with Bats developer spotlightPublished on March 6, 2025 • By Dave and HaiDeveloper spotlight: Cats with Bats reimagines how developers learn with AISee all engineering postsArrow Right

Check out our newsletter

A monthly-ish digest of all the best new blog posts and features First NameEmailSign me upSign me up

Platform

Mux Video

* [Video API](https://www.mux.com/blog/</video-api>)
* [Features](https://www.mux.com/blog/</features>)
* [On-Demand](https://www.mux.com/blog/</on-demand>)
* [Live](https://www.mux.com/blog/</live>)
* [Interactive](https://www.mux.com/blog/</interactive-live-streaming>)
* [Encoding](https://www.mux.com/blog/</encoding>)
* [Pricing](https://www.mux.com/blog/</pricing/video>)

Mux Data

* [Overview](https://www.mux.com/blog/</data>)
* [Features](https://www.mux.com/blog/</data/features>)
* [Monitoring](https://www.mux.com/blog/</data-operations>)
* [Pricing](https://www.mux.com/blog/</pricing/data>)

Mux Player

* [Overview](https://www.mux.com/blog/</player>)

Developers

Resources