mirror of
https://github.com/Cuteminded/jellyframe.git
synced 2026-10-17 17:00:38 +02:00
Add cast carousel with portrait navigation
Replaces the details page’s text-only cast list with a dedicated `CastCarousel` UI component that shows portrait cards, names, and roles in a horizontally scrollable row. It adds previous/next controls, hides the internal scrollbar, and handles horizontal wheel input without breaking vertical page scrolling. Also adds `tests/test_cast.gd` to cover card rendering, image-tag based portrait loading, navigation button state, wheel behavior, long-text layout safety, and responsive sizing.
This commit is contained in:
1 parent
4f28515204
commit
1d07bcb46f
5 files changed
+210
-8
No files matched your search
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://b04oh6smudi8m
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c0d108vllnr6y
|
||||
Reference in new issue
Block a user