feat(formats,viewer): movie subtitles, voice decode, and the movie manifest
The movie cutscene subtitle + voice pipeline, driven by the ADVERTISE_MOVIE manifest (the authoritative movie -> subtitle -> voice index). Also flushes several sessions of local WIP (async viewer loading, grouped-pool XBG7/hero-ship decode, drawlog tooling). See docs/HANDOFF-movie-voice-subtitles-2026-07-19.md. Subtitles (movie_subtitle.rs): - Full movie->track->text chain; join multi-line captions sharing one timing (fixes S13A dropped "Look at it father" line); overlap-safe active_cues(); Latin-1 accents preserved. Voice (slb.rs): XACT .slb -> XMA1 RIFF; take the FIRST sub-wave bounded by its declared data size (fixes S10-S16 alternate-take garble); list_voice_clips. Manifest (movie_manifest.rs): parse ADVERTISE_MOVIE (0x5B983A08) for the real movie->voice binding (not always VOICE_<movie>; e.g. hokyu -> VOICE_D_* in etc\). Resolve the token's sound.pak path via sounds.tbl. DIRECT bindings only — the demo-id shared-clip fallback for unbound hokyu movies was verified WRONG in-game and reverted (unbound hokyu stay unvoiced; correct join key is an OPEN problem). Viewer: manifest-driven voice (movie player toggle + solo button), standalone "Voice Lines" browser, stacked caption overlay. Tests: 46 formats-lib + 11 viewer-lib + movie_manifest/movie_subtitle/slb disc tests; full workspace green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,10 @@
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::input::mouse::{MouseMotion, MouseWheel};
|
||||
use bevy::render::render_asset::RenderAssetUsages;
|
||||
use bevy::render::render_resource::{
|
||||
Extent3d, TextureDimension, TextureFormat, TextureViewDescriptor, TextureViewDimension,
|
||||
};
|
||||
|
||||
pub struct OrbitCameraPlugin;
|
||||
|
||||
@@ -54,10 +58,17 @@ impl Default for OrbitCamera {
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_camera(mut commands: Commands) {
|
||||
fn spawn_camera(mut commands: Commands, mut images: ResMut<Assets<Image>>) {
|
||||
let orbit = OrbitCamera::default();
|
||||
let transform = orbit_transform(&orbit);
|
||||
|
||||
// The game's ship shader reflects a shared HDR environment cubemap off the
|
||||
// hull (three cube samples — see the shader RE). Reproduce that "look" with a
|
||||
// procedural sky cube feeding Bevy's image-based lighting, so metallic
|
||||
// surfaces reflect an environment instead of reading flat. Procedural (not a
|
||||
// bundled KTX2) so it also works on WASM with no extra assets.
|
||||
let env = make_env_cubemap(&mut images);
|
||||
|
||||
commands.spawn((
|
||||
Camera3d::default(),
|
||||
// Wide near/far so both tiny weapons and multi-thousand-unit stages fit;
|
||||
@@ -69,9 +80,78 @@ fn spawn_camera(mut commands: Commands) {
|
||||
}),
|
||||
transform,
|
||||
orbit,
|
||||
EnvironmentMapLight {
|
||||
diffuse_map: env.clone(),
|
||||
specular_map: env,
|
||||
intensity: 900.0,
|
||||
..default()
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
/// Build a small procedural sky cubemap (6 faces) for image-based lighting.
|
||||
///
|
||||
/// A vertical gradient (bright zenith → warm horizon → dark ground) plus a warm
|
||||
/// "sun" highlight roughly where the game's key light sits — enough for a
|
||||
/// metallic hull to read as reflective rather than flat. Stored as an sRGB cube
|
||||
/// (filterable, no half-float encoding needed); a single mip means reflections
|
||||
/// stay sharp (fine for a shiny ship).
|
||||
fn make_env_cubemap(images: &mut Assets<Image>) -> Handle<Image> {
|
||||
let size: i32 = 64;
|
||||
// Approx key-light direction from the RE (PS c32 ≈ (0,-0.76,-0.65), i.e. light
|
||||
// arriving from top-front); place the bright spot there.
|
||||
let sun = Vec3::new(0.0, 0.85, 0.5).normalize();
|
||||
let zenith = Vec3::new(0.70, 0.80, 0.95);
|
||||
let horizon = Vec3::new(0.42, 0.45, 0.50);
|
||||
let ground = Vec3::new(0.09, 0.09, 0.11);
|
||||
let srgb = |c: f32| (c.clamp(0.0, 1.0).powf(1.0 / 2.2) * 255.0).round() as u8;
|
||||
|
||||
let mut data: Vec<u8> = Vec::with_capacity((size * size * 6 * 4) as usize);
|
||||
for face in 0..6 {
|
||||
for y in 0..size {
|
||||
for x in 0..size {
|
||||
let u = (x as f32 + 0.5) / size as f32 * 2.0 - 1.0;
|
||||
let v = (y as f32 + 0.5) / size as f32 * 2.0 - 1.0;
|
||||
// Standard cube-face → direction mapping.
|
||||
let d = match face {
|
||||
0 => Vec3::new(1.0, -v, -u),
|
||||
1 => Vec3::new(-1.0, -v, u),
|
||||
2 => Vec3::new(u, 1.0, v),
|
||||
3 => Vec3::new(u, -1.0, -v),
|
||||
4 => Vec3::new(u, -v, 1.0),
|
||||
_ => Vec3::new(-u, -v, -1.0),
|
||||
}
|
||||
.normalize();
|
||||
let mut col = if d.y >= 0.0 {
|
||||
horizon.lerp(zenith, (d.y).powf(0.6))
|
||||
} else {
|
||||
horizon.lerp(ground, (-d.y).powf(0.5))
|
||||
};
|
||||
let s = d.dot(sun).max(0.0).powf(60.0);
|
||||
col += Vec3::new(1.0, 0.85, 0.6) * s * 1.5;
|
||||
data.extend_from_slice(&[srgb(col.x), srgb(col.y), srgb(col.z), 255]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut image = Image::new(
|
||||
Extent3d {
|
||||
width: size as u32,
|
||||
height: size as u32,
|
||||
depth_or_array_layers: 6,
|
||||
},
|
||||
TextureDimension::D2,
|
||||
data,
|
||||
TextureFormat::Rgba8UnormSrgb,
|
||||
RenderAssetUsages::RENDER_WORLD,
|
||||
);
|
||||
image.texture_view_descriptor = Some(TextureViewDescriptor {
|
||||
dimension: Some(TextureViewDimension::Cube),
|
||||
..default()
|
||||
});
|
||||
images.add(image)
|
||||
}
|
||||
|
||||
fn orbit_camera(
|
||||
mut query: Query<(&mut OrbitCamera, &mut Transform, &mut Projection)>,
|
||||
mouse_buttons: Res<ButtonInput<MouseButton>>,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -72,9 +72,28 @@ pub fn run() {
|
||||
}
|
||||
|
||||
fn setup_scene(mut commands: Commands) {
|
||||
commands.spawn(DirectionalLight {
|
||||
illuminance: 10_000.0,
|
||||
shadows_enabled: true,
|
||||
..default()
|
||||
// Bright ambient so surfaces facing away from every light aren't pure black.
|
||||
commands.insert_resource(AmbientLight {
|
||||
color: Color::srgb(0.9, 0.93, 1.0),
|
||||
brightness: 900.0,
|
||||
});
|
||||
// A multi-directional rig (key + fills + rim + underside) so an orbiting
|
||||
// camera always has some light on the visible side — the single front light
|
||||
// left the backside unreadable.
|
||||
let lights = [
|
||||
(Vec3::new(1.0, 2.0, 1.5), 10_000.0, true), // key: front-top-right
|
||||
(Vec3::new(-2.0, 1.0, 0.5), 4_500.0, false), // fill: left
|
||||
(Vec3::new(0.5, 0.8, -2.0), 6_000.0, false), // rim: behind
|
||||
(Vec3::new(0.0, -1.5, 0.5), 2_500.0, false), // underside fill
|
||||
];
|
||||
for (from, lux, shadows) in lights {
|
||||
commands.spawn((
|
||||
DirectionalLight {
|
||||
illuminance: lux,
|
||||
shadows_enabled: shadows,
|
||||
..default()
|
||||
},
|
||||
Transform::from_translation(from).looking_at(Vec3::ZERO, Vec3::Y),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,13 @@ use bevy::prelude::*;
|
||||
use bevy_egui::{egui, EguiContexts};
|
||||
|
||||
use crate::iso_loader::{
|
||||
FileInfo, FileSelected, ImageRgba, IsoState, ModelPreview, PakContent, PakView, RequestOpenDir,
|
||||
RequestOpenIso, SkyboxPreview, TextPreview, TexturePreview, VideoPreview, IsoLoaderSystemSet,
|
||||
AudioPreview, FileInfo, FileSelected, ImageRgba, IsoState, ModelPreview, MovieSubtitles,
|
||||
MovieVoice, PakContent, PakView, RequestAudio, RequestOpenDir, RequestOpenIso, RequestSubtitles,
|
||||
RequestVoiceLibrary, SkyboxPreview, TextPreview, TexturePreview, VideoPreview, VoiceLibrary,
|
||||
IsoLoaderSystemSet,
|
||||
};
|
||||
use crate::ViewerState;
|
||||
use sylpheed_formats::SubLang;
|
||||
|
||||
pub struct ViewerUiPlugin;
|
||||
|
||||
@@ -123,6 +126,18 @@ fn render_dir(
|
||||
}
|
||||
}
|
||||
|
||||
/// Bundled event writers for the UI, to keep `draw_viewer_ui` under Bevy's
|
||||
/// 16-parameter system limit.
|
||||
#[derive(bevy::ecs::system::SystemParam)]
|
||||
struct UiEvents<'w> {
|
||||
open_iso: EventWriter<'w, RequestOpenIso>,
|
||||
open_dir: EventWriter<'w, RequestOpenDir>,
|
||||
file_selected: EventWriter<'w, FileSelected>,
|
||||
subtitles: EventWriter<'w, RequestSubtitles>,
|
||||
audio: EventWriter<'w, RequestAudio>,
|
||||
voice_lib: EventWriter<'w, RequestVoiceLibrary>,
|
||||
}
|
||||
|
||||
fn draw_viewer_ui(
|
||||
mut contexts: EguiContexts,
|
||||
mut viewer: ResMut<ViewerState>,
|
||||
@@ -135,9 +150,11 @@ fn draw_viewer_ui(
|
||||
model: Res<ModelPreview>,
|
||||
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>,
|
||||
mut subtitles: ResMut<MovieSubtitles>,
|
||||
mut movie_voice: ResMut<MovieVoice>,
|
||||
mut audio: ResMut<AudioPreview>,
|
||||
mut voice_lib: ResMut<VoiceLibrary>,
|
||||
mut events: UiEvents,
|
||||
) {
|
||||
let ctx = contexts.ctx_mut();
|
||||
|
||||
@@ -148,11 +165,11 @@ fn draw_viewer_ui(
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
if ui.button("Open ISO disc image…").clicked() {
|
||||
open_iso_events.send_default();
|
||||
events.open_iso.send_default();
|
||||
ui.close_menu();
|
||||
}
|
||||
if ui.button("Open extracted folder…").clicked() {
|
||||
open_dir_events.send_default();
|
||||
events.open_dir.send_default();
|
||||
ui.close_menu();
|
||||
}
|
||||
ui.separator();
|
||||
@@ -167,6 +184,17 @@ fn draw_viewer_ui(
|
||||
);
|
||||
});
|
||||
|
||||
ui.menu_button("View", |ui| {
|
||||
if ui.button("🎙 Voice Lines…").clicked() {
|
||||
voice_lib.open = true;
|
||||
if !voice_lib.loaded && !voice_lib.loading {
|
||||
voice_lib.loading = true;
|
||||
events.voice_lib.send_default();
|
||||
}
|
||||
ui.close_menu();
|
||||
}
|
||||
});
|
||||
|
||||
ui.menu_button("Help", |ui| {
|
||||
if ui.button("Controls…").clicked() {
|
||||
// TODO: controls popup
|
||||
@@ -178,6 +206,65 @@ fn draw_viewer_ui(
|
||||
});
|
||||
});
|
||||
|
||||
// ── Standalone voice-line browser (floating window) ───────────────────
|
||||
if voice_lib.open {
|
||||
let mut open = true;
|
||||
egui::Window::new("🎙 Voice Lines")
|
||||
.default_width(360.0)
|
||||
.default_height(480.0)
|
||||
.open(&mut open)
|
||||
.show(ctx, |ui| {
|
||||
if voice_lib.loading {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spinner();
|
||||
ui.label("Reading sounds.tbl…");
|
||||
});
|
||||
ctx.request_repaint();
|
||||
} else if !voice_lib.loaded {
|
||||
ui.label("Open a game source first.");
|
||||
} else {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Filter:");
|
||||
ui.text_edit_singleline(&mut voice_lib.filter);
|
||||
});
|
||||
ui.label(
|
||||
egui::RichText::new(format!("{} clips", voice_lib.clips.len()))
|
||||
.weak()
|
||||
.small(),
|
||||
);
|
||||
ui.separator();
|
||||
let f = voice_lib.filter.to_lowercase();
|
||||
let clips: Vec<_> = voice_lib
|
||||
.clips
|
||||
.iter()
|
||||
.filter(|c| f.is_empty() || c.name.to_lowercase().contains(&f))
|
||||
.take(2000)
|
||||
.cloned()
|
||||
.collect();
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
for c in &clips {
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("▶").on_hover_text(&c.name).clicked() {
|
||||
audio.generation = audio.generation.wrapping_add(1);
|
||||
audio.loading = true;
|
||||
audio.active = true;
|
||||
audio.name = c.display.clone();
|
||||
events.audio.send(RequestAudio {
|
||||
clip: c.name.clone(),
|
||||
display: c.display.clone(),
|
||||
movie: None,
|
||||
generation: audio.generation,
|
||||
});
|
||||
}
|
||||
ui.label(&c.display);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
voice_lib.open = open;
|
||||
}
|
||||
|
||||
// ── Left panel: file browser ──────────────────────────────────────────
|
||||
egui::SidePanel::left("file_browser")
|
||||
.resizable(true)
|
||||
@@ -226,7 +313,7 @@ fn draw_viewer_ui(
|
||||
force_open,
|
||||
selected,
|
||||
loading,
|
||||
&mut file_selected_events,
|
||||
&mut events.file_selected,
|
||||
&files,
|
||||
);
|
||||
}
|
||||
@@ -252,7 +339,13 @@ fn draw_viewer_ui(
|
||||
});
|
||||
|
||||
// ── Central area ──────────────────────────────────────────────────────
|
||||
if browser.selected.is_some() && model.active && !browser.loading {
|
||||
if audio.active {
|
||||
// Standalone audio player takes over the central panel.
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
ctx.request_repaint();
|
||||
draw_audio_player(ui, &mut audio);
|
||||
});
|
||||
} else if browser.selected.is_some() && model.active && !browser.loading {
|
||||
// 3D model: draw the central panel TRANSPARENT so the real Bevy scene
|
||||
// (mesh + orbit camera) shows through, with just an info overlay on top.
|
||||
egui::CentralPanel::default()
|
||||
@@ -280,6 +373,9 @@ fn draw_viewer_ui(
|
||||
if browser.loading {
|
||||
// A file was just clicked — show immediate feedback while the
|
||||
// background read + decode runs, instead of the previous item.
|
||||
// Keep repainting so the spinner animates even if the app is in a
|
||||
// reactive (idle) winit mode while the worker thread churns.
|
||||
ctx.request_repaint();
|
||||
let name = browser
|
||||
.selected
|
||||
.and_then(|i| browser.files.get(i))
|
||||
@@ -292,7 +388,43 @@ fn draw_viewer_ui(
|
||||
});
|
||||
});
|
||||
} else if video.active {
|
||||
draw_video_player(ui, &mut video);
|
||||
let mut lang_changed = false;
|
||||
let mut play_voice_solo = false;
|
||||
draw_video_player(
|
||||
ui,
|
||||
&mut video,
|
||||
&mut subtitles,
|
||||
&mut movie_voice,
|
||||
&mut lang_changed,
|
||||
&mut play_voice_solo,
|
||||
);
|
||||
if play_voice_solo {
|
||||
if let Some(movie) = movie_voice.movie.clone() {
|
||||
video.playing = false; // pause the video; audio takes over
|
||||
audio.generation = audio.generation.wrapping_add(1);
|
||||
audio.loading = true;
|
||||
audio.active = true; // show the panel immediately (spinner)
|
||||
audio.name = format!("VOICE_{movie}");
|
||||
events.audio.send(RequestAudio {
|
||||
clip: String::new(),
|
||||
display: format!("VOICE_{movie}"),
|
||||
movie: Some((movie.clone(), movie_voice.lang)),
|
||||
generation: audio.generation,
|
||||
});
|
||||
}
|
||||
}
|
||||
if lang_changed {
|
||||
if let Some(movie) = subtitles.movie.clone() {
|
||||
subtitles.generation = subtitles.generation.wrapping_add(1);
|
||||
subtitles.cues.clear();
|
||||
subtitles.loading = true;
|
||||
events.subtitles.send(RequestSubtitles {
|
||||
movie,
|
||||
lang: subtitles.lang,
|
||||
generation: subtitles.generation,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if !skybox.faces.is_empty() {
|
||||
draw_skybox_grid(ui, &skybox);
|
||||
} else if pak_view.loaded {
|
||||
@@ -830,7 +962,14 @@ fn fmt_time(secs: f32) -> String {
|
||||
/// 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) {
|
||||
fn draw_video_player(
|
||||
ui: &mut egui::Ui,
|
||||
video: &mut VideoPreview,
|
||||
subs: &mut MovieSubtitles,
|
||||
voice: &mut MovieVoice,
|
||||
lang_changed: &mut bool,
|
||||
play_voice_solo: &mut bool,
|
||||
) {
|
||||
// ── Keyboard shortcuts (consumed so focused widgets don't also act). ──
|
||||
let (mut toggle_play, mut skip) = (false, 0.0_f32);
|
||||
ui.input_mut(|i| {
|
||||
@@ -862,6 +1001,47 @@ fn draw_video_player(ui: &mut egui::Ui, video: &mut VideoPreview) {
|
||||
ui.separator();
|
||||
ui.colored_label(egui::Color32::YELLOW, "no audio");
|
||||
}
|
||||
|
||||
// Subtitle + voice controls (right-aligned): language, CC, and Voice.
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let before = subs.lang;
|
||||
egui::ComboBox::from_id_source("subtitle_lang")
|
||||
.selected_text(subs.lang.label())
|
||||
.show_ui(ui, |ui| {
|
||||
for lang in SubLang::ALL {
|
||||
ui.selectable_value(&mut subs.lang, lang, lang.label());
|
||||
}
|
||||
});
|
||||
if subs.lang != before {
|
||||
*lang_changed = true;
|
||||
}
|
||||
ui.toggle_value(&mut subs.enabled, "CC")
|
||||
.on_hover_text("Show subtitles");
|
||||
if subs.loading {
|
||||
ui.spinner();
|
||||
} else if subs.enabled && subs.cues.is_empty() {
|
||||
ui.weak("(no subtitles)");
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
// Voice-over track: the movie's own stream carries only music+SFX, so
|
||||
// this layers in the localized cutscene voice.
|
||||
ui.toggle_value(&mut voice.enabled, "🗣 Voice")
|
||||
.on_hover_text("Play the cutscene voice track over the movie");
|
||||
if voice.loading {
|
||||
ui.spinner();
|
||||
} else if !voice.available {
|
||||
ui.weak("(no voice)");
|
||||
}
|
||||
if voice.available
|
||||
&& ui
|
||||
.button("🎧")
|
||||
.on_hover_text("Listen to the voice track on its own")
|
||||
.clicked()
|
||||
{
|
||||
*play_voice_solo = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.separator();
|
||||
|
||||
@@ -873,6 +1053,7 @@ fn draw_video_player(ui: &mut egui::Ui, video: &mut VideoPreview) {
|
||||
let scale = (avail.x / w).min(frame_h / h);
|
||||
let (disp_w, disp_h) = (w * scale, h * scale);
|
||||
|
||||
let mut frame_rect = None;
|
||||
if let Some(egui_id) = video.egui_id {
|
||||
ui.allocate_ui(egui::vec2(avail.x, frame_h), |ui| {
|
||||
ui.vertical_centered(|ui| {
|
||||
@@ -884,10 +1065,26 @@ fn draw_video_player(ui: &mut egui::Ui, video: &mut VideoPreview) {
|
||||
if resp.clicked() {
|
||||
video.playing = !video.playing;
|
||||
}
|
||||
frame_rect = Some(resp.rect);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Caption overlay: every cue active at the current position, stacked over
|
||||
// the lower third of the frame (overlapping spans show together, newest —
|
||||
// last in start order — lowest).
|
||||
if let Some(rect) = frame_rect {
|
||||
let active = subs.active_cues(video.position);
|
||||
if !active.is_empty() {
|
||||
let joined = active
|
||||
.iter()
|
||||
.map(|c| c.text.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
paint_caption(ui, rect, &joined);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Transport bar ──
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button(if video.playing { "⏸" } else { "▶" }).clicked() {
|
||||
@@ -931,6 +1128,86 @@ fn draw_video_player(ui: &mut egui::Ui, video: &mut VideoPreview) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Draw a subtitle caption centered along the bottom of `rect`, with a
|
||||
/// semi-transparent backing box so it stays legible over any frame.
|
||||
fn paint_caption(ui: &egui::Ui, rect: egui::Rect, text: &str) {
|
||||
let painter = ui.painter_at(rect);
|
||||
// Font scales with the frame; clamped so it's readable but not huge.
|
||||
let size = (rect.height() * 0.045).clamp(13.0, 30.0);
|
||||
let font = egui::FontId::proportional(size);
|
||||
let wrap = rect.width() * 0.9;
|
||||
let galley = painter.layout(
|
||||
text.to_string(),
|
||||
font,
|
||||
egui::Color32::WHITE,
|
||||
wrap,
|
||||
);
|
||||
let margin = egui::vec2(10.0, 6.0);
|
||||
let box_size = galley.size() + margin * 2.0;
|
||||
let top_left = egui::pos2(
|
||||
rect.center().x - box_size.x / 2.0,
|
||||
rect.bottom() - box_size.y - rect.height() * 0.04,
|
||||
);
|
||||
let bg = egui::Rect::from_min_size(top_left, box_size);
|
||||
painter.rect_filled(bg, 4.0, egui::Color32::from_black_alpha(160));
|
||||
painter.galley(top_left + margin, galley, egui::Color32::WHITE);
|
||||
}
|
||||
|
||||
/// Standalone audio player: a transport bar for a decoded voice/sound track with
|
||||
/// no video. Playback state lives in `AudioPreview`; `advance_audio_playback`
|
||||
/// reacts to it.
|
||||
fn draw_audio_player(ui: &mut egui::Ui, audio: &mut AudioPreview) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading(&audio.name);
|
||||
ui.separator();
|
||||
if audio.loading {
|
||||
ui.spinner();
|
||||
ui.label("decoding…");
|
||||
} else {
|
||||
ui.label("voice track");
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button("✖ Close").clicked() {
|
||||
audio.active = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.separator();
|
||||
ui.add_space(ui.available_height() * 0.4);
|
||||
|
||||
// Big centered play/pause + a speaker glyph, since there's nothing to show.
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(egui::RichText::new("🔊").size(64.0));
|
||||
});
|
||||
ui.add_space(12.0);
|
||||
|
||||
// Transport bar.
|
||||
ui.horizontal(|ui| {
|
||||
if ui
|
||||
.button(if audio.playing { "⏸" } else { "▶" })
|
||||
.clicked()
|
||||
{
|
||||
audio.playing = !audio.playing;
|
||||
}
|
||||
ui.label(fmt_time(audio.position));
|
||||
let tl_width = (ui.available_width() - 170.0).max(80.0);
|
||||
ui.spacing_mut().slider_width = tl_width;
|
||||
let mut pos = audio.position;
|
||||
let resp = ui.add(
|
||||
egui::Slider::new(&mut pos, 0.0..=audio.duration.max(0.1)).show_value(false),
|
||||
);
|
||||
if resp.changed() {
|
||||
audio.position = pos;
|
||||
audio.seek_request = Some(pos);
|
||||
}
|
||||
ui.label(fmt_time(audio.duration));
|
||||
ui.separator();
|
||||
ui.label("🔊");
|
||||
ui.spacing_mut().slider_width = 90.0;
|
||||
ui.add(egui::Slider::new(&mut audio.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
|
||||
|
||||
Reference in New Issue
Block a user