type: voice, Requirement.value, playable resource cards
A voice requirement records in the browser and ships PCM to the relay
as one binary frame (voice.js, same mount/stop contract as gesture);
its value becomes {key, digest, duration_ms}. Requirement.value
presets a field and, on a dynamic page, takes the URL segment - so
value: "{key}" tells the voice field where to record. A url resource
source takes the segment too. Resource items with an https `audio`
field render an <audio> player. The gesture widget reports picks to
the relay for ranking.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
39fdb8e0e9
commit
07776be2bb
+81
-3
@@ -498,6 +498,28 @@ mod prosekit {
|
||||
// prosekit's hidden-input bridge for the value, yes.js's typed-handle
|
||||
// lifecycle for cleanup - it may own a live WebSocket to a
|
||||
// redoal-relay, which SPA navigation must close (`stop()`).
|
||||
#[cfg(feature = "hydrate")]
|
||||
#[cfg(feature = "hydrate")]
|
||||
mod voice {
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen(module = "/voice.js")]
|
||||
extern "C" {
|
||||
pub type VoiceWidget;
|
||||
|
||||
#[wasm_bindgen(js_name = mountVoice)]
|
||||
pub fn mount_voice(
|
||||
container: &web_sys::HtmlDivElement,
|
||||
hidden: &web_sys::HtmlInputElement,
|
||||
relay_url: &str,
|
||||
key: &str,
|
||||
) -> VoiceWidget;
|
||||
|
||||
#[wasm_bindgen(method)]
|
||||
pub fn stop(this: &VoiceWidget);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
mod gesture {
|
||||
use wasm_bindgen::prelude::*;
|
||||
@@ -766,12 +788,16 @@ fn AlternativeCard(
|
||||
.or_insert_with(|| RwSignal::new(Vec::new()));
|
||||
select_multi.insert(req.name.clone(), req.multiple);
|
||||
} else {
|
||||
if req.kind == "gesture" {
|
||||
// Widget-owned fields hold JSON objects, not strings.
|
||||
if req.kind == "gesture" || req.kind == "voice" {
|
||||
gesture_fields.insert(req.name.clone());
|
||||
}
|
||||
// A voice field's preset is the key it records at,
|
||||
// not a value to submit.
|
||||
let preset = if req.kind == "voice" { String::new() } else { req.value.clone().unwrap_or_default() };
|
||||
field_map
|
||||
.entry(req.name.clone())
|
||||
.or_insert_with(|| RwSignal::new(String::new()));
|
||||
.or_insert_with(|| RwSignal::new(preset));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1138,6 +1164,54 @@ fn AlternativeCard(
|
||||
.into_any();
|
||||
}
|
||||
|
||||
if req.kind == "voice" {
|
||||
let container_ref: NodeRef<leptos::html::Div> = NodeRef::new();
|
||||
let hidden_ref: NodeRef<leptos::html::Input> = NodeRef::new();
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
let relay = req.relay.clone().unwrap_or_default();
|
||||
let key = req.value.clone().unwrap_or_default();
|
||||
let widget: StoredValue<Option<voice::VoiceWidget>, LocalStorage> =
|
||||
StoredValue::new_local(None);
|
||||
Effect::new(move |_| {
|
||||
let (Some(container), Some(hidden)) =
|
||||
(container_ref.get(), hidden_ref.get())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if widget.with_value(|w| w.is_some()) {
|
||||
return;
|
||||
}
|
||||
widget.set_value(Some(voice::mount_voice(
|
||||
&container, &hidden, &relay, &key,
|
||||
)));
|
||||
});
|
||||
on_cleanup(move || {
|
||||
widget.update_value(|opt| {
|
||||
if let Some(w) = opt.take() {
|
||||
w.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return view! {
|
||||
<div class="field">
|
||||
{label_text}
|
||||
<input
|
||||
id=field_id
|
||||
type="hidden"
|
||||
node_ref=hidden_ref
|
||||
prop:value=move || sig.get()
|
||||
on:input=move |ev| sig.set(event_target_value(&ev))
|
||||
/>
|
||||
<div class="voice-wrap" node_ref=container_ref></div>
|
||||
</div>
|
||||
}
|
||||
.into_any();
|
||||
}
|
||||
|
||||
if req.kind == "prosekit" {
|
||||
let container_ref: NodeRef<leptos::html::Div> = NodeRef::new();
|
||||
let hidden_ref: NodeRef<leptos::html::Input> = NodeRef::new();
|
||||
@@ -1454,8 +1528,11 @@ fn ItemCard(item: serde_json::Value) -> impl IntoView {
|
||||
let name = text_field(&obj, &["name", "title"]);
|
||||
let description = text_field(&obj, &["description"]);
|
||||
let url = text_field(&obj, &["url", "html_url"]);
|
||||
// A playable recording (an https URL to audio) renders as a
|
||||
// player, not a link - the relay's /blob/<digest>.wav.
|
||||
let audio = text_field(&obj, &["audio"]).filter(|a| a.starts_with("https://"));
|
||||
|
||||
let known = ["name", "title", "description", "url", "html_url"];
|
||||
let known = ["name", "title", "description", "url", "html_url", "audio"];
|
||||
let extra: Vec<(String, String)> = obj
|
||||
.iter()
|
||||
.filter(|(k, v)| !known.contains(&k.as_str()) && !v.is_null())
|
||||
@@ -1495,6 +1572,7 @@ fn ItemCard(item: serde_json::Value) -> impl IntoView {
|
||||
}
|
||||
})}
|
||||
{description.map(|d| view! { <p class="item-card-description">{d}</p> })}
|
||||
{audio.map(|src| view! { <audio class="item-card-audio" controls preload="none" src=src></audio> })}
|
||||
{(!extra.is_empty())
|
||||
.then(|| {
|
||||
view! {
|
||||
|
||||
+44
-3
@@ -439,7 +439,13 @@ pub struct Requirement {
|
||||
/// the selected file's current content.
|
||||
#[serde(default)]
|
||||
pub bind: Option<Bind>,
|
||||
/// `type: gesture` only - ws(s):// URL of a redoal-relay instance
|
||||
/// A preset value the field starts with. On a dynamic page the
|
||||
/// URL segment substitutes into `{name}` placeholders here, like
|
||||
/// resource keys - `value: "{key}"` on a hidden field carries the
|
||||
/// page's key into the answer.
|
||||
#[serde(default)]
|
||||
pub value: Option<String>,
|
||||
/// `type: gesture` / `type: voice` - ws(s):// URL of a redoal-relay instance
|
||||
/// the drawing widget connects to for live echoes of similar
|
||||
/// strokes. Absent means the widget works offline: the drawn path
|
||||
/// still submits, it just never gets a network-computed key or
|
||||
@@ -817,15 +823,26 @@ pub fn resolve_question(
|
||||
}
|
||||
}
|
||||
};
|
||||
// A url source's address takes the segment too:
|
||||
// `https://relay.redoal.com/place/{key}`.
|
||||
let substitute_url = |src: &mut ResourceSource| {
|
||||
if let ResourceSource::Url { url } = src {
|
||||
for (name, value) in &captures {
|
||||
*url = url.replace(&format!("{{{name}}}"), value);
|
||||
}
|
||||
}
|
||||
};
|
||||
for alt in &mut resolved.alternatives {
|
||||
for feature in &mut alt.features {
|
||||
if let Some(res) = &mut feature.resource {
|
||||
substitute(&mut res.key);
|
||||
substitute_url(&mut res.source);
|
||||
}
|
||||
for req in &mut feature.requirements {
|
||||
if let Some(res) = &mut req.resource {
|
||||
substitute(&mut res.key);
|
||||
}
|
||||
substitute(&mut req.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1151,9 +1168,9 @@ pub fn validate_questions(
|
||||
);
|
||||
}
|
||||
if let Some(relay) = &requirement.relay {
|
||||
if requirement.kind != "gesture" {
|
||||
if requirement.kind != "gesture" && requirement.kind != "voice" {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?} feature {:?}: requirement {:?} declares relay but is type {:?} - relay only makes sense on type: gesture",
|
||||
"question {:?} alternative {:?} feature {:?}: requirement {:?} declares relay but is type {:?} - relay only makes sense on type: gesture or voice",
|
||||
question.id, alternative.name, feature.name, requirement.name, requirement.kind
|
||||
);
|
||||
}
|
||||
@@ -2019,4 +2036,28 @@ alternatives:
|
||||
let odd = serde_json::json!({"k": "a/b c"});
|
||||
assert_eq!(template_action("/p/{k}", &odd).as_deref(), Some("/p/a-b-c"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_page_fills_requirement_values() {
|
||||
let q: Question = serde_yaml::from_str(
|
||||
"id: /shape/[key]\nname: P\nalternatives:\n - name: A\n features:\n - name: F\n requirements:\n - name: key\n type: hidden\n value: \"{key}\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mut qs = std::collections::HashMap::new();
|
||||
qs.insert(q.id.clone(), q);
|
||||
let page = resolve_question(&qs, "/shape/20aa98").unwrap();
|
||||
assert_eq!(page.alternatives[0].features[0].requirements[0].value.as_deref(), Some("20aa98"));
|
||||
|
||||
let q: Question = serde_yaml::from_str(
|
||||
"id: /shape/[key]\nname: P\nalternatives:\n - name: A\n features:\n - name: F\n resource:\n source: { kind: url, url: \"https://relay.example/place/{key}\" }\n public: true\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mut qs = std::collections::HashMap::new();
|
||||
qs.insert(q.id.clone(), q);
|
||||
let page = resolve_question(&qs, "/shape/20aa98").unwrap();
|
||||
match &page.alternatives[0].features[0].resource.as_ref().unwrap().source {
|
||||
ResourceSource::Url { url } => assert_eq!(url, "https://relay.example/place/20aa98"),
|
||||
_ => panic!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user