Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add scene audio editor #980

Merged
merged 6 commits into from
Nov 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions luda-editor/new-client/src/audio_util/audio_storage.rs
Original file line number Diff line number Diff line change
@@ -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<AudioLoadState> {
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<dyn Error + Send + Sync>),
}
struct AudioStorage {
storage: RwLock<HashMap<String, Arc<AudioLoadState>>>,
}
impl AudioStorage {
fn new() -> Self {
Self {
storage: RwLock::new(HashMap::new()),
}
}
fn get(&self, sprite_id: &str) -> Option<Arc<AudioLoadState>> {
self.storage.read().unwrap().get(sprite_id).cloned()
}
fn set(&self, sprite_id: String, load_state: AudioLoadState) -> Arc<AudioLoadState> {
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}")
}
3 changes: 3 additions & 0 deletions luda-editor/new-client/src/audio_util/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
mod audio_storage;

pub use audio_storage::*;
1 change: 1 addition & 0 deletions luda-editor/new-client/src/episode_editor/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
mod properties_panel;
mod scene_audio_editor;
mod scene_list;
mod scene_preview;
mod scene_sprite_editor;
Expand Down
11 changes: 9 additions & 2 deletions luda-editor/new-client/src/episode_editor/properties_panel.rs
Original file line number Diff line number Diff line change
@@ -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::*};
Expand Down Expand Up @@ -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);
});
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Px>,
pub asset_docs: Sig<'a, HashMap<String, AssetDoc>>,
pub selected_audio: &'a Option<SceneSound>,
pub set_audio: &'a dyn Fn(Option<SceneSound>),
}

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::<HashSet<AssetSystemTag>>(Default::default);

let on_select = |audio_id: Option<String>| {
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::<HashMap<String, AssetDoc>>()
});

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<Px>,
asset_docs: Sig<'a, HashMap<String, AssetDoc>>,
selected_audio: &'a Option<SceneSound>,
on_select: &'a dyn Fn(Option<String>),
}
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<String>| {
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<Px>,
audio_id: Option<String>,
text: String,
is_on: bool,
on_select: &'a dyn Fn(Option<String>),
}
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::<Option<Hovering>>(|| 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,
}
Original file line number Diff line number Diff line change
@@ -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<Px>,
pub scene: &'a Scene,
pub update_scene: &'a dyn Fn(Scene),
pub asset_docs: Sig<'a, HashMap<String, AssetDoc>>,
}

impl Component for SceneAudioEditor<'_> {
fn render(self, ctx: &RenderCtx) {
let Self {
wh,
scene,
update_scene,
asset_docs,
} = self;

let set_audio = |audio: Option<SceneSound>| {
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)
});
}
}
Loading
Loading