@coventia/softphone 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/{call-DUVnDxsz.d.cts → call-CaPDWVk3.d.cts} +50 -1
  2. package/dist/{call-DUVnDxsz.d.ts → call-CaPDWVk3.d.ts} +50 -1
  3. package/dist/{chunk-YWI6EQYS.js → chunk-EYEDPD7Y.js} +108 -15
  4. package/dist/chunk-EYEDPD7Y.js.map +1 -0
  5. package/dist/{chunk-LMJXX4YM.js → chunk-UHNLEBBO.js} +4 -2
  6. package/dist/chunk-UHNLEBBO.js.map +1 -0
  7. package/dist/element/index.d.ts +76 -11
  8. package/dist/element/index.js +1411 -478
  9. package/dist/element/index.js.map +1 -1
  10. package/dist/{engine-EPCKf-Bl.d.ts → engine-BgBsIcEa.d.ts} +1 -1
  11. package/dist/{engine-CDjkrXHY.d.cts → engine-CU2rEo3g.d.cts} +1 -1
  12. package/dist/index.cjs +318 -32
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.d.cts +5 -5
  15. package/dist/index.d.ts +5 -5
  16. package/dist/index.js +157 -19
  17. package/dist/index.js.map +1 -1
  18. package/dist/react/index.cjs +2 -0
  19. package/dist/react/index.cjs.map +1 -1
  20. package/dist/react/index.d.cts +2 -2
  21. package/dist/react/index.d.ts +2 -2
  22. package/dist/react/index.js +1 -1
  23. package/dist/{softphone-BtdgZWkm.d.ts → softphone-DEt0TtQw.d.ts} +26 -10
  24. package/dist/{softphone-DHG9MTNg.d.cts → softphone-dZMkW8V1.d.cts} +26 -10
  25. package/dist/softphone.css +192 -46
  26. package/dist/{sources-5L2R4HTV.js → sources-M3GYMS6V.js} +58 -3
  27. package/dist/sources-M3GYMS6V.js.map +1 -0
  28. package/dist/testing/index.d.cts +2 -2
  29. package/dist/testing/index.d.ts +2 -2
  30. package/dist/ui/index.cjs +1308 -366
  31. package/dist/ui/index.cjs.map +1 -1
  32. package/dist/ui/index.d.cts +40 -7
  33. package/dist/ui/index.d.ts +40 -7
  34. package/dist/ui/index.js +1308 -369
  35. package/dist/ui/index.js.map +1 -1
  36. package/dist/zadarma/index.cjs +106 -13
  37. package/dist/zadarma/index.cjs.map +1 -1
  38. package/dist/zadarma/index.d.cts +2 -2
  39. package/dist/zadarma/index.d.ts +2 -2
  40. package/dist/zadarma/index.js +1 -1
  41. package/dist/zadarma-engine-L5DEFXUS.js +5 -0
  42. package/dist/{zadarma-engine-4VOPIBXG.js.map → zadarma-engine-L5DEFXUS.js.map} +1 -1
  43. package/package.json +1 -1
  44. package/dist/chunk-LMJXX4YM.js.map +0 -1
  45. package/dist/chunk-YWI6EQYS.js.map +0 -1
  46. package/dist/sources-5L2R4HTV.js.map +0 -1
  47. package/dist/zadarma-engine-4VOPIBXG.js +0 -5
package/dist/ui/index.cjs CHANGED
@@ -13128,6 +13128,14 @@ var ArrowRight = createLucideIcon("ArrowRight", [
13128
13128
  ["path", { d: "m12 5 7 7-7 7", key: "xquz4c" }]
13129
13129
  ]);
13130
13130
 
13131
+ // ../../node_modules/lucide-react/dist/esm/icons/bell-ring.js
13132
+ var BellRing = createLucideIcon("BellRing", [
13133
+ ["path", { d: "M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9", key: "1qo2s2" }],
13134
+ ["path", { d: "M10.3 21a1.94 1.94 0 0 0 3.4 0", key: "qgo35s" }],
13135
+ ["path", { d: "M4 2C2.8 3.7 2 5.7 2 8", key: "tap9e0" }],
13136
+ ["path", { d: "M22 8c0-2.3-.8-4.3-2-6", key: "5bb3ad" }]
13137
+ ]);
13138
+
13131
13139
  // ../../node_modules/lucide-react/dist/esm/icons/calendar-clock.js
13132
13140
  var CalendarClock = createLucideIcon("CalendarClock", [
13133
13141
  ["path", { d: "M21 7.5V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3.5", key: "1osxxc" }],
@@ -13242,6 +13250,13 @@ var MonitorSmartphone = createLucideIcon("MonitorSmartphone", [
13242
13250
  ["rect", { width: "6", height: "10", x: "16", y: "12", rx: "2", key: "1egngj" }]
13243
13251
  ]);
13244
13252
 
13253
+ // ../../node_modules/lucide-react/dist/esm/icons/music.js
13254
+ var Music = createLucideIcon("Music", [
13255
+ ["path", { d: "M9 18V5l12-2v13", key: "1jmyc2" }],
13256
+ ["circle", { cx: "6", cy: "18", r: "3", key: "fqmcym" }],
13257
+ ["circle", { cx: "18", cy: "16", r: "3", key: "1hluhg" }]
13258
+ ]);
13259
+
13245
13260
  // ../../node_modules/lucide-react/dist/esm/icons/pause.js
13246
13261
  var Pause = createLucideIcon("Pause", [
13247
13262
  ["rect", { x: "14", y: "4", width: "4", height: "16", rx: "1", key: "zuxfzm" }],
@@ -13367,6 +13382,19 @@ var Users = createLucideIcon("Users", [
13367
13382
  ["path", { d: "M16 3.13a4 4 0 0 1 0 7.75", key: "1da9ce" }]
13368
13383
  ]);
13369
13384
 
13385
+ // ../../node_modules/lucide-react/dist/esm/icons/volume-2.js
13386
+ var Volume2 = createLucideIcon("Volume2", [
13387
+ [
13388
+ "path",
13389
+ {
13390
+ d: "M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z",
13391
+ key: "uqj9uw"
13392
+ }
13393
+ ],
13394
+ ["path", { d: "M16 9a5 5 0 0 1 0 6", key: "1q6k2b" }],
13395
+ ["path", { d: "M19.364 18.364a9 9 0 0 0 0-12.728", key: "ijwkga" }]
13396
+ ]);
13397
+
13370
13398
  // ../../node_modules/lucide-react/dist/esm/icons/x.js
13371
13399
  var X = createLucideIcon("X", [
13372
13400
  ["path", { d: "M18 6 6 18", key: "1bl5f8" }],
@@ -13493,7 +13521,9 @@ function useDevices() {
13493
13521
  outputs: audio.outputs,
13494
13522
  currentInputId: audio.currentInputId,
13495
13523
  currentOutputId: audio.currentOutputId,
13524
+ currentRingtoneOutputId: audio.currentRingtoneOutputId,
13496
13525
  canSelectOutput: audio.canSelectOutput,
13526
+ canSelectRingtoneOutput: audio.canSelectRingtoneOutput,
13497
13527
  setInput: (deviceId) => audio.setInput(deviceId),
13498
13528
  setOutput: (deviceId) => audio.setOutput(deviceId),
13499
13529
  setRingtoneOutput: (deviceId) => audio.setRingtoneOutput(deviceId),
@@ -13721,151 +13751,582 @@ function caretAfterDigits(formatted, digitsBefore) {
13721
13751
  function digitsBeforeCaret(text, caret) {
13722
13752
  return (text.slice(0, caret).match(/\d/g) ?? []).length;
13723
13753
  }
13724
- var KEYS = [
13725
- ["1", "", true],
13726
- ["2", "ABC", true],
13727
- ["3", "DEF", true],
13728
- ["4", "GHI", true],
13729
- ["5", "JKL", true],
13730
- ["6", "MNO", true],
13731
- ["7", "PQRS", true],
13732
- ["8", "TUV", true],
13733
- ["9", "WXYZ", true],
13734
- ["*", "", false],
13735
- ["0", "+", true],
13736
- ["#", "", false]
13754
+
13755
+ // src/ui/dialing/keys.ts
13756
+ var KEYPAD_KEYS = [
13757
+ ["1", ""],
13758
+ ["2", "ABC"],
13759
+ ["3", "DEF"],
13760
+ ["4", "GHI"],
13761
+ ["5", "JKL"],
13762
+ ["6", "MNO"],
13763
+ ["7", "PQRS"],
13764
+ ["8", "TUV"],
13765
+ ["9", "WXYZ"],
13766
+ ["*", ""],
13767
+ ["0", "+"],
13768
+ ["#", ""]
13737
13769
  ];
13738
- function Dialpad({ outcome = null, onIntent } = {}) {
13739
- const softphone = useSoftphone();
13740
- const state = useSoftphoneState();
13741
- const [value, setValue] = React.useState("");
13742
- const [country2, setCountry] = React.useState(DEFAULT_COUNTRY);
13743
- const format = useNumberFormatter();
13744
- const inputRef = React.useRef(null);
13745
- const caretRef = React.useRef(null);
13746
- const display = format(country2, value);
13747
- const target = toE164(country2, value);
13748
- const canCall = state === "online" && target.length > 1;
13749
- React.useLayoutEffect(() => {
13750
- const caret = caretRef.current;
13751
- if (caret === null || !inputRef.current) return;
13752
- caretRef.current = null;
13753
- const pos = caretAfterDigits(display, caret);
13754
- inputRef.current.setSelectionRange(pos, pos);
13755
- }, [display]);
13756
- const append = (key) => {
13757
- onIntent?.();
13758
- setValue((v) => v + key);
13759
- };
13760
- const backspace = () => {
13761
- onIntent?.();
13762
- setValue((v) => v.slice(0, -1));
13770
+
13771
+ // src/ui/audio/classic-ring.ts
13772
+ var C6 = 1046.5;
13773
+ var E6 = 1318.51;
13774
+ var HARMONICITY = 4;
13775
+ var INDEX = 1.8;
13776
+ var CLASSIC_CYCLE_SECONDS = 3;
13777
+ var CLASSIC_GAIN = 0.3841;
13778
+ function classicRingGraph(ctx, dest) {
13779
+ const output = ctx.createGain();
13780
+ output.gain.value = CLASSIC_GAIN;
13781
+ const chain = ["highpass", "highpass", "lowpass", "lowpass"].map((type) => {
13782
+ const f = ctx.createBiquadFilter();
13783
+ f.type = type;
13784
+ f.frequency.value = type === "highpass" ? 250 : 7500;
13785
+ f.Q.value = 0.707;
13786
+ return f;
13787
+ });
13788
+ for (let i = 0; i < chain.length - 1; i++) chain[i].connect(chain[i + 1]);
13789
+ chain[chain.length - 1].connect(output);
13790
+ output.connect(dest);
13791
+ return { input: chain[0], output };
13792
+ }
13793
+ function strike(ctx, input, t, freq, vel, track) {
13794
+ const carrier = ctx.createOscillator();
13795
+ carrier.frequency.value = freq;
13796
+ const modulator = ctx.createOscillator();
13797
+ modulator.frequency.value = freq * HARMONICITY;
13798
+ const depth = ctx.createGain();
13799
+ depth.gain.setValueAtTime(0, t);
13800
+ depth.gain.linearRampToValueAtTime(INDEX * freq, t + 4e-3);
13801
+ depth.gain.setTargetAtTime(0, t + 4e-3, 0.02);
13802
+ modulator.connect(depth);
13803
+ depth.connect(carrier.frequency);
13804
+ const amp = ctx.createGain();
13805
+ amp.gain.setValueAtTime(0, t);
13806
+ amp.gain.linearRampToValueAtTime(vel, t + 5e-3);
13807
+ amp.gain.setTargetAtTime(0, t + 5e-3, 0.15);
13808
+ amp.gain.setTargetAtTime(0, t + 0.045, 0.08);
13809
+ carrier.connect(amp);
13810
+ amp.connect(input);
13811
+ for (const o of [carrier, modulator]) {
13812
+ o.start(t);
13813
+ o.stop(t + 0.7);
13814
+ track(o);
13815
+ }
13816
+ }
13817
+ function scheduleClassicCycle(ctx, input, t0, track) {
13818
+ for (const ring of [0, 0.6]) {
13819
+ for (let i = 0; i < 8; i++) strike(ctx, input, t0 + ring + i * 0.05, i % 2 ? E6 : C6, 0.55 + 0.1 * (i % 2), track);
13820
+ }
13821
+ return CLASSIC_CYCLE_SECONDS;
13822
+ }
13823
+
13824
+ // src/ui/audio/audio.ts
13825
+ var TONE_HZ = 425;
13826
+ function createRemoteAudio() {
13827
+ const el = document.createElement("audio");
13828
+ el.autoplay = true;
13829
+ el.style.display = "none";
13830
+ document.body.appendChild(el);
13831
+ return {
13832
+ attach(stream) {
13833
+ el.srcObject = stream;
13834
+ void el.play().catch(() => {
13835
+ });
13836
+ },
13837
+ detach() {
13838
+ el.srcObject = null;
13839
+ },
13840
+ destroy() {
13841
+ el.srcObject = null;
13842
+ el.remove();
13843
+ },
13844
+ async setOutputDevice(deviceId) {
13845
+ if (typeof el.setSinkId !== "function") return;
13846
+ try {
13847
+ await el.setSinkId(deviceId ?? "");
13848
+ } catch {
13849
+ }
13850
+ }
13763
13851
  };
13764
- const call = () => {
13765
- if (!canCall) return;
13766
- onIntent?.();
13767
- void softphone.call(target);
13852
+ }
13853
+ var shared = null;
13854
+ function sharedContext() {
13855
+ if (shared) return shared;
13856
+ const Ctor = typeof AudioContext !== "undefined" ? AudioContext : globalThis.webkitAudioContext;
13857
+ if (!Ctor) return null;
13858
+ shared = new Ctor();
13859
+ return shared;
13860
+ }
13861
+ function primeAudio() {
13862
+ const ctx = sharedContext();
13863
+ if (ctx && ctx.state === "suspended") void ctx.resume?.().catch(() => {
13864
+ });
13865
+ }
13866
+ function playRingtone(options = {}) {
13867
+ const ctx = sharedContext();
13868
+ if (!ctx) return { stop() {
13869
+ } };
13870
+ const bus = ctx.createGain();
13871
+ const route = routeToDevice(ctx, bus, options.sinkId ?? null);
13872
+ const voice = options.buffer ? fileVoice(ctx, bus, options.buffer, options.loop ?? null) : synthesisedVoice(ctx, bus);
13873
+ return {
13874
+ stop() {
13875
+ voice.stop();
13876
+ route.stop();
13877
+ }
13768
13878
  };
13769
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-view", children: [
13770
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-dial-prefix", children: /* @__PURE__ */ jsxRuntime.jsx(CountryPicker, { value: country2, onChange: setCountry }) }),
13771
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-dial-head", children: [
13772
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cv-dial-side", "aria-hidden": true }),
13773
- /* @__PURE__ */ jsxRuntime.jsx(
13774
- "input",
13775
- {
13776
- ref: inputRef,
13777
- className: "cv-display",
13778
- value: display,
13779
- onChange: (e) => {
13780
- onIntent?.();
13781
- caretRef.current = digitsBeforeCaret(e.target.value, e.target.selectionStart ?? 0);
13782
- setValue(e.target.value.replace(/[^\d+]/g, ""));
13783
- },
13784
- onKeyDown: (e) => e.key === "Enter" && call(),
13785
- placeholder: "N\xFAmero",
13786
- "aria-label": "N\xFAmero a marcar",
13787
- inputMode: "tel"
13788
- }
13789
- ),
13790
- /* @__PURE__ */ jsxRuntime.jsx(
13791
- "button",
13792
- {
13793
- type: "button",
13794
- className: "cv-dial-side",
13795
- "data-hidden": value.length === 0,
13796
- onClick: backspace,
13797
- "aria-label": "Borrar",
13798
- children: /* @__PURE__ */ jsxRuntime.jsx(Delete, { size: 19 })
13799
- }
13800
- )
13801
- ] }),
13802
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "cv-dial-outcome", role: "status", part: "error", "data-shown": outcome !== null, children: outcome?.text ?? "" }),
13803
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-keypad", children: KEYS.map(([key, letters, dialable]) => /* @__PURE__ */ jsxRuntime.jsxs(
13804
- "button",
13805
- {
13806
- type: "button",
13807
- className: "cv-key",
13808
- "data-dialable": dialable,
13809
- onClick: () => dialable && append(key),
13810
- "aria-disabled": !dialable,
13811
- title: dialable ? void 0 : "Solo disponible durante una llamada",
13812
- children: [
13813
- /* @__PURE__ */ jsxRuntime.jsx("b", { children: key }),
13814
- letters ? /* @__PURE__ */ jsxRuntime.jsx("small", { children: letters }) : null
13815
- ]
13816
- },
13817
- key
13818
- )) }),
13819
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-dial-foot", children: /* @__PURE__ */ jsxRuntime.jsx(
13820
- "button",
13821
- {
13822
- type: "button",
13823
- className: "cv-round cv-round--answer",
13824
- onClick: call,
13825
- disabled: !canCall,
13826
- "aria-label": "Llamar",
13827
- part: "call-button",
13828
- children: /* @__PURE__ */ jsxRuntime.jsx(Phone, { size: 26 })
13879
+ }
13880
+ function fileVoice(ctx, bus, buffer, loop) {
13881
+ const FILE_LEVEL = 1;
13882
+ const RAMP = 0.02;
13883
+ const LEAD = 0.08;
13884
+ const gain = ctx.createGain();
13885
+ gain.gain.value = 0;
13886
+ gain.connect(bus);
13887
+ const src = ctx.createBufferSource();
13888
+ src.buffer = buffer;
13889
+ src.loop = true;
13890
+ const points = loop && loop.start >= 0 && loop.end > loop.start && loop.end <= buffer.duration + 1e-3 ? loop : null;
13891
+ if (points) {
13892
+ src.loopStart = points.start;
13893
+ src.loopEnd = points.end;
13894
+ }
13895
+ src.connect(gain);
13896
+ let stopped = false;
13897
+ void Promise.resolve(ctx.resume?.()).catch(() => {
13898
+ }).finally(() => {
13899
+ if (stopped) return;
13900
+ const at = ctx.currentTime + LEAD;
13901
+ gain.gain.setValueAtTime(0, at);
13902
+ gain.gain.linearRampToValueAtTime(FILE_LEVEL, at + RAMP);
13903
+ src.start(at, points?.start ?? 0);
13904
+ });
13905
+ return {
13906
+ stop() {
13907
+ stopped = true;
13908
+ try {
13909
+ src.stop();
13910
+ } catch {
13829
13911
  }
13830
- ) })
13831
- ] });
13912
+ try {
13913
+ gain.disconnect();
13914
+ } catch {
13915
+ }
13916
+ }
13917
+ };
13832
13918
  }
