diff --git a/components/ItemGrid/LoadVideoContentTask.bs b/components/ItemGrid/LoadVideoContentTask.bs index a10ba724c..9efe8d14c 100644 --- a/components/ItemGrid/LoadVideoContentTask.bs +++ b/components/ItemGrid/LoadVideoContentTask.bs @@ -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 = "" @@ -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" @@ -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, @@ -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 @@ -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 diff --git a/components/details/ModernItemDetails.bs b/components/details/ModernItemDetails.bs index dcd050178..d17abdf02 100644 --- a/components/details/ModernItemDetails.bs +++ b/components/details/ModernItemDetails.bs @@ -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) @@ -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) diff --git a/components/extras/LoadExtrasTask.bs b/components/extras/LoadExtrasTask.bs index bd7c409d8..f2a3b6742 100644 --- a/components/extras/LoadExtrasTask.bs +++ b/components/extras/LoadExtrasTask.bs @@ -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 @@ -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 diff --git a/components/home/HomeRows.bs b/components/home/HomeRows.bs index 80f3d6970..f93c383ef 100644 --- a/components/home/HomeRows.bs +++ b/components/home/HomeRows.bs @@ -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). diff --git a/components/home/LoadItemsTask.bs b/components/home/LoadItemsTask.bs index 1957e1028..ec92074b7 100644 --- a/components/home/LoadItemsTask.bs +++ b/components/home/LoadItemsTask.bs @@ -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) diff --git a/components/music/AudioPlayerView.bs b/components/music/AudioPlayerView.bs index 21f792a64..b06f85721 100644 --- a/components/music/AudioPlayerView.bs +++ b/components/music/AudioPlayerView.bs @@ -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 diff --git a/components/video/OSD.bs b/components/video/OSD.bs index e33278146..7e61d0f58 100644 --- a/components/video/OSD.bs +++ b/components/video/OSD.bs @@ -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) diff --git a/components/video/VideoPlayerView.bs b/components/video/VideoPlayerView.bs index ac1f509d7..478d5a971 100644 --- a/components/video/VideoPlayerView.bs +++ b/components/video/VideoPlayerView.bs @@ -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 @@ -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 @@ -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 diff --git a/settings/settings.json b/settings/settings.json index 3d5108fdd..42c8eb836 100644 --- a/settings/settings.json +++ b/settings/settings.json @@ -294,6 +294,90 @@ "id": "series" } ] + }, + { + "title": "Collections Row Show Episodes", + "description": "Include TV episodes when displaying collections on the Home Screen", + "settingName": "ui.home.collectionsRowShowEpisodes", + "type": "bool", + "default": "false" + }, + { + "title": "Playlists Row Show Episodes", + "description": "Include TV episodes when displaying playlists on the Home Screen", + "settingName": "ui.home.playlistsRowShowEpisodes", + "type": "bool", + "default": "false" + }, + { + "title": "Classic Layout Home Rows Padding", + "description": "Adjust vertical padding between home screen rows in classic layout", + "settingName": "ui.home.classicRowsPadding", + "type": "radio", + "default": "30", + "options": [ + { "title": "10 px", "id": "10" }, + { "title": "20 px", "id": "20" }, + { "title": "30 px (Default)", "id": "30" }, + { "title": "40 px", "id": "40" }, + { "title": "50 px", "id": "50" }, + { "title": "60 px", "id": "60" }, + { "title": "70 px", "id": "70" }, + { "title": "80 px", "id": "80" }, + { "title": "90 px", "id": "90" }, + { "title": "100 px", "id": "100" }, + { "title": "110 px", "id": "110" }, + { "title": "120 px", "id": "120" }, + { "title": "130 px", "id": "130" } + ] + }, + { + "title": "Modern Layout Home Rows Padding", + "description": "Adjust vertical padding between home screen rows in modern layout", + "settingName": "ui.home.modernRowsPadding", + "type": "radio", + "default": "460", + "options": [ + { "title": "360 px", "id": "360" }, + { "title": "380 px", "id": "380" }, + { "title": "400 px", "id": "400" }, + { "title": "420 px", "id": "420" }, + { "title": "440 px", "id": "440" }, + { "title": "460 px (Default)", "id": "460" }, + { "title": "480 px", "id": "480" }, + { "title": "500 px", "id": "500" }, + { "title": "520 px", "id": "520" }, + { "title": "540 px", "id": "540" }, + { "title": "560 px", "id": "560" } + ] + }, + { + "title": "Recommendation Engine Source", + "description": "Select the recommendation engine source for home screen recommendations", + "settingName": "ui.home.recommendationSource", + "type": "radio", + "default": "server", + "options": [ + { + "title": "Server", + "id": "server" + }, + { + "title": "Local", + "id": "local" + }, + { + "title": "Hybrid", + "id": "hybrid" + } + ] + }, + { + "title": "Apply Parental Rating Cap to Recommendations", + "description": "Filter home screen recommendations according to user parental rating limits", + "settingName": "ui.home.recommendationsParentalCap", + "type": "bool", + "default": "true" } ] }, @@ -1558,6 +1642,126 @@ "title": "Video Playback Preferences", "description": "Core video engine and streaming quality settings", "children": [ + { + "title": "Resume Rewind", + "description": "Automatically rewind a few seconds when resuming playback from a saved bookmark position.", + "settingName": "playback.resumeRewind", + "type": "radio", + "default": "0", + "options": [ + { + "title": "Off", + "id": "0" + }, + { + "title": "2 Seconds", + "id": "2" + }, + { + "title": "3 Seconds", + "id": "3" + }, + { + "title": "5 Seconds", + "id": "5" + }, + { + "title": "7 Seconds", + "id": "7" + }, + { + "title": "10 Seconds", + "id": "10" + } + ] + }, + { + "title": "Unpause Rewind", + "description": "Automatically rewind a few seconds when resuming video playback after being paused.", + "settingName": "playback.unpauseRewind", + "type": "radio", + "default": "0", + "options": [ + { + "title": "Off", + "id": "0" + }, + { + "title": "1 Second", + "id": "1" + }, + { + "title": "2 Seconds", + "id": "2" + }, + { + "title": "3 Seconds", + "id": "3" + }, + { + "title": "5 Seconds", + "id": "5" + } + ] + }, + { + "title": "Skip Forward Length", + "description": "The amount of time to jump forward when pressing D-pad Right during playback.", + "settingName": "playback.skipForwardLength", + "type": "radio", + "default": "10", + "options": [ + { + "title": "5 Seconds", + "id": "5" + }, + { + "title": "10 Seconds", + "id": "10" + }, + { + "title": "15 Seconds", + "id": "15" + }, + { + "title": "30 Seconds", + "id": "30" + }, + { + "title": "60 Seconds", + "id": "60" + } + ] + }, + { + "title": "Skip Back Length", + "description": "The amount of time to jump backward when pressing D-pad Left during playback.", + "settingName": "playback.skipBackLength", + "type": "radio", + "default": "10", + "options": [ + { + "title": "5 Seconds", + "id": "5" + }, + { + "title": "10 Seconds", + "id": "10" + }, + { + "title": "15 Seconds", + "id": "15" + }, + { + "title": "30 Seconds", + "id": "30" + }, + { + "title": "60 Seconds", + "id": "60" + } + ] + }, { "title": "Bitrate Limit", "description": "Configure the maximum playback bitrate.", @@ -1722,6 +1926,90 @@ "default": "false" } ] + }, + { + "title": "Media Bar Playback Time Above Left", + "description": "Select information to display above the progress bar on the left side", + "settingName": "playback.timeAboveLeft", + "type": "radio", + "default": "elapsed", + "options": [ + { "title": "None", "id": "none" }, + { "title": "Current Time", "id": "time" }, + { "title": "Elapsed Time", "id": "elapsed" }, + { "title": "Remaining Time", "id": "remaining" }, + { "title": "Ends At Time", "id": "endsAt" } + ] + }, + { + "title": "Media Bar Playback Time Above Center", + "description": "Select information to display above the progress bar in the center", + "settingName": "playback.timeAboveCenter", + "type": "radio", + "default": "none", + "options": [ + { "title": "None", "id": "none" }, + { "title": "Current Time", "id": "time" }, + { "title": "Elapsed Time", "id": "elapsed" }, + { "title": "Remaining Time", "id": "remaining" }, + { "title": "Ends At Time", "id": "endsAt" } + ] + }, + { + "title": "Media Bar Playback Time Above Right", + "description": "Select information to display above the progress bar on the right side", + "settingName": "playback.timeAboveRight", + "type": "radio", + "default": "remaining", + "options": [ + { "title": "None", "id": "none" }, + { "title": "Current Time", "id": "time" }, + { "title": "Elapsed Time", "id": "elapsed" }, + { "title": "Remaining Time", "id": "remaining" }, + { "title": "Ends At Time", "id": "endsAt" } + ] + }, + { + "title": "Media Bar Playback Time Below Left", + "description": "Select information to display below the progress bar on the left side", + "settingName": "playback.timeBelowLeft", + "type": "radio", + "default": "none", + "options": [ + { "title": "None", "id": "none" }, + { "title": "Current Time", "id": "time" }, + { "title": "Elapsed Time", "id": "elapsed" }, + { "title": "Remaining Time", "id": "remaining" }, + { "title": "Ends At Time", "id": "endsAt" } + ] + }, + { + "title": "Media Bar Playback Time Below Center", + "description": "Select information to display below the progress bar in the center", + "settingName": "playback.timeBelowCenter", + "type": "radio", + "default": "none", + "options": [ + { "title": "None", "id": "none" }, + { "title": "Current Time", "id": "time" }, + { "title": "Elapsed Time", "id": "elapsed" }, + { "title": "Remaining Time", "id": "remaining" }, + { "title": "Ends At Time", "id": "endsAt" } + ] + }, + { + "title": "Media Bar Playback Time Below Right", + "description": "Select information to display below the progress bar on the right side", + "settingName": "playback.timeBelowRight", + "type": "radio", + "default": "endsAt", + "options": [ + { "title": "None", "id": "none" }, + { "title": "Current Time", "id": "time" }, + { "title": "Elapsed Time", "id": "elapsed" }, + { "title": "Remaining Time", "id": "remaining" }, + { "title": "Ends At Time", "id": "endsAt" } + ] } ] }, @@ -1729,6 +2017,30 @@ "title": "Audio Preferences", "description": "Audio tracks, processing, and related settings", "children": [ + { + "title": "Music Playback Time Display", + "description": "Choose whether the music player displays elapsed time or remaining time", + "settingName": "playback.musicTimeDisplay", + "type": "radio", + "default": "elapsed", + "options": [ + { + "title": "Elapsed Time", + "id": "elapsed" + }, + { + "title": "Remaining Time", + "id": "remaining" + } + ] + }, + { + "title": "Prefer Audio Description", + "description": "Automatically prefer Audio Description (descriptive audio for visually impaired) tracks when matching audio streams.", + "settingName": "playback.preferAudioDescription", + "type": "bool", + "default": "false" + }, { "title": "Preferred audio language", "description": "Choose the default audio language for playback", @@ -1996,6 +2308,13 @@ "title": "Subtitle Preferences", "description": "Change subtitle modes, default languages, and other options", "children": [ + { + "title": "Prefer SDH Subtitles", + "description": "Automatically prefer Subtitles for the Deaf and Hard of Hearing (SDH) tracks when matching subtitle streams.", + "settingName": "playback.preferSdhSubtitles", + "type": "bool", + "default": "false" + }, { "title": "Subtitle Mode", "description": "Choose the default subtitle mode for playback", @@ -2712,6 +3031,13 @@ "settingName": "ui.itemdetail.expandedTabs", "type": "bool", "default": "true" + }, + { + "title": "Show Technical Media Details", + "description": "Display technical details (codec, bitrate, resolution, container) on item details screens", + "settingName": "ui.library.showTechnicalDetails", + "type": "bool", + "default": "true" } ] }, diff --git a/source/utils/settingsSync.bs b/source/utils/settingsSync.bs index 33cdb3c89..85ca0057f 100644 --- a/source/utils/settingsSync.bs +++ b/source/utils/settingsSync.bs @@ -52,6 +52,12 @@ namespace settingsSync ' Settings the server can now store that already had a switch here. { pluginKey: "cinemaModeEnabled", rokuKey: "playback.cinemamode", type: "bool" }, { pluginKey: "autoplayNextEpisode", rokuKey: "playback.showNextUpAfterFinish", type: "bool" }, + { pluginKey: "resumeSubtractDuration", rokuKey: "playback.resumeRewind", type: "intToStr" }, + { pluginKey: "unpauseRewindDuration", rokuKey: "playback.unpauseRewind", type: "intToStr" }, + { pluginKey: "skipBackLength", rokuKey: "playback.skipBackLength", type: "intToStr" }, + { pluginKey: "skipForwardLength", rokuKey: "playback.skipForwardLength", type: "intToStr" }, + { pluginKey: "preferAudioDescription", rokuKey: "playback.preferAudioDescription", type: "bool" }, + { pluginKey: "preferSdhSubtitles", rokuKey: "playback.preferSdhSubtitles", type: "bool" }, { pluginKey: "seerrBlockNsfw", rokuKey: "seerr.blockNsfw", type: "bool" }, { pluginKey: "nextUpMaxDays", rokuKey: "ui.details.maxdaysnextup", type: "intToStr" }, ' Roku registry stores seconds as an integer, matching the server's representation. @@ -108,6 +114,30 @@ namespace settingsSync { pluginKey: "sonarrCalendarShowDate", rokuKey: "calendar.sonarr.showDate", type: "bool" }, { pluginKey: "mergeRadarrSonarrCalendars", rokuKey: "calendar.merge", type: "bool" }, + { pluginKey: "playbackTimeAboveLeft", rokuKey: "playback.timeAboveLeft", type: "direct" }, + { pluginKey: "playbackTimeAboveCenter", rokuKey: "playback.timeAboveCenter", type: "direct" }, + { pluginKey: "playbackTimeAboveRight", rokuKey: "playback.timeAboveRight", type: "direct" }, + { pluginKey: "playbackTimeBelowLeft", rokuKey: "playback.timeBelowLeft", type: "direct" }, + { pluginKey: "playbackTimeBelowCenter", rokuKey: "playback.timeBelowCenter", type: "direct" }, + { pluginKey: "playbackTimeBelowRight", rokuKey: "playback.timeBelowRight", type: "direct" }, + { pluginKey: "musicPlaybackTimeDisplay", rokuKey: "playback.musicTimeDisplay", type: "direct" }, + + { pluginKey: "collectionsRowShowEpisodes", rokuKey: "ui.home.collectionsRowShowEpisodes", type: "bool" }, + { pluginKey: "playlistsRowShowEpisodes", rokuKey: "ui.home.playlistsRowShowEpisodes", type: "bool" }, + { pluginKey: "showCastButton", rokuKey: "navbar.show_cast", type: "bool" }, + { pluginKey: "classicHomeRowsPadding", rokuKey: "ui.home.classicRowsPadding", type: "direct" }, + { pluginKey: "modernHomeRowsPadding", rokuKey: "ui.home.modernRowsPadding", type: "direct" }, + { pluginKey: "detailShowTechnicalDetails", rokuKey: "ui.library.showTechnicalDetails", type: "bool" }, + { pluginKey: "recommendationSystemSource", rokuKey: "ui.home.recommendationSource", type: "direct" }, + { pluginKey: "recommendationsApplyParentalRatingCap", rokuKey: "ui.home.recommendationsParentalCap", type: "bool" }, + { pluginKey: "screensaverMode", rokuKey: "screensaver.mode", type: "direct" }, + { pluginKey: "detailButtonOrderTv", rokuKey: "ui.itemdetail.buttonOrder", type: "jsonArray" }, + { pluginKey: "hiddenDetailButtonsTv", rokuKey: "ui.itemdetail.hiddenButtons", type: "jsonArray" }, + { pluginKey: "subtitleMode", rokuKey: "playback.subs.mode", type: "subtitleModeFormat" }, + { pluginKey: "defaultSubtitleLanguage", rokuKey: "playback.subs.language", type: "direct" }, + { pluginKey: "defaultAudioLanguage", rokuKey: "playback.audioPreferredLanguage", type: "direct" }, + { pluginKey: "preferDefaultAudioTrack", rokuKey: "playback.playDefaultAudioTrack", type: "bool" }, + { pluginKey: "customThemeId", rokuKey: "ui.theme.customThemeId", type: "direct" } ] end function @@ -184,6 +214,37 @@ namespace settingsSync session.user.Update("settings", tmpSettings) + currentConfig = m.global.session.user.Configuration + if isValid(currentConfig) + configChanged = false + if isValid(profile.subtitleMode) + subMode = settingsSync.PluginToRoku(profile.subtitleMode, "subtitleModeFormat") + if isValid(subMode) + currentConfig.AddReplace("SubtitleMode", subMode) + configChanged = true + end if + end if + if isValid(profile.defaultSubtitleLanguage) + subLang = profile.defaultSubtitleLanguage + if isStringEqual(subLang, "anyLanguage") then subLang = "" + currentConfig.AddReplace("SubtitleLanguagePreference", subLang) + configChanged = true + end if + if isValid(profile.defaultAudioLanguage) + audLang = profile.defaultAudioLanguage + if isStringEqual(audLang, "anyLanguage") then audLang = "" + currentConfig.AddReplace("AudioLanguagePreference", audLang) + configChanged = true + end if + if isValid(profile.preferDefaultAudioTrack) + currentConfig.AddReplace("PlayDefaultAudioTrack", toBoolean(profile.preferDefaultAudioTrack)) + configChanged = true + end if + if configChanged + session.user.Update("Configuration", currentConfig) + end if + end if + if isValid(m.global) and m.global.hasField("overlaySettingsChanged") m.global.overlaySettingsChanged = m.global.overlaySettingsChanged + 1 end if @@ -402,6 +463,13 @@ namespace settingsSync if pluginStr = "movies" then return "movies_only" if pluginStr = "tv" then return "tv_only" return "both" + else if conversionType = "subtitleModeFormat" + valStr = LCase(pluginValue.toStr()) + if valStr = "default" then return "Default" + if valStr = "always" then return "Always" + if valStr = "onlyforced" then return "OnlyForced" + if valStr = "none" then return "None" + return "Default" end if return invalid end function @@ -445,6 +513,13 @@ namespace settingsSync if rokuStr = "movies_only" then return "movies" if rokuStr = "tv_only" then return "tv" return "both" + else if conversionType = "subtitleModeFormat" + valStr = LCase(rokuStr) + if valStr = "default" then return "default" + if valStr = "always" then return "always" + if valStr = "onlyforced" then return "onlyforced" + if valStr = "none" then return "none" + return "default" end if return invalid end function