import "pkg:/source/api/ApiClient.bs"
import "pkg:/source/api/imageHelpers.bs"
import "pkg:/source/enums/HomeAction.bs"
import "pkg:/source/translationKeys.bs"
import "pkg:/source/utils/config.bs"
import "pkg:/source/utils/translate.bs"
sub init()
m.top.id = "overhang"
' save node references
m.logo = m.top.findNode("logo")
m.logoPoster = createLogoPoster() ' save node to memory for faster load time
m.title = m.top.findNode("title")
m.clock = m.top.findNode("clock")
m.isClockHidden = false
' Create user dropdown
m.userDropdown = CreateObject("roSGNode", "JRDropdown")
m.userDropdown.id = "userDropdown"
m.userDropdown.menuWidth = 300
m.userDropdown.visible = false ' hidden until user logs in
m.top.appendChild(m.userDropdown)
' Reposition dropdown when trigger button width changes (e.g., username change while clock is hidden)
m.userDropdown.observeField("triggerWidth", "onDropdownTriggerWidthChanged")
' Create overhang icon buttons (search and settings shortcuts)
m.searchIcon = CreateObject("roSGNode", "JROverhangIcon")
m.searchIcon.id = "searchIcon"
m.searchIcon.icon = "pkg:/images/icons/search_$$RES$$.png"
m.searchIcon.actionId = HomeAction.OPEN_SEARCH
m.searchIcon.visible = false
m.top.appendChild(m.searchIcon)
m.settingsIcon = CreateObject("roSGNode", "JROverhangIcon")
m.settingsIcon.id = "settingsIcon"
m.settingsIcon.icon = "pkg:/images/icons/settings_$$RES$$.png"
m.settingsIcon.actionId = HomeAction.OPEN_SETTINGS
m.settingsIcon.visible = false
m.top.appendChild(m.settingsIcon)
' Observe icon actions — proxy to overhang-level iconAction field
m.searchIcon.observeField("selectedAction", "onIconAction")
m.settingsIcon.observeField("selectedAction", "onIconAction")
' Observe icon focus exit signals for focus bridging
m.searchIcon.observeField("requestFocusExit", "onSearchIconRequestsFocusExit")
m.settingsIcon.observeField("requestFocusExit", "onSettingsIconRequestsFocusExit")
' Position dropdown so trigger text aligns vertically with overhang title/tabs.
' The trigger button has 30px padding, so offset the dropdown upward to compensate.
positionUserDropdown()
' Create animations programmatically to avoid interpolator field reference issues
createSlideAnimations()
end sub
' Create slide animations programmatically
sub createSlideAnimations()
' Create slide up animation
m.slideUpAnimation = createObject("roSGNode", "Animation")
m.slideUpAnimation.id = "slideUp"
m.slideUpAnimation.duration = 0.5
m.slideUpAnimation.repeat = false
slideUpInterpolator = createObject("roSGNode", "Vector2DFieldInterpolator")
slideUpInterpolator.key = [0.0, 0.5]
slideUpInterpolator.keyValue = [[0, 0], [0, -200]]
slideUpInterpolator.fieldToInterp = m.top.id + ".translation"
m.slideUpAnimation.appendChild(slideUpInterpolator)
m.top.appendChild(m.slideUpAnimation)
' Create slide down animation
m.slideDownAnimation = createObject("roSGNode", "Animation")
m.slideDownAnimation.id = "slideDown"
m.slideDownAnimation.duration = 0.5
m.slideDownAnimation.delay = 0.2
m.slideDownAnimation.repeat = false
slideDownInterpolator = createObject("roSGNode", "Vector2DFieldInterpolator")
slideDownInterpolator.key = [0.0, 0.5]
slideDownInterpolator.keyValue = [[0, -200], [0, 0]]
slideDownInterpolator.fieldToInterp = m.top.id + ".translation"
m.slideDownAnimation.appendChild(slideDownInterpolator)
m.top.appendChild(m.slideDownAnimation)
end sub
sub onIsVisibleChanged()
if m.top.isMoveAnimationDisabled
m.top.translation = [0, 0]
return
end if
if m.top.isVisible
m.slideDownAnimation.control = "start"
return
end if
m.slideUpAnimation.control = "start"
end sub
sub onTitleChanged()
m.title.text = m.top.title
end sub
' ============================================
' USER DROPDOWN
' ============================================
sub onCurrentUserChanged()
username = m.top.currentUser
if username = ""
' No user logged in — hide the dropdown
m.userDropdown.visible = false
m.userDropdown.triggerText = ""
m.userDropdown.triggerIconUri = ""
else
' User logged in — configure and show the dropdown
m.userDropdown.triggerText = username
m.userDropdown.visible = true
' Set user avatar image
localUser = m.global.user
if isValidAndNotEmpty(localUser.primaryImageTag)
m.userDropdown.triggerIconBlendColor = "0xFFFFFFFF"
if m.global.server.serverUrl <> ""
m.userDropdown.triggerIconUri = GetUserAvatarURL(localUser, 36, 36)
end if
else
m.userDropdown.triggerIconBlendColor = m.global.constants.colorTextSecondary
m.userDropdown.triggerIconUri = "pkg:/images/icons/person_36px_$$RES$$.png"
end if
' Set menu items — user-related actions only
m.userDropdown.items = [
{ title: translate(translationKeys.LabelChangeUser), id: HomeAction.CHANGE_USER },
{ title: translate(translationKeys.LabelChangeServer), id: HomeAction.CHANGE_SERVER },
{ title: translate(translationKeys.ButtonSignOut), id: HomeAction.SIGN_OUT }
]
end if
' Refresh icon visibility — depends on both currentUser and showIcons
updateIconVisibility()
end sub
' Controls icon visibility. Icons require both showIcons=true (set by the active screen)
' AND a logged-in user. This lets screens opt in (Home) without icons appearing on
' screens that only set currentUser (e.g., Settings).
sub onShouldShowIconsChanged()
updateIconVisibility()
end sub
sub updateIconVisibility()
shouldShow = m.top.shouldShowIcons and m.top.currentUser <> ""
m.searchIcon.visible = shouldShow
m.settingsIcon.visible = shouldShow
if shouldShow
positionOverhangIcons()
end if
end sub
' Returns the user dropdown reference for direct focus management by screens
function getUserDropdown() as object
return m.userDropdown
end function
' Returns the search icon reference for focus return observers
function getSearchIcon() as object
return m.searchIcon
end function
' Returns the settings icon reference for focus return observers
function getSettingsIcon() as object
return m.settingsIcon
end function
' Proxies icon selectedAction to the overhang-level iconAction field
sub onIconAction(msg as object)
node = msg.getRoSGNode()
m.top.iconAction = node.actionId
end sub
' ============================================
' FOCUS BRIDGING: TAB BAR <-> ICONS <-> DROPDOWN
' ============================================
' Focus chain (left to right): TabBar → Search → Settings → Dropdown
' Wraps: TabBar LEFT at first tab → Dropdown, Dropdown RIGHT → TabBar first tab
' Called when tab bar signals at its boundary — route into the icon/dropdown chain.
sub onTabBarRequestsUserMenu()
direction = m.tabBar.requestFocusToUserMenu
if direction = "right"
' Last tab → next element rightward: search icon (or dropdown if icons hidden)
if m.searchIcon.visible
m.searchIcon.setFocus(true)
else if isValid(m.userDropdown) and m.userDropdown.visible
m.userDropdown.setFocus(true)
else
' Nothing to the right — wrap to first tab
m.tabBar.focusedTabIndex = 0
end if
else if direction = "left"
' First tab → wrap to rightmost element: dropdown (or settings/search if dropdown hidden)
if isValid(m.userDropdown) and m.userDropdown.visible
m.userDropdown.setFocus(true)
else if m.settingsIcon.visible
m.settingsIcon.setFocus(true)
else if m.searchIcon.visible
m.searchIcon.setFocus(true)
else
' Nothing — wrap to last tab
tabCount = m.tabBar.findNode("tabContainer").getChildCount()
m.tabBar.focusedTabIndex = tabCount - 1
end if
end if
end sub
' Called when search icon signals LEFT or RIGHT exit
sub onSearchIconRequestsFocusExit()
direction = m.searchIcon.requestFocusExit
if direction = "left"
' Search → left: tab bar last tab (or wrap to dropdown if no tabs)
if isValid(m.tabBar) and m.tabBar.visible
tabCount = m.tabBar.findNode("tabContainer").getChildCount()
m.tabBar.focusedTabIndex = tabCount - 1
m.tabBar.setFocus(true)
else if isValid(m.userDropdown) and m.userDropdown.visible
m.userDropdown.setFocus(true)
else if m.settingsIcon.visible
m.settingsIcon.setFocus(true)
end if
else if direction = "right"
' Search → right: settings icon
if m.settingsIcon.visible
m.settingsIcon.setFocus(true)
else if isValid(m.userDropdown) and m.userDropdown.visible
m.userDropdown.setFocus(true)
end if
end if
end sub
' Called when settings icon signals LEFT or RIGHT exit
sub onSettingsIconRequestsFocusExit()
direction = m.settingsIcon.requestFocusExit
if direction = "left"
' Settings → left: search icon
if m.searchIcon.visible
m.searchIcon.setFocus(true)
else if isValid(m.tabBar) and m.tabBar.visible
tabCount = m.tabBar.findNode("tabContainer").getChildCount()
m.tabBar.focusedTabIndex = tabCount - 1
m.tabBar.setFocus(true)
end if
else if direction = "right"
' Settings → right: dropdown
if isValid(m.userDropdown) and m.userDropdown.visible
m.userDropdown.setFocus(true)
else if isValid(m.tabBar) and m.tabBar.visible
m.tabBar.focusedTabIndex = 0
m.tabBar.setFocus(true)
end if
end if
end sub
' Called when user dropdown signals LEFT or RIGHT exit
sub onUserDropdownRequestsTabs()
direction = m.userDropdown.requestFocusExit
if direction = "left"
' Dropdown → left: settings icon (or search, or tab bar)
if m.settingsIcon.visible
m.settingsIcon.setFocus(true)
else if m.searchIcon.visible
m.searchIcon.setFocus(true)
else if isValid(m.tabBar) and m.tabBar.visible
tabCount = m.tabBar.findNode("tabContainer").getChildCount()
m.tabBar.focusedTabIndex = tabCount - 1
m.tabBar.setFocus(true)
end if
else if direction = "right"
' Dropdown → right: wrap to tab bar first tab (or search if no tabs)
if isValid(m.tabBar) and m.tabBar.visible
m.tabBar.focusedTabIndex = 0
m.tabBar.setFocus(true)
else if m.searchIcon.visible
m.searchIcon.setFocus(true)
else if m.settingsIcon.visible
m.settingsIcon.setFocus(true)
end if
end if
end sub
' ============================================
' POSITIONING
' ============================================
' Hides the overhang clock and repositions the user dropdown to fill the freed space.
' Called by Home.bs when the user's uiDesignHideClock setting is enabled.
sub hideClock()
if isValid(m.clock)
m.top.removeChild(m.clock)
m.clock = invalid
end if
m.isClockHidden = true
positionUserDropdown()
end sub
' Repositions the dropdown when its trigger button width changes.
' Only matters when clock is hidden (right-aligned mode), since
' the left-aligned default position doesn't depend on trigger width.
sub onDropdownTriggerWidthChanged()
if m.isClockHidden
positionUserDropdown()
end if
end sub
' Positions the user dropdown in the overhang.
' When the clock is visible, the dropdown is left-aligned at x=1450 (its original position).
' When the clock is hidden, the dropdown is right-aligned so its right edge meets the
' 5% action-safe boundary (x=1824), matching where the clock's right edge sits.
sub positionUserDropdown()
if not isValid(m.userDropdown) then return
dropdownPadding = m.userDropdown.padding
yPos = 54 - dropdownPadding
if m.isClockHidden
' Right-align: position so the trigger button's right edge lands at x=1824.
' triggerWidth includes padding on both sides, so the full button ends at 1824.
triggerWidth = m.userDropdown.triggerWidth
if triggerWidth > 0
xPos = 1824 - triggerWidth - dropdownPadding
else
' Trigger hasn't rendered yet — use default position; will reposition on triggerWidth change
xPos = 1450 - dropdownPadding
end if
else
xPos = 1450 - dropdownPadding
end if
m.userDropdown.translation = [xPos, yPos]
' Reposition icons relative to the dropdown whenever it moves
if m.searchIcon.visible
positionOverhangIcons()
end if
end sub
' Positions the search and settings icons to the left of the user dropdown
' with equal spacing between all three elements (search, settings, dropdown).
sub positionOverhangIcons()
if not isValid(m.searchIcon) or not isValid(m.settingsIcon) then return
if not isValid(m.userDropdown) then return
iconSize = 66 ' Must match OVERHANG_ICON_SIZE in JROverhangIcon.bs
gap = 24 ' Equal spacing between elements
' Anchor from the dropdown's visible left edge.
' The dropdown's translation already includes its own padding offset
' (set by positionUserDropdown), and the buttonBackground starts at [0,0]
' within the dropdown, so translation[0] IS the visible button edge.
dropdownX = m.userDropdown.translation[0]
dropdownPadding = m.userDropdown.padding
yPos = 54 - dropdownPadding ' Aligns with dropdown (54 is the overhang baseline)
settingsX = dropdownX - gap - iconSize
searchX = settingsX - gap - iconSize
m.settingsIcon.translation = [settingsX, yPos]
m.searchIcon.translation = [searchX, yPos]
end sub
' component boolean field isVisible has changed value
sub onIsLogoVisibleChanged()
if m.top.isLogoVisible
if not isValid(m.logo)
m.logo = m.logoPoster
m.top.appendChild(m.logoPoster)
m.title.translation = [360, 51]
m.title.maxWidth = 300
end if
else
' remove the logo
if isValid(m.logo)
m.top.removeChild(m.logo)
m.logo = invalid
m.title.translation = [96, 51]
m.title.maxWidth = 1560
end if
end if
positionTabBar()
end sub
' Create and return a logo poster node
function createLogoPoster()
logoPoster = createObject("roSGNode", "Poster")
logoPoster.id = "logo"
logoPoster.uri = "pkg:/images/branding/logo.png"
logoPoster.translation = "[96, 54]"
logoPoster.width = "180"
logoPoster.height = "39"
return logoPoster
end function
' ============================================
' TAB BAR
' ============================================
' onTabsChanged: Swaps between title label and tab bar.
' Empty/invalid tabs restores the title label; non-empty creates a JRTabBar.
sub onTabsChanged()
tabs = m.top.tabs
if not isValidAndNotEmpty(tabs)
' No tabs — hide the tab bar (preserve it for instant reuse on return)
' and restore the title label
if isValid(m.tabBar)
m.tabBar.unobserveField("isReady") ' cancel any pending ready observer before restoring title
m.tabBar.visible = false
end if
m.title.visible = true
return
end if
if not isValid(m.tabBar)
m.tabBar = CreateObject("roSGNode", "JRTabBar")
m.tabBar.observeField("selectedTabId", "onTabBarSelectionChanged")
m.tabBar.observeField("requestFocusToUserMenu", "onTabBarRequestsUserMenu")
m.top.appendChild(m.tabBar)
end if
' Set up focus bridging between tab bar and dropdown
if isValid(m.userDropdown)
m.userDropdown.unobserveField("requestFocusExit") ' prevent stacking
m.userDropdown.observeField("requestFocusExit", "onUserDropdownRequestsTabs")
end if
m.tabBar.visible = true
positionTabBar()
' Sync current selection before setting tabs (so the correct tab starts selected)
if isValidAndNotEmpty(m.top.selectedTabId)
m.tabBar.selectedTabId = m.top.selectedTabId
end if
m.tabBar.tabs = tabs
' After setting tabs, check if they were preserved (return path) or rebuilt (init).
' Preserved tabs are already sized → hide title instantly.
' Rebuilt tabs need render tracking → keep title visible until ready.
if m.tabBar.isReady
m.title.visible = false
else
m.tabBar.unobserveField("isReady") ' prevent stacking duplicate observers on rapid tab changes
m.tabBar.observeField("isReady", "onTabBarReady")
end if
end sub
' Called when newly created tabs finish sizing. Swaps the title for the
' fully-formed tab bar in a single frame — no blank gap or sizing dance.
sub onTabBarReady()
if isValid(m.tabBar)
m.tabBar.unobserveField("isReady")
end if
m.title.visible = false
end sub
' Positions the tab bar so tab text aligns with where the title label sits.
' TextButton adds padding around the text, so we offset the tab bar to compensate.
sub positionTabBar()
if not isValid(m.tabBar) then return
tabPadding = 30 ' Must match JRTab's padding value
fontSize = m.global.constants.fontSizeLarge
buttonHeight = int(fontSize * 0.7) + (tabPadding * 2)
yOffset = int((buttonHeight - fontSize) / 2.0)
if isValid(m.logo)
m.tabBar.translation = [360 - tabPadding, 51 - yOffset]
else
m.tabBar.translation = [96 - tabPadding, 51 - yOffset]
end if
end sub
' Proxy tab bar selection changes to the overhang's selectedTabId field
sub onTabBarSelectionChanged()
if isValid(m.tabBar)
m.top.selectedTabId = m.tabBar.selectedTabId
end if
end sub
' Returns the tab bar node reference for direct focus management by screens
function getTabBar() as object
return m.tabBar
end function
sub resetTime()
if isValid(m.clock)
m.clock.callFunc("resetTime")
end if
end sub