13833
-
13834
- // src/ui/lib/format.ts
13835
- function formatDuration(totalSeconds) {
13836
- const s = Math.max(0, Math.floor(totalSeconds));
13837
- const hh = Math.floor(s / 3600);
13838
- const mm = Math.floor(s % 3600 / 60);
13839
- const ss = s % 60;
13840
- const pad = (n) => String(n).padStart(2, "0");
13841
- return hh > 0 ? `${hh}:${pad(mm)}:${pad(ss)}` : `${mm}:${pad(ss)}`;
13919
+ function routeToDevice(ctx, bus, sinkId) {
13920
+ const toDefault = () => {
13921
+ bus.connect(ctx.destination);
13922
+ };
13923
+ const el = sinkId && typeof document !== "undefined" ? document.createElement("audio") : null;
13924
+ const dest = el && typeof ctx.createMediaStreamDestination === "function" ? ctx.createMediaStreamDestination() : null;
13925
+ const canRoute = el && dest && typeof el.setSinkId === "function";
13926
+ if (!canRoute || !sinkId) {
13927
+ toDefault();
13928
+ return { stop: () => safeDisconnect(bus) };
13929
+ }
13930
+ let stopped = false;
13931
+ el.style.display = "none";
13932
+ el.srcObject = dest.stream;
13933
+ document.body.appendChild(el);
13934
+ bus.connect(dest);
13935
+ void el.setSinkId(sinkId).then(() => el.play()).catch((error) => {
13936
+ if (stopped) return;
13937
+ console.warn("[softphone] sound could not be sent to the chosen device; using the default output", error);
13938
+ safeDisconnect(bus);
13939
+ teardown(el);
13940
+ toDefault();
13941
+ });
13942
+ return {
13943
+ stop() {
13944
+ stopped = true;
13945
+ safeDisconnect(bus);
13946
+ teardown(el);
13947
+ }
13948
+ };
13842
13949
  }
13843
- function initials(name) {
13844
- if (!name) return "#";
13845
- const words = name.trim().split(/\s+/).filter(Boolean);
13846
- if (words.length === 0 || /^\+?\d/.test(words[0])) return "#";
13847
- return words.slice(0, 2).map((w) => w[0].toUpperCase()).join("");
13950
+ function safeDisconnect(node) {
13951
+ try {
13952
+ node.disconnect();
13953
+ } catch {
13954
+ }
13848
13955
  }
13849
- function elapsedSeconds(since, now) {
13850
- if (!since) return 0;
13851
- return Math.floor((now - since.getTime()) / 1e3);
13956
+ function teardown(el) {
13957
+ el.pause();
13958
+ el.srcObject = null;
13959
+ el.remove();
13852
13960
  }
13853
- function formatAge(totalSeconds) {
13854
- const s = Math.max(0, Math.floor(totalSeconds));
13855
- if (s < 60) return "justo ahora";
13856
- const mm = Math.floor(s / 60);
13857
- if (mm < 60) return `hace ${mm} min`;
13858
- const hh = Math.floor(mm / 60);
13859
- return `hace ${hh} h`;
13961
+ var TEST_TONE_MS = 900;
13962
+ function playTestTone(sinkId) {
13963
+ const ctx = sharedContext();
13964
+ if (!ctx) return { stop() {
13965
+ } };
13966
+ const bus = ctx.createGain();
13967
+ const route = routeToDevice(ctx, bus, sinkId);
13968
+ const LEVEL = 0.5;
13969
+ const NOTES = [
13970
+ { hz: 660, at: 0.05 },
13971
+ { hz: 880, at: 0.4 }
13972
+ ];
13973
+ const oscs = [];
13974
+ let stopped = false;
13975
+ let timer = null;
13976
+ const stop = () => {
13977
+ if (stopped) return;
13978
+ stopped = true;
13979
+ if (timer) clearTimeout(timer);
13980
+ for (const o of oscs) {
13981
+ try {
13982
+ o.stop();
13983
+ } catch {
13984
+ }
13985
+ }
13986
+ route.stop();
13987
+ };
13988
+ void Promise.resolve(ctx.resume?.()).catch(() => {
13989
+ }).finally(() => {
13990
+ if (stopped) return;
13991
+ const t0 = ctx.currentTime;
13992
+ for (const { hz, at } of NOTES) {
13993
+ const gain = ctx.createGain();
13994
+ gain.gain.value = 0;
13995
+ gain.connect(bus);
13996
+ const osc = ctx.createOscillator();
13997
+ osc.type = "sine";
13998
+ osc.frequency.value = hz;
13999
+ osc.connect(gain);
14000
+ const start = t0 + at;
14001
+ gain.gain.setValueAtTime(0, start);
14002
+ gain.gain.linearRampToValueAtTime(LEVEL, start + 0.015);
14003
+ gain.gain.setValueAtTime(LEVEL, start + 0.25);
14004
+ gain.gain.linearRampToValueAtTime(0, start + 0.32);
14005
+ osc.start(start);
14006
+ osc.stop(start + 0.34);
14007
+ oscs.push(osc);
14008
+ }
14009
+ timer = setTimeout(stop, TEST_TONE_MS);
14010
+ });
14011
+ return { stop };
14012
+ }
14013
+ function synthesisedVoice(ctx, bus) {
14014
+ const { input, output } = classicRingGraph(ctx, bus);
14015
+ const scheduled = /* @__PURE__ */ new Set();
14016
+ const track = (o) => {
14017
+ scheduled.add(o);
14018
+ o.onended = () => scheduled.delete(o);
14019
+ };
14020
+ const LOOKAHEAD = 0.5;
14021
+ const TICK_MS = 150;
14022
+ const LEAD = 0.08;
14023
+ let stopped = false;
14024
+ let timer = null;
14025
+ let nextAt = 0;
14026
+ const pump = () => {
14027
+ while (nextAt < ctx.currentTime + LOOKAHEAD) nextAt += scheduleClassicCycle(ctx, input, nextAt, track);
14028
+ };
14029
+ void Promise.resolve(ctx.resume?.()).catch(() => {
14030
+ }).finally(() => {
14031
+ if (stopped) return;
14032
+ nextAt = ctx.currentTime + LEAD;
14033
+ pump();
14034
+ timer = setInterval(pump, TICK_MS);
14035
+ });
14036
+ return {
14037
+ stop() {
14038
+ stopped = true;
14039
+ if (timer) clearInterval(timer);
14040
+ for (const o of scheduled) {
14041
+ try {
14042
+ o.stop();
14043
+ } catch {
14044
+ }
14045
+ }
14046
+ scheduled.clear();
14047
+ try {
14048
+ output.disconnect();
14049
+ } catch {
14050
+ }
14051
+ }
14052
+ };
13860
14053
  }
13861
-
13862
- // src/ui/lib/error-text.ts
13863
- var ERROR_TEXT = {
13864
- // ── Engine lifecycle ────────────────────────────────────────────────────────
13865
- // Integrator mistakes rather than user-facing failures, but they must still read as something
13866
- // rather than as a blank panel if one ever reaches the UI.
13867
- "engine/duplicate-instance": {
13868
- title: "Tel\xE9fono duplicado",
14054
+ function playCallEndedTone() {
14055
+ const ctx = sharedContext();
14056
+ if (!ctx) return;
14057
+ const gain = ctx.createGain();
14058
+ gain.gain.value = 0;
14059
+ gain.connect(ctx.destination);
14060
+ const osc = ctx.createOscillator();
14061
+ osc.type = "sine";
14062
+ osc.frequency.value = TONE_HZ;
14063
+ osc.connect(gain);
14064
+ osc.start();
14065
+ const LEVEL = 0.55;
14066
+ const RAMP = 8e-3;
14067
+ const BEEP = 0.25;
14068
+ void Promise.resolve(ctx.resume?.()).catch(() => {
14069
+ }).finally(() => {
14070
+ const at = ctx.currentTime + 0.02;
14071
+ gain.gain.setValueAtTime(0, at);
14072
+ gain.gain.linearRampToValueAtTime(LEVEL, at + RAMP);
14073
+ gain.gain.setValueAtTime(LEVEL, at + BEEP - RAMP);
14074
+ gain.gain.linearRampToValueAtTime(0, at + BEEP);
14075
+ const totalMs = (at + BEEP - ctx.currentTime) * 1e3;
14076
+ setTimeout(() => {
14077
+ try {
14078
+ osc.stop();
14079
+ } catch {
14080
+ }
14081
+ try {
14082
+ gain.disconnect();
14083
+ } catch {
14084
+ }
14085
+ }, totalMs);
14086
+ });
14087
+ }
14088
+ var DTMF_ROWS_HZ = [697, 770, 852, 941];
14089
+ var DTMF_COLS_HZ = [1209, 1336, 1477];
14090
+ var DTMF_LAYOUT = ["123", "456", "789", "*0#"];
14091
+ function dtmfFrequencies(key) {
14092
+ if (key.length !== 1) return null;
14093
+ for (let row = 0; row < DTMF_LAYOUT.length; row++) {
14094
+ const col = DTMF_LAYOUT[row].indexOf(key);
14095
+ if (col !== -1) return [DTMF_ROWS_HZ[row], DTMF_COLS_HZ[col]];
14096
+ }
14097
+ return null;
14098
+ }
14099
+ function playKeyTone(key) {
14100
+ const hz = dtmfFrequencies(key);
14101
+ if (!hz) return;
14102
+ const ctx = sharedContext();
14103
+ if (!ctx) return;
14104
+ const LEVEL = 0.2;
14105
+ const RAMP = 6e-3;
14106
+ const DURATION = 0.12;
14107
+ const LEAD = 0.01;
14108
+ const gain = ctx.createGain();
14109
+ gain.gain.value = 0;
14110
+ gain.connect(ctx.destination);
14111
+ const oscillators = hz.map((freq) => {
14112
+ const osc = ctx.createOscillator();
14113
+ osc.type = "sine";
14114
+ osc.frequency.value = freq;
14115
+ osc.connect(gain);
14116
+ osc.start();
14117
+ return osc;
14118
+ });
14119
+ const fire = () => {
14120
+ const at = ctx.currentTime + LEAD;
14121
+ gain.gain.setValueAtTime(0, at);
14122
+ gain.gain.linearRampToValueAtTime(LEVEL, at + RAMP);
14123
+ gain.gain.setValueAtTime(LEVEL, at + DURATION - RAMP);
14124
+ gain.gain.linearRampToValueAtTime(0, at + DURATION);
14125
+ for (const osc of oscillators) osc.stop(at + DURATION);
14126
+ oscillators[0].onended = () => gain.disconnect();
14127
+ };
14128
+ if (ctx.state === "running") fire();
14129
+ else void Promise.resolve(ctx.resume?.()).catch(() => {
14130
+ }).finally(fire);
14131
+ }
14132
+ var ringbackUrl = null;
14133
+ function ringbackSource() {
14134
+ if (ringbackUrl) return ringbackUrl;
14135
+ const sampleRate = 48e3;
14136
+ const channels = 2;
14137
+ const bytesPerSample = 2;
14138
+ const amp = 0.5;
14139
+ const toneSec = 1.5;
14140
+ const silenceSec = 3;
14141
+ const totalFrames = Math.round((toneSec + silenceSec) * sampleRate);
14142
+ const toneFrames = Math.round(toneSec * sampleRate);
14143
+ const ramp = Math.round(0.02 * sampleRate);
14144
+ const blockAlign = channels * bytesPerSample;
14145
+ const dataSize = totalFrames * blockAlign;
14146
+ const buffer = new ArrayBuffer(44 + dataSize);
14147
+ const view = new DataView(buffer);
14148
+ const str = (off, s) => {
14149
+ for (let i = 0; i < s.length; i++) view.setUint8(off + i, s.charCodeAt(i));
14150
+ };
14151
+ str(0, "RIFF");
14152
+ view.setUint32(4, 36 + dataSize, true);
14153
+ str(8, "WAVE");
14154
+ str(12, "fmt ");
14155
+ view.setUint32(16, 16, true);
14156
+ view.setUint16(20, 1, true);
14157
+ view.setUint16(22, channels, true);
14158
+ view.setUint32(24, sampleRate, true);
14159
+ view.setUint32(28, sampleRate * blockAlign, true);
14160
+ view.setUint16(32, blockAlign, true);
14161
+ view.setUint16(34, bytesPerSample * 8, true);
14162
+ str(36, "data");
14163
+ view.setUint32(40, dataSize, true);
14164
+ for (let i = 0; i < totalFrames; i++) {
14165
+ let sample = 0;
14166
+ if (i < toneFrames) {
14167
+ let env = 1;
14168
+ if (i < ramp) env = i / ramp;
14169
+ else if (i > toneFrames - ramp) env = Math.max(0, (toneFrames - i) / ramp);
14170
+ sample = Math.sin(2 * Math.PI * TONE_HZ * (i / sampleRate)) * amp * env;
14171
+ }
14172
+ const s16 = Math.round(sample * 32767);
14173
+ const off = 44 + i * blockAlign;
14174
+ for (let c = 0; c < channels; c++) view.setInt16(off + c * bytesPerSample, s16, true);
14175
+ }
14176
+ ringbackUrl = URL.createObjectURL(new Blob([buffer], { type: "audio/wav" }));
14177
+ return ringbackUrl;
14178
+ }
14179
+ function playRingback() {
14180
+ const el = document.createElement("audio");
14181
+ el.src = ringbackSource();
14182
+ el.loop = true;
14183
+ el.style.display = "none";
14184
+ document.body.appendChild(el);
14185
+ void el.play().catch(() => {
14186
+ });
14187
+ return {
14188
+ stop() {
14189
+ el.pause();
14190
+ el.remove();
14191
+ }
14192
+ };
14193
+ }
14194
+ var NOT_DIALABLE = /* @__PURE__ */ new Set(["*", "#"]);
14195
+ function Dialpad({ outcome = null, onIntent, keyTones = true } = {}) {
14196
+ const softphone = useSoftphone();
14197
+ const state = useSoftphoneState();
14198
+ const [value, setValue] = React.useState("");
14199
+ const [country2, setCountry] = React.useState(DEFAULT_COUNTRY);
14200
+ const format = useNumberFormatter();
14201
+ const inputRef = React.useRef(null);
14202
+ const caretRef = React.useRef(null);
14203
+ const display = format(country2, value);
14204
+ const target = toE164(country2, value);
14205
+ const canCall = state === "online" && target.length > 1;
14206
+ React.useLayoutEffect(() => {
14207
+ const caret = caretRef.current;
14208
+ if (caret === null || !inputRef.current) return;
14209
+ caretRef.current = null;
14210
+ const pos = caretAfterDigits(display, caret);
14211
+ inputRef.current.setSelectionRange(pos, pos);
14212
+ }, [display]);
14213
+ const append = (key) => {
14214
+ onIntent?.();
14215
+ setValue((v) => v + key);
14216
+ if (keyTones) playKeyTone(key);
14217
+ };
14218
+ const backspace = () => {
14219
+ onIntent?.();
14220
+ setValue((v) => v.slice(0, -1));
14221
+ };
14222
+ const call = () => {
14223
+ if (!canCall) return;
14224
+ onIntent?.();
14225
+ void softphone.call(target);
14226
+ };
14227
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-view", children: [
14228
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-dial-prefix", children: /* @__PURE__ */ jsxRuntime.jsx(CountryPicker, { value: country2, onChange: setCountry }) }),
14229
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-dial-head", children: [
14230
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cv-dial-side", "aria-hidden": true }),
14231
+ /* @__PURE__ */ jsxRuntime.jsx(
14232
+ "input",
14233
+ {
14234
+ ref: inputRef,
14235
+ className: "cv-display",
14236
+ value: display,
14237
+ onChange: (e) => {
14238
+ onIntent?.();
14239
+ caretRef.current = digitsBeforeCaret(e.target.value, e.target.selectionStart ?? 0);
14240
+ setValue(e.target.value.replace(/[^\d+]/g, ""));
14241
+ },
14242
+ onKeyDown: (e) => e.key === "Enter" && call(),
14243
+ placeholder: "N\xFAmero",
14244
+ "aria-label": "N\xFAmero a marcar",
14245
+ inputMode: "tel"
14246
+ }
14247
+ ),
14248
+ /* @__PURE__ */ jsxRuntime.jsx(
14249
+ "button",
14250
+ {
14251
+ type: "button",
14252
+ className: "cv-dial-side",
14253
+ "data-hidden": value.length === 0,
14254
+ onClick: backspace,
14255
+ "aria-label": "Borrar",
14256
+ children: /* @__PURE__ */ jsxRuntime.jsx(Delete, { size: 19 })
14257
+ }
14258
+ )
14259
+ ] }),
14260
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "cv-dial-outcome", role: "status", part: "error", "data-shown": outcome !== null, children: outcome?.text ?? "" }),
14261
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-keypad", children: KEYPAD_KEYS.map(([key, letters]) => {
14262
+ const dialable = !NOT_DIALABLE.has(key);
14263
+ return /* @__PURE__ */ jsxRuntime.jsxs(
14264
+ "button",
14265
+ {
14266
+ type: "button",
14267
+ className: "cv-key",
14268
+ "data-dialable": dialable,
14269
+ onClick: () => dialable && append(key),
14270
+ "aria-disabled": !dialable,
14271
+ title: dialable ? void 0 : "Solo disponible durante una llamada",
14272
+ children: [
14273
+ /* @__PURE__ */ jsxRuntime.jsx("b", { children: key }),
14274
+ letters ? /* @__PURE__ */ jsxRuntime.jsx("small", { children: letters }) : null
14275
+ ]
14276
+ },
14277
+ key
14278
+ );
14279
+ }) }),
14280
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-dial-foot", children: /* @__PURE__ */ jsxRuntime.jsx(
14281
+ "button",
14282
+ {
14283
+ type: "button",
14284
+ className: "cv-round cv-round--answer",
14285
+ onClick: call,
14286
+ disabled: !canCall,
14287
+ "aria-label": "Llamar",
14288
+ part: "call-button",
14289
+ children: /* @__PURE__ */ jsxRuntime.jsx(Phone, { size: 26 })
14290
+ }
14291
+ ) })
14292
+ ] });
14293
+ }
14294
+
14295
+ // src/ui/lib/format.ts
14296
+ function formatDuration(totalSeconds) {
14297
+ const s = Math.max(0, Math.floor(totalSeconds));
14298
+ const hh = Math.floor(s / 3600);
14299
+ const mm = Math.floor(s % 3600 / 60);
14300
+ const ss = s % 60;
14301
+ const pad = (n) => String(n).padStart(2, "0");
14302
+ return hh > 0 ? `${hh}:${pad(mm)}:${pad(ss)}` : `${mm}:${pad(ss)}`;
14303
+ }
14304
+ function initials(name) {
14305
+ if (!name) return "#";
14306
+ const words = name.trim().split(/\s+/).filter(Boolean);
14307
+ if (words.length === 0 || /^\+?\d/.test(words[0])) return "#";
14308
+ return words.slice(0, 2).map((w) => w[0].toUpperCase()).join("");
14309
+ }
14310
+ function elapsedSeconds(since, now) {
14311
+ if (!since) return 0;
14312
+ return Math.floor((now - since.getTime()) / 1e3);
14313
+ }
14314
+ function formatAge(totalSeconds) {
14315
+ const s = Math.max(0, Math.floor(totalSeconds));
14316
+ if (s < 60) return "justo ahora";
14317
+ const mm = Math.floor(s / 60);
14318
+ if (mm < 60) return `hace ${mm} min`;
14319
+ const hh = Math.floor(mm / 60);
14320
+ return `hace ${hh} h`;
14321
+ }
14322
+
14323
+ // src/ui/lib/error-text.ts
14324
+ var ERROR_TEXT = {
14325
+ // ── Engine lifecycle ────────────────────────────────────────────────────────
14326
+ // Integrator mistakes rather than user-facing failures, but they must still read as something
14327
+ // rather than as a blank panel if one ever reaches the UI.
14328
+ "engine/duplicate-instance": {
14329
+ title: "Tel\xE9fono duplicado",
13869
14330
  body: "Hay otro tel\xE9fono abierto en esta p\xE1gina. Cierra uno y vuelve a cargar."
13870
14331
  },
13871
14332
  "engine/not-started": { title: "El tel\xE9fono a\xFAn no est\xE1 listo" },
@@ -14055,7 +14516,9 @@ function Control({
14055
14516
  label,
14056
14517
  pressed = false,
14057
14518
  tone,
14058
- onClick
14519
+ onClick,
14520
+ disabled = false,
14521
+ title
14059
14522
  }) {
14060
14523
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-ctrl-item", children: [
14061
14524
  /* @__PURE__ */ jsxRuntime.jsx(
@@ -14067,6 +14530,8 @@ function Control({
14067
14530
  "aria-pressed": pressed,
14068
14531
  "aria-label": label,
14069
14532
  onClick,
14533
+ disabled,
14534
+ title,
14070
14535
  children: /* @__PURE__ */ jsxRuntime.jsx(Icon3, { size: 19 })
14071
14536
  }
14072
14537
  ),
@@ -14205,18 +14670,22 @@ function TransferPicker({
14205
14670
  }
14206
14671
  function CallScreen({
14207
14672
  call,
14208
- incomingCallAction
14673
+ incomingCallAction,
14674
+ keyTones = true
14209
14675
  }) {
14210
14676
  const view = useCall(call);
14211
14677
  const softphone = useSoftphone();
14212
- const [showTransfer, setShowTransfer] = React.useState(false);
14678
+ const [panel, setPanel] = React.useState(null);
14679
+ const showTransfer = panel === "transfer";
14680
+ const showKeypad = panel === "keypad";
14681
+ const showDevices = panel === "devices";
14682
+ const togglePanel = (p) => setPanel((cur) => cur === p ? null : p);
14213
14683
  const [transferTo, setTransferTo] = React.useState("");
14214
14684
  const [consultCall, setConsultCall] = React.useState(null);
14215
14685
  const [consultOutcome, setConsultOutcome] = React.useState(null);
14216
14686
  const [myUserId, setMyUserId] = React.useState(null);
14217
14687
  const consultView = useCall(consultCall);
14218
14688
  const consultIntentRef = React.useRef(null);
14219
- const [showDevices, setShowDevices] = React.useState(false);
14220
14689
  const controlsRef = React.useRef(null);
14221
14690
  const now = useTicker(view?.state === "active");
14222
14691
  const consultNow = useTicker(consultView?.state === "active");
@@ -14273,20 +14742,31 @@ function CallScreen({
14273
14742
  React.useEffect(() => {
14274
14743
  if (!showDevices) return;
14275
14744
  const onKey = (e) => {
14276
- if (e.key === "Escape") setShowDevices(false);
14745
+ if (e.key === "Escape") setPanel(null);
14746
+ };
14747
+ document.addEventListener("keydown", onKey);
14748
+ return () => document.removeEventListener("keydown", onKey);
14749
+ }, [showDevices]);
14750
+ React.useEffect(() => {
14751
+ if (!showKeypad) return;
14752
+ const onKey = (e) => {
14753
+ if (e.key === "Escape") setPanel(null);
14277
14754
  };
14278
14755
  document.addEventListener("keydown", onKey);
14279
14756
  if (!isConnected) return () => document.removeEventListener("keydown", onKey);
14280
14757
  const root = controlsRef.current?.getRootNode() ?? document;
14281
14758
  const onDown = (e) => {
14282
- if (!controlsRef.current?.contains(e.target)) setShowDevices(false);
14759
+ if (!controlsRef.current?.contains(e.target)) setPanel(null);
14283
14760
  };
14284
14761
  root.addEventListener("pointerdown", onDown);
14285
14762
  return () => {
14286
14763
  document.removeEventListener("keydown", onKey);
14287
14764
  root.removeEventListener("pointerdown", onDown);
14288
14765
  };
14289
- }, [showDevices, isConnected]);
14766
+ }, [showKeypad, isConnected]);
14767
+ React.useEffect(() => {
14768
+ if (view && view.state !== "active") setPanel((cur) => cur === "keypad" ? null : cur);
14769
+ }, [view?.state]);
14290
14770
  if (!view || view.state === "ended") return null;
14291
14771
  const s = view.state;
14292
14772
  const isRinging = s === "ringing";
@@ -14312,9 +14792,9 @@ function CallScreen({
14312
14792
  const [rawM = "0", ss = "00"] = formatDuration(elapsedSeconds(view.answeredAt, now)).split(":");
14313
14793
  const mm = rawM.padStart(2, "0");
14314
14794
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-view", children: [
14315
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-call", children: !inCall && showDevices ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-call-devices-inline", children: [
14795
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-call", children: showDevices ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-call-devices-inline", children: [
14316
14796
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-call-devices-header", children: [
14317
- /* @__PURE__ */ jsxRuntime.jsxs("button", { type: "button", className: "cv-call-devices-chip", onClick: () => setShowDevices(false), children: [
14797
+ /* @__PURE__ */ jsxRuntime.jsxs("button", { type: "button", className: "cv-call-devices-chip", onClick: () => setPanel(null), children: [
14318
14798
  /* @__PURE__ */ jsxRuntime.jsx(ArrowLeft, { size: 13, "aria-hidden": true }),
14319
14799
  "Volver"
14320
14800
  ] }),
@@ -14322,7 +14802,7 @@ function CallScreen({
14322
14802
  ] }),
14323
14803
  /* @__PURE__ */ jsxRuntime.jsxs("p", { className: "cv-call-devices-context", children: [
14324
14804
  showNumber ? `${number} \xB7 ` : "",
14325
- STATUS_LABEL[s] ?? ""
14805
+ s === "active" ? `${mm}:${ss}` : STATUS_LABEL[s] ?? ""
14326
14806
  ] }),
14327
14807
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-settings-section-body", children: /* @__PURE__ */ jsxRuntime.jsx(DevicePickers, {}) })
14328
14808
  ] }) : transferPickerActive ? (
@@ -14410,7 +14890,7 @@ function CallScreen({
14410
14890
  /* @__PURE__ */ jsxRuntime.jsx("i", { children: ":" }),
14411
14891
  /* @__PURE__ */ jsxRuntime.jsx("b", { children: ss })
14412
14892
  ] }) : /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-call-status", children: s === "held" ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cv-badge-hold", children: STATUS_LABEL[s] }) : STATUS_LABEL[s] ?? "" }),
14413
- !inCall ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-call-chips", children: [
14893
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-call-chips", children: [
14414
14894
  incomingAction ? /* @__PURE__ */ jsxRuntime.jsxs(
14415
14895
  "button",
14416
14896
  {
@@ -14423,11 +14903,11 @@ function CallScreen({
14423
14903
  ]
14424
14904
  }
14425
14905
  ) : null,
14426
- /* @__PURE__ */ jsxRuntime.jsxs("button", { type: "button", className: "cv-call-devices-chip", onClick: () => setShowDevices(true), children: [
14906
+ /* @__PURE__ */ jsxRuntime.jsxs("button", { type: "button", className: "cv-call-devices-chip", onClick: () => setPanel("devices"), children: [
14427
14907
  /* @__PURE__ */ jsxRuntime.jsx(Headphones, { size: 13, "aria-hidden": true }),
14428
14908
  "Dispositivos"
14429
14909
  ] })
14430
- ] }) : null
14910
+ ] })
14431
14911
  ] }) }),
