use-timeline

PreviousNext
Docs
limeplayhook

Preview

Loading preview…
registry/default/hooks/use-timeline.ts
"use client"

import type shaka from "shaka-player"
import type { StateCreator } from "zustand"

import clamp from "lodash.clamp"
import React, { useCallback } from "react"

import { useInterval } from "@/registry/default/hooks/use-interval"
import {
  MediaReadyState,
  type PlayerStore,
} from "@/registry/default/hooks/use-player"
import { noop, off, on, toFixedNumber } from "@/registry/default/lib/utils"
import {
  useGetStore,
  useMediaStore,
} from "@/registry/default/ui/media-provider"

export interface TimelineStore {
  buffered: shaka.extern.BufferedRange[]
  currentTime: number
  duration: number
  hoveringTime: number
  isHovering: boolean
  isLive: boolean
  liveLatency: null | number
  progress: number
}

export const createTimelineStore: StateCreator<
  PlayerStore & TimelineStore,
  [],
  [],
  TimelineStore
> = () => ({
  buffered: [],
  currentTime: 0,
  duration: 0,
  hoveringTime: 0,
  isHovering: false,
  isLive: false,
  liveLatency: null,
  progress: 0,
})

export interface useTimelineStatesProps {
  /**
   * Interval in milliseconds to update the states
   * @default 500
   */
  updateDuration?: number
}

export function useTimeline() {
  const store = useGetStore()
  const mediaRef = useMediaStore((state) => state.mediaRef)
  const duration = useMediaStore((state) => state.duration)
  const isLive = useMediaStore((state) => state.isLive)
  const player = useMediaStore((state) => state.player)

  const getTimeFromEvent = useCallback(
    (event: React.PointerEvent) => {
      const rect = event.currentTarget.getBoundingClientRect()
      const percentage = (event.clientX - rect.left) / rect.width
      const clampedPercentage = Math.max(0, Math.min(1, percentage))
      return duration ? clampedPercentage * duration : 0
    },
    [duration]
  )

  const seek = useCallback(
    (time: number) => {
      if (!mediaRef.current || !Number.isFinite(duration)) return

      const media = mediaRef.current

      let actualSeekTime = time
      let storeCurrentTime = time

      if (isLive && player) {
        const seekRange = player.seekRange()
        actualSeekTime = clamp(time, seekRange.start, seekRange.end)
        storeCurrentTime = actualSeekTime - seekRange.start
      } else {
        actualSeekTime = clamp(time, 0, duration)
        storeCurrentTime = actualSeekTime
      }

      store.setState({
        currentTime: storeCurrentTime,
        progress: storeCurrentTime / duration,
      })

      media.currentTime = actualSeekTime
    },
    [mediaRef, duration, isLive, player, store]
  )

  const setHoveringTime = useCallback(
    (time: number) => {
      if (!Number.isFinite(store.getState().duration)) return

      store.setState({
        hoveringTime: time,
      })
    },
    [store]
  )

  function setIsHovering(isHovering: boolean) {
    store.setState({ isHovering })
  }

  const processBufferedRanges = useCallback(
    (
      bufferedRanges: shaka.extern.BufferedRange[],
      variant: "combined" | "default" | "from-zero" = "default"
    ): Array<{ startPercent: number; widthPercent: number }> => {
      if (!bufferedRanges.length || !duration) {
        return []
      }

      let normalizedBuffered: shaka.extern.BufferedRange[] = []

      if (variant === "combined") {
        const combinedBuffered = bufferedRanges.reduce(
          (acc, range) => {
            acc.start = Math.min(acc.start, range.start)
            acc.end = Math.max(acc.end, range.end)
            return acc
          },
          { end: 0, start: Infinity }
        )

        if (combinedBuffered.start !== Infinity) {
          normalizedBuffered = [
            {
              end: combinedBuffered.end,
              start: combinedBuffered.start,
            },
          ]
        }
      } else if (variant === "from-zero") {
        normalizedBuffered = bufferedRanges.map((range) => ({
          end: range.end,
          start: 0,
        }))
      } else {
        normalizedBuffered = bufferedRanges
      }

      if (!normalizedBuffered.length) {
        return []
      }

      return normalizedBuffered.map((range) => {
        let startPercent: number
        let widthPercent: number

        if (isLive && player) {
          const seekRange = player.seekRange()
          const relativeStart = Math.max(0, range.start - seekRange.start)
          const relativeEnd = Math.max(0, range.end - seekRange.start)

          startPercent = (relativeStart / duration) * 100
          widthPercent = ((relativeEnd - relativeStart) / duration) * 100
        } else {
          startPercent = (range.start / duration) * 100
          widthPercent = ((range.end - range.start) / duration) * 100
        }

        return { startPercent, widthPercent }
      })
    },
    [duration, isLive, player]
  )

  return {
    getTimeFromEvent,
    processBufferedRanges,
    seek,
    setHoveringTime,
    setIsHovering,
  }
}

