Files
Lizzy fbbd9d7c4e Rename project from Jellyframe to jellyXR
Rebrand the app to jellyXR so the name is not tied to a single headset
2026-10-27 13:36:22 +01:00

363 lines
16 KiB
GDScript

class_name SettingsView
extends VBoxContainer
signal screen_changed
signal screen_follow_changed
signal environment_changed
signal stereo_selected(value: int)
signal projection_selected(value: int)
signal eyes_swapped
signal status(message: String)
var settings: JellyXRSettings
var stereo_mode := Stereo.Mode.MONO
var projection_mode := VideoProjection.Mode.SCREEN
var swap_eyes := false
var passthrough_supported := false
var sort_choices: GridContainer
var screen_dimensions: GridContainer
var stereo_choices: HFlowContainer
var reset_screen_button: Button
var playback_choices: VBoxContainer
var transcode_options: VBoxContainer
var transcode_choices: HFlowContainer
var sections: Array[VBoxContainer] = []
var scroll: ScrollContainer
var dragging_slider := false
var navigation_host: Container
var navigation: GridContainer
var navigation_buttons: Array[Button] = []
var section_heading: Label
var sort_order_choices: HBoxContainer
var environment_choices: VBoxContainer
var environment_buttons: Dictionary = {}
var follow_switch: CheckButton
const SECTION_TITLES := ["Library sorting", "Screen", "Environment", "Playback"]
const SECTION_ICONS := [
preload("res://assets/icons/material/video_library.svg"),
preload("res://assets/icons/material/tv.svg"),
preload("res://assets/icons/material/home.svg"),
preload("res://assets/icons/material/play_arrow.svg")
]
func _ready() -> void:
add_theme_constant_override("separation", 20)
_build_navigation()
section_heading = Widgets.label(SECTION_TITLES[0], self, 28)
scroll = ScrollContainer.new()
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
scroll.follow_focus = true
add_child(scroll)
var body := VBoxContainer.new()
body.size_flags_horizontal = Control.SIZE_EXPAND_FILL
scroll.add_child(body)
for index in range(4):
var section := VBoxContainer.new()
section.add_theme_constant_override("separation", 24)
section.visible = index == 0
body.add_child(section)
sections.append(section)
_build_library(sections[0])
_build_screen(sections[1])
_build_environment(sections[2])
_build_playback(sections[3])
resized.connect(_adapt_columns)
scroll.resized.connect(_adapt_columns)
_adapt_columns()
func _build_navigation() -> void:
navigation = GridContainer.new()
navigation.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
navigation.add_theme_constant_override("v_separation", 8)
navigation.add_theme_constant_override("h_separation", 8)
navigation_host.add_child(navigation)
var group := ButtonGroup.new()
for index in range(4):
var title: String = SECTION_TITLES[index]
var button := Widgets.button(title, func(): _show_section(index), navigation)
button.toggle_mode = true
button.button_group = group
button.button_pressed = index == 0
button.accessibility_name = title
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
button.icon = SECTION_ICONS[index]
button.expand_icon = true
button.add_theme_constant_override("icon_max_width", 24)
button.add_theme_constant_override("h_separation", 12)
button.add_theme_font_size_override("font_size", 19)
button.custom_minimum_size = Vector2(0, 64)
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_style_control(button)
navigation_buttons.append(button)
func _show_section(index: int) -> void:
for section_index in range(sections.size()):
sections[section_index].visible = section_index == index
navigation_buttons[index].button_pressed = true
section_heading.text = SECTION_TITLES[index]
scroll.scroll_vertical = 0
func _card(parent: VBoxContainer, title: String = "") -> VBoxContainer:
var panel := PanelContainer.new()
panel.add_theme_stylebox_override("panel", Widgets.surface(Color("162133"), 20, 20))
parent.add_child(panel)
var body := VBoxContainer.new()
body.add_theme_constant_override("separation", 12)
panel.add_child(body)
if not title.is_empty(): Widgets.label(title, body, 22)
return body
func _style_control(control: Button) -> void:
for state in ["normal", "hover", "pressed", "hover_pressed", "disabled", "focus"]:
var color := Color("202a3d")
if state in ["hover", "disabled"]: color = Color("34435b")
if state in ["pressed", "hover_pressed"]: color = Color("2b4b50")
if state == "focus": color = Color(0, 0, 0, 0)
var style := Widgets.surface(color, 12, 12)
if state in ["hover", "hover_pressed", "focus"]:
style.set_border_width_all(3)
style.border_color = Color("6de3cf")
control.add_theme_stylebox_override(state, style)
control.add_theme_color_override("font_disabled_color", Color("8793a8"))
func _hint(message: String, parent: Node) -> void:
var label := Widgets.label(message, parent, 18)
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
label.add_theme_color_override("font_color", Color("a4aec0"))
func _build_library(parent: VBoxContainer) -> void:
_hint("Choose how to sort your libraries. Home shows the newest additions first.", parent)
var order_card := _card(parent, "Sort order")
sort_order_choices = HBoxContainer.new()
sort_order_choices.add_theme_constant_override("separation", 12)
order_card.add_child(sort_order_choices)
var order_group := ButtonGroup.new()
for value in ["Ascending", "Descending"]:
_choice(value, value == settings.library_sort_order, order_group, sort_order_choices,
func(): settings.library_sort_order = value; _save())
_hint("Ascending sorts A to Z, lowest to highest, or oldest to newest. Descending reverses the order. This setting does not affect Random.", order_card)
var sort_card := _card(parent, "Sort by")
sort_choices = GridContainer.new()
sort_choices.add_theme_constant_override("h_separation", 8)
sort_choices.add_theme_constant_override("v_separation", 8)
sort_card.add_child(sort_choices)
var group := ButtonGroup.new()
for option in JellyXRSettings.LIBRARY_SORT_OPTIONS:
_choice(option[0], option[1] == settings.library_sort_by, group, sort_choices,
func(): settings.library_sort_by = option[1]; _save())
func _build_playback(parent: VBoxContainer) -> void:
parent = _card(parent, "Playback quality")
_hint("Choose the video quality. Your choice applies to the next video you start.", parent)
playback_choices = VBoxContainer.new()
playback_choices.add_theme_constant_override("separation", 12)
parent.add_child(playback_choices)
var group := ButtonGroup.new()
var conversion_hint := "Jellyfin converts the video to the resolution you choose below, up to 30 fps."
for option in [
["Automatic", "auto", "Play the original file when supported. Otherwise, Jellyfin converts it."],
["Original full resolution", "original", "Play the original file without conversion. Your device must support its format."],
["Transcoded", "transcoded", conversion_hint]
]:
var row := VBoxContainer.new()
playback_choices.add_child(row)
_choice(option[0], option[1] == settings.playback_mode, group, row,
func(): _select_playback_mode(option[1]))
_hint(option[2], row)
_build_transcode_options(parent)
func _select_playback_mode(value: String) -> void:
settings.playback_mode = value
transcode_options.visible = value == "transcoded"
_save()
func _build_transcode_options(parent: VBoxContainer) -> void:
transcode_options = VBoxContainer.new()
transcode_options.add_theme_constant_override("separation", 12)
transcode_options.visible = settings.playback_mode == "transcoded"
parent.add_child(transcode_options)
Widgets.label("Transcoded resolution", transcode_options, 26)
transcode_choices = HFlowContainer.new()
transcode_choices.add_theme_constant_override("h_separation", 12)
transcode_choices.add_theme_constant_override("v_separation", 12)
transcode_options.add_child(transcode_choices)
var group := ButtonGroup.new()
for resolution in JellyXRSettings.TRANSCODE_PRESETS:
var preset: Dictionary = JellyXRSettings.TRANSCODE_PRESETS[resolution]
_choice(preset.label, settings.transcode_resolution == resolution, group, transcode_choices,
func(): settings.transcode_resolution = resolution; _save())
_hint("Choose a maximum resolution. Smaller videos keep their size. Higher resolutions need a faster connection and more server processing power.", transcode_options)
func _choice(title: String, selected: bool, group: ButtonGroup, parent: Node, action: Callable) -> CheckBox:
var choice := CheckBox.new()
choice.text = title
choice.accessibility_name = title
choice.button_group = group
choice.button_pressed = selected
choice.custom_minimum_size = Vector2(0, 56)
choice.size_flags_horizontal = Control.SIZE_EXPAND_FILL
choice.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
_style_control(choice)
choice.pressed.connect(action)
parent.add_child(choice)
return choice
func _build_screen(parent: VBoxContainer) -> void:
_build_projection(parent)
_build_video_format(parent)
_build_screen_position(parent)
func _build_screen_position(parent: VBoxContainer) -> void:
parent = _card(parent)
var header := HFlowContainer.new()
header.add_theme_constant_override("h_separation", 18)
header.add_theme_constant_override("v_separation", 12)
parent.add_child(header)
Widgets.label("Screen position", header, 26).size_flags_horizontal = Control.SIZE_EXPAND_FILL
reset_screen_button = Widgets.button("Reset to defaults", _reset_screen, header)
reset_screen_button.tooltip_text = "Reset screen size and position. Keep your environment and video format."
_hint(_screen_position_hint(), parent)
screen_dimensions = GridContainer.new()
screen_dimensions.columns = 2
screen_dimensions.add_theme_constant_override("h_separation", 24)
screen_dimensions.add_theme_constant_override("v_separation", 18)
parent.add_child(screen_dimensions)
for entry in [["Width", "width", 1.5, 7.0, " m"], ["Distance", "distance", 1.5, 8.0, " m"], ["Height", "height", -2.0, 2.0, " m"], ["Curvature", "curvature", 0.0, 1.2, ""]]:
_slider(entry, screen_dimensions)
func _screen_position_hint() -> String:
if projection_mode != VideoProjection.Mode.SCREEN:
return "Screen position does not affect 180° or 360° video. Recenter to change your forward direction."
if settings.is_theatre():
return "Cinema and Theatre have fixed screens. Choose Void or Passthrough on the Environment page to adjust it."
return "Set the screen size, distance, height and curve."
func _build_video_format(parent: VBoxContainer) -> void:
parent = _card(parent, "Picture format")
_hint("jellyXR detects the 3D format. Choose a different format if the picture looks wrong.", parent)
stereo_choices = HFlowContainer.new()
stereo_choices.add_theme_constant_override("h_separation", 12)
parent.add_child(stereo_choices)
var group := ButtonGroup.new()
for entry in [["2D", Stereo.Mode.MONO], ["Half-SBS", Stereo.Mode.HALF_SBS], ["Full-SBS", Stereo.Mode.FULL_SBS], ["Top-bottom", Stereo.Mode.TOP_BOTTOM]]:
_choice(entry[0], entry[1] == stereo_mode, group, stereo_choices, func(): stereo_mode = entry[1]; stereo_selected.emit(entry[1]))
var swap := CheckButton.new()
swap.text = "Swap left and right eyes"
swap.accessibility_name = swap.text
_style_control(swap)
swap.custom_minimum_size.y = 56
swap.button_pressed = swap_eyes
swap.toggled.connect(func(enabled: bool): swap_eyes = enabled; eyes_swapped.emit())
parent.add_child(swap)
func _build_projection(parent: VBoxContainer) -> void:
parent = _card(parent, "Video projection")
_hint("180° fills the view in front of you. 360° surrounds you. Both use equirectangular video and hide the environment during playback. Fisheye and cubemap layouts are not supported.", parent)
var choices := HFlowContainer.new()
choices.add_theme_constant_override("h_separation", 12)
choices.add_theme_constant_override("v_separation", 12)
parent.add_child(choices)
var group := ButtonGroup.new()
for entry in [["Screen", VideoProjection.Mode.SCREEN], ["180°", VideoProjection.Mode.EQUIRECTANGULAR_180], ["360°", VideoProjection.Mode.EQUIRECTANGULAR]]:
_choice(entry[0], entry[1] == projection_mode, group, choices, func():
projection_mode = entry[1]
projection_selected.emit(entry[1])
_rebuild_screen.call_deferred())
func _build_environment(parent: VBoxContainer) -> void:
_hint("Choose a background for your screen.", parent)
environment_choices = VBoxContainer.new()
environment_choices.add_theme_constant_override("separation", 16)
parent.add_child(environment_choices)
var adjustable := _card(environment_choices, "Void & Passthrough")
var fixed := _card(environment_choices, "Cinema & Theatre")
var group := ButtonGroup.new()
var descriptions := [
"A dark background. Change the screen size and position in Screen settings.",
"See your room around the screen. Change its size and position in Screen settings." if passthrough_supported else "This device does not support passthrough.",
"A cinema with a fixed screen. Recenter to face it.",
"A simpler cinema with a fixed screen. Recenter to face it."
]
var values := ["Void", "Passthrough", "Cinema", "Theatre"]
for index in range(values.size()):
var value: String = values[index]
var card := adjustable if index < 2 else fixed
var choice := _choice(value, value == settings.environment, group, card,
func(): settings.environment = value; _rebuild_screen(); environment_changed.emit(); _save())
environment_buttons[value] = choice
_hint(descriptions[index], card)
if value == "Passthrough" and not passthrough_supported:
choice.disabled = true
choice.tooltip_text = descriptions[index]
_build_follow_switch(adjustable)
func _build_follow_switch(parent: VBoxContainer) -> void:
var toggle := CheckButton.new()
toggle.text = "Follow your movement"
toggle.accessibility_name = toggle.text
toggle.button_pressed = settings.follow_screen
toggle.custom_minimum_size.y = 56
_style_control(toggle)
parent.add_child(toggle)
follow_switch = toggle
toggle.toggled.connect(func(enabled: bool):
settings.follow_screen = enabled
screen_follow_changed.emit()
_save())
_hint("The screen follows you as you move or turn. Applies to both Void and Passthrough. Does not affect 180° or 360° video.", parent)
func _reset_screen() -> void:
settings.reset_screen()
_rebuild_screen()
if reset_screen_button.is_visible_in_tree(): reset_screen_button.grab_focus()
screen_changed.emit()
_save()
func _rebuild_screen() -> void:
dragging_slider = false
var section := sections[1]
for child in section.get_children():
section.remove_child(child)
child.queue_free()
_build_screen(section)
_adapt_columns()
func _slider(entry: Array, parent: Node) -> void:
var row := VBoxContainer.new()
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_theme_constant_override("separation", 4)
parent.add_child(row)
var labels := HBoxContainer.new()
row.add_child(labels)
var name_label := Widgets.label(entry[0], labels, 22)
name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var value_label := Widgets.label("%.2f%s" % [settings.get(entry[1]), entry[4]], labels, 20)
var slider := preload("res://scripts/ui/screen_position_slider.gd").new()
slider.accessibility_name = entry[0]
slider.custom_minimum_size.y = 48
slider.min_value = entry[2]
slider.max_value = entry[3]
slider.step = 0.05
slider.value = settings.get(entry[1])
slider.editable = not settings.is_theatre() and projection_mode == VideoProjection.Mode.SCREEN
row.add_child(slider)
slider.value_changed.connect(func(value: float):
settings.set(entry[1], value)
value_label.text = "%.2f%s" % [value, entry[4]]
screen_changed.emit()
if not dragging_slider: _save())
slider.drag_started.connect(func(): dragging_slider = true)
slider.drag_ended.connect(func(_changed: bool): dragging_slider = false; _save())
func _save() -> void:
status.emit("Settings saved." if settings.save() == OK else "Could not save settings. Check available storage and try again.")
func _adapt_columns() -> void:
var content_width := scroll.size.x - 40
if sort_choices != null:
sort_choices.columns = 2 if content_width >= 540 else 1
if screen_dimensions != null:
screen_dimensions.columns = 2 if content_width >= 540 else 1