14432
14912
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-controls", ref: controlsRef, children: isRinging ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-controls-primary", children: [
14433
14913
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-ctrl-item", children: [
@@ -14486,17 +14966,19 @@ function CallScreen({
14486
14966
  label: "Transferir",
14487
14967
  pressed: showTransfer,
14488
14968
  tone: "accent",
14489
- onClick: () => setShowTransfer((v) => !v)
14969
+ onClick: () => togglePanel("transfer")
14490
14970
  }
14491
14971
  ),
14492
14972
  /* @__PURE__ */ jsxRuntime.jsx(
14493
14973
  Control,
14494
14974
  {
14495
- icon: Headphones,
14496
- label: "Dispositivos",
14497
- pressed: showDevices,
14975
+ icon: Grid3x3,
14976
+ label: "Teclado",
14977
+ pressed: showKeypad,
14498
14978
  tone: "accent",
14499
- onClick: () => setShowDevices((v) => !v)
14979
+ disabled: s !== "active",
14980
+ title: s === "held" ? "Reanuda la llamada para usar el teclado" : s === "reconnecting" ? "No disponible mientras se reconecta" : void 0,
14981
+ onClick: () => togglePanel("keypad")
14500
14982
  }
14501
14983
  )
14502
14984
  ] }) : null,
