OverviewFilesCommitsIssuesDocsReleasesPeers

godot-keet / scripts / image_lightbox.gd

409 lines · gd ref:
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)