source_utils_trickplay.bs

namespace trickplay

  ' Calculates which thumbnail index corresponds to a given video position
  ' @param {Float} position - Current video position in seconds
  ' @param {Integer} interval - Milliseconds between thumbnails
  ' @return {Integer} - Zero-based thumbnail index
  function calculateThumbnailIndex(position as float, interval as integer) as integer
    if interval <= 0 then return 0
    ' Convert position from seconds to milliseconds and divide by interval
    return int((position * 1000) / interval)
  end function

  ' Calculates which tile contains a given thumbnail index
  ' @param {Integer} thumbnailIndex - Zero-based thumbnail index
  ' @param {Integer} tileWidth - Number of thumbnails per row in tile
  ' @param {Integer} tileHeight - Number of thumbnail rows in tile
  ' @return {Integer} - Zero-based tile index
  function calculateTileIndex(thumbnailIndex as integer, tileWidth as integer, tileHeight as integer) as integer
    if tileWidth <= 0 or tileHeight <= 0 then return 0
    thumbsPerTile = tileWidth * tileHeight
    if thumbsPerTile <= 0 then return 0
    return int(thumbnailIndex / thumbsPerTile)
  end function

  ' Calculates the clipping rectangle within a tile for a specific thumbnail
  ' @param {Integer} thumbnailIndex - Zero-based thumbnail index
  ' @param {Integer} tileWidth - Number of thumbnails per row in tile
  ' @param {Integer} tileHeight - Number of thumbnail rows in tile
  ' @param {Integer} thumbWidth - Width of individual thumbnail in pixels
  ' @param {Integer} thumbHeight - Height of individual thumbnail in pixels
  ' @return {Object} - {x, y, width, height} clipping rectangle
  function calculateClippingRect(thumbnailIndex as integer, tileWidth as integer, tileHeight as integer, thumbWidth as integer, thumbHeight as integer) as object
    if tileWidth <= 0 or tileHeight <= 0
      return { x: 0, y: 0, width: thumbWidth, height: thumbHeight }
    end if

    thumbsPerTile = tileWidth * tileHeight
    if thumbsPerTile <= 0
      return { x: 0, y: 0, width: thumbWidth, height: thumbHeight }
    end if

    ' Calculate position within tile
    positionInTile = thumbnailIndex mod thumbsPerTile
    row = int(positionInTile / tileWidth)
    col = positionInTile mod tileWidth

    return {
      x: col * thumbWidth,
      y: row * thumbHeight,
      width: thumbWidth,
      height: thumbHeight
    }
  end function

  ' Builds the Jellyfin API URL for a trickplay tile
  ' @param {String} videoID - Jellyfin video item ID
  ' @param {Integer} width - Resolution width of trickplay images
  ' @param {Integer} tileIndex - Zero-based tile index
  ' @return {String} - API endpoint path
  function buildTrickplayUrl(videoID as string, width as integer, tileIndex as integer) as string
    return "Videos/" + videoID + "/Trickplay/" + width.ToStr() + "/" + tileIndex.ToStr() + ".jpg"
  end function

  ' Builds the cachefs:/ path for storing a trickplay tile
  ' @param {String} videoID - Jellyfin video item ID
  ' @param {Integer} width - Resolution width of trickplay images
  ' @param {Integer} tileIndex - Zero-based tile index
  ' @return {String} - cachefs:/ file path
  function buildTrickplayCachePath(videoID as string, width as integer, tileIndex as integer) as string
    return "cachefs:/trickplay_" + videoID + "_" + width.ToStr() + "_" + tileIndex.ToStr() + ".jpg"
  end function

  ' Selects the best trickplay resolution based on device capabilities
  ' SD (720p) uses smallest available, FHD (1920p) uses largest, scales between
  ' @param {Object} trickplayData - Trickplay data object with width keys (e.g., {"320": {...}, "640": {...}})
  ' @param {Integer} deviceWidth - Device max video width (e.g., 1920 for FHD, 720 for SD)
  ' @return {String} - Best width key to use, or invalid if no data available
  function selectBestResolution(trickplayData as object, deviceWidth as integer) as dynamic
    if not isValid(trickplayData) or trickplayData.count() = 0
      return invalid
    end if

    ' Collect all available widths and sort ascending
    availableWidths = []
    for each widthKey in trickplayData
      width = val(widthKey)
      if width > 0
        availableWidths.push(width)
      end if
    end for

    if availableWidths.count() = 0 then return invalid

    ' Sort widths ascending
    for i = 0 to availableWidths.count() - 2
      for j = i + 1 to availableWidths.count() - 1
        if availableWidths[j] < availableWidths[i]
          temp = availableWidths[i]
          availableWidths[i] = availableWidths[j]
          availableWidths[j] = temp
        end if
      end for
    end for

    ' Select resolution based on device width:
    ' <= 720 (SD): smallest available
    ' >= 1920 (FHD): largest available
    ' Between: scale proportionally
    if deviceWidth <= 720
      ' SD - use smallest
      return availableWidths[0].ToStr()
    else if deviceWidth >= 1920
      ' FHD or higher - use largest
      return availableWidths[availableWidths.count() - 1].ToStr()
    else
      ' HD (between SD and FHD) - pick middle option if available
      middleIndex = int(availableWidths.count() / 2)
      return availableWidths[middleIndex].ToStr()
    end if
  end function

  ' Converts a thumbnail index to video position in seconds
  ' @param {Integer} index - Zero-based thumbnail index
  ' @param {Integer} interval - Milliseconds between thumbnails
  ' @return {Float} - Video position in seconds
  function thumbnailIndexToPosition(index as integer, interval as integer) as float
    if interval <= 0 then return 0.0
    ' Calculate time in milliseconds (index * interval), then convert to seconds
    return (index * interval) / 1000.0
  end function

  ' buildConfigFromMetadata: Extracts and validates trickplay config from item metadata
  '
  ' Provides fail-fast validation at config creation time rather than at usage time.
  ' Reusable across all content types (movies, episodes, live TV, recordings, etc.)
  '
  ' @param {object} itemMeta - Item metadata from ItemMetaData()
  ' @param {string} videoID - Video item ID
  ' @param {integer} deviceWidth - Device max video width for resolution selection
  ' @param {integer} initialPosition - Starting playback position in seconds
  ' @return {dynamic} - Valid config object or invalid if trickplay unavailable/invalid
  function buildConfigFromMetadata(itemMeta as object, videoID as string, deviceWidth as integer, initialPosition as integer) as dynamic
    if not isValid(itemMeta) then return invalid
    if not isValidAndNotEmpty(itemMeta.Trickplay) then return invalid

    ' Get first video ID key from Trickplay data
    trickplayData = invalid
    for each videoKey in itemMeta.Trickplay
      trickplayData = itemMeta.Trickplay[videoKey]
      exit for
    end for

    if not isValid(trickplayData) then return invalid

    ' Select best resolution based on device
    bestWidthKey = selectBestResolution(trickplayData, deviceWidth)
    if not isValid(bestWidthKey) then return invalid
    if not trickplayData.doesExist(bestWidthKey) then return invalid

    widthConfig = trickplayData[bestWidthKey]

    ' Build config object
    config = {
      videoID: videoID,
      width: int(val(bestWidthKey)),
      height: widthConfig.Height,
      interval: widthConfig.Interval,
      thumbnailCount: widthConfig.ThumbnailCount,
      tileWidth: widthConfig.TileWidth,
      tileHeight: widthConfig.TileHeight,
      initialPosition: initialPosition
    }

    ' Validate config before returning
    if not validateConfig(config)
      return invalid
    end if

    return config
  end function

  ' validateConfig: Validates trickplay configuration object
  '
  ' Ensures all required fields exist and have valid values.
  ' Numeric fields must be positive.
  '
  ' @param {object} config - Config object to validate
  ' @return {boolean} - True if valid, false otherwise
  function validateConfig(config as object) as boolean
    if not isValid(config) then return false

    requiredFields = ["videoID", "width", "height", "interval", "thumbnailCount", "tileWidth", "tileHeight"]

    for each field in requiredFields
      if not config.doesExist(field) then return false
      if not isValid(config[field]) then return false

      ' Validate numeric fields are positive
      if field <> "videoID"
        if config[field] <= 0 then return false
      end if
    end for

    return true
  end function

end namespace