export function useTimelineStates({
  updateDuration = 500,
}: useTimelineStatesProps = {}) {
  const store = useGetStore()
  const player = useMediaStore((s) => s.player)
  const mediaRef = useMediaStore((state) => state.mediaRef)
  const canPlay = useMediaStore((state) => state.canPlay)
  const readyState = useMediaStore((state) => state.readyState)

  const isLive = player?.isLive() ?? false

  const onTimeUpdate = () => {
    if (!mediaRef.current || !player) return

    if (readyState < MediaReadyState.HAVE_METADATA) return

    let currentTime = mediaRef.current.currentTime
    let liveLatency = isLive ? 0 : null
    let progress = 0

    if (isLive) {
      const seekRange = player.seekRange()
      liveLatency =
        mediaRef.current.currentTime === 0
          ? 0
          : seekRange.end - mediaRef.current.currentTime

      liveLatency = toFixedNumber(clamp(liveLatency, 0, seekRange.end), 4)

      progress =
        1 -
        (seekRange.end - mediaRef.current.currentTime) /
          (seekRange.end - seekRange.start)

      progress = toFixedNumber(clamp(progress, 0, 1), 4)
    } else {
      currentTime = clamp(
        mediaRef.current.currentTime,
        0,
        store.getState().duration
      )
      progress = toFixedNumber(currentTime / store.getState().duration, 4)
    }

    store.setState({
      currentTime,
      isLive: isLive,
      liveLatency,
      progress,
      ...(isLive && {
        duration: player.seekRange().end - player.seekRange().start,
      }),
    })
  }

  const onDurationChange = React.useCallback(() => {
    if (!mediaRef.current || !player) return

    const seekRange = player.seekRange()
    const playerDuration = player.isLive()
      ? seekRange.end - seekRange.start
      : mediaRef.current.duration

    if (playerDuration && Number.isFinite(playerDuration)) {
      store.setState({ duration: playerDuration })
    }
  }, [store, mediaRef, player])

  const onBuffer = React.useCallback(() => {
    if (!player) return

    const bufferedInfo = player.getBufferedInfo()

    if (player.isBuffering()) {
      return
    }

    store.setState({ buffered: bufferedInfo.total })
  }, [store, player])

  useInterval(onTimeUpdate, updateDuration)

  React.useEffect(() => {
    if (!mediaRef.current || !player) return noop

    const media = mediaRef.current

    if (canPlay) {
      onTimeUpdate()
      onDurationChange()
      onBuffer()
    }

    on(media, ["durationchange", "loading"], onDurationChange)
    on(media, "progress", onBuffer)
    on(player, ["trackschanged", "loading"], onBuffer)

    return () => {
      off(media, ["durationchange", "loading"], onDurationChange)
      off(media, "progress", onBuffer)
      off(player, ["trackschanged", "loading"], onBuffer)
    }
  }, [mediaRef, player, canPlay])
}

Installation

npx shadcn@latest add @limeplay/use-timeline

Usage

import { UseTimeline } from "@/hooks/use-timeline"
const value = UseTimeline()