extends Window
## Media lightbox: image preview + inline video player with seek bar.
##
## Layout:
## [ Image or Video content (fills available space) ]
## [ ▶ ] [ 0:23 ] [=====●===================] [ 2:45 ] [🔊] [==vol==]
## [ < 1 / 3 > (nav arrows) ]
## [ status text ]
@onready var image_display: TextureRect = %ImageDisplay
@onready var video_container: Control = %VideoContainer
@onready var video_player: Control = %VideoPlayer # VideoPlayback (GoZen)
@onready var player_bar: PanelContainer = %PlayerBar
@onready var play_pause_btn: Button = %PlayPauseBtn
@onready var current_time: Label = %CurrentTime
@onready var seek_slider: HSlider = %SeekSlider
@onready var duration_label: Label = %DurationLabel
@onready var mute_btn: Button = %MuteBtn
@onready var volume_slider: HSlider = %VolumeSlider
@onready var prev_btn: Button = %PrevBtn
@onready var next_btn: Button = %NextBtn
@onready var count_label: Label = %CountLabel
@onready var status_label: Label = %StatusLabel
var _room_id: String = ""
var _files: Array = [] # Array of {file_id, file_name, http_link, is_video}
var _current_idx: int = 0
var _texture_cache: KeetTextureCache = KeetTextureCache.new()
var _is_video_mode: bool = false
var _seeking: bool = false # True while user is dragging the slider
var _video_duration: float = 0.0
var _muted: bool = false
var _pre_mute_volume: float = 80.0 # Remember volume before mute
func _ready() -> void:
close_requested.connect(_on_close)
prev_btn.pressed.connect(_on_prev)
next_btn.pressed.connect(_on_next)
play_pause_btn.pressed.connect(_on_play_pause)
# Seek slider: user drags to scrub through video
seek_slider.drag_started.connect(func(): _seeking = true)
seek_slider.drag_ended.connect(_on_seek_drag_ended)
seek_slider.value_changed.connect(_on_seek_value_changed)
# Volume controls
mute_btn.pressed.connect(_on_mute_toggle)
volume_slider.value_changed.connect(_on_volume_changed)
_apply_volume(volume_slider.value)
# Click on video toggles play/pause
video_container.gui_input.connect(_on_video_click)
func _process(_delta: float) -> void:
if not _is_video_mode or not video_player.is_open():
return
if not _seeking:
_update_time_display()
func _on_close() -> void:
_stop_video()
hide()
func _unhandled_key_input(event: InputEvent) -> void:
if not visible:
return
if event is InputEventKey and event.pressed:
match event.keycode:
KEY_LEFT:
if _is_video_mode and video_player.is_open():
# Seek back 5 seconds
var target: int = maxi(video_player.current_frame - int(video_player.get_video_framerate() * 5.0), 0)
video_player.seek_frame(target)
else:
_on_prev()
get_viewport().set_input_as_handled()
KEY_RIGHT:
if _is_video_mode and video_player.is_open():
# Seek forward 5 seconds
var target: int = mini(video_player.current_frame + int(video_player.get_video_framerate() * 5.0), video_player.get_video_frame_count())
video_player.seek_frame(target)
else:
_on_next()
get_viewport().set_input_as_handled()
KEY_ESCAPE:
_on_close()
get_viewport().set_input_as_handled()
KEY_SPACE:
if _is_video_mode:
_on_play_pause()
get_viewport().set_input_as_handled()
KEY_M:
if _is_video_mode:
_on_mute_toggle()
get_viewport().set_input_as_handled()
KEY_UP:
if _is_video_mode:
volume_slider.value = minf(volume_slider.value + 10.0, 100.0)
get_viewport().set_input_as_handled()
KEY_DOWN:
if _is_video_mode:
volume_slider.value = maxf(volume_slider.value - 10.0, 0.0)
get_viewport().set_input_as_handled()
func _on_video_click(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
if _is_video_mode and video_player.is_open():
_on_play_pause()
func _on_mute_toggle() -> void:
if _muted:
_muted = false
volume_slider.value = _pre_mute_volume
mute_btn.text = _volume_icon(_pre_mute_volume)
else:
_pre_mute_volume = volume_slider.value
_muted = true
volume_slider.set_value_no_signal(0.0)
_apply_volume(0.0)
mute_btn.text = "🔇"
func _on_volume_changed(value: float) -> void:
_muted = value == 0.0
mute_btn.text = _volume_icon(value)
_apply_volume(value)
func _apply_volume(percent: float) -> void:
if video_player and video_player.audio_player:
if percent <= 0.0:
video_player.audio_player.volume_db = -80.0
else:
video_player.audio_player.volume_db = linear_to_db(percent / 100.0)
func _volume_icon(percent: float) -> String:
if percent <= 0.0:
return "🔇"
if percent < 50.0:
return "🔉"
return "🔊"
## Show a single media file with optional siblings for navigation.
func show_image(room_id: String, file_id: String, sibling_files: Array) -> void:
_room_id = room_id
_files = sibling_files
_current_idx = 0
for i: int in range(_files.size()):
var f: Dictionary = _files[i]
if f.get("file_id", "") == file_id:
_current_idx = i
break
_update_nav()
_load_current()
popup_centered()
func _on_prev() -> void:
if _current_idx > 0:
_stop_video()
_current_idx -= 1
_update_nav()
_load_current()
func _on_next() -> void:
if _current_idx < _files.size() - 1:
_stop_video()
_current_idx += 1
_update_nav()
_load_current()
func _on_play_pause() -> void:
if not _is_video_mode or not video_player.is_open():
return
if video_player.is_playing:
video_player.pause()
play_pause_btn.text = "▶"
else:
video_player.play()
play_pause_btn.text = "⏸"
func _stop_video() -> void:
if video_player and video_player.is_open():
video_player.close()
_is_video_mode = false
_video_duration = 0.0
func _update_nav() -> void:
var total: int = _files.size()
count_label.text = "%d / %d" % [_current_idx + 1, total]
prev_btn.disabled = _current_idx <= 0
next_btn.disabled = _current_idx >= total - 1
prev_btn.visible = total > 1
next_btn.visible = total > 1
count_label.visible = total > 1
func _update_time_display() -> void:
if not video_player.is_open():
return
var pos_sec: float = float(video_player.current_frame) / maxf(video_player.get_video_framerate(), 1.0)
var dur_sec: float = _video_duration
current_time.text = _format_time(pos_sec)
duration_label.text = _format_time(dur_sec)
# Update slider position (0-100)
if dur_sec > 0.0:
seek_slider.set_value_no_signal(pos_sec / dur_sec * 100.0)
func _on_seek_drag_ended(value_changed: bool) -> void:
_seeking = false
if value_changed and video_player.is_open() and _video_duration > 0.0:
var target_sec: float = (seek_slider.value / 100.0) * _video_duration
var target_frame: int = int(target_sec * video_player.get_video_framerate())
video_player.seek_frame(clampi(target_frame, 0, video_player.get_video_frame_count()))
func _on_seek_value_changed(value: float) -> void:
# Live preview of time while dragging
if _seeking and _video_duration > 0.0:
var preview_sec: float = (value / 100.0) * _video_duration
current_time.text = _format_time(preview_sec)
func _format_time(seconds: float) -> String:
var total_sec: int = int(maxf(seconds, 0.0))
var hrs: int = total_sec / 3600
var mins: int = (total_sec % 3600) / 60
var secs: int = total_sec % 60
if hrs > 0:
return "%d:%02d:%02d" % [hrs, mins, secs]
return "%d:%02d" % [mins, secs]
func _load_current() -> void:
if _current_idx < 0 or _current_idx >= _files.size():
return
var f: Dictionary = _files[_current_idx]
var file_id: String = f.get("file_id", "")
var file_name: String = f.get("file_name", "media")
var http_link: String = f.get("http_link", "")
var is_video: bool = f.get("is_video", false)
title = file_name
status_label.text = "Loading..."
if is_video:
_load_video(file_id, file_name)
else:
_load_image(file_id, http_link)
func _load_image(file_id: String, http_link: String) -> void:
_is_video_mode = false
image_display.visible = true
image_display.texture = null
video_container.visible = false
player_bar.visible = false
var cached: ImageTexture = _texture_cache.get_cached(file_id)
if cached != null:
image_display.texture = cached
status_label.text = ""
return
KeetService.file_mgr.get_preview(_room_id, file_id, "large", func(data: Dictionary) -> void:
if not is_instance_valid(self):
return
var link: String = str(data.get("httpLink", ""))
if link == "":
link = str(data.get("fullHttpLink", ""))
if link != "":
_fetch_image(link, file_id)
else:
status_label.text = "No preview available"
)
func _load_video(file_id: String, file_name: String) -> void:
_is_video_mode = true
image_display.visible = false
video_container.visible = true
player_bar.visible = true
# Reset player bar to loading state
play_pause_btn.text = "▶"
play_pause_btn.disabled = true
current_time.text = "0:00"
duration_label.text = "0:00"
seek_slider.set_value_no_signal(0.0)
seek_slider.editable = false
status_label.text = "Loading video..."
KeetService.file_mgr.get_preview(_room_id, file_id, "medium", func(data: Dictionary) -> void:
if not is_instance_valid(self):
return
var full_link: String = str(data.get("fullHttpLink", ""))
var preview_link: String = str(data.get("httpLink", ""))
print("[lightbox] video: full=%s preview=%s" % [full_link.substr(0, 60), preview_link.substr(0, 60)])
if full_link != "":
_play_video(full_link, file_name)
elif preview_link != "":
_play_video(preview_link, file_name)
else:
status_label.text = "No video URL available"
player_bar.visible = false
)
func _play_video(video_url: String, file_name: String) -> void:
print("[lightbox] playing: %s" % video_url.substr(0, 100))
status_label.text = "Loading stream..."
if not video_player.video_loaded.is_connected(_on_video_loaded):
video_player.video_loaded.connect(_on_video_loaded)
if not video_player.video_ended.is_connected(_on_video_ended):
video_player.video_ended.connect(_on_video_ended)
video_player.set_video_path(video_url)
# Timeout fallback — download if streaming fails
get_tree().create_timer(10.0).timeout.connect(func():
if not video_player.is_open() and _is_video_mode:
print("[lightbox] stream timeout, downloading")
_download_and_play(video_url, file_name)
)
func _on_video_loaded() -> void:
_video_duration = float(video_player.get_video_frame_count()) / maxf(video_player.get_video_framerate(), 1.0)
duration_label.text = _format_time(_video_duration)
play_pause_btn.disabled = false
seek_slider.editable = true
status_label.text = ""
# Apply current volume setting to newly loaded video
_apply_volume(volume_slider.value if not _muted else 0.0)
# Ready to play — user initiates via button, click, or Space
play_pause_btn.text = "▶"
status_label.text = "Press ▶ to play"
func _on_video_ended() -> void:
play_pause_btn.text = "▶"
status_label.text = ""
func _download_and_play(video_url: String, file_name: String) -> void:
var safe_name: String = file_name.get_file().replace(" ", "_")
if safe_name == "":
safe_name = "video.mp4"
var tmp_path: String = "/tmp/keet-lightbox-%s" % safe_name
status_label.text = "Downloading..."
var http_req := HTTPRequest.new()
http_req.download_file = tmp_path
add_child(http_req)
http_req.request_completed.connect(func(result: int, response_code: int, _headers: PackedStringArray, _body: PackedByteArray):
http_req.queue_free()
if result != HTTPRequest.RESULT_SUCCESS or (response_code != 200 and response_code != 206):
status_label.text = "Download failed (%d)" % response_code
return
print("[lightbox] downloaded to %s" % tmp_path)
video_player.set_video_path(tmp_path)
)
http_req.request(video_url)
func _fetch_image(http_link: String, file_id: String) -> void:
var http_req := HTTPRequest.new()
add_child(http_req)
http_req.request_completed.connect(func(result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray):
http_req.queue_free()
if result != HTTPRequest.RESULT_SUCCESS or response_code != 200:
status_label.text = "Failed to load"
return
var content_type: String = ""
for h: String in headers:
if h.to_lower().begins_with("content-type:"):
content_type = h.substr(h.find(":") + 1).strip_edges().to_lower()
break
var tex: ImageTexture = _texture_cache.decode_bytes_and_cache(file_id, body, content_type)
if tex != null:
image_display.texture = tex
status_label.text = ""
else:
status_label.text = "Failed to decode"
)
http_req.request(http_link)