@@ -14514,7 +14996,22 @@ function CallScreen({
14514
14996
  ),
14515
14997
  /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cv-ctrl-label", "aria-hidden": true, children: "Colgar" })
14516
14998
  ] }) }),
14517
- inCall && showDevices ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-device-popover cv-device-popover--above", role: "dialog", "aria-label": "Dispositivos de audio", children: /* @__PURE__ */ jsxRuntime.jsx(DevicePickers, {}) }) : null
14999
+ inCall && showKeypad ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-keypad-popover cv-keypad-popover--above", role: "dialog", "aria-label": "Teclado", children: KEYPAD_KEYS.map(([key, letters]) => /* @__PURE__ */ jsxRuntime.jsxs(
15000
+ "button",
15001
+ {
15002
+ type: "button",
15003
+ className: "cv-key",
15004
+ onClick: () => {
15005
+ call.sendDigits(key);
15006
+ if (keyTones) playKeyTone(key);
15007
+ },
15008
+ children: [
15009
+ /* @__PURE__ */ jsxRuntime.jsx("b", { children: key }),
15010
+ letters ? /* @__PURE__ */ jsxRuntime.jsx("small", { children: letters }) : null
15011
+ ]
15012
+ },
15013
+ key
15014
+ )) }) : null
14518
15015
  ] }) })
