import "pkg:/source/translationKeys.bs"
import "pkg:/source/utils/mediaDisplayTitle.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/translate.bs"
import "pkg:/source/utils/versionResume.bs"
' Labels for the alternate versions (MediaSources) of one item.
'
' A version label has to answer "how is this file different from the others?" in a slot that
' ellipsizes: the details screen's collapsed Video trigger is 278 px wide. Before, every label
' was the full MediaSource.Name followed by resolution and codec, so a release-style name
' ("Silicon Valley - S04E06 - Customer Service WEBDL-1080p") filled the slot and the stream
' info was cut off. Measured on a Roku Ultra against Jellyfin 12.0. Since 12.0 resumes the
' exact file that holds the position and never upgrades on its own, the label is what tells a
' viewer which file they are about to play.
'
' ONE RULE per part, each computed across ALL the versions, which is why this takes the whole
' list rather than one source:
'
' * Stream info: only the video fields that DIFFER between versions (resolution, codec, HDR
' range), and it comes first, so it survives the ellipsis. Versions whose video is
' identical (a bitrate ladder) get none, since repeating "1080p AV1" on every row does
' not help anyone choose. Audio is left out: the Audio dropdown lists it per version.
' * Name: the words EVERY version shares are dropped from the start and the end, comparing
' case-insensitively with space, ".", "-" and "_" as separators. Only shared words go, so
' nothing that tells two versions apart is lost; the rest is kept as written. Then the
' words at either end that repeat a label the row's stream info already shows go too
' ("1080p", not "1080p · 1080p", for Jellyfin's "Movie - 1080p" naming). That is a
' plain comparison against whole labels, never a reading: names are user-controlled, so
' nothing here reads quality out of them ("2160p" stays beside "4K").
' * Fallback: a version left with no text at all shows its full name, then its full stream
' summary. Two versions that are identical in every field keep identical labels; an
' ordinal would be arbitrary (see remoteSubtitles.subtitleDropdownEntries).
'
' TWO FORMS, because the places that name a version do different jobs:
'
' * `title` is the FULL label (stream info + name), for the lists a viewer CHOOSES from:
' the details screen's open Video menu and the in-player Select Video Source dialog.
' * `triggerTitle` is the SHORT label, for the places that only say which version is
' current: the collapsed Video trigger and the player title. It is the stream info alone
' when that identifies the version among the others, and the full label otherwise.
'
' Why the short form drops the name: a name is free text. Sometimes it is the only thing that
' matters (an edition), sometimes it is a whole release filename. Jellyfin returns the full
' filename when a version's file shares no naming pattern with the others (measured on 12.0:
' "silicon.valley.s04e06.720p.web.h264-tbs"), and MediaSourceInfo has no edition field to read
' instead (checked in the 12.0 spec). The cost accepted: two editions that also differ in
' quality show only the quality once picked ("4K HEVC", not "Director's Cut"); the list the
' viewer picked from showed both.
'
' A single version has nothing to be told apart from and keeps the plain stream summary.
'
' PURE: plain values in, plain values out, so every rule is unit-testable.
namespace versionLabels
const STREAM_NAME_SEPARATOR = " · "
const MARKER_SEPARATOR = " · "
' labelsFor: A label for each version, in the order given.
'
' The caller passes exactly the versions the user chooses between (the details screen shows
' only VideoFile sources), because each label depends on the others. Entries that are not
' a source with an Id are skipped, as both pickers skip them.
'
' @param {dynamic} mediaSources - MediaSourceInfo array
' @param {dynamic} inProgressSourceId - the version the viewer is partway through, or "" /
' invalid for none. Marks `title` only: the marker means something only beside the rows
' it is distinguishing from.
' @return {object} array of { id, title, triggerTitle } (see TWO FORMS above)
function labelsFor(mediaSources as dynamic, inProgressSourceId as dynamic) as object
labels = []
sources = []
if type(mediaSources) = "roArray"
for each source in mediaSources
if isValid(source) and isValidAndNotEmpty(source.Id) then sources.push(source)
end for
end if
if sources.count() = 0 then return labels
if sources.count() = 1
summary = versionLabels.streamSummary(sources[0])
labels.push({ id: sources[0].Id, title: summary, triggerTitle: summary })
return labels
end if
fields = []
names = []
for each source in sources
fields.push(versionLabels.videoFields(source))
name = ""
if isValidAndNotEmpty(source.Name) then name = source.Name
names.push(name)
end for
differs = {
resolution: versionLabels.fieldDiffers(fields, "resolution"),
codec: versionLabels.fieldDiffers(fields, "codec"),
range: versionLabels.fieldDiffers(fields, "range")
}
distinct = versionLabels.distinctNameParts(names)
marker = translate(translationKeys.LabelInProgress)
streamInfos = []
streamInfoCounts = {}
nameParts = []
for i = 0 to sources.count() - 1
parts = []
for each key in ["resolution", "codec", "range"]
if differs[key] and fields[i][key] <> "" then parts.push(fields[i][key])
end for
streamInfo = parts.join(" ")
streamInfos.push(streamInfo)
streamInfoCounts[streamInfo] = (streamInfoCounts[streamInfo] ?? 0) + 1
nameParts.push(versionLabels.trimShownLabels(distinct[i], parts))
end for
for i = 0 to sources.count() - 1
streamInfo = streamInfos[i]
label = streamInfo
if nameParts[i] <> ""
if label <> "" then label += STREAM_NAME_SEPARATOR
label += nameParts[i]
end if
if label = "" then label = names[i]
if label = "" then label = versionLabels.streamSummary(sources[i])
shortLabel = label
if streamInfo <> "" and streamInfoCounts[streamInfo] = 1 then shortLabel = streamInfo
title = label
if versionResume.idsMatch(sources[i].Id, inProgressSourceId) then title += MARKER_SEPARATOR + marker
labels.push({ id: sources[i].Id, title: title, triggerTitle: shortLabel })
end for
return labels
end function
' triggerLabelFor: The short label of one version, judged against all of them. What the
' player shows beside the title.
'
' @param {dynamic} mediaSources - MediaSourceInfo array
' @param {dynamic} sourceId - the version to name (matched ignoring case)
' @return {string} the label, or "" when there is nothing to tell apart or no such version
function triggerLabelFor(mediaSources as dynamic, sourceId as dynamic) as string
labels = versionLabels.labelsFor(mediaSources, invalid)
if labels.count() < 2 then return ""
for each label in labels
if versionResume.idsMatch(label.id, sourceId) then return label.triggerTitle
end for
return ""
end function
' streamSummary: The full video summary of one version ("1080p H264"), or "N/A" when it
' has no video stream.
function streamSummary(source as object) as string
if not isValid(source) then return "N/A"
return formatVideoDisplayTitle(getFirstVideoStream(source.MediaStreams))
end function
' videoFields: The comparable parts of a version's first video stream, each written as
' formatVideoDisplayTitle writes it ("" when absent). The range is only named for HDR, as
' there: "SDR" on every row is noise.
function videoFields(source as object) as object
fields = { resolution: "", codec: "", range: "" }
stream = getFirstVideoStream(source.MediaStreams)
if not isValid(stream) then return fields
width = 0
height = 0
isInterlaced = false
if isValid(stream.Width) then width = stream.Width
if isValid(stream.Height) then height = stream.Height
if isValid(stream.IsInterlaced) then isInterlaced = stream.IsInterlaced
fields.resolution = getVideoResolutionLabel(width, height, isInterlaced)
if isValidAndNotEmpty(stream.Codec) then fields.codec = UCase(stream.Codec)
if isValid(stream.VideoRange) and LCase(stream.VideoRange) = "hdr" then fields.range = formatVideoRangeLabel(stream)
return fields
end function
function fieldDiffers(fields as object, key as string) as boolean
for each entry in fields
if entry[key] <> fields[0][key] then return true
end for
return false
end function
' distinctNameParts: Each name with the words every name shares removed from both ends.
'
' @param {object} names - array of strings ("" for a version with no name)
' @return {object} array of strings, index-aligned with names; "" where nothing is left
function distinctNameParts(names as object) as object
tokenLists = []
minCount = -1
for each name in names
tokens = versionLabels.tokenize(name)
tokenLists.push(tokens)
if minCount < 0 or tokens.count() < minCount then minCount = tokens.count()
end for
prefix = 0
while prefix < minCount and versionLabels.allTokensMatch(tokenLists, prefix, false)
prefix++
end while
suffix = 0
while prefix + suffix < minCount and versionLabels.allTokensMatch(tokenLists, suffix, true)
suffix++
end while
parts = []
for i = 0 to names.count() - 1
tokens = tokenLists[i]
first = prefix
last = tokens.count() - 1 - suffix
if first > last
parts.push("")
else
startPos = tokens[first].start
endPos = tokens[last].start + tokens[last].length
parts.push(names[i].mid(startPos, endPos - startPos))
end if
end for
return parts
end function
' trimShownLabels: A name part without the words at either end that repeat a label its row
' already shows. Each label is matched whole, ignoring case, so a label that is several
' words ("DV 8.1") never matches part of a name ("Part 8"). Words in the middle stay, so
' what is left is still cut from the text unchanged.
'
' @param {string} text - a name part from distinctNameParts
' @param {object} shownLabels - the row's stream-info labels ("1080p", "HEVC", ...)
' @return {string} the trimmed part; "" when every word repeats a shown label
function trimShownLabels(text as string, shownLabels as object) as string
if text = "" or shownLabels.count() = 0 then return text
' roAssociativeArray keys compare ignoring case
shown = {}
for each label in shownLabels
shown[label] = true
end for
tokens = versionLabels.tokenize(text)
first = 0
last = tokens.count() - 1
while first <= last and shown.doesExist(tokens[first].lower)
first++
end while
while last >= first and shown.doesExist(tokens[last].lower)
last--
end while
if first > last then return ""
startPos = tokens[first].start
return text.mid(startPos, tokens[last].start + tokens[last].length - startPos)
end function
' allTokensMatch: Whether every list has the same word at `offset`, counted from the start,
' or from the end when fromEnd is true. Case-insensitive.
function allTokensMatch(tokenLists as object, offset as integer, fromEnd as boolean) as boolean
expected = invalid
for each tokens in tokenLists
index = offset
if fromEnd then index = tokens.count() - 1 - offset
word = tokens[index].lower
if not isValid(expected)
expected = word
else if word <> expected
return false
end if
end for
return true
end function
' tokenize: The words of a name, split on space, ".", "-" and "_", each with its position so
' the kept part can be cut from the original text unchanged.
'
' @return {object} array of { start, length, lower } (start is 0-based)
function tokenize(name as string) as object
tokens = []
start = -1
for i = 0 to name.len()
isSeparator = true
if i < name.len()
ch = name.mid(i, 1)
isSeparator = ch = " " or ch = "." or ch = "-" or ch = "_"
end if
if isSeparator
if start >= 0
tokens.push({ start: start, length: i - start, lower: LCase(name.mid(start, i - start)) })
start = -1
end if
else if start < 0
start = i
end if
end for
return tokens
end function
end namespace