Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions components/ItemGrid/LoadVideoContentTask.bs
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,15 @@ sub LoadItems_AddVideoContent(video as object, mediaSourceId as dynamic, audio_s
end if

' PlayStart requires the time to be in seconds
video.content.PlayStart = int(playbackPosition / 10000000)
playStartSec = int(playbackPosition / 10000000)
if playStartSec > 0
resumeRewindSec = val(m.global.session.user.settings["playback.resumeRewind"] ?? "0")
if resumeRewindSec > 0
playStartSec = playStartSec - resumeRewindSec
if playStartSec < 0 then playStartSec = 0
end if
end if
video.content.PlayStart = playStartSec

if not isValid(mediaSourceId) then mediaSourceId = video.id
if meta.live then mediaSourceId = ""
Expand Down Expand Up @@ -685,6 +693,7 @@ function sortSubtitles(MediaStreams)
tracks = { "forced": [], "default": [], "normal": [], "text": [] }
'Too many args for using substitute
prefered_lang = getPreferredLanguage("playback.subs.language") ?? ""
preferSdh = toBoolean(m.global.session.user.settings["playback.preferSdhSubtitles"] ?? false)
for each stream in MediaStreams
if stream.type = "Subtitle"

Expand All @@ -693,6 +702,9 @@ function sortSubtitles(MediaStreams)
url = buildURL(stream.DeliveryUrl)
end if

' stream is rebuilt below without these fields, so read them while they are still here
isSdhTrack = toBoolean(chainLookupReturn(stream, "IsHearingImpaired", false)) or instr(1, LCase(chainLookupReturn(stream, "displaytitle", "")), "sdh") > 0

stream = {
"Track": { "Language": stream.language, "Description": stream.displaytitle, "TrackName": url },
"IsTextSubtitleStream": stream.IsTextSubtitleStream,
Expand All @@ -711,7 +723,8 @@ function sortSubtitles(MediaStreams)
trackType = "normal"
end if

if prefered_lang <> "" and prefered_lang = stream.Track.Language
matchesLanguage = prefered_lang = "" or prefered_lang = stream.Track.Language
if matchesLanguage and (prefered_lang <> "" or (preferSdh and isSdhTrack))
tracks[trackType].unshift(stream)

if stream.IsTextSubtitleStream
Expand Down Expand Up @@ -761,6 +774,19 @@ function FindPreferredAudioStream(streams as dynamic) as integer

firstAudioTrack = -1

preferAudioDesc = toBoolean(m.global.session.user.settings["playback.preferAudioDescription"] ?? false)
if preferAudioDesc
for i = 0 to streams.Count() - 1
if LCase(streams[i].Type) = "audio"
if toBoolean(chainLookupReturn(streams[i], "IsAudioDescription", false)) or instr(1, LCase(chainLookupReturn(streams[i], "DisplayTitle", "")), "audio description") > 0
if not isValid(preferredLanguage) or isStringEqual(chainLookupReturn(streams[i], "Language", invalid), preferredLanguage)
return i
end if
end if
end if
end for
end if

' No user selection and not configured to play the default, how about a preferred language?
if isValid(preferredLanguage)
for i = 0 to streams.Count() - 1
Expand Down
18 changes: 16 additions & 2 deletions components/details/ModernItemDetails.bs
Original file line number Diff line number Diff line change
Expand Up @@ -1199,9 +1199,10 @@ sub updateCodecPills()
end if
end for

' Hide codecRow entirely when no codecs
' Hide codecRow entirely when no codecs or when technical details are disabled
showTechDetails = toBoolean(m.global.session.user.settings["ui.library.showTechnicalDetails"] ?? true)
codecRow = m.top.findNode("codecRow")
if isValid(codecRow) then codecRow.visible = anyCodec
if isValid(codecRow) then codecRow.visible = anyCodec and showTechDetails
end sub

sub populateRatings(itemData as object)
Expand Down Expand Up @@ -2207,6 +2208,19 @@ sub addMusicButtons()
end sub

sub addButton(config as object)
' Stored as a JSON string, so skip parsing on the common empty case
hiddenRaw = m.global.session.user.settings["ui.itemdetail.hiddenButtons"] ?? "[]"
if hiddenRaw <> "" and hiddenRaw <> "[]"
hiddenButtons = ParseJson(hiddenRaw)
if isValid(hiddenButtons)
for each hiddenId in hiddenButtons
if isStringEqual(hiddenId.ToStr(), config.action) or isStringEqual(hiddenId.ToStr(), config.id)
return
end if
end for
end if
end if

buttonGroup = detailButtons.createModernButton(config, m.buttonGroups.count())
buttonGroup.observeField("focusedChild", "onButtonFocusChanged")
m.buttonRow.appendChild(buttonGroup)
Expand Down
136 changes: 136 additions & 0 deletions components/extras/LoadExtrasTask.bs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import "pkg:/source/api/sdk.bs"
import "pkg:/source/enums/ItemType.bs"
import "pkg:/source/utils/deviceCapabilities.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/HomeRecommendations.bs"

' Fast parallel loading task for TV show details
' Loads seasons, next up, people, and similar items simultaneously
Expand Down Expand Up @@ -543,6 +544,141 @@ function loadPeople() as object
end function

function loadSimilar() as object
settings = m.global.session.user.settings
recSource = settings["ui.home.recommendationSource"] ?? ""
if recSource = ""
recSource = settings["ui.home.sinceYouWatchedSource"] ?? "local"
end if

if LCase(recSource) = "online"
onlineItems = loadOnlineSimilar()
if isValidAndNotEmpty(onlineItems) then return onlineItems
else if LCase(recSource) = "local"
localItems = loadLocalSimilar()
if isValidAndNotEmpty(localItems) then return localItems
end if

return loadJellyfinSimilar()
end function

function loadOnlineSimilar() as object
baseItem = invalid
if isMultiServer()
serverData = getServerData()
url = Substitute("Items/{0}", m.top.itemId)
req = APIRequestForServer(serverData.serverUrl, serverData.userId, serverData.authToken, url, { "userId": serverData.userId, "fields": "ProviderIds" })
if isValid(req) then baseItem = getJson(req)
else
baseItem = api.items.GetByID(m.top.itemId, { userId: getUserId(), fields: "ProviderIds" })
end if

if not isChainValid(baseItem, "ProviderIds.Tmdb") then return []
tmdbId = baseItem.ProviderIds.Tmdb.ToStr()
if tmdbId = "" then return []

mediaType = "movie"
if isChainValid(baseItem, "Type") and baseItem.Type = "Series" then mediaType = "tv"

config = GetSeerrConfig()
if not isValid(config) or config.enabled <> true then return []

req = APIRequest("/Moonfin/Seerr/Api/" + mediaType + "/" + tmdbId + "/recommendations", {})
if not isValid(req) then return []
data = getJson(req)
if not isChainValid(data, "results") then return []

results = []
count = 0
for each res in data.results
if count >= 25 then exit for
tmp = createSGNode("HomeData")
tmp.json = res
tmp.imageWidth = 180
tmp.labelText = isChainValid(res, "title") ? res.title : res.name
tmp.type = mediaType = "movie" ? "Movie" : "Series"
if isChainValid(res, "posterPath") and isValidAndNotEmpty(res.posterPath)
tmp.posterURL = "https://image.tmdb.org/t/p/w300" + res.posterPath
end if
results.push(tmp)
count = count + 1
end for
return results
end function

function loadLocalSimilar() as object
baseItem = invalid
if isMultiServer()
serverData = getServerData()
url = Substitute("Items/{0}", m.top.itemId)
req = APIRequestForServer(serverData.serverUrl, serverData.userId, serverData.authToken, url, { "userId": serverData.userId, "fields": "Genres,Tags,People,Studios,OfficialRating,ProductionYear" })
if isValid(req) then baseItem = getJson(req)
else
baseItem = api.items.GetByID(m.top.itemId, { userId: getUserId(), fields: "Genres,Tags,People,Studios,OfficialRating,ProductionYear" })
end if

if not isValid(baseItem) then return []
ctx = HomeRecommendations.buildContext(baseItem)

candidateTypes = "Movie,Series"
candidateFields = "Genres,Tags,People,UserData,OfficialRating,ProductionYear,CommunityRating,Studios,PremiereDate,ImageTags"

data = apiItemsGet({
userId: getUserId(),
includeItemTypes: candidateTypes,
genres: ctx.genres.Join("|"),
recursive: true,
limit: 40,
fields: candidateFields,
EnableTotalRecordCount: false
})

candidates = {}
if isChainValid(data, "Items")
for each item in data.Items
if isValid(item.Id) and item.Id <> m.top.itemId then candidates[item.Id] = item
end for
end if

scored = []
for each key in candidates
cand = candidates[key]
score = HomeRecommendations.scoreCandidate(cand, ctx)
if score > 0 then scored.push({ item: cand, score: score })
end for

if scored.count() = 0 then return []

results = []
count = 0
for each entry in scored
if count >= 25 then exit for
item = entry.item
tmp = createSGNode("HomeData")
if isMultiServer()
item._serverUrl = m.top.serverUrl
item._userId = m.top.serverUserId
item._authToken = m.top.serverAuthToken
end if
tmp.json = item
tmp.imageWidth = 180
imgParams = { "maxHeight": 270, "maxWidth": 180 }
if hasImageTagPrimary(item) then imgParams.Tag = item.ImageTags.Primary
if isMultiServer()
normalizedServerUrl = m.top.serverUrl
if normalizedServerUrl.right(1) = "/" then normalizedServerUrl = normalizedServerUrl.left(normalizedServerUrl.len() - 1)
tmp.posterURL = normalizedServerUrl + "/Items/" + item.Id + "/Images/Primary?maxWidth=180&maxHeight=270&quality=90"
else
tmp.posterURL = ImageURL(item.Id, "Primary", imgParams)
end if
tmp.labelText = item.Name
tmp.type = item.Type
results.push(tmp)
count = count + 1
end for
return results
end function

function loadJellyfinSimilar() as object
similar = []

' Normalize server URL once if multi-server
Expand Down
26 changes: 23 additions & 3 deletions components/home/HomeRows.bs
Original file line number Diff line number Diff line change
Expand Up @@ -148,10 +148,30 @@ sub updateSize()
'Set width of Rows to cut off at edge of Safe Zone
m.top.itemSize = [1703, itemHeight]

modernRows = isModernHomeRows()

' spacing between rows (modern reserves space for the focused card info block)
m.top.itemSpacing = modernRows ? [0, 210] : [0, 60]
if isModernHomeRows()
paddingKey = "ui.home.modernRowsPadding"
compactSpacing = 360
spaciousSpacing = 560
defaultSpacing = 460
else
paddingKey = "ui.home.classicRowsPadding"
compactSpacing = 10
spaciousSpacing = 90
defaultSpacing = 30
end if

' Padding arrives as either a pixel value or a named preset
padding = m.global.session.user.settings[paddingKey]
paddingStr = isValid(padding) ? padding.ToStr() : ""
verticalSpacing = Val(paddingStr)
if verticalSpacing <= 0
if isStringEqual(paddingStr, "compact") then verticalSpacing = compactSpacing
if isStringEqual(paddingStr, "spacious") then verticalSpacing = spaciousSpacing
if verticalSpacing <= 0 then verticalSpacing = defaultSpacing
end if

m.top.itemSpacing = [0, verticalSpacing]

' spacing between items in a row. Modern keeps cards tight, the focused card
' makes its own room by sliding the following cells aside (reflow).
Expand Down
3 changes: 2 additions & 1 deletion components/home/LoadItemsTask.bs
Original file line number Diff line number Diff line change
Expand Up @@ -598,7 +598,8 @@ function loadSinceYouWatched() as object
rowIndex = 1
if isValid(m.top.metadata) and isValid(m.top.metadata.rowIndex) then rowIndex = m.top.metadata.rowIndex

sourceMode = getStringSetting(settings, "ui.home.sinceYouWatchedSource", "local")
recSource = getStringSetting(settings, "ui.home.recommendationSource", "")
sourceMode = recSource <> "" ? recSource : getStringSetting(settings, "ui.home.sinceYouWatchedSource", "local")
sourceItem = getStringSetting(settings, "ui.home.sinceYouWatchedSourceItem", "recentlyWatched")
sourceType = getStringSetting(settings, "ui.home.sinceYouWatchedSourceType", "movies")
includeWatched = getBoolSetting(settings, "ui.home.sinceYouWatchedIncludeWatched", false)
Expand Down
7 changes: 6 additions & 1 deletion components/music/AudioPlayerView.bs
Original file line number Diff line number Diff line change
Expand Up @@ -521,7 +521,12 @@ sub onAudioDataChanged()
m.songDuration = currentItem.RunTimeTicks / 10000000.0

' Update displayed total audio length
m.totalLengthTimestamp.text = ticksToHuman(currentItem.RunTimeTicks)
musicDisplay = m.global.session.user.settings["playback.musicTimeDisplay"] ?? "elapsed"
if musicDisplay = "remaining"
m.totalLengthTimestamp.text = "-" + ticksToHuman(currentItem.RunTimeTicks)
else
m.totalLengthTimestamp.text = ticksToHuman(currentItem.RunTimeTicks)
end if
end if

' Validate lyrics data and for now only support timed lyrics
Expand Down
26 changes: 24 additions & 2 deletions components/video/OSD.bs
Original file line number Diff line number Diff line change
Expand Up @@ -83,20 +83,42 @@ sub init()
positionSecondaryControls()
end sub

function formatOSDTimeSlot(slotKey as string) as string
slotMode = m.global.session.user.settings[slotKey] ?? "none"
if slotMode = "none" or slotMode = "" then return ""
if slotMode = "elapsed" then return secondsToHuman(m.top.positionTime, true)
if slotMode = "remaining" then return "-" + secondsToHuman(m.top.remainingPositionTime, true)
if slotMode = "endsAt" then return "Ends at " + m.top.videoEndingTime
if slotMode = "time" then return m.top.videoEndingTime
return ""
end function

' onProgressPercentageChanged: Handler for changes to m.top.progressPercentage param
'
sub onProgressPercentageChanged()
totalTime = m.top.positionTime + m.top.remainingPositionTime
m.videoRemainingTime.text = secondsToHuman(m.top.positionTime, true) + " / " + secondsToHuman(totalTime, true)
m.progressBar.width = m.progressBarBackground.width * m.top.progressPercentage

aboveLeft = formatOSDTimeSlot("playback.timeAboveLeft")
aboveRight = formatOSDTimeSlot("playback.timeAboveRight")
if aboveLeft <> "" or aboveRight <> ""
m.videoRemainingTime.text = (aboveLeft <> "" ? aboveLeft : "") + (aboveRight <> "" ? " / " + aboveRight : "")
else
m.videoRemainingTime.text = secondsToHuman(m.top.positionTime, true) + " / " + secondsToHuman(totalTime, true)
end if

' Update circle indicator position (centered on progress, offset by circle radius)
indicatorX = 103 + (m.progressBarBackground.width * m.top.progressPercentage) - 10
m.progressIndicator.translation = [indicatorX, 812]
end sub

sub setVideoEndingTime()
m.videoEndingTime.text = "Ends at " + m.top.videoEndingTime
belowRight = formatOSDTimeSlot("playback.timeBelowRight")
if belowRight <> ""
m.videoEndingTime.text = belowRight
else
m.videoEndingTime.text = "Ends at " + m.top.videoEndingTime
end if
end sub

sub updateSeekPreview(totalTime as float)
Expand Down
20 changes: 18 additions & 2 deletions components/video/VideoPlayerView.bs
Original file line number Diff line number Diff line change
Expand Up @@ -1276,6 +1276,7 @@ sub onState()

' Handle OSD visibility based on playback state
if isStringEqual(m.top.state, MediaPlaybackState.PAUSED)
m.wasPaused = true
' Keep OSD visible when paused
if not m.scrubActive
if not m.osd.visible
Expand All @@ -1291,6 +1292,16 @@ sub onState()
m.osd.inactiveTimeout = 5
' Hide dark overlay when playing
m.pauseOverlay.visible = false

if m.wasPaused ?? false
m.wasPaused = false
unpauseRewindSec = val(m.global.session.user.settings["playback.unpauseRewind"] ?? "0")
if unpauseRewindSec > 0 and m.top.position > unpauseRewindSec
newPos = m.top.position - unpauseRewindSec
if newPos < 0 then newPos = 0
m.top.seek = newPos
end if
end if
end if

' When buffering, start timer to monitor buffering process
Expand Down Expand Up @@ -1906,11 +1917,16 @@ function onKeyEvent(key as string, press as boolean) as boolean
if not m.osd.visible and not m.top.trickPlayBar.visible
if not stateAllowsOSD() then return true

skipFwd = val(m.global.session.user.settings["playback.skipForwardLength"] ?? "10")
skipBack = val(m.global.session.user.settings["playback.skipBackLength"] ?? "10")
if skipFwd <= 0 then skipFwd = 10
if skipBack <= 0 then skipBack = 10

if key = KeyCode.RIGHT
newPosition = m.top.position + 10
newPosition = m.top.position + skipFwd
if newPosition > m.top.duration then newPosition = m.top.duration
else
newPosition = m.top.position - 10
newPosition = m.top.position - skipBack
if newPosition < 0 then newPosition = 0
end if

Expand Down
Loading