diff --git a/scripts/ui/cast_carousel.gd b/scripts/ui/cast_carousel.gd new file mode 100644 index 0000000..462813a --- /dev/null +++ b/scripts/ui/cast_carousel.gd @@ -0,0 +1,97 @@ +class_name CastCarousel +extends VBoxContainer + +var people: Array = [] +var artwork: ArtworkLoader +var scroll: ScrollContainer +var cards: HBoxContainer +var previous_button: Button +var next_button: Button + +const PORTRAIT_SIZE := Vector2(180, 270) +const CARD_SPACING := 18 + +func _ready() -> void: + add_theme_constant_override("separation", 18) + var heading := HBoxContainer.new() + heading.add_theme_constant_override("separation", 12) + add_child(heading) + var title := Widgets.label("Cast & crew", heading, 28) + title.size_flags_horizontal = Control.SIZE_EXPAND_FILL + previous_button = Widgets.button("<", _move.bind(-1), heading) + next_button = Widgets.button(">", _move.bind(1), heading) + for button in [previous_button, next_button]: + button.custom_minimum_size = Vector2(64, 56) + previous_button.accessibility_name = "Previous cast members" + previous_button.tooltip_text = "Previous cast members" + next_button.accessibility_name = "Next cast members" + next_button.tooltip_text = "Next cast members" + scroll = ScrollContainer.new() + scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_SHOW_NEVER + scroll.custom_minimum_size.y = 360 + scroll.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(scroll) + cards = HBoxContainer.new() + cards.add_theme_constant_override("separation", CARD_SPACING) + scroll.add_child(cards) + for person in people: + if person.get("Name"): _add_person(person) + var bar := scroll.get_h_scroll_bar() + bar.mouse_filter = Control.MOUSE_FILTER_IGNORE + bar.value_changed.connect(func(_value: float): _update_navigation()) + bar.changed.connect(func(): _update_navigation.call_deferred()) + _update_navigation.call_deferred() + +func _add_person(person: Dictionary) -> void: + var card := VBoxContainer.new() + card.custom_minimum_size.x = PORTRAIT_SIZE.x + card.add_theme_constant_override("separation", 8) + cards.add_child(card) + var frame := PanelContainer.new() + frame.custom_minimum_size = PORTRAIT_SIZE + frame.size_flags_vertical = Control.SIZE_SHRINK_BEGIN + frame.add_theme_stylebox_override("panel", Widgets.surface(Color("25334a"), 12)) + card.add_child(frame) + var fallback := Widgets.label(str(person.Name).left(1).to_upper(), frame, 48) + fallback.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + fallback.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + fallback.add_theme_color_override("font_color", Color("b0bbce")) + var portrait := TextureRect.new() + portrait.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + portrait.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED + portrait.mouse_filter = Control.MOUSE_FILTER_IGNORE + portrait.accessibility_name = "Portrait of " + str(person.Name) + frame.add_child(portrait) + if person.get("Id"): + var image_item := person.duplicate() + if person.get("PrimaryImageTag"): + image_item["ImageTags"] = {"Primary": person.PrimaryImageTag} + artwork.fetch(portrait, image_item) + _caption(str(person.Name), card, 22) + var role := str(person.get("Role") if person.get("Role") else person.get("Type", "")) + if not role.is_empty(): + _caption(role, card, 20).add_theme_color_override("font_color", Color("b0bbce")) + +func _caption(value: String, parent: Node, font_size: int) -> Label: + var label := Widgets.label(value, parent, font_size) + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + label.mouse_filter = Control.MOUSE_FILTER_IGNORE + label.add_theme_constant_override("line_spacing", 3) + return label + +func _move(direction: int) -> void: + scroll.scroll_horizontal += int(direction * maxf(PORTRAIT_SIZE.x + CARD_SPACING, scroll.size.x - PORTRAIT_SIZE.x - CARD_SPACING)) + +func _gui_input(event: InputEvent) -> void: + if not event is InputEventMouseButton or not event.pressed: return + if event.button_index not in [MOUSE_BUTTON_WHEEL_LEFT, MOUSE_BUTTON_WHEEL_RIGHT]: return + var direction := 1 if event.button_index == MOUSE_BUTTON_WHEEL_RIGHT else -1 + scroll.scroll_horizontal += int(direction * maxf(1.0, event.factor * 80)) + accept_event() + +func _update_navigation() -> void: + var bar := scroll.get_h_scroll_bar() + previous_button.disabled = bar.value <= bar.min_value + next_button.disabled = bar.value >= bar.max_value - bar.page diff --git a/scripts/ui/cast_carousel.gd.uid b/scripts/ui/cast_carousel.gd.uid new file mode 100644 index 0000000..0559829 --- /dev/null +++ b/scripts/ui/cast_carousel.gd.uid @@ -0,0 +1 @@ +uid://b04oh6smudi8m diff --git a/scripts/ui/details.gd b/scripts/ui/details.gd index b35651e..919765c 100644 --- a/scripts/ui/details.gd +++ b/scripts/ui/details.gd @@ -16,6 +16,7 @@ var title_label: Label var overview_label: Label var poster: TextureRect var backdrop: TextureRect +var cast_carousel: CastCarousel func _ready() -> void: add_theme_constant_override("separation", 18) @@ -154,14 +155,10 @@ func _build_information() -> void: func _build_people() -> void: var people: Array = item.get("People", []) if item.get("People") else [] if people.is_empty(): return - Widgets.label("Cast & crew", body, 28) - var names := VBoxContainer.new() - names.add_theme_constant_override("separation", 12) - body.add_child(names) - for person in people: - if not person.get("Name"): continue - var role := str(person.get("Role") if person.get("Role") else person.get("Type", "")) - _wrapped(str(person.Name) + (" / " + role if not role.is_empty() else ""), names, 22) + cast_carousel = CastCarousel.new() + cast_carousel.people = people + cast_carousel.artwork = artwork + body.add_child(cast_carousel) func _image(parent: Node) -> TextureRect: var image := TextureRect.new() diff --git a/tests/test_cast.gd b/tests/test_cast.gd new file mode 100644 index 0000000..6447fd5 --- /dev/null +++ b/tests/test_cast.gd @@ -0,0 +1,106 @@ +extends SceneTree + +class FixtureArtwork extends ArtworkLoader: + var fetched: Array = [] + func fetch(target: TextureRect, item: Dictionary, _backdrop: bool = false) -> void: + if not item.get("ImageTags", {}).has("Primary"): return + fetched.append(item) + var texture := GradientTexture2D.new() + var gradient := Gradient.new() + gradient.colors = PackedColorArray([Color("574c78"), Color("25334a")]) + texture.gradient = gradient + target.texture = texture + +var failures := 0 + +func _initialize() -> void: + call_deferred("run_checks") + +func settle() -> void: + for frame in range(8): await process_frame + +func check(condition: bool, message: String) -> void: + if not condition: + failures += 1 + push_error(message) + +func run_checks() -> void: + root.size = Vector2i(1536, 960) + var shell := UiShell.new() + root.add_child(shell) + shell.set_signed_in(true) + shell.select("movies", "Movies") + var artwork := FixtureArtwork.new() + root.add_child(artwork) + var people: Array = [] + for index in range(12): + people.append({"Id": str(index), "Name": "Cast member %d" % index, "Role": "Character %d" % index, "PrimaryImageTag": "portrait-tag" if index == 0 else ""}) + people[1].Name = "A performer with a very long full name" + people[1].Role = "A character with a long name that must remain fully readable" + people.append({"Id": "unnamed"}) + var view := DetailsView.new() + view.api = JellyfinClient.new() + view.add_child(view.api) + view.artwork = artwork + view.item = {"Id": "movie", "Type": "Movie", "Name": "Movie details", "People": people} + view.size_flags_vertical = Control.SIZE_EXPAND_FILL + shell.page.add_child(view) + await settle() + var cast := view.cast_carousel + var spacer := Control.new() + spacer.custom_minimum_size.y = 400 + view.body.add_child(spacer) + await settle() + check(cast.cards.get_child_count() == 12, "Each named person must have one portrait card.") + check(artwork.fetched.size() == 1 and artwork.fetched[0].ImageTags.Primary == "portrait-tag", "Jellyfin person image tags must load portraits by person ID.") + check(cast.cards.get_child(0).get_child(0).get_child(1).texture != null, "Available portraits must appear in their cards.") + check(cast.previous_button.disabled and not cast.next_button.disabled, "Only forward navigation should be available at the start.") + check(not cast.scroll.get_h_scroll_bar().visible, "The cast scrollbar must stay hidden.") + var outer_position := view.scroll.scroll_vertical + cast.next_button.pressed.emit() + await settle() + check(cast.scroll.scroll_horizontal > 0 and not cast.previous_button.disabled, "Next must scroll the row horizontally.") + check(view.scroll.scroll_vertical == outer_position, "Carousel navigation must preserve the main page's scroll position.") + cast.scroll.scroll_horizontal = 0 + view.scroll.ensure_control_visible(cast) + await settle() + for direction in [MOUSE_BUTTON_WHEEL_DOWN, MOUSE_BUTTON_WHEEL_UP]: + var position := view.scroll.scroll_vertical + var wheel := InputEventMouseButton.new() + wheel.button_index = direction + wheel.pressed = true + wheel.position = cast.scroll.get_global_rect().position + Vector2(90, 90) + wheel.global_position = wheel.position + root.push_input(wheel, true) + await settle() + check(cast.scroll.scroll_horizontal == 0, "Vertical wheel input over a portrait must not move the cast row.") + check(view.scroll.scroll_vertical > position if direction == MOUSE_BUTTON_WHEEL_DOWN else view.scroll.scroll_vertical < position, "Vertical wheel input over the cast must scroll the main page in the requested direction.") + for direction in [MOUSE_BUTTON_WHEEL_RIGHT, MOUSE_BUTTON_WHEEL_LEFT]: + var horizontal_position := cast.scroll.scroll_horizontal + var vertical_position := view.scroll.scroll_vertical + var wheel := InputEventMouseButton.new() + wheel.button_index = direction + wheel.pressed = true + wheel.factor = 0.2 + wheel.position = cast.scroll.get_global_rect().position + Vector2(90, 90) + wheel.global_position = wheel.position + root.push_input(wheel, true) + await settle() + check(cast.scroll.scroll_horizontal > horizontal_position if direction == MOUSE_BUTTON_WHEEL_RIGHT else cast.scroll.scroll_horizontal < horizontal_position, "Horizontal wheel input must move the cast row in the requested direction.") + check(view.scroll.scroll_vertical == vertical_position, "Horizontal input must not move the details page vertically.") + cast.scroll.scroll_horizontal = 0 + await settle() + check(cast.cards.get_child(1).get_combined_minimum_size().y <= cast.scroll.size.y, "Long names and roles must not be clipped vertically.") + if "--capture" in OS.get_cmdline_user_args(): + await RenderingServer.frame_post_draw + root.get_texture().get_image().save_png("res://dist/cast-preview.png") + cast.scroll.scroll_horizontal = 100000 + await settle() + check(cast.next_button.disabled and not cast.previous_button.disabled, "Next must be disabled at the end.") + for width in [1280, 960]: + root.size.x = width + await settle() + check(cast.size.x <= view.scroll.size.x, "The carousel must fit narrower panels.") + check(cast.cards.get_child(0).size.x == 180, "Portrait cards must retain their width while scrolling.") + print("Cast checks: ", "PASS" if failures == 0 else str(failures) + " failures") + quit(1 if failures else 0) diff --git a/tests/test_cast.gd.uid b/tests/test_cast.gd.uid new file mode 100644 index 0000000..ddbf969 --- /dev/null +++ b/tests/test_cast.gd.uid @@ -0,0 +1 @@ +uid://c0d108vllnr6y