components_video_TrickplayTileLoader.bs
import "pkg:/source/api/baserequest.bs"
import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/trickplay.bs"
' Task node for downloading trickplay tiles
' No eviction during scrubbing - user should always see preview images
' cachefs:/ has its own size limits that Roku manages automatically
sub init()
m.top.functionName = "loadTiles"
m.log = new log.Logger("TrickplayTileLoader")
' Download time tracking for adaptive preloading
m.downloadTimes = [] ' Recent download times in seconds
m.maxTrackedDownloads = 10 ' Keep last N download times for average
end sub
' Main task function - runs in background thread
' Downloads and caches requested trickplay tiles
sub loadTiles()
m.log.info("Loading tiles", m.top.videoID, "width", m.top.width, "count", m.top.tileIndexes.count())
if not isValid(m.top.tileIndexes) or m.top.tileIndexes.count() = 0
m.log.warn("No tiles requested")
m.top.status = "no_tiles_requested"
return
end if
loadedTiles = {}
failedTiles = []
for each tileIndex in m.top.tileIndexes
cachePath = trickplay.buildTrickplayCachePath(m.top.videoID, m.top.width, tileIndex)
' Check if tile already exists in cache
fs = CreateObject("roFileSystem")
if fs.Exists(cachePath)
m.log.debug("Cache hit for tile", tileIndex)
loadedTiles[tileIndex.ToStr()] = cachePath
else
' Download tile
m.log.debug("Cache miss, downloading tile", tileIndex)
success = downloadTile(tileIndex, cachePath)
if success
loadedTiles[tileIndex.ToStr()] = cachePath
else
m.log.warn("Failed to download tile", tileIndex)
failedTiles.push(tileIndex)
end if
end if
end for
' Update output fields
m.top.loadedTiles = loadedTiles
m.top.failedTiles = failedTiles
m.top.avgDownloadTime = getAverageDownloadTime()
m.top.status = "complete"
m.log.info("Tiles loaded", "success", loadedTiles.count(), "failed", failedTiles.count(), "avgTime", m.top.avgDownloadTime)
end sub
' Downloads a single tile from the Jellyfin API
' Tracks download time for adaptive preloading calculations
' @param {Integer} tileIndex - Zero-based tile index
' @param {String} cachePath - cachefs:/ path to save the tile
' @return {Boolean} - True if download succeeded
function downloadTile(tileIndex as integer, cachePath as string) as boolean
' Track download start time
startTime = createObject("roDateTime")
startMs = startTime.asSeconds() * 1000 + startTime.getMilliseconds()
' Build API URL for this tile
apiPath = trickplay.buildTrickplayUrl(m.top.videoID, m.top.width, tileIndex)
' Create API request
req = APIRequest(apiPath, {})
if not isValid(req)
m.log.error("Failed to create API request for tile", tileIndex)
return false
end if
' Download image directly to file using AsyncGetToFile
' This is the correct way to download binary data (images) on Roku
' Using GetToString + WriteAsciiFile corrupts binary data
port = CreateObject("roMessagePort")
req.setMessagePort(port)
' AsyncGetToFile downloads directly to the specified path
if not req.AsyncGetToFile(cachePath)
m.log.error("Failed to start async download for tile", tileIndex)
' Clean up any partial file if the download fails to start
fs = CreateObject("roFileSystem")
if fs.Exists(cachePath) then fs.Delete(cachePath)
return false
end if
' Wait for response
resp = wait(30000, port)
' Check for timeout (resp = invalid)
if resp = invalid
m.log.error("Request timeout for tile", tileIndex)
' Clean up partial file that may have been written before timeout
fs = CreateObject("roFileSystem")
if fs.Exists(cachePath) then fs.Delete(cachePath)
return false
end if
' Check for invalid response type
if type(resp) <> "roUrlEvent"
m.log.error("Invalid response type for tile", tileIndex, "type", type(resp))
return false
end if
if resp.GetResponseCode() <> 200
m.log.error("HTTP error for tile", tileIndex, "status", resp.GetResponseCode())
' Clean up partial file if download failed
fs = CreateObject("roFileSystem")
if fs.Exists(cachePath) then fs.Delete(cachePath)
return false
end if
' Verify file exists and has content
fs = CreateObject("roFileSystem")
if not fs.Exists(cachePath)
m.log.error("File not found after download", cachePath)
return false
end if
stat = fs.Stat(cachePath)
if not isValid(stat) or stat.size = 0
m.log.error("Downloaded file is empty", tileIndex, cachePath)
fs.Delete(cachePath)
return false
end if
' Track download time
endTime = createObject("roDateTime")
endMs = endTime.asSeconds() * 1000 + endTime.getMilliseconds()
downloadTimeSec = (endMs - startMs) / 1000.0
' Add to tracking array (keep last N)
m.downloadTimes.push(downloadTimeSec)
if m.downloadTimes.count() > m.maxTrackedDownloads
m.downloadTimes.shift()
end if
m.log.debug("Tile downloaded", tileIndex, "time", downloadTimeSec, "size", stat.size)
return true
end function
' Calculates average download time from recent downloads
' @return {Float} - Average download time in seconds, or 3.0 if no data
function getAverageDownloadTime() as float
if m.downloadTimes.count() = 0 then return 3.0
total = 0.0
for each t in m.downloadTimes
total = total + t
end for
return total / m.downloadTimes.count()
end function