diff --git a/luda-editor/new-client/src/audio_util/audio_storage.rs b/luda-editor/new-client/src/audio_util/audio_storage.rs new file mode 100644 index 000000000..3e1e86d4a --- /dev/null +++ b/luda-editor/new-client/src/audio_util/audio_storage.rs @@ -0,0 +1,84 @@ +use namui::*; +use std::{ + collections::HashMap, + error::Error, + sync::{Arc, RwLock}, +}; + +lazy_static! { + static ref AUDIO_STORAGE: AudioStorage = AudioStorage::new(); +} + +pub fn get_or_load_audio(audio_id: String) -> Arc { + let Some(load_state) = AUDIO_STORAGE.get(&audio_id) else { + let loading = AUDIO_STORAGE.set(audio_id.clone(), AudioLoadState::Loading); + spawn(async move { + let audio_id = audio_id.clone(); + + let request = match network::http::Request::get(audio_url(&audio_id)).body(()) { + Ok(response) => response, + Err(error) => { + AUDIO_STORAGE.set(audio_id, AudioLoadState::Error(error.into())); + return; + } + }; + let bytes = match request.send().await { + Ok(response) => response.bytes(), + Err(error) => { + AUDIO_STORAGE.set(audio_id, AudioLoadState::Error(error.into())); + return; + } + }; + let bytes = match bytes.await { + Ok(bytes) => bytes, + Err(error) => { + AUDIO_STORAGE.set(audio_id, AudioLoadState::Error(error.into())); + return; + } + }; + let load_state = match Audio::from_ogg_opus_bytes(bytes) { + Ok(audio) => AudioLoadState::Loaded { audio }, + Err(error) => AudioLoadState::Error(error.into()), + }; + + AUDIO_STORAGE.set(audio_id, load_state); + }); + return loading; + }; + load_state +} + +pub enum AudioLoadState { + Loading, + Loaded { + audio: Audio, + }, + #[allow(unused)] + Error(Box), +} +struct AudioStorage { + storage: RwLock>>, +} +impl AudioStorage { + fn new() -> Self { + Self { + storage: RwLock::new(HashMap::new()), + } + } + fn get(&self, sprite_id: &str) -> Option> { + self.storage.read().unwrap().get(sprite_id).cloned() + } + fn set(&self, sprite_id: String, load_state: AudioLoadState) -> Arc { + let load_state = Arc::new(load_state); + self.storage + .write() + .unwrap() + .insert(sprite_id, load_state.clone()); + load_state + } +} + +fn audio_url(audio_id: &str) -> String { + const PREFIX: &str = "http://localhost:4566/visual-novel-asset/audio/after-transcode"; + format!("{PREFIX}/{audio_id}") +} diff --git a/luda-editor/new-client/src/audio_util/mod.rs b/luda-editor/new-client/src/audio_util/mod.rs new file mode 100644 index 000000000..93a3dbf17 --- /dev/null +++ b/luda-editor/new-client/src/audio_util/mod.rs @@ -0,0 +1,3 @@ +mod audio_storage; + +pub use audio_storage::*; diff --git a/luda-editor/new-client/src/episode_editor/mod.rs b/luda-editor/new-client/src/episode_editor/mod.rs index 10c1167d1..b82b6c5b9 100644 --- a/luda-editor/new-client/src/episode_editor/mod.rs +++ b/luda-editor/new-client/src/episode_editor/mod.rs @@ -1,4 +1,5 @@ mod properties_panel; +mod scene_audio_editor; mod scene_list; mod scene_preview; mod scene_sprite_editor; diff --git a/luda-editor/new-client/src/episode_editor/properties_panel.rs b/luda-editor/new-client/src/episode_editor/properties_panel.rs index 559805330..244212416 100644 --- a/luda-editor/new-client/src/episode_editor/properties_panel.rs +++ b/luda-editor/new-client/src/episode_editor/properties_panel.rs @@ -1,4 +1,4 @@ -use super::scene_sprite_editor::SceneSpriteEditor; +use super::{scene_audio_editor::SceneAudioEditor, scene_sprite_editor::SceneSpriteEditor}; use luda_rpc::{AssetDoc, EpisodeEditAction, Scene}; use namui::*; use namui_prebuilt::{button, table::*}; @@ -66,7 +66,14 @@ impl Component for PropertiesPanel<'_> { }); } PropertiesPanelTab::Background => {} - PropertiesPanelTab::Audio => {} + PropertiesPanelTab::Audio => { + ctx.add(SceneAudioEditor { + wh, + scene, + update_scene, + asset_docs, + }); + } }), ])(wh, ctx); }); diff --git a/luda-editor/new-client/src/episode_editor/scene_audio_editor/audio_select_tool.rs b/luda-editor/new-client/src/episode_editor/scene_audio_editor/audio_select_tool.rs new file mode 100644 index 000000000..ebf455784 --- /dev/null +++ b/luda-editor/new-client/src/episode_editor/scene_audio_editor/audio_select_tool.rs @@ -0,0 +1,226 @@ +use crate::*; +use audio_util::{get_or_load_audio, AudioLoadState}; +use list_view::AutoListView; +use luda_rpc::*; +use std::collections::{HashMap, HashSet}; +use time::now; + +pub struct AudioSelectTool<'a> { + pub wh: Wh, + pub asset_docs: Sig<'a, HashMap>, + pub selected_audio: &'a Option, + pub set_audio: &'a dyn Fn(Option), +} + +impl Component for AudioSelectTool<'_> { + fn render(self, ctx: &RenderCtx) { + let Self { + wh, + asset_docs, + selected_audio, + set_audio, + } = self; + + let (selected_tags, set_selected_tags) = + ctx.state::>(Default::default); + + let on_select = |audio_id: Option| { + let audio = audio_id.map(|audio_id| SceneSound { + sound_id: audio_id, + volume: selected_audio + .as_ref() + .map(|selected_audio| selected_audio.volume) + .unwrap_or(100.percent()), + }); + set_audio(audio); + }; + + let tag_filtered_asset_docs = ctx.memo(|| { + asset_docs + .iter() + .filter(|(_id, asset_tag)| { + if !matches!(asset_tag.asset_kind, AssetKind::Audio) { + return false; + } + asset_tag.tags.iter().any(|tag| match tag { + AssetTag::System { tag } => selected_tags.contains(tag), + AssetTag::Custom { .. } => false, + }) + }) + .map(|(id, audio)| (id.clone(), audio.clone())) + .collect::>() + }); + + let tag_toggle_button = |tag: AssetSystemTag| { + let is_on = selected_tags.contains(&tag); + let text = match tag { + AssetSystemTag::AudioCharacter => "인물", + AssetSystemTag::AudioProp => "사물", + AssetSystemTag::AudioBackground => "배경", + _ => unreachable!(), + }; + + table::ratio(1, move |wh, ctx| { + ctx.add(simple_toggle_button(wh, text, is_on, |_| { + set_selected_tags.mutate(move |selected_tags| { + if selected_tags.contains(&tag) { + selected_tags.remove(&tag); + } else { + selected_tags.insert(tag); + } + }); + })); + }) + }; + + ctx.compose(|ctx| { + table::vertical([ + table::fixed( + 64.px(), + table::horizontal([ + table::fixed(64.px(), |_, _| {}), + tag_toggle_button(AssetSystemTag::AudioCharacter), + table::fixed(16.px(), |_, _| {}), + tag_toggle_button(AssetSystemTag::AudioProp), + table::fixed(16.px(), |_, _| {}), + tag_toggle_button(AssetSystemTag::AudioBackground), + table::fixed(64.px(), |_, _| {}), + ]), + ), + table::ratio(1, |wh, ctx| { + ctx.add(AudioList { + wh, + asset_docs: tag_filtered_asset_docs, + selected_audio, + on_select: &on_select, + }); + }), + ])(wh, ctx) + }); + } +} + +struct AudioList<'a> { + wh: Wh, + asset_docs: Sig<'a, HashMap>, + selected_audio: &'a Option, + on_select: &'a dyn Fn(Option), +} +impl Component for AudioList<'_> { + fn render(self, ctx: &RenderCtx) { + let Self { + wh, + asset_docs, + selected_audio, + on_select, + } = self; + + let item_wh = Wh::new(wh.width, 48.px()); + let render_item = |text: String, audio_id: Option| { + let is_on = selected_audio + .as_ref() + .map(|selected_audio| &selected_audio.sound_id) + .eq(&audio_id.as_ref()); + + ( + audio_id.clone().unwrap_or_default(), + AudioListItem { + wh: item_wh, + audio_id, + text, + is_on, + on_select, + }, + ) + }; + + let mut items = vec![render_item("없음".to_string(), None)]; + items.extend(asset_docs.values().filter_map(|asset_doc| { + let AssetKind::Audio = asset_doc.asset_kind else { + return None; + }; + Some(render_item( + asset_doc.name.to_string(), + Some(asset_doc.id.clone()), + )) + })); + + ctx.add(AutoListView { + height: wh.height, + scroll_bar_width: 10.px(), + item_wh, + items: items.into_iter(), + }); + } +} + +struct AudioListItem<'a> { + wh: Wh, + audio_id: Option, + text: String, + is_on: bool, + on_select: &'a dyn Fn(Option), +} +impl Component for AudioListItem<'_> { + fn render(self, ctx: &RenderCtx) { + let Self { + wh, + audio_id, + text, + is_on, + on_select, + } = self; + + let audio = audio_id.clone().map(get_or_load_audio); + let (hovering, set_hovering) = ctx.state::>(|| None); + let (play_handle, set_play_handle) = ctx.state(|| None); + + ctx.interval("play audio if hovering", 1.sec(), |_| { + let Some((Hovering { started_at }, audio)) = + hovering.as_ref().as_ref().zip(audio.as_ref()) + else { + return; + }; + if play_handle.is_some() { + return; + } + if now() - started_at < 1.sec() { + return; + } + let AudioLoadState::Loaded { audio } = audio.as_ref() else { + return; + }; + let play_handle = audio.play_repeat(); + set_play_handle.set(Some(play_handle)); + }); + + ctx.add( + simple_toggle_button(wh, text, is_on, |_| { + on_select(audio_id); + }) + .attach_event(|event| { + let Event::MouseMove { event } = event else { + return; + }; + match hovering.is_some() { + true => { + if event.is_local_xy_in() { + return; + } + set_hovering.set(None); + set_play_handle.set(None); + } + false => { + if !event.is_local_xy_in() { + return; + } + set_hovering.set(Some(Hovering { started_at: now() })); + } + } + }), + ); + } +} +struct Hovering { + started_at: Instant, +} diff --git a/luda-editor/new-client/src/episode_editor/scene_audio_editor/mod.rs b/luda-editor/new-client/src/episode_editor/scene_audio_editor/mod.rs new file mode 100644 index 000000000..74bde1007 --- /dev/null +++ b/luda-editor/new-client/src/episode_editor/scene_audio_editor/mod.rs @@ -0,0 +1,51 @@ +mod audio_select_tool; +mod volume_tool; + +use luda_rpc::{AssetDoc, Scene, SceneSound}; +use namui::*; +use namui_prebuilt::*; +use std::collections::HashMap; + +pub struct SceneAudioEditor<'a> { + pub wh: Wh, + pub scene: &'a Scene, + pub update_scene: &'a dyn Fn(Scene), + pub asset_docs: Sig<'a, HashMap>, +} + +impl Component for SceneAudioEditor<'_> { + fn render(self, ctx: &RenderCtx) { + let Self { + wh, + scene, + update_scene, + asset_docs, + } = self; + + let set_audio = |audio: Option| { + let mut scene = scene.clone(); + scene.bgm = audio; + update_scene(scene); + }; + + ctx.compose(|ctx| { + table::vertical([ + table::fixed(64.px(), |wh, ctx| { + ctx.add(volume_tool::VolumeTool { + wh, + selected_audio: &scene.bgm, + set_audio: &set_audio, + }); + }), + table::ratio(1, |wh, ctx| { + ctx.add(audio_select_tool::AudioSelectTool { + wh, + asset_docs: asset_docs.clone(), + selected_audio: &scene.bgm, + set_audio: &set_audio, + }); + }), + ])(wh, ctx) + }); + } +} diff --git a/luda-editor/new-client/src/episode_editor/scene_audio_editor/volume_tool.rs b/luda-editor/new-client/src/episode_editor/scene_audio_editor/volume_tool.rs new file mode 100644 index 000000000..7581b12b0 --- /dev/null +++ b/luda-editor/new-client/src/episode_editor/scene_audio_editor/volume_tool.rs @@ -0,0 +1,173 @@ +use crate::*; +use luda_rpc::*; + +pub struct VolumeTool<'a> { + pub wh: Wh, + pub selected_audio: &'a Option, + pub set_audio: &'a dyn Fn(Option), +} + +impl Component for VolumeTool<'_> { + fn render(self, ctx: &RenderCtx) { + let Self { + wh, + selected_audio, + set_audio, + } = self; + + let set_volume = |volume: Percent| { + let Some(selected_audio) = selected_audio else { + return; + }; + let mut audio = selected_audio.clone(); + audio.volume = volume; + set_audio(Some(audio)); + }; + + ctx.compose(|ctx| { + table::vertical([ + table::fixed(32.px(), |wh, ctx| { + ctx.add(typography::title::left(wh.height, "볼륨", Color::WHITE)); + }), + table::ratio(1, |wh, ctx| { + ctx.add(Slider { + wh, + value: selected_audio + .as_ref() + .map_or(0.percent(), |selected_audio| selected_audio.volume), + on_change: &set_volume, + disabled: selected_audio.is_none(), + }); + }), + ])(wh, ctx) + }); + } +} + +struct Slider<'a> { + wh: Wh, + value: Percent, + on_change: &'a dyn Fn(Percent), + disabled: bool, +} +impl Component for Slider<'_> { + fn render(self, ctx: &RenderCtx) { + let Self { + wh, + value, + on_change, + disabled, + } = self; + + let (dragging, set_dragging) = ctx.state::>(|| None); + + let displaying_value = dragging.as_ref().unwrap_or(value); + let thumb_radius = wh.height * 0.5; + let body_rect = Rect::Xywh { + x: thumb_radius, + y: wh.height * 0.25, + width: wh.width - (thumb_radius * 2.0), + height: wh.height * 0.5, + }; + let thumb_rect = Rect::Xywh { + x: (body_rect.width() * displaying_value), + y: 0.px(), + width: thumb_radius * 2.0, + height: thumb_radius * 2.0, + }; + let (thumb_color, active_body_color) = match disabled { + true => ( + Color::from_u8(0xBB, 0xBB, 0xBB, 0xFF), + Color::from_u8(0xAA, 0xAA, 0xAA, 0xFF), + ), + false => ( + Color::from_u8(0x42, 0xA5, 0xF5, 0xFF), + Color::from_u8(0x21, 0x96, 0xF3, 0xFF), + ), + }; + + ctx.add(path( + Path::new().add_oval(thumb_rect), + Paint::new(thumb_color), + )); + ctx.add(rect(RectParam { + rect: Rect::Xywh { + x: body_rect.x(), + y: body_rect.y(), + width: body_rect.width() * displaying_value, + height: body_rect.height(), + }, + style: RectStyle { + fill: Some(RectFill { + color: active_body_color, + }), + stroke: None, + ..Default::default() + }, + })); + ctx.add(rect(RectParam { + rect: body_rect, + style: RectStyle { + fill: Some(RectFill { + color: Color::from_u8(0x88, 0x88, 0x88, 0xFF), + }), + stroke: None, + ..Default::default() + }, + })); + + ctx.add( + rect(RectParam { + rect: Rect::Xywh { + x: body_rect.x(), + y: 0.px(), + width: body_rect.width(), + height: wh.height, + }, + style: RectStyle { + fill: Some(RectFill { + color: Color::TRANSPARENT, + }), + stroke: None, + ..Default::default() + }, + }) + .attach_event(|event| { + if disabled { + return; + } + + let update_dragging = |event: &MouseEvent| { + set_dragging.set(Some( + (((event.local_xy().x - thumb_radius) / body_rect.width()).clamp(0.0, 1.0) + * 100.0) + .percent(), + )); + }; + + match dragging.as_ref() { + Some(dragging) => match event { + Event::MouseMove { event } => { + update_dragging(&event); + } + Event::MouseUp { .. } | Event::VisibilityChange => { + set_dragging.set(None); + on_change(*dragging); + } + _ => (), + }, + None => { + let Event::MouseDown { event } = event else { + return; + }; + if !event.is_local_xy_in() { + return; + } + event.stop_propagation(); + update_dragging(&event); + } + } + }), + ); + } +} diff --git a/luda-editor/new-client/src/lib.rs b/luda-editor/new-client/src/lib.rs index 872a2dfd5..29410da66 100644 --- a/luda-editor/new-client/src/lib.rs +++ b/luda-editor/new-client/src/lib.rs @@ -1,4 +1,5 @@ mod asset_manage_page; +mod audio_util; mod episode_editor; mod home; mod network; diff --git a/luda-editor/new-server/rpc/src/types/scene.rs b/luda-editor/new-server/rpc/src/types/scene.rs index ff2be80f7..40f6a8931 100644 --- a/luda-editor/new-server/rpc/src/types/scene.rs +++ b/luda-editor/new-server/rpc/src/types/scene.rs @@ -1,4 +1,4 @@ use migration::schema::SceneDoc; +pub use migration::schema::SceneSound; pub type Scene = SceneDoc; -