14519
15016
  ] });
14520
15017
  }
@@ -15053,20 +15550,640 @@ function HistoryTab({ source }) {
15053
15550
  selected ? /* @__PURE__ */ jsxRuntime.jsx(HistoryDetail, { entry: selected, onBack: () => setSelected(null) }) : null
15054
15551
  ] });
15055
15552
  }
15056
- function SettingsSection({
15057
- title,
15058
- children
15059
- }) {
15060
- return /* @__PURE__ */ jsxRuntime.jsxs("section", { className: "cv-settings-section", children: [
15061
- /* @__PURE__ */ jsxRuntime.jsx("h3", { className: "cv-settings-section-title", children: title }),
15062
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-settings-section-body", children })
15553
+
15554
+ // src/ui/audio/ringtone-store.ts
15555
+ var stores = /* @__PURE__ */ new WeakMap();
15556
+ function ringtoneStore(softphone) {
15557
+ const source = softphone.sources?.ringtones;
15558
+ if (!source) return null;
15559
+ let store = stores.get(softphone);
15560
+ if (!store) {
15561
+ store = createRingtoneStore(source);
15562
+ stores.set(softphone, store);
15563
+ }
15564
+ return store;
15565
+ }
15566
+ function createRingtoneStore(source, decodeImpl = decodeRingtone) {
15567
+ let settings = null;
15568
+ let audioWanted = false;
15569
+ let loaded = null;
15570
+ let loading2 = null;
15571
+ let inFlight = null;
15572
+ const listeners = /* @__PURE__ */ new Set();
15573
+ const notify = () => {
15574
+ for (const l of listeners) l();
15575
+ };
15576
+ const effectiveUrl = () => {
15577
+ if (!settings) return null;
15578
+ return settings.catalogue.find((r) => r.id === settings.effective)?.url ?? null;
15579
+ };
15580
+ const loadEffective = async () => {
15581
+ const url = effectiveUrl();
15582
+ if (!audioWanted || !url || loaded?.url === url || loading2 === url) return;
15583
+ loading2 = url;
15584
+ try {
15585
+ const buffer = await decodeImpl(url);
15586
+ if (audioWanted && effectiveUrl() === url) {
15587
+ loaded = { url, buffer };
15588
+ notify();
15589
+ }
15590
+ } catch (error) {
15591
+ console.warn(`[softphone] ringtone could not be loaded, using the built-in tone: ${url}`, error);
15592
+ } finally {
15593
+ if (loading2 === url) loading2 = null;
15594
+ }
15595
+ };
15596
+ const apply = (next) => {
15597
+ settings = next;
15598
+ if (loaded && loaded.url !== effectiveUrl()) loaded = null;
15599
+ notify();
15600
+ };
15601
+ return {
15602
+ getSettings: () => settings,
15603
+ readyBuffer: () => loaded && loaded.url === effectiveUrl() ? loaded.buffer : null,
15604
+ readyLoop: () => {
15605
+ if (!loaded || loaded.url !== effectiveUrl() || !settings) return null;
15606
+ return settings.catalogue.find((r) => r.id === settings.effective)?.loop ?? null;
15607
+ },
15608
+ refresh() {
15609
+ inFlight ??= (async () => {
15610
+ if (!settings && source.cached) {
15611
+ const early = await source.cached().catch(() => null);
15612
+ if (early && !settings) {
15613
+ apply(early);
15614
+ void loadEffective();
15615
+ }
15616
+ }
15617
+ try {
15618
+ apply(await source.get());
15619
+ } catch (error) {
15620
+ console.warn("[softphone] ringtone settings could not be loaded; keeping the previous ones", error);
15621
+ } finally {
15622
+ inFlight = null;
15623
+ }
15624
+ await loadEffective();
15625
+ })();
15626
+ return inFlight;
15627
+ },
15628
+ async setMine(id) {
15629
+ apply(await source.setMine(id));
15630
+ void loadEffective();
15631
+ },
15632
+ wantAudio(on) {
15633
+ if (audioWanted === on) return;
15634
+ audioWanted = on;
15635
+ if (!on) {
15636
+ loaded = null;
15637
+ notify();
15638
+ } else {
15639
+ void loadEffective();
15640
+ }
15641
+ },
15642
+ decode: (url) => decodeImpl(url),
15643
+ subscribe(listener) {
15644
+ listeners.add(listener);
15645
+ return () => listeners.delete(listener);
15646
+ }
15647
+ };
15648
+ }
15649
+ async function decodeRingtone(url) {
15650
+ const Offline = typeof OfflineAudioContext !== "undefined" ? OfflineAudioContext : globalThis.webkitOfflineAudioContext;
15651
+ if (!Offline) throw new Error("this browser cannot decode audio files");
15652
+ const res = await fetch(url, { credentials: "omit" });
15653
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
15654
+ const bytes = await res.arrayBuffer();
15655
+ return new Offline(1, 1, 48e3).decodeAudioData(bytes);
15656
+ }
15657
+ function SettingsSection({
15658
+ title,
15659
+ children
15660
+ }) {
15661
+ return /* @__PURE__ */ jsxRuntime.jsxs("section", { className: "cv-settings-section", children: [
15662
+ /* @__PURE__ */ jsxRuntime.jsx("h3", { className: "cv-settings-section-title", children: title }),
15663
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-settings-section-body", children })
15664
+ ] });
15665
+ }
15666
+ function SettingsNavRow({
15667
+ icon: Icon3,
15668
+ title,
15669
+ value,
15670
+ onOpen,
15671
+ rowRef
15672
+ }) {
15673
+ const inner = /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
15674
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cv-settings-row-icon", "aria-hidden": true, children: /* @__PURE__ */ jsxRuntime.jsx(Icon3, { size: 16 }) }),
15675
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "cv-settings-nav-text", children: [
15676
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cv-settings-nav-title", children: title }),
15677
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cv-settings-nav-value", children: value })
15678
+ ] }),
15679
+ onOpen ? /* @__PURE__ */ jsxRuntime.jsx(ChevronRight, { size: 16, className: "cv-settings-nav-chevron", "aria-hidden": true }) : null
15680
+ ] });
15681
+ return onOpen ? /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", className: "cv-settings-nav", onClick: onOpen, ref: rowRef, children: inner }) : /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-settings-nav cv-settings-nav--static", children: inner });
15682
+ }
15683
+ function ChoiceList({
15684
+ label,
15685
+ choices,
15686
+ value,
15687
+ onSelect,
15688
+ onReselect
15689
+ }) {
15690
+ const name = React.useId();
15691
+ const pointer = React.useRef(false);
15692
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-choices", role: "radiogroup", "aria-label": label, children: choices.map((c) => {
15693
+ const checked = c.value === value;
15694
+ return /* @__PURE__ */ jsxRuntime.jsxs(
15695
+ "label",
15696
+ {
15697
+ className: "cv-choice",
15698
+ "data-checked": checked || void 0,
15699
+ onPointerDown: () => {
15700
+ pointer.current = true;
15701
+ },
15702
+ children: [
15703
+ /* @__PURE__ */ jsxRuntime.jsx(
15704
+ "input",
15705
+ {
15706
+ type: "radio",
15707
+ className: "cv-choice-input",
15708
+ name,
15709
+ value: c.value,
15710
+ checked,
15711
+ onChange: () => {
15712
+ const via = pointer.current ? "pointer" : "keyboard";
15713
+ pointer.current = false;
15714
+ onSelect(c.value, via);
15715
+ },
15716
+ onClick: () => {
15717
+ if (c.value === value) {
15718
+ pointer.current = false;
15719
+ onReselect?.(c.value);
15720
+ }
15721
+ }
15722
+ }
15723
+ ),
15724
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "cv-choice-text", children: [
15725
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cv-choice-label", children: c.label }),
15726
+ c.sub ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cv-choice-sub", children: c.sub }) : null
15727
+ ] }),
15728
+ c.trailing,
15729
+ /* @__PURE__ */ jsxRuntime.jsx(Check, { size: 16, className: "cv-choice-check", "aria-hidden": true })
15730
+ ]
15731
+ },
15732
+ c.value
15733
+ );
15734
+ }) });
15735
+ }
15736
+ function SettingsPage({
15737
+ title,
15738
+ intro,
15739
+ onBack,
15740
+ children
15741
+ }) {
15742
+ const titleId = React.useId();
15743
+ const heading = React.useRef(null);
15744
+ React.useEffect(() => heading.current?.focus(), []);
15745
+ return /* @__PURE__ */ jsxRuntime.jsxs(
15746
+ "div",
15747
+ {
15748
+ className: "cv-detail cv-settings-page",
15749
+ role: "region",
15750
+ "aria-labelledby": titleId,
15751
+ onKeyDown: (e) => {
15752
+ if (e.key === "Escape") {
15753
+ e.stopPropagation();
15754
+ onBack();
15755
+ }
15756
+ },
15757
+ children: [
15758
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-detail-head", children: /* @__PURE__ */ jsxRuntime.jsxs("button", { type: "button", className: "cv-detail-back", onClick: onBack, children: [
15759
+ /* @__PURE__ */ jsxRuntime.jsx(ArrowLeft, { size: 16, "aria-hidden": true }),
15760
+ "Ajustes"
15761
+ ] }) }),
15762
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-settings-page-body", children: [
15763
+ /* @__PURE__ */ jsxRuntime.jsx("h2", { className: "cv-settings-page-title", id: titleId, tabIndex: -1, ref: heading, children: title }),
15764
+ intro ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "cv-settings-page-intro", children: intro }) : null,
15765
+ children
15766
+ ] })
15767
+ ]
15768
+ }
15769
+ );
15770
+ }
15771
+ var SYSTEM_DEFAULT = "";
15772
+ var PAGES = {
15773
+ input: {
15774
+ title: "Micr\xF3fono",
15775
+ intro: "El micr\xF3fono que usa el tel\xE9fono en las llamadas.",
15776
+ empty: "No se encontr\xF3 ning\xFAn micr\xF3fono."
15777
+ },
15778
+ output: {
15779
+ title: "Altavoces",
15780
+ intro: "D\xF3nde oyes a la otra persona durante una llamada.",
15781
+ empty: "No se encontr\xF3 ning\xFAn altavoz."
15782
+ },
15783
+ ringtone: {
15784
+ title: "Salida del tono",
15785
+ intro: "D\xF3nde suena el tono cuando entra una llamada. La llamada se oye por \xABAltavoces\xBB.",
15786
+ empty: "No se encontr\xF3 ning\xFAn altavoz."
15787
+ }
15788
+ };
15789
+ function deviceSummary(kind, list, id) {
15790
+ const i = id ? list.findIndex((d) => d.deviceId === id) : -1;
15791
+ return i >= 0 ? deviceLabel(list[i], i, kind) : "Predeterminado del sistema";
15792
+ }
15793
+ function DevicePage({ kind, onBack }) {
15794
+ const devices = useDevices();
15795
+ const page = PAGES[kind];
15796
+ const list = kind === "input" ? devices.inputs : devices.outputs;
15797
+ const current = kind === "input" ? devices.currentInputId : kind === "output" ? devices.currentOutputId : devices.currentRingtoneOutputId;
15798
+ const apply = (id) => kind === "input" ? devices.setInput(id) : kind === "output" ? devices.setOutput(id) : devices.setRingtoneOutput(id);
15799
+ const choices = [
15800
+ { value: SYSTEM_DEFAULT, label: "Predeterminado del sistema" },
15801
+ ...list.map((d, i) => ({ value: d.deviceId, label: deviceLabel(d, i, kind) }))
15802
+ ];
15803
+ return /* @__PURE__ */ jsxRuntime.jsxs(SettingsPage, { title: page.title, intro: page.intro, onBack, children: [
15804
+ list.length > 0 ? /* @__PURE__ */ jsxRuntime.jsx(
15805
+ ChoiceList,
15806
+ {
15807
+ label: page.title,
15808
+ choices,
15809
+ value: current ?? SYSTEM_DEFAULT,
15810
+ onSelect: (v) => void apply(v === SYSTEM_DEFAULT ? null : v)
15811
+ }
15812
+ ) : /* @__PURE__ */ jsxRuntime.jsx("p", { className: "cv-settings-page-empty", children: page.empty }),
15813
+ kind === "output" ? /* @__PURE__ */ jsxRuntime.jsx(SpeakerTest, { sinkId: devices.currentOutputId }) : null
15814
+ ] });
15815
+ }
15816
+ function SpeakerTest({ sinkId }) {
15817
+ const [testing, setTesting] = React.useState(false);
15818
+ const tone = React.useRef(null);
15819
+ const timer = React.useRef(null);
15820
+ React.useEffect(
15821
+ () => () => {
15822
+ tone.current?.stop();
15823
+ if (timer.current) clearTimeout(timer.current);
15824
+ },
15825
+ []
15826
+ );
15827
+ const test = () => {
15828
+ primeAudio();
15829
+ tone.current?.stop();
15830
+ tone.current = playTestTone(sinkId);
15831
+ setTesting(true);
15832
+ if (timer.current) clearTimeout(timer.current);
15833
+ timer.current = setTimeout(() => setTesting(false), TEST_TONE_MS);
15834
+ };
15835
+ return /* @__PURE__ */ jsxRuntime.jsxs("button", { type: "button", className: "cv-btn cv-settings-test", onClick: test, disabled: testing, children: [
15836
+ /* @__PURE__ */ jsxRuntime.jsx(Volume2, { size: 16, "aria-hidden": true }),
15837
+ testing ? "Sonando\u2026" : "Probar altavoz"
15063
15838
  ] });
15064
15839
  }
