Add an in-explorer video player for the dat/movie/*.wmv cutscenes. Decode via the ffmpeg/ffprobe CLI (no libav linking): video streams from an `ffmpeg -f rawvideo -pix_fmt rgba` pipe read into fixed w*h*4 frame chunks over a bounded channel (paused/behind => ffmpeg back-pressures on its pipe); one reused Image is overwritten in place each tick. Audio is best-effort: the track is pre-decoded to a temp stereo WAV and played through a rodio Sink (volume / play / pause / seek-via-skip_duration for free). Native-only under cfg(not(wasm32)); VideoPreview registers unconditionally so the UI compiles for wasm. Mirrors the .pak load pipeline (FileSelected -> bg thread -> IsoLoaderMsg::VideoLoaded -> apply_loaded_video -> advance_video_playback), with a single free_video teardown wired into every other viewer's reset. Controls: - play/pause button, click-on-frame toggle, Space / arrow (+-10s) shortcuts - seekable timeline with mm:ss labels; volume slider (greyed when no audio) - live scrubbing: a coalescing one-shot `ffmpeg -ss -frames:v 1` grabber always jumps to the knob's latest position, so dragging shows the frame under the knob in real time; that grabbed frame also bridges the gap after a commit-seek so there's no black flash while the streaming decoder catches up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
645 lines
26 KiB
Rust
645 lines
26 KiB
Rust
//! egui-based debug UI for the asset viewer.
|
||
//!
|
||
//! Provides panels for:
|
||
//! - File browser (list ISO / extracted game files, filter by name)
|
||
//! - Texture inspector (preview + format info)
|
||
//! - File info (size, detected format for non-texture files)
|
||
//! - RE notes (track discoveries during reverse engineering)
|
||
|
||
use bevy::prelude::*;
|
||
use bevy_egui::{egui, EguiContexts};
|
||
|
||
use crate::iso_loader::{
|
||
FileInfo, FileSelected, IsoState, PakView, RequestOpenDir, RequestOpenIso, SkyboxPreview,
|
||
TextPreview, TexturePreview, VideoPreview, IsoLoaderSystemSet,
|
||
};
|
||
use crate::ViewerState;
|
||
|
||
pub struct ViewerUiPlugin;
|
||
|
||
impl Plugin for ViewerUiPlugin {
|
||
fn build(&self, app: &mut App) {
|
||
app.insert_resource(FileBrowserState::default());
|
||
// Run after the iso_loader chain so we see the frame's final state.
|
||
app.add_systems(Update, draw_viewer_ui.after(IsoLoaderSystemSet));
|
||
}
|
||
}
|
||
|
||
/// State for the file browser panel.
|
||
#[derive(Resource, Default)]
|
||
pub struct FileBrowserState {
|
||
pub files: Vec<String>,
|
||
pub selected: Option<usize>,
|
||
pub filter: String,
|
||
/// True from the moment a file is clicked until its content is applied —
|
||
/// drives the "Loading…" indicator so the click registers immediately.
|
||
pub loading: bool,
|
||
}
|
||
|
||
// ── Directory tree ────────────────────────────────────────────────────────────
|
||
|
||
/// One directory node in the ephemeral file tree. Rebuilt from the flat path
|
||
/// list each frame; leaves carry their original index into `browser.files` so
|
||
/// selection stays stable regardless of tree shape or filtering.
|
||
#[derive(Default)]
|
||
struct TreeDir {
|
||
/// Child directories, keyed by name (`BTreeMap` = stable alphabetical order).
|
||
dirs: std::collections::BTreeMap<String, TreeDir>,
|
||
/// Leaf files: (display name, original index into `browser.files`).
|
||
files: Vec<(String, usize)>,
|
||
}
|
||
|
||
/// True for an IPFB data segment (`foo.p00`, `foo.p01`, …). These are the pack's
|
||
/// data body, not standalone files — the `.pak` index is what the user opens, so
|
||
/// they're hidden from the tree (their content loads when the `.pak` is opened).
|
||
fn is_pak_segment(leaf: &str) -> bool {
|
||
matches!(leaf.rsplit_once('.'), Some((_, ext))
|
||
if ext.len() == 3
|
||
&& ext.as_bytes()[0] == b'p'
|
||
&& ext.as_bytes()[1].is_ascii_digit()
|
||
&& ext.as_bytes()[2].is_ascii_digit())
|
||
}
|
||
|
||
/// Split a full path into (directory components, leaf name).
|
||
fn split_for_tree(full: &str) -> (Vec<String>, String) {
|
||
let mut comps: Vec<String> = full.split('/').map(str::to_string).collect();
|
||
let leaf = comps.pop().unwrap_or_default();
|
||
(comps, leaf)
|
||
}
|
||
|
||
/// Build the directory tree, keeping only leaves whose full path contains
|
||
/// `filter` (case-insensitive; empty filter keeps everything). `.pNN` data
|
||
/// segments are skipped — only their `.pak` index is shown.
|
||
fn build_tree(files: &[String], filter: &str) -> TreeDir {
|
||
let filter = filter.to_lowercase();
|
||
let mut root = TreeDir::default();
|
||
for (idx, full) in files.iter().enumerate() {
|
||
if !filter.is_empty() && !full.to_lowercase().contains(&filter) {
|
||
continue;
|
||
}
|
||
let (dirs, leaf) = split_for_tree(full);
|
||
if is_pak_segment(&leaf) {
|
||
continue;
|
||
}
|
||
let mut node = &mut root;
|
||
for comp in dirs {
|
||
node = node.dirs.entry(comp).or_default();
|
||
}
|
||
node.files.push((leaf, idx));
|
||
}
|
||
root
|
||
}
|
||
|
||
/// Render a directory node recursively: sub-directories (collapsible) first,
|
||
/// then leaf files as selectable labels.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn render_dir(
|
||
ui: &mut egui::Ui,
|
||
node: &TreeDir,
|
||
force_open: bool,
|
||
selected: &mut Option<usize>,
|
||
loading: &mut bool,
|
||
events: &mut EventWriter<FileSelected>,
|
||
files: &[String],
|
||
) {
|
||
for (name, child) in &node.dirs {
|
||
egui::CollapsingHeader::new(format!("📁 {name}"))
|
||
.id_salt(name)
|
||
.default_open(force_open)
|
||
.show(ui, |ui| {
|
||
render_dir(ui, child, force_open, selected, loading, events, files);
|
||
});
|
||
}
|
||
for (leaf, idx) in &node.files {
|
||
let is_selected = *selected == Some(*idx);
|
||
if ui
|
||
.add(egui::SelectableLabel::new(is_selected, leaf.as_str()))
|
||
.clicked()
|
||
{
|
||
*selected = Some(*idx);
|
||
*loading = true; // show the spinner until the content is applied
|
||
events.send(FileSelected(files[*idx].clone()));
|
||
}
|
||
}
|
||
}
|
||
|
||
fn draw_viewer_ui(
|
||
mut contexts: EguiContexts,
|
||
mut viewer: ResMut<ViewerState>,
|
||
mut browser: ResMut<FileBrowserState>,
|
||
iso_state: Res<IsoState>,
|
||
preview: Res<TexturePreview>,
|
||
text_preview: Res<TextPreview>,
|
||
mut pak_view: ResMut<PakView>,
|
||
skybox: Res<SkyboxPreview>,
|
||
mut video: ResMut<VideoPreview>,
|
||
file_info: Res<FileInfo>,
|
||
mut open_iso_events: EventWriter<RequestOpenIso>,
|
||
mut open_dir_events: EventWriter<RequestOpenDir>,
|
||
mut file_selected_events: EventWriter<FileSelected>,
|
||
) {
|
||
let ctx = contexts.ctx_mut();
|
||
|
||
// ── Menu bar ──────────────────────────────────────────────────────────
|
||
egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
|
||
egui::menu::bar(ui, |ui| {
|
||
ui.menu_button("File", |ui| {
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
{
|
||
if ui.button("Open ISO disc image…").clicked() {
|
||
open_iso_events.send_default();
|
||
ui.close_menu();
|
||
}
|
||
if ui.button("Open extracted folder…").clicked() {
|
||
open_dir_events.send_default();
|
||
ui.close_menu();
|
||
}
|
||
ui.separator();
|
||
if ui.button("Quit").clicked() {
|
||
std::process::exit(0);
|
||
}
|
||
}
|
||
#[cfg(target_arch = "wasm32")]
|
||
ui.colored_label(
|
||
egui::Color32::GRAY,
|
||
"File loading not available in browser",
|
||
);
|
||
});
|
||
|
||
ui.menu_button("Help", |ui| {
|
||
if ui.button("Controls…").clicked() {
|
||
// TODO: controls popup
|
||
}
|
||
if ui.button("About").clicked() {
|
||
// TODO: about dialog
|
||
}
|
||
});
|
||
});
|
||
});
|
||
|
||
// ── Left panel: file browser ──────────────────────────────────────────
|
||
egui::SidePanel::left("file_browser")
|
||
.resizable(true)
|
||
.default_width(280.0)
|
||
.show(ctx, |ui| {
|
||
ui.heading("Game Files");
|
||
ui.separator();
|
||
|
||
// Filter input
|
||
ui.horizontal(|ui| {
|
||
ui.label("Filter:");
|
||
ui.text_edit_singleline(&mut browser.filter);
|
||
});
|
||
ui.separator();
|
||
|
||
// File list
|
||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||
if iso_state.loading {
|
||
ui.spinner();
|
||
ui.label("Loading…");
|
||
} else if let Some(err) = &iso_state.error {
|
||
ui.colored_label(
|
||
egui::Color32::RED,
|
||
format!("⚠ Error:\n{}", err),
|
||
);
|
||
} else if browser.files.is_empty() {
|
||
ui.colored_label(
|
||
egui::Color32::YELLOW,
|
||
"No files loaded.\nUse File → Open ISO disc image\n\
|
||
or File → Open extracted folder.",
|
||
);
|
||
} else {
|
||
// Build a directory tree from the flat path list. Cheap to
|
||
// rebuild every frame (a few hundred short paths); cloning
|
||
// `files` first sidesteps borrowing `browser` immutably and
|
||
// mutably (selected) at the same time.
|
||
let files = browser.files.clone();
|
||
let tree = build_tree(&files, &browser.filter);
|
||
let force_open = !browser.filter.is_empty();
|
||
let FileBrowserState {
|
||
selected, loading, ..
|
||
} = &mut *browser;
|
||
render_dir(
|
||
ui,
|
||
&tree,
|
||
force_open,
|
||
selected,
|
||
loading,
|
||
&mut file_selected_events,
|
||
&files,
|
||
);
|
||
}
|
||
});
|
||
});
|
||
|
||
// ── Bottom panel: status bar ──────────────────────────────────────────
|
||
egui::TopBottomPanel::bottom("status_bar").show(ctx, |ui| {
|
||
// `horizontal_wrapped` so the bar wraps instead of overflowing the
|
||
// window on narrow widths; the controls hint is right-aligned and only
|
||
// the source's final path component is shown (full path on hover).
|
||
ui.horizontal_wrapped(|ui| {
|
||
if let Some(label) = &iso_state.source_label {
|
||
let short = label.rsplit(['/', '\\']).next().unwrap_or(label);
|
||
ui.label(short).on_hover_text(label);
|
||
ui.separator();
|
||
}
|
||
ui.label(format!("{} files", browser.files.len()));
|
||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||
ui.label("LMB orbit · RMB pan · scroll zoom · R reset");
|
||
});
|
||
});
|
||
});
|
||
|
||
// ── Central area ──────────────────────────────────────────────────────
|
||
if browser.selected.is_some() {
|
||
egui::CentralPanel::default().show(ctx, |ui| {
|
||
if browser.loading {
|
||
// A file was just clicked — show immediate feedback while the
|
||
// background read + decode runs, instead of the previous item.
|
||
let name = browser
|
||
.selected
|
||
.and_then(|i| browser.files.get(i))
|
||
.map(|p| p.rsplit('/').next().unwrap_or(p))
|
||
.unwrap_or("");
|
||
ui.centered_and_justified(|ui| {
|
||
ui.horizontal(|ui| {
|
||
ui.spinner();
|
||
ui.label(format!("Loading {name}…"));
|
||
});
|
||
});
|
||
} else if video.active {
|
||
draw_video_player(ui, &mut video);
|
||
} else if !skybox.faces.is_empty() {
|
||
draw_skybox_grid(ui, &skybox);
|
||
} else if pak_view.loaded {
|
||
draw_pak_browser(ui, &mut pak_view);
|
||
} else if let Some(text) = &text_preview.content {
|
||
// ── Plain-text viewer ──
|
||
ui.horizontal(|ui| {
|
||
ui.heading(&file_info.name);
|
||
ui.separator();
|
||
ui.label(&text_preview.encoding);
|
||
ui.separator();
|
||
ui.checkbox(&mut viewer.text_wrap, "Wrap");
|
||
if text_preview.truncated {
|
||
ui.separator();
|
||
ui.colored_label(egui::Color32::YELLOW, "(truncated)");
|
||
}
|
||
});
|
||
ui.separator();
|
||
|
||
egui::ScrollArea::both().show(ui, |ui| {
|
||
// Read-only: `&str` is a non-editable TextBuffer, and
|
||
// `.interactive(false)` keeps it selectable/copyable.
|
||
let mut buf = text.as_str();
|
||
let mut edit = egui::TextEdit::multiline(&mut buf)
|
||
.font(egui::TextStyle::Monospace)
|
||
.code_editor()
|
||
.desired_width(f32::INFINITY)
|
||
.interactive(false);
|
||
if !viewer.text_wrap {
|
||
// Don't wrap: let ScrollArea scroll horizontally.
|
||
edit = edit.clip_text(false);
|
||
}
|
||
ui.add(edit);
|
||
});
|
||
} else if let Some(egui_id) = preview.egui_id {
|
||
// Texture preview
|
||
ui.heading(&file_info.name);
|
||
ui.label(&preview.format_info);
|
||
if !preview.format_info.is_empty() && preview.format_info.contains("failed") {
|
||
ui.colored_label(egui::Color32::YELLOW, &preview.format_info);
|
||
}
|
||
ui.separator();
|
||
|
||
// Scale to fit, preserving aspect ratio, never upscale
|
||
let avail = ui.available_size();
|
||
let scale = if preview.width == 0 || preview.height == 0 {
|
||
1.0_f32
|
||
} else {
|
||
(avail.x / preview.width as f32)
|
||
.min(avail.y / preview.height as f32)
|
||
.min(1.0)
|
||
};
|
||
let display_w = preview.width as f32 * scale;
|
||
let display_h = preview.height as f32 * scale;
|
||
|
||
ui.add(egui::Image::new(egui::load::SizedTexture::new(
|
||
egui_id,
|
||
[display_w, display_h],
|
||
)));
|
||
} else {
|
||
// Non-texture file info
|
||
ui.heading(&file_info.name);
|
||
if !preview.format_info.is_empty() {
|
||
ui.colored_label(egui::Color32::YELLOW, &preview.format_info);
|
||
}
|
||
if file_info.size_bytes > 0 {
|
||
ui.separator();
|
||
ui.label(format!("Size: {} bytes", file_info.size_bytes));
|
||
if let Some(fmt) = file_info.detected_format {
|
||
ui.label(format!("Detected format: {:?}", fmt));
|
||
}
|
||
} else if iso_state.loading {
|
||
ui.spinner();
|
||
ui.label("Loading file…");
|
||
}
|
||
}
|
||
});
|
||
} else {
|
||
// No file selected — show welcome / RE notes
|
||
egui::CentralPanel::default().show(ctx, |ui| {
|
||
ui.heading("Project Sylpheed: Arc of Deception");
|
||
ui.label("Asset Viewer — Milestone 1");
|
||
ui.separator();
|
||
|
||
ui.collapsing("Getting Started", |ui| {
|
||
ui.label("Option A — open an ISO directly:");
|
||
ui.code(" File → Open ISO disc image…");
|
||
ui.separator();
|
||
ui.label("Option B — extract first, then open folder:");
|
||
ui.code(" xdvdfs unpack sylpheed.iso ./assets/");
|
||
ui.code(" File → Open extracted folder…");
|
||
ui.separator();
|
||
ui.label("Click a .xpr file in the left panel to preview the texture.");
|
||
});
|
||
|
||
ui.collapsing("Reverse Engineering Notes", |ui| {
|
||
ui.label("Document your findings here as you RE the formats.");
|
||
ui.separator();
|
||
egui::Grid::new("re_notes").striped(true).show(ui, |ui| {
|
||
ui.strong("Extension");
|
||
ui.strong("Status");
|
||
ui.strong("Notes");
|
||
ui.end_row();
|
||
|
||
ui.label(".xpr");
|
||
ui.colored_label(egui::Color32::LIGHT_GREEN, "✓ Partial");
|
||
ui.label("XPR2 container + DXT1/3/5 textures");
|
||
ui.end_row();
|
||
|
||
ui.label("?");
|
||
ui.colored_label(egui::Color32::YELLOW, "⏳ TODO");
|
||
ui.label("Mesh format — need to identify extension");
|
||
ui.end_row();
|
||
|
||
ui.label("?");
|
||
ui.colored_label(egui::Color32::YELLOW, "⏳ TODO");
|
||
ui.label("Audio format — likely XWB wave banks");
|
||
ui.end_row();
|
||
|
||
ui.label("?");
|
||
ui.colored_label(egui::Color32::YELLOW, "⏳ TODO");
|
||
ui.label("Mission/level data — format unknown");
|
||
ui.end_row();
|
||
});
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
// ── Data pack (IPFB / IDXD) browser ───────────────────────────────────────────
|
||
|
||
/// Master-detail view for an open `.pak`: entry list on the left, the selected
|
||
/// entry's IDXD schema + property table on the right.
|
||
fn draw_pak_browser(ui: &mut egui::Ui, pak: &mut PakView) {
|
||
ui.horizontal(|ui| {
|
||
ui.heading(&pak.name);
|
||
ui.separator();
|
||
ui.label(format!("{} entries", pak.rows.len()));
|
||
});
|
||
if let Some(err) = &pak.error {
|
||
ui.separator();
|
||
ui.colored_label(egui::Color32::RED, format!("⚠ {err}"));
|
||
return;
|
||
}
|
||
ui.separator();
|
||
|
||
// Clone rows so the two column closures can read them while we record the
|
||
// click into a local (avoids borrowing `pak` mutably inside the closures).
|
||
let rows = pak.rows.clone();
|
||
let selected = pak.selected;
|
||
let mut new_selection = None;
|
||
|
||
ui.columns(2, |cols| {
|
||
// Left — entry list.
|
||
egui::ScrollArea::vertical()
|
||
.id_salt("pak_list")
|
||
.show(&mut cols[0], |ui| {
|
||
for (i, row) in rows.iter().enumerate() {
|
||
let text = egui::RichText::new(format!(
|
||
"{:08x} {:<7} {}",
|
||
row.hash, row.format, row.identity
|
||
))
|
||
.monospace();
|
||
if ui
|
||
.add(egui::SelectableLabel::new(selected == Some(i), text))
|
||
.clicked()
|
||
{
|
||
new_selection = Some(i);
|
||
}
|
||
}
|
||
});
|
||
|
||
// Right — detail of the selected entry.
|
||
egui::ScrollArea::vertical()
|
||
.id_salt("pak_detail")
|
||
.show(&mut cols[1], |ui| match selected.and_then(|i| rows.get(i)) {
|
||
None => {
|
||
ui.label("Select an entry to inspect.");
|
||
}
|
||
Some(row) => match &row.detail {
|
||
Some(d) => {
|
||
let title = if row.identity.is_empty() {
|
||
row.format.as_str()
|
||
} else {
|
||
row.identity.as_str()
|
||
};
|
||
ui.heading(title);
|
||
ui.label(format!("schema 0x{:08x} count {}", d.schema_hash, d.count));
|
||
ui.label(format!("{} bytes hash {:08x}", row.size, row.hash));
|
||
ui.separator();
|
||
if d.fields.is_empty() {
|
||
ui.label("(no explicit-value fields)");
|
||
} else {
|
||
egui::Grid::new("pak_fields")
|
||
.striped(true)
|
||
.num_columns(2)
|
||
.show(ui, |ui| {
|
||
for (k, v) in &d.fields {
|
||
ui.label(k);
|
||
ui.monospace(v);
|
||
ui.end_row();
|
||
}
|
||
});
|
||
}
|
||
}
|
||
None => {
|
||
ui.heading(&row.format);
|
||
ui.label(format!("{} bytes hash {:08x}", row.size, row.hash));
|
||
if !row.identity.is_empty() {
|
||
ui.label(&row.identity);
|
||
}
|
||
ui.separator();
|
||
ui.label("Not an IDXD object — no field table.");
|
||
}
|
||
},
|
||
});
|
||
});
|
||
|
||
if new_selection.is_some() {
|
||
pak.selected = new_selection;
|
||
}
|
||
}
|
||
|
||
// ── Video player (WMV cutscenes) ──────────────────────────────────────────────
|
||
|
||
/// Format seconds as `mm:ss`.
|
||
fn fmt_time(secs: f32) -> String {
|
||
let s = secs.max(0.0) as u32;
|
||
format!("{:02}:{:02}", s / 60, s % 60)
|
||
}
|
||
|
||
/// Central-panel video player: the current frame plus a transport bar
|
||
/// (play/pause, seekable timeline, volume). Also supports click-to-toggle and
|
||
/// keyboard shortcuts (space = play/pause, ←/→ = skip 10 s). Playback state
|
||
/// lives in `VideoPreview`; the decode/audio engine reacts to it in
|
||
/// `advance_video_playback`.
|
||
fn draw_video_player(ui: &mut egui::Ui, video: &mut VideoPreview) {
|
||
// ── Keyboard shortcuts (consumed so focused widgets don't also act). ──
|
||
let (mut toggle_play, mut skip) = (false, 0.0_f32);
|
||
ui.input_mut(|i| {
|
||
if i.consume_key(egui::Modifiers::NONE, egui::Key::Space) {
|
||
toggle_play = true;
|
||
}
|
||
if i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowRight) {
|
||
skip += 10.0;
|
||
}
|
||
if i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowLeft) {
|
||
skip -= 10.0;
|
||
}
|
||
});
|
||
if toggle_play {
|
||
video.playing = !video.playing;
|
||
}
|
||
if skip != 0.0 {
|
||
let t = (video.position + skip).clamp(0.0, video.duration);
|
||
video.position = t;
|
||
video.scrub_request = Some(t); // instant preview via the grabber…
|
||
video.seek_request = Some(t); // …then the stream restarts here
|
||
}
|
||
|
||
ui.horizontal(|ui| {
|
||
ui.heading(&video.name);
|
||
ui.separator();
|
||
ui.label(format!("{}×{} · wmv3 / wmapro", video.width, video.height));
|
||
if !video.has_audio {
|
||
ui.separator();
|
||
ui.colored_label(egui::Color32::YELLOW, "no audio");
|
||
}
|
||
});
|
||
ui.separator();
|
||
|
||
// Reserve room for the transport row so the frame never pushes it off-screen.
|
||
const CONTROLS_H: f32 = 34.0;
|
||
let avail = ui.available_size();
|
||
let frame_h = (avail.y - CONTROLS_H).max(0.0);
|
||
let (w, h) = (video.width.max(1) as f32, video.height.max(1) as f32);
|
||
let scale = (avail.x / w).min(frame_h / h);
|
||
let (disp_w, disp_h) = (w * scale, h * scale);
|
||
|
||
if let Some(egui_id) = video.egui_id {
|
||
ui.allocate_ui(egui::vec2(avail.x, frame_h), |ui| {
|
||
ui.vertical_centered(|ui| {
|
||
// Click the frame to toggle play/pause.
|
||
let resp = ui.add(
|
||
egui::Image::new(egui::load::SizedTexture::new(egui_id, [disp_w, disp_h]))
|
||
.sense(egui::Sense::click()),
|
||
);
|
||
if resp.clicked() {
|
||
video.playing = !video.playing;
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// ── Transport bar ──
|
||
ui.horizontal(|ui| {
|
||
if ui.button(if video.playing { "⏸" } else { "▶" }).clicked() {
|
||
video.playing = !video.playing;
|
||
}
|
||
ui.label(fmt_time(video.position));
|
||
|
||
// Timeline fills the space left after reserving room for the total-time
|
||
// label and the volume control on the right. Dragging scrubs live (the
|
||
// grabber shows the frame under the knob); release commits the seek.
|
||
let reserved = 170.0;
|
||
let tl_width = (ui.available_width() - reserved).max(80.0);
|
||
ui.spacing_mut().slider_width = tl_width;
|
||
let mut pos = video.position;
|
||
let resp =
|
||
ui.add(egui::Slider::new(&mut pos, 0.0..=video.duration.max(0.1)).show_value(false));
|
||
if resp.changed() {
|
||
video.position = pos; // knob + label follow immediately
|
||
video.scrub_request = Some(pos);
|
||
if resp.dragged() {
|
||
video.scrubbing = true;
|
||
} else {
|
||
// A click on the track (no drag) → commit right away.
|
||
video.seek_request = Some(pos);
|
||
video.scrubbing = false;
|
||
}
|
||
}
|
||
if resp.drag_stopped() {
|
||
video.seek_request = Some(pos);
|
||
video.scrubbing = false;
|
||
}
|
||
ui.label(fmt_time(video.duration));
|
||
|
||
ui.separator();
|
||
ui.label("🔊");
|
||
ui.spacing_mut().slider_width = 90.0;
|
||
ui.add_enabled(
|
||
video.has_audio,
|
||
egui::Slider::new(&mut video.volume, 0.0..=1.0).show_value(false),
|
||
);
|
||
});
|
||
}
|
||
|
||
// ── World cubemap (skybox) viewer ─────────────────────────────────────────────
|
||
|
||
/// Show a world cubemap's 6 faces in a labelled grid (D3D9 order). A true
|
||
/// interactive skybox is a follow-up — the face data + order are validated, but
|
||
/// the wgpu cube-sampling handedness needs visual confirmation first.
|
||
fn draw_skybox_grid(ui: &mut egui::Ui, skybox: &SkyboxPreview) {
|
||
ui.horizontal(|ui| {
|
||
ui.heading("World cubemap");
|
||
ui.separator();
|
||
ui.label(&skybox.info);
|
||
});
|
||
ui.label("6 cube faces, D3D9 order (+X −X +Y −Y +Z −Z).");
|
||
ui.separator();
|
||
|
||
egui::ScrollArea::both().show(ui, |ui| {
|
||
const CELL: f32 = 240.0;
|
||
egui::Grid::new("cube_faces")
|
||
.num_columns(3)
|
||
.spacing([10.0, 10.0])
|
||
.show(ui, |ui| {
|
||
for (i, face) in skybox.faces.iter().enumerate() {
|
||
ui.vertical(|ui| {
|
||
ui.strong(face.label);
|
||
let aspect = face.height as f32 / face.width.max(1) as f32;
|
||
ui.add(egui::Image::new(egui::load::SizedTexture::new(
|
||
face.egui_id,
|
||
[CELL, CELL * aspect],
|
||
)));
|
||
});
|
||
if (i + 1) % 3 == 0 {
|
||
ui.end_row();
|
||
}
|
||
}
|
||
});
|
||
});
|
||
}
|