15840
+
15841
+ // src/ui/audio/preview.ts
15842
+ var PREVIEW_MAX_MS = 1e4;
15843
+ var BUILT_IN_PREVIEW_MS = 6e3;
15844
+ function previewDurationMs(buffer, loop) {
15845
+ if (!buffer) return BUILT_IN_PREVIEW_MS;
15846
+ const seconds = loop ? loop.end - loop.start : buffer.duration;
15847
+ return Math.min(seconds * 1e3, PREVIEW_MAX_MS);
15848
+ }
15849
+ function previewRingtone(ringtone, options = {}) {
15850
+ primeAudio();
15851
+ let tone = null;
15852
+ let timer = null;
15853
+ let over = false;
15854
+ let settle;
15855
+ let fail;
15856
+ const done = new Promise((resolve, reject) => {
15857
+ settle = resolve;
15858
+ fail = reject;
15859
+ });
15860
+ done.catch(() => {
15861
+ });
15862
+ const stop = () => {
15863
+ if (over) return;
15864
+ over = true;
15865
+ if (timer) clearTimeout(timer);
15866
+ tone?.stop();
15867
+ settle();
15868
+ };
15869
+ const play = (buffer) => {
15870
+ if (over) return;
15871
+ const loop = buffer ? ringtone.loop ?? null : null;
15872
+ tone = playRingtone({ buffer, loop, sinkId: options.sinkId ?? null });
15873
+ timer = setTimeout(stop, previewDurationMs(buffer, loop));
15874
+ };
15875
+ if (!ringtone.url) {
15876
+ play(null);
15877
+ } else {
15878
+ decodeRingtone(ringtone.url).then(play, (error) => {
15879
+ if (over) return;
15880
+ over = true;
15881
+ fail(error);
15882
+ });
15883
+ }
15884
+ return { stop, done };
15885
+ }
15886
+
15887
+ // src/ui/views/settings/ringtone-state.ts
15888
+ var noSubscribe = () => () => {
15889
+ };
15890
+ var noSettings = () => null;
15891
+ function useRingtoneSettings(store) {
15892
+ const settings = React.useSyncExternalStore(store?.subscribe ?? noSubscribe, store?.getSettings ?? noSettings);
15893
+ const [failed, setFailed] = React.useState(false);
15894
+ const load = React.useCallback(() => {
15895
+ if (!store) return () => {
15896
+ };
15897
+ let live = true;
15898
+ setFailed(false);
15899
+ void store.refresh().then(() => {
15900
+ if (live) setFailed(store.getSettings() === null);
15901
+ });
15902
+ return () => {
15903
+ live = false;
15904
+ };
15905
+ }, [store]);
15906
+ React.useEffect(load, [load]);
15907
+ return { settings, failed, retry: load };
15908
+ }
15909
+ function ringtoneSummary(settings, failed) {
15910
+ if (!settings) return failed ? "No disponible" : "Cargando\u2026";
15911
+ const byId = (id) => id ? settings.catalogue.find((r) => r.id === id) : void 0;
15912
+ const mine = byId(settings.mine);
15913
+ if (mine) return mine.name;
15914
+ const org = byId(settings.orgDefault);
15915
+ if (org) return `${org.name} \xB7 organizaci\xF3n`;
15916
+ return settings.catalogue.find((r) => r.url === null)?.name ?? "Predeterminado";
15917
+ }
15918
+ function useRingtonePreview(store, sinkId) {
15919
+ const [playing, setPlaying] = React.useState(null);
15920
+ const [error, setError] = React.useState(null);
15921
+ const current = React.useRef(null);
15922
+ const generation = React.useRef(0);
15923
+ const stop = React.useCallback(() => {
15924
+ generation.current += 1;
15925
+ const c = current.current;
15926
+ current.current = null;
15927
+ if (c?.timer) clearTimeout(c.timer);
15928
+ c?.tone?.stop();
15929
+ setPlaying(null);
15930
+ }, []);
15931
+ React.useEffect(() => stop, [stop]);
15932
+ const start = (settings, choice) => {
15933
+ stop();
15934
+ primeAudio();
15935
+ setError(null);
15936
+ const gen = generation.current;
15937
+ const orgDefault = settings.catalogue.some((r) => r.id === settings.orgDefault) ? settings.orgDefault : null;
15938
+ const id = choice || orgDefault;
15939
+ const option = settings.catalogue.find((r) => r.id === id) ?? settings.catalogue.find((r) => r.url === null);
15940
+ setPlaying(choice);
15941
+ current.current = { tone: null, timer: null };
15942
+ const play = (buffer) => {
15943
+ if (gen !== generation.current) return;
15944
+ const loop = buffer ? option?.loop ?? null : null;
15945
+ const tone = playRingtone({ buffer, loop, sinkId });
15946
+ current.current = { tone, timer: setTimeout(stop, previewDurationMs(buffer, loop)) };
15947
+ };
15948
+ if (!option?.url) {
15949
+ play(null);
15950
+ return;
15951
+ }
15952
+ store.decode(option.url).then(play, () => {
15953
+ if (gen !== generation.current) return;
15954
+ stop();
15955
+ setError("No se pudo reproducir el tono.");
15956
+ });
15957
+ };
15958
+ return { playing, error, start, stop };
15959
+ }
15960
+ var ORG_DEFAULT = "";
15961
+ var KEYBOARD_SETTLE_MS = 300;
15962
+ function RingtonePage({
15963
+ store,
15964
+ settings,
15965
+ failed,
15966
+ onRetry,
15967
+ onBack
15968
+ }) {
15969
+ const softphone = useSoftphone();
15970
+ const devices = useDevices();
15971
+ const preview = useRingtonePreview(store, devices.canSelectRingtoneOutput ? devices.currentRingtoneOutputId : null);
15972
+ const [pending, setPending] = React.useState(void 0);
15973
+ const [error, setError] = React.useState(null);
15974
+ const save = useSerialSave(store, {
15975
+ onSettled: () => setPending(void 0),
15976
+ onFailed: () => {
15977
+ setPending(void 0);
15978
+ setError("No se pudo guardar el tono. Int\xE9ntalo de nuevo.");
15979
+ }
15980
+ });
15981
+ const latest = React.useRef(settings);
15982
+ latest.current = settings;
15983
+ const settle = React.useRef(null);
15984
+ const { stop: stopPreview } = preview;
15985
+ React.useEffect(() => softphone.on("incomingCall" /* IncomingCall */, stopPreview), [softphone, stopPreview]);
15986
+ React.useEffect(
15987
+ () => () => {
15988
+ if (settle.current) {
15989
+ clearTimeout(settle.current.timer);
15990
+ save(settle.current.value || null);
15991
+ }
15992
+ },
15993
+ [save]
15994
+ );
15995
+ const commit = (value) => {
15996
+ save(value || null);
15997
+ if (latest.current) preview.start(latest.current, value);
15998
+ };
15999
+ const onSelect = (value, via) => {
16000
+ setError(null);
16001
+ setPending(value);
16002
+ if (settle.current) clearTimeout(settle.current.timer);
16003
+ settle.current = null;
16004
+ if (via === "pointer") {
16005
+ commit(value);
16006
+ return;
16007
+ }
16008
+ preview.stop();
16009
+ settle.current = {
16010
+ value,
16011
+ timer: setTimeout(() => {
16012
+ settle.current = null;
16013
+ commit(value);
16014
+ }, KEYBOARD_SETTLE_MS)
16015
+ };
16016
+ };
16017
+ const onReselect = (value) => {
16018
+ if (!settings) return;
16019
+ if (preview.playing === value) preview.stop();
16020
+ else preview.start(settings, value);
16021
+ };
16022
+ return /* @__PURE__ */ jsxRuntime.jsx(
16023
+ SettingsPage,
16024
+ {
16025
+ title: "Tono de llamada",
16026
+ intro: "Lo que suena cuando entra una llamada. Se guarda en tu cuenta, as\xED que es el mismo en todos tus dispositivos.",
16027
+ onBack,
16028
+ children: settings ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
16029
+ /* @__PURE__ */ jsxRuntime.jsx(
16030
+ ChoiceList,
16031
+ {
16032
+ label: "Tono de llamada",
16033
+ choices: choicesFor(settings, preview.playing),
16034
+ value: pending !== void 0 ? pending : currentValue(settings),
16035
+ onSelect,
16036
+ onReselect
16037
+ }
16038
+ ),
16039
+ error ?? preview.error ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "cv-settings-error", role: "alert", children: error ?? preview.error }) : null
16040
+ ] }) : failed ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-empty", children: [
16041
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "cv-empty-title", children: "No se pudieron cargar los tonos." }),
16042
+ /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", className: "cv-btn cv-settings-retry", onClick: onRetry, children: "Reintentar" })
16043
+ ] }) : /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-empty", role: "status", children: [
16044
+ /* @__PURE__ */ jsxRuntime.jsx(LoaderCircle, { className: "cv-empty-spinner", size: 18, "aria-hidden": true }),
16045
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cv-empty-title", children: "Cargando\u2026" })
16046
+ ] })
16047
+ }
16048
+ );
16049
+ }
16050
+ function currentValue(settings) {
16051
+ return settings.mine && settings.catalogue.some((r) => r.id === settings.mine) ? settings.mine : ORG_DEFAULT;
16052
+ }
16053
+ function choicesFor(settings, playing) {
16054
+ const org = settings.orgDefault ? settings.catalogue.find((r) => r.id === settings.orgDefault) : void 0;
16055
+ const builtIn = settings.catalogue.find((r) => r.url === null);
16056
+ const mark = (value) => playing === value ? /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "cv-choice-playing", children: [
16057
+ /* @__PURE__ */ jsxRuntime.jsx(Volume2, { size: 15, "aria-hidden": true }),
16058
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cv-sr-only", children: "Sonando" })
16059
+ ] }) : void 0;
16060
+ const orgChoice = {
16061
+ value: ORG_DEFAULT,
16062
+ label: org ? "Predeterminado de la organizaci\xF3n" : "Predeterminado",
16063
+ sub: (org ?? builtIn)?.name ?? "",
16064
+ trailing: mark(ORG_DEFAULT)
16065
+ };
16066
+ return [orgChoice, ...settings.catalogue.map((r) => ({ value: r.id, label: r.name, trailing: mark(r.id) }))];
16067
+ }
16068
+ function useSerialSave(store, handlers) {
16069
+ const wanted = React.useRef(null);
16070
+ const busy = React.useRef(false);
16071
+ const on = React.useRef(handlers);
16072
+ on.current = handlers;
16073
+ const [save] = React.useState(() => (id) => {
16074
+ wanted.current = { id };
16075
+ if (busy.current) return;
16076
+ busy.current = true;
16077
+ void (async () => {
16078
+ while (wanted.current) {
16079
+ const { id: next } = wanted.current;
16080
+ wanted.current = null;
16081
+ try {
16082
+ await store.setMine(next);
16083
+ if (!wanted.current) on.current.onSettled();
16084
+ } catch {
16085
+ if (!wanted.current) on.current.onFailed();
16086
+ }
16087
+ }
16088
+ busy.current = false;
16089
+ })();
16090
+ });
16091
+ return save;
16092
+ }
15065
16093
  function SettingsTab() {
15066
- return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-view cv-settings", children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-settings-body", children: [
15067
- /* @__PURE__ */ jsxRuntime.jsx(SettingsSection, { title: "Dispositivos", children: /* @__PURE__ */ jsxRuntime.jsx(DevicePickers, {}) }),
15068
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "cv-settings-more", children: "M\xE1s ajustes se ir\xE1n a\xF1adiendo pr\xF3ximamente." })
15069
- ] }) });
16094
+ const softphone = useSoftphone();
16095
+ const devices = useDevices();
16096
+ const store = ringtoneStore(softphone);
16097
+ const ringtone = useRingtoneSettings(store);
16098
+ const [page, setPage] = React.useState(null);
16099
+ const list = React.useRef(null);
16100
+ const rows = React.useRef({});
16101
+ const returnTo = React.useRef(null);
16102
+ React.useEffect(() => {
16103
+ void softphone.audio.refresh();
16104
+ }, [softphone]);
16105
+ React.useEffect(() => {
16106
+ list.current?.toggleAttribute("inert", page !== null);
16107
+ if (page === null && returnTo.current) {
16108
+ rows.current[returnTo.current]?.focus();
16109
+ returnTo.current = null;
16110
+ }
16111
+ }, [page]);
16112
+ const open = (p) => () => {
16113
+ returnTo.current = p;
16114
+ setPage(p);
16115
+ };
16116
+ const back = () => setPage(null);
16117
+ const rowRef = (p) => (el) => {
16118
+ rows.current[p] = el;
16119
+ };
16120
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-view cv-settings", children: [
16121
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-settings-body", ref: list, children: [
16122
+ store ? /* @__PURE__ */ jsxRuntime.jsx(SettingsSection, { title: "Llamadas entrantes", children: /* @__PURE__ */ jsxRuntime.jsx(
16123
+ SettingsNavRow,
16124
+ {
16125
+ icon: Music,
16126
+ title: "Tono de llamada",
16127
+ value: ringtoneSummary(ringtone.settings, ringtone.failed),
16128
+ onOpen: open("tone"),
16129
+ rowRef: rowRef("tone")
16130
+ }
16131
+ ) }) : null,
16132
+ /* @__PURE__ */ jsxRuntime.jsxs(SettingsSection, { title: "Dispositivos", children: [
16133
+ /* @__PURE__ */ jsxRuntime.jsx(
16134
+ SettingsNavRow,
16135
+ {
16136
+ icon: Mic,
16137
+ title: "Micr\xF3fono",
16138
+ value: deviceSummary("input", devices.inputs, devices.currentInputId),
16139
+ onOpen: open("input"),
16140
+ rowRef: rowRef("input")
16141
+ }
16142
+ ),
16143
+ devices.canSelectOutput ? /* @__PURE__ */ jsxRuntime.jsx(
16144
+ SettingsNavRow,
16145
+ {
16146
+ icon: Headphones,
16147
+ title: "Altavoces",
16148
+ value: deviceSummary("output", devices.outputs, devices.currentOutputId),
16149
+ onOpen: open("output"),
16150
+ rowRef: rowRef("output")
16151
+ }
16152
+ ) : (
16153
+ // Safari and most of Firefox have no `setSinkId`: offering a page whose choice silently does
16154
+ // nothing is worse than saying where the choice really lives.
16155
+ /* @__PURE__ */ jsxRuntime.jsx(
16156
+ SettingsNavRow,
16157
+ {
16158
+ icon: Headphones,
16159
+ title: "Altavoces",
16160
+ value: "Tu navegador no permite elegirlos desde aqu\xED \u2014 c\xE1mbialos en los ajustes del sistema."
16161
+ }
16162
+ )
16163
+ ),
16164
+ devices.canSelectRingtoneOutput ? /* @__PURE__ */ jsxRuntime.jsx(
16165
+ SettingsNavRow,
16166
+ {
16167
+ icon: BellRing,
16168
+ title: "Salida del tono",
16169
+ value: deviceSummary("ringtone", devices.outputs, devices.currentRingtoneOutputId),
16170
+ onOpen: open("ringtone"),
16171
+ rowRef: rowRef("ringtone")
16172
+ }
16173
+ ) : null
16174
+ ] })
16175
+ ] }),
16176
+ page === "tone" && store ? /* @__PURE__ */ jsxRuntime.jsx(
16177
+ RingtonePage,
16178
+ {
16179
+ store,
16180
+ settings: ringtone.settings,
16181
+ failed: ringtone.failed,
16182
+ onRetry: ringtone.retry,
16183
+ onBack: back
16184
+ }
16185
+ ) : page !== null && page !== "tone" ? /* @__PURE__ */ jsxRuntime.jsx(DevicePage, { kind: page, onBack: back }) : null
16186
+ ] });
15070
16187
  }
15071
16188
 
15072
16189
  // src/core/env.ts
@@ -15289,203 +16406,6 @@ function useCallOutcome() {
15289
16406
  clear
15290
16407
  };
15291
16408
  }
15292
-
15293
- // src/ui/audio/audio.ts
15294
- var TONE_HZ = 425;
15295
- function createRemoteAudio() {
15296
- const el = document.createElement("audio");
15297
- el.autoplay = true;
15298
- el.style.display = "none";
15299
- document.body.appendChild(el);
15300
- return {
15301
- attach(stream) {
15302
- el.srcObject = stream;
15303
- void el.play().catch(() => {
15304
- });
15305
- },
15306
- detach() {
15307
- el.srcObject = null;
15308
- },
15309
- destroy() {
15310
- el.srcObject = null;
15311
- el.remove();
15312
- },
15313
- async setOutputDevice(deviceId) {
15314
- if (typeof el.setSinkId !== "function") return;
15315
- try {
15316
- await el.setSinkId(deviceId ?? "");
15317
- } catch {
15318
- }
15319
- }
15320
- };
15321
- }
15322
- var shared = null;
15323
- function sharedContext() {
15324
- if (shared) return shared;
15325
- const Ctor = typeof AudioContext !== "undefined" ? AudioContext : globalThis.webkitAudioContext;
15326
- if (!Ctor) return null;
15327
- shared = new Ctor();
15328
- return shared;
15329
- }
15330
- function primeAudio() {
15331
- const ctx = sharedContext();
15332
- if (ctx && ctx.state === "suspended") void ctx.resume?.().catch(() => {
15333
- });
15334
- }
15335
- function playRingtone() {
15336
- const ctx = sharedContext();
15337
- if (!ctx) return { stop() {
15338
- } };
15339
- const gain = ctx.createGain();
15340
- gain.gain.value = 0;
15341
- gain.connect(ctx.destination);
15342
- const osc = ctx.createOscillator();
15343
- osc.type = "sine";
15344
- osc.frequency.value = TONE_HZ;
15345
- osc.connect(gain);
15346
- osc.start();
15347
- const LEVEL = 0.8;
15348
- const RAMP = 0.02;
15349
- const burst = (at, dur) => {
15350
- gain.gain.setValueAtTime(0, at);
15351
- gain.gain.linearRampToValueAtTime(LEVEL, at + RAMP);
15352
- gain.gain.setValueAtTime(LEVEL, at + dur - RAMP);
15353
- gain.gain.linearRampToValueAtTime(0, at + dur);
15354
- };
15355
- const scheduleCycle = (at) => {
15356
- burst(at, 0.4);
15357
- burst(at + 0.6, 0.4);
15358
- return 3;
15359
- };
15360
- const LOOKAHEAD = 0.5;
15361
- const TICK_MS = 150;
15362
- const LEAD = 0.08;
15363
- let stopped = false;
15364
- let timer = null;
15365
- let nextAt = 0;
15366
- const pump = () => {
15367
- while (nextAt < ctx.currentTime + LOOKAHEAD) nextAt += scheduleCycle(nextAt);
15368
- };
15369
- void Promise.resolve(ctx.resume?.()).catch(() => {
15370
- }).finally(() => {
15371
- if (stopped) return;
15372
- nextAt = ctx.currentTime + LEAD;
15373
- pump();
15374
- timer = setInterval(pump, TICK_MS);
15375
- });
15376
- return {
15377
- stop() {
15378
- stopped = true;
15379
- if (timer) clearInterval(timer);
15380
- try {
15381
- osc.stop();
15382
- } catch {
15383
- }
15384
- try {
15385
- gain.disconnect();
15386
- } catch {
15387
- }
15388
- }
15389
- };
15390
- }
15391
- function playCallEndedTone() {
15392
- const ctx = sharedContext();
15393
- if (!ctx) return;
15394
- const gain = ctx.createGain();
15395
- gain.gain.value = 0;
15396
- gain.connect(ctx.destination);
15397
- const osc = ctx.createOscillator();
15398
- osc.type = "sine";
15399
- osc.frequency.value = TONE_HZ;
15400
- osc.connect(gain);
15401
- osc.start();
15402
- const LEVEL = 0.55;
15403
- const RAMP = 8e-3;
15404
- const BEEP = 0.25;
15405
- void Promise.resolve(ctx.resume?.()).catch(() => {
15406
- }).finally(() => {
15407
- const at = ctx.currentTime + 0.02;
15408
- gain.gain.setValueAtTime(0, at);
15409
- gain.gain.linearRampToValueAtTime(LEVEL, at + RAMP);
15410
- gain.gain.setValueAtTime(LEVEL, at + BEEP - RAMP);
15411
- gain.gain.linearRampToValueAtTime(0, at + BEEP);
15412
- const totalMs = (at + BEEP - ctx.currentTime) * 1e3;
15413
- setTimeout(() => {
15414
- try {
15415
- osc.stop();
15416
- } catch {
15417
- }
15418
- try {
15419
- gain.disconnect();
15420
- } catch {
15421
- }
15422
- }, totalMs);
15423
- });
15424
- }
15425
- var ringbackUrl = null;
15426
- function ringbackSource() {
15427
- if (ringbackUrl) return ringbackUrl;
15428
- const sampleRate = 48e3;
15429
- const channels = 2;
15430
- const bytesPerSample = 2;
15431
- const amp = 0.5;
15432
- const toneSec = 1.5;
15433
- const silenceSec = 3;
15434
- const totalFrames = Math.round((toneSec + silenceSec) * sampleRate);
15435
- const toneFrames = Math.round(toneSec * sampleRate);
15436
- const ramp = Math.round(0.02 * sampleRate);
15437
- const blockAlign = channels * bytesPerSample;
15438
- const dataSize = totalFrames * blockAlign;
15439
- const buffer = new ArrayBuffer(44 + dataSize);
15440
- const view = new DataView(buffer);
15441
- const str = (off, s) => {
15442
- for (let i = 0; i < s.length; i++) view.setUint8(off + i, s.charCodeAt(i));
15443
- };
15444
- str(0, "RIFF");
15445
- view.setUint32(4, 36 + dataSize, true);
15446
- str(8, "WAVE");
15447
- str(12, "fmt ");
15448
- view.setUint32(16, 16, true);
15449
- view.setUint16(20, 1, true);
15450
- view.setUint16(22, channels, true);
15451
- view.setUint32(24, sampleRate, true);
15452
- view.setUint32(28, sampleRate * blockAlign, true);
15453
- view.setUint16(32, blockAlign, true);
15454
- view.setUint16(34, bytesPerSample * 8, true);
15455
- str(36, "data");
15456
- view.setUint32(40, dataSize, true);
15457
- for (let i = 0; i < totalFrames; i++) {
15458
- let sample = 0;
15459
- if (i < toneFrames) {
15460
- let env = 1;
15461
- if (i < ramp) env = i / ramp;
15462
- else if (i > toneFrames - ramp) env = Math.max(0, (toneFrames - i) / ramp);
15463
- sample = Math.sin(2 * Math.PI * TONE_HZ * (i / sampleRate)) * amp * env;
15464
- }
15465
- const s16 = Math.round(sample * 32767);
15466
- const off = 44 + i * blockAlign;
15467
- for (let c = 0; c < channels; c++) view.setInt16(off + c * bytesPerSample, s16, true);
15468
- }
15469
- ringbackUrl = URL.createObjectURL(new Blob([buffer], { type: "audio/wav" }));
15470
- return ringbackUrl;
15471
- }
15472
- function playRingback() {
15473
- const el = document.createElement("audio");
15474
- el.src = ringbackSource();
15475
- el.loop = true;
15476
- el.style.display = "none";
15477
- document.body.appendChild(el);
15478
- void el.play().catch(() => {
15479
- });
15480
- return {
15481
- stop() {
15482
- el.pause();
15483
- el.remove();
15484
- }
15485
- };
15486
- }
15487
-
15488
- // src/ui/audio/useCallAudio.ts
15489
16409
  function useCallAudio(enabled) {
15490
16410
  const softphone = useSoftphone();
15491
16411
  const remote = React.useRef(null);
@@ -15502,14 +16422,29 @@ function useCallAudio(enabled) {
15502
16422
  const offDeviceChange = softphone.on("deviceChange" /* DeviceChange */, () => {
15503
16423
  void remote.current?.setOutputDevice(softphone.audio.currentOutputId);
15504
16424
  });
16425
+ const ringtones = ringtoneStore(softphone);
16426
+ ringtones?.wantAudio(true);
16427
+ const refreshRingtone = () => {
16428
+ if (softphone.isLeader) void ringtones?.refresh();
16429
+ };
16430
+ refreshRingtone();
16431
+ const offLeader = softphone.on("leaderChanged" /* LeaderChanged */, ({ isLeader }) => {
16432
+ if (isLeader) refreshRingtone();
16433
+ });
15505
16434
  const stopTone = () => {
15506
16435
  tone.current?.stop();
15507
16436
  tone.current = null;
15508
16437
  };
15509
16438
  const setTone = (next) => {
15510
16439
  stopTone();
15511
- if (next === "ringtone") tone.current = playRingtone();
15512
- else if (next === "ringback") tone.current = playRingback();
16440
+ if (next === "ringtone") {
16441
+ const audio = softphone.audio;
16442
+ tone.current = playRingtone({
16443
+ buffer: ringtones?.readyBuffer() ?? null,
16444
+ loop: ringtones?.readyLoop() ?? null,
16445
+ sinkId: audio.canSelectRingtoneOutput ? audio.currentRingtoneOutputId : null
16446
+ });
16447
+ } else if (next === "ringback") tone.current = playRingback();
15513
16448
  };
15514
16449
  const perCall = [];
15515
16450
  const followedCalls = /* @__PURE__ */ new Set();
@@ -15540,6 +16475,7 @@ function useCallAudio(enabled) {
15540
16475
  stopTone();
15541
16476
  remote.current?.detach();
15542
16477
  if (wasConnected && (reason === "local" || reason === "remote")) playCallEndedTone();
16478
+ refreshRingtone();
15543
16479
  })
15544
16480
  );
15545
16481
  };
@@ -15557,6 +16493,8 @@ function useCallAudio(enabled) {
15557
16493
  offIncoming();
15558
16494
  offActive();
15559
16495
  offDeviceChange();
16496
+ offLeader();
16497
+ ringtones?.wantAudio(false);
15560
16498
  for (const off of perCall) off();
15561
16499
  stopTone();
15562
16500
  remote.current?.destroy();
@@ -15703,7 +16641,7 @@ function SoftphoneWidget(props) {
15703
16641
  ...theme === "system" ? {} : { "data-cv-theme": theme },
15704
16642
  children: [
15705
16643
  screenIsFree ? /* @__PURE__ */ jsxRuntime.jsx(TransferNotices, { notices: transferNotices, dismiss: dismissTransferNotice }) : null,
15706
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-body", children: isFollower ? /* @__PURE__ */ jsxRuntime.jsx(LeaderGate, {}) : blocked ? /* @__PURE__ */ jsxRuntime.jsx(MicPrompt, { state: mic.state, onRequest: () => void mic.request() }) : call ? /* @__PURE__ */ jsxRuntime.jsx(CallScreen, { call, ...incomingCallAction ? { incomingCallAction } : {} }) : fatal ? /* @__PURE__ */ jsxRuntime.jsx(ErrorGate, { error: fatal, onRetry: retry }) : tab === "contacts" && contactsSource ? /* @__PURE__ */ jsxRuntime.jsx(ContactsTab, { source: contactsSource, ...contactAction ? { contactAction } : {} }) : tab === "history" && historySource ? /* @__PURE__ */ jsxRuntime.jsx(HistoryTab, { source: historySource }) : tab === "settings" ? /* @__PURE__ */ jsxRuntime.jsx(SettingsTab, {}) : /* @__PURE__ */ jsxRuntime.jsx(Dialpad, { outcome, onIntent: clearOutcome }) }),
16644
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-body", children: isFollower ? /* @__PURE__ */ jsxRuntime.jsx(LeaderGate, {}) : blocked ? /* @__PURE__ */ jsxRuntime.jsx(MicPrompt, { state: mic.state, onRequest: () => void mic.request() }) : call ? /* @__PURE__ */ jsxRuntime.jsx(CallScreen, { call, keyTones: sounds, ...incomingCallAction ? { incomingCallAction } : {} }) : fatal ? /* @__PURE__ */ jsxRuntime.jsx(ErrorGate, { error: fatal, onRetry: retry }) : tab === "contacts" && contactsSource ? /* @__PURE__ */ jsxRuntime.jsx(ContactsTab, { source: contactsSource, ...contactAction ? { contactAction } : {} }) : tab === "history" && historySource ? /* @__PURE__ */ jsxRuntime.jsx(HistoryTab, { source: historySource }) : tab === "settings" ? /* @__PURE__ */ jsxRuntime.jsx(SettingsTab, {}) : /* @__PURE__ */ jsxRuntime.jsx(Dialpad, { outcome, onIntent: clearOutcome, keyTones: sounds }) }),
15707
16645
  screenIsFree ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-tabs", role: "tablist", children: tabs.map(({ id, icon: Icon3, label }) => /* @__PURE__ */ jsxRuntime.jsxs(
15708
16646
  "button",
15709
16647
  {
@@ -15731,6 +16669,7 @@ lucide-react/dist/esm/createLucideIcon.js:
15731
16669
  lucide-react/dist/esm/icons/arrow-left-right.js:
15732
16670
  lucide-react/dist/esm/icons/arrow-left.js:
15733
16671
  lucide-react/dist/esm/icons/arrow-right.js:
16672
+ lucide-react/dist/esm/icons/bell-ring.js:
15734
16673
  lucide-react/dist/esm/icons/calendar-clock.js:
15735
16674
  lucide-react/dist/esm/icons/check.js:
15736
16675
  lucide-react/dist/esm/icons/chevron-down.js:
@@ -15746,6 +16685,7 @@ lucide-react/dist/esm/icons/loader-circle.js:
15746
16685
  lucide-react/dist/esm/icons/mic-off.js:
15747
16686
  lucide-react/dist/esm/icons/mic.js:
15748
16687
  lucide-react/dist/esm/icons/monitor-smartphone.js:
16688
+ lucide-react/dist/esm/icons/music.js:
15749
16689
  lucide-react/dist/esm/icons/pause.js:
15750
16690
  lucide-react/dist/esm/icons/phone-incoming.js:
15751
16691
  lucide-react/dist/esm/icons/phone-missed.js:
@@ -15759,6 +16699,7 @@ lucide-react/dist/esm/icons/timer.js:
15759
16699
  lucide-react/dist/esm/icons/triangle-alert.js:
15760
16700
  lucide-react/dist/esm/icons/user.js:
15761
16701
  lucide-react/dist/esm/icons/users.js:
16702
+ lucide-react/dist/esm/icons/volume-2.js:
15762
16703
  lucide-react/dist/esm/icons/x.js:
15763
16704
  lucide-react/dist/esm/lucide-react.js:
15764
16705
  (**
@@ -15775,5 +16716,6 @@ exports.Dialpad = Dialpad;
15775
16716
  exports.HistoryTab = HistoryTab;
15776
16717
  exports.MicPermissionGate = MicPermissionGate;
15777
16718
  exports.SoftphoneWidget = SoftphoneWidget;
16719
+ exports.previewRingtone = previewRingtone;
15778
16720
  //# sourceMappingURL=index.cjs.map
15779
16721
  //# sourceMappingURL=index.cjs.map