@coventia/softphone 0.2.1 → 0.4.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 (40) hide show
  1. package/dist/{call-VKq1I8yP.d.cts → call-C6YpT6uR.d.cts} +107 -1
  2. package/dist/{call-VKq1I8yP.d.ts → call-C6YpT6uR.d.ts} +107 -1
  3. package/dist/chunk-5E5OEXS2.js +14 -0
  4. package/dist/chunk-5E5OEXS2.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 +124 -9
  8. package/dist/element/index.js +1837 -413
  9. package/dist/element/index.js.map +1 -1
  10. package/dist/{engine-BlQBo2Ys.d.cts → engine-CDdaSojx.d.cts} +1 -1
  11. package/dist/{engine-C0BOkIcW.d.ts → engine-DPft99Wj.d.ts} +1 -1
  12. package/dist/index.cjs +213 -13
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.d.cts +16 -6
  15. package/dist/index.d.ts +16 -6
  16. package/dist/index.js +75 -10
  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-DlGV12Xp.d.cts → softphone-CY5d2WWY.d.cts} +28 -10
  24. package/dist/{softphone-Cyq3hiIM.d.ts → softphone-DXgmTyrX.d.ts} +28 -10
  25. package/dist/softphone.css +364 -35
  26. package/dist/{sources-IRKNQPWW.js → sources-ONYQW733.js} +125 -6
  27. package/dist/sources-ONYQW733.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 +1450 -33
  31. package/dist/ui/index.cjs.map +1 -1
  32. package/dist/ui/index.d.cts +25 -3
  33. package/dist/ui/index.d.ts +25 -3
  34. package/dist/ui/index.js +1445 -36
  35. package/dist/ui/index.js.map +1 -1
  36. package/dist/zadarma/index.d.cts +2 -2
  37. package/dist/zadarma/index.d.ts +2 -2
  38. package/package.json +1 -1
  39. package/dist/chunk-LMJXX4YM.js.map +0 -1
  40. package/dist/sources-IRKNQPWW.js.map +0 -1
package/dist/ui/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { isWebRtcSupported } from '../chunk-KAXEMJ7E.js';
2
- import { useSoftphone, useSoftphoneState, useCall, usePresence, useActiveCall, useIsLeader, useLastError, useDevices } from '../chunk-LMJXX4YM.js';
2
+ import { useSoftphone, useSoftphoneState, useCall, usePresence, useActiveCall, useIsLeader, useLastError, useDevices } from '../chunk-UHNLEBBO.js';
3
3
  import '../chunk-7R6LOOAI.js';
4
- import { forwardRef, createElement, useState, useRef, useLayoutEffect, useEffect, useCallback, useMemo } from 'react';
4
+ import { isOutboundNumberRefused } from '../chunk-5E5OEXS2.js';
5
+ import { forwardRef, createElement, useState, useRef, useLayoutEffect, useEffect, useCallback, useMemo, useSyncExternalStore, useId } from 'react';
5
6
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
6
7
 
7
8
  // ../../node_modules/lucide-react/dist/esm/shared/src/utils.js
@@ -89,6 +90,14 @@ var ArrowRight = createLucideIcon("ArrowRight", [
89
90
  ["path", { d: "m12 5 7 7-7 7", key: "xquz4c" }]
90
91
  ]);
91
92
 
93
+ // ../../node_modules/lucide-react/dist/esm/icons/bell-ring.js
94
+ var BellRing = createLucideIcon("BellRing", [
95
+ ["path", { d: "M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9", key: "1qo2s2" }],
96
+ ["path", { d: "M10.3 21a1.94 1.94 0 0 0 3.4 0", key: "qgo35s" }],
97
+ ["path", { d: "M4 2C2.8 3.7 2 5.7 2 8", key: "tap9e0" }],
98
+ ["path", { d: "M22 8c0-2.3-.8-4.3-2-6", key: "5bb3ad" }]
99
+ ]);
100
+
92
101
  // ../../node_modules/lucide-react/dist/esm/icons/calendar-clock.js
93
102
  var CalendarClock = createLucideIcon("CalendarClock", [
94
103
  ["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" }],
@@ -203,6 +212,13 @@ var MonitorSmartphone = createLucideIcon("MonitorSmartphone", [
203
212
  ["rect", { width: "6", height: "10", x: "16", y: "12", rx: "2", key: "1egngj" }]
204
213
  ]);
205
214
 
215
+ // ../../node_modules/lucide-react/dist/esm/icons/music.js
216
+ var Music = createLucideIcon("Music", [
217
+ ["path", { d: "M9 18V5l12-2v13", key: "1jmyc2" }],
218
+ ["circle", { cx: "6", cy: "18", r: "3", key: "fqmcym" }],
219
+ ["circle", { cx: "18", cy: "16", r: "3", key: "1hluhg" }]
220
+ ]);
221
+
206
222
  // ../../node_modules/lucide-react/dist/esm/icons/pause.js
207
223
  var Pause = createLucideIcon("Pause", [
208
224
  ["rect", { x: "14", y: "4", width: "4", height: "16", rx: "1", key: "zuxfzm" }],
@@ -328,6 +344,19 @@ var Users = createLucideIcon("Users", [
328
344
  ["path", { d: "M16 3.13a4 4 0 0 1 0 7.75", key: "1da9ce" }]
329
345
  ]);
330
346
 
347
+ // ../../node_modules/lucide-react/dist/esm/icons/volume-2.js
348
+ var Volume2 = createLucideIcon("Volume2", [
349
+ [
350
+ "path",
351
+ {
352
+ 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",
353
+ key: "uqj9uw"
354
+ }
355
+ ],
356
+ ["path", { d: "M16 9a5 5 0 0 1 0 6", key: "1q6k2b" }],
357
+ ["path", { d: "M19.364 18.364a9 9 0 0 0 0-12.728", key: "ijwkga" }]
358
+ ]);
359
+
331
360
  // ../../node_modules/lucide-react/dist/esm/icons/x.js
332
361
  var X = createLucideIcon("X", [
333
362
  ["path", { d: "M18 6 6 18", key: "1bl5f8" }],
@@ -339,6 +368,36 @@ var PACKED = "AC247 AD376 AE971 AF93 AG1 AI1 AL355 AM374 AO244 AR54 AS1 AT43 AU6
339
368
  var DIAL_CODES = new Map(
340
369
  PACKED.split(" ").map((token) => [token.slice(0, 2), Number(token.slice(2))])
341
370
  );
371
+ var REGIONS_BY_CODE = (() => {
372
+ const map = /* @__PURE__ */ new Map();
373
+ for (const [region, code] of DIAL_CODES) map.set(code, [...map.get(code) ?? [], region]);
374
+ return map;
375
+ })();
376
+ var MAIN_REGION = {
377
+ 1: "US",
378
+ 7: "RU",
379
+ 39: "IT",
380
+ 44: "GB",
381
+ 47: "NO",
382
+ 61: "AU",
383
+ 212: "MA",
384
+ 262: "RE",
385
+ 290: "SH",
386
+ 358: "FI",
387
+ 590: "GP",
388
+ 599: "CW"
389
+ };
390
+ function regionOfInternational(typed) {
391
+ const text = String(typed ?? "").trim();
392
+ if (!text.startsWith("+")) return null;
393
+ const digits = text.slice(1).replace(/\D/g, "");
394
+ for (let length = 1; length <= Math.min(3, digits.length); length++) {
395
+ const code = Number(digits.slice(0, length));
396
+ const regions = REGIONS_BY_CODE.get(code);
397
+ if (regions) return MAIN_REGION[code] ?? regions[0] ?? null;
398
+ }
399
+ return null;
400
+ }
342
401
  var DEFAULT_COUNTRY = "ES";
343
402
  var REGION_NAMES = (() => {
344
403
  try {
@@ -390,12 +449,16 @@ function CountryMark({ code }) {
390
449
  }
391
450
  function CountryPicker({
392
451
  value,
393
- onChange
452
+ onChange,
453
+ fromNumber = false
394
454
  }) {
395
455
  const [open, setOpen] = useState(false);
396
456
  const [query, setQuery] = useState("");
397
457
  const rootRef = useRef(null);
398
458
  const inputRef = useRef(null);
459
+ useEffect(() => {
460
+ if (fromNumber) setOpen(false);
461
+ }, [fromNumber]);
399
462
  const results = useMemo(() => {
400
463
  const q = query.trim().toLowerCase().replace(/^\+/, "");
401
464
  if (!q) return COUNTRIES;
@@ -424,6 +487,12 @@ function CountryPicker({
424
487
  else setQuery("");
425
488
  }, [open]);
426
489
  const dial = DIAL_CODES.get(value);
490
+ if (fromNumber) {
491
+ return /* @__PURE__ */ jsx("div", { className: "cv-cp", ref: rootRef, children: /* @__PURE__ */ jsxs("span", { className: "cv-cp-chip", "data-from-number": true, title: "El prefijo viene del n\xFAmero marcado", part: "country-chip", children: [
492
+ /* @__PURE__ */ jsx(CountryMark, { code: value || "+" }),
493
+ /* @__PURE__ */ jsx("span", { className: "cv-sr-only", children: dial ? `Prefijo del n\xFAmero marcado: ${countryName(value)} (+${dial})` : "Prefijo del n\xFAmero marcado" })
494
+ ] }) });
495
+ }
427
496
  return /* @__PURE__ */ jsxs("div", { className: "cv-cp", ref: rootRef, children: [
428
497
  /* @__PURE__ */ jsxs(
429
498
  "button",
@@ -567,6 +636,59 @@ var KEYPAD_KEYS = [
567
636
  ["#", ""]
568
637
  ];
569
638
 
639
+ // src/ui/audio/classic-ring.ts
640
+ var C6 = 1046.5;
641
+ var E6 = 1318.51;
642
+ var HARMONICITY = 4;
643
+ var INDEX = 1.8;
644
+ var CLASSIC_CYCLE_SECONDS = 3;
645
+ var CLASSIC_GAIN = 0.3841;
646
+ function classicRingGraph(ctx, dest) {
647
+ const output = ctx.createGain();
648
+ output.gain.value = CLASSIC_GAIN;
649
+ const chain = ["highpass", "highpass", "lowpass", "lowpass"].map((type) => {
650
+ const f = ctx.createBiquadFilter();
651
+ f.type = type;
652
+ f.frequency.value = type === "highpass" ? 250 : 7500;
653
+ f.Q.value = 0.707;
654
+ return f;
655
+ });
656
+ for (let i = 0; i < chain.length - 1; i++) chain[i].connect(chain[i + 1]);
657
+ chain[chain.length - 1].connect(output);
658
+ output.connect(dest);
659
+ return { input: chain[0], output };
660
+ }
661
+ function strike(ctx, input, t, freq, vel, track) {
662
+ const carrier = ctx.createOscillator();
663
+ carrier.frequency.value = freq;
664
+ const modulator = ctx.createOscillator();
665
+ modulator.frequency.value = freq * HARMONICITY;
666
+ const depth = ctx.createGain();
667
+ depth.gain.setValueAtTime(0, t);
668
+ depth.gain.linearRampToValueAtTime(INDEX * freq, t + 4e-3);
669
+ depth.gain.setTargetAtTime(0, t + 4e-3, 0.02);
670
+ modulator.connect(depth);
671
+ depth.connect(carrier.frequency);
672
+ const amp = ctx.createGain();
673
+ amp.gain.setValueAtTime(0, t);
674
+ amp.gain.linearRampToValueAtTime(vel, t + 5e-3);
675
+ amp.gain.setTargetAtTime(0, t + 5e-3, 0.15);
676
+ amp.gain.setTargetAtTime(0, t + 0.045, 0.08);
677
+ carrier.connect(amp);
678
+ amp.connect(input);
679
+ for (const o of [carrier, modulator]) {
680
+ o.start(t);
681
+ o.stop(t + 0.7);
682
+ track(o);
683
+ }
684
+ }
685
+ function scheduleClassicCycle(ctx, input, t0, track) {
686
+ for (const ring of [0, 0.6]) {
687
+ 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);
688
+ }
689
+ return CLASSIC_CYCLE_SECONDS;
690
+ }
691
+
570
692
  // src/ui/audio/audio.ts
571
693
  var TONE_HZ = 425;
572
694
  function createRemoteAudio() {
@@ -609,30 +731,159 @@ function primeAudio() {
609
731
  if (ctx && ctx.state === "suspended") void ctx.resume?.().catch(() => {
610
732
  });
611
733
  }
612
- function playRingtone() {
734
+ function playRingtone(options = {}) {
613
735
  const ctx = sharedContext();
614
736
  if (!ctx) return { stop() {
615
737
  } };
738
+ const bus = ctx.createGain();
739
+ const route = routeToDevice(ctx, bus, options.sinkId ?? null);
740
+ const voice = options.buffer ? fileVoice(ctx, bus, options.buffer, options.loop ?? null) : synthesisedVoice(ctx, bus);
741
+ return {
742
+ stop() {
743
+ voice.stop();
744
+ route.stop();
745
+ }
746
+ };
747
+ }
748
+ function fileVoice(ctx, bus, buffer, loop) {
749
+ const FILE_LEVEL = 1;
750
+ const RAMP = 0.02;
751
+ const LEAD = 0.08;
616
752
  const gain = ctx.createGain();
617
753
  gain.gain.value = 0;
618
- gain.connect(ctx.destination);
619
- const osc = ctx.createOscillator();
620
- osc.type = "sine";
621
- osc.frequency.value = TONE_HZ;
622
- osc.connect(gain);
623
- osc.start();
624
- const LEVEL = 0.8;
625
- const RAMP = 0.02;
626
- const burst = (at, dur) => {
754
+ gain.connect(bus);
755
+ const src = ctx.createBufferSource();
756
+ src.buffer = buffer;
757
+ src.loop = true;
758
+ const points = loop && loop.start >= 0 && loop.end > loop.start && loop.end <= buffer.duration + 1e-3 ? loop : null;
759
+ if (points) {
760
+ src.loopStart = points.start;
761
+ src.loopEnd = points.end;
762
+ }
763
+ src.connect(gain);
764
+ let stopped = false;
765
+ void Promise.resolve(ctx.resume?.()).catch(() => {
766
+ }).finally(() => {
767
+ if (stopped) return;
768
+ const at = ctx.currentTime + LEAD;
627
769
  gain.gain.setValueAtTime(0, at);
628
- gain.gain.linearRampToValueAtTime(LEVEL, at + RAMP);
629
- gain.gain.setValueAtTime(LEVEL, at + dur - RAMP);
630
- gain.gain.linearRampToValueAtTime(0, at + dur);
770
+ gain.gain.linearRampToValueAtTime(FILE_LEVEL, at + RAMP);
771
+ src.start(at, points?.start ?? 0);
772
+ });
773
+ return {
774
+ stop() {
775
+ stopped = true;
776
+ try {
777
+ src.stop();
778
+ } catch {
779
+ }
780
+ try {
781
+ gain.disconnect();
782
+ } catch {
783
+ }
784
+ }
631
785
  };
632
- const scheduleCycle = (at) => {
633
- burst(at, 0.4);
634
- burst(at + 0.6, 0.4);
635
- return 3;
786
+ }
787
+ function routeToDevice(ctx, bus, sinkId) {
788
+ const toDefault = () => {
789
+ bus.connect(ctx.destination);
790
+ };
791
+ const el = sinkId && typeof document !== "undefined" ? document.createElement("audio") : null;
792
+ const dest = el && typeof ctx.createMediaStreamDestination === "function" ? ctx.createMediaStreamDestination() : null;
793
+ const canRoute = el && dest && typeof el.setSinkId === "function";
794
+ if (!canRoute || !sinkId) {
795
+ toDefault();
796
+ return { stop: () => safeDisconnect(bus) };
797
+ }
798
+ let stopped = false;
799
+ el.style.display = "none";
800
+ el.srcObject = dest.stream;
801
+ document.body.appendChild(el);
802
+ bus.connect(dest);
803
+ void el.setSinkId(sinkId).then(() => el.play()).catch((error) => {
804
+ if (stopped) return;
805
+ console.warn("[softphone] sound could not be sent to the chosen device; using the default output", error);
806
+ safeDisconnect(bus);
807
+ teardown(el);
808
+ toDefault();
809
+ });
810
+ return {
811
+ stop() {
812
+ stopped = true;
813
+ safeDisconnect(bus);
814
+ teardown(el);
815
+ }
816
+ };
817
+ }
818
+ function safeDisconnect(node) {
819
+ try {
820
+ node.disconnect();
821
+ } catch {
822
+ }
823
+ }
824
+ function teardown(el) {
825
+ el.pause();
826
+ el.srcObject = null;
827
+ el.remove();
828
+ }
829
+ var TEST_TONE_MS = 900;
830
+ function playTestTone(sinkId) {
831
+ const ctx = sharedContext();
832
+ if (!ctx) return { stop() {
833
+ } };
834
+ const bus = ctx.createGain();
835
+ const route = routeToDevice(ctx, bus, sinkId);
836
+ const LEVEL = 0.5;
837
+ const NOTES = [
838
+ { hz: 660, at: 0.05 },
839
+ { hz: 880, at: 0.4 }
840
+ ];
841
+ const oscs = [];
842
+ let stopped = false;
843
+ let timer = null;
844
+ const stop = () => {
845
+ if (stopped) return;
846
+ stopped = true;
847
+ if (timer) clearTimeout(timer);
848
+ for (const o of oscs) {
849
+ try {
850
+ o.stop();
851
+ } catch {
852
+ }
853
+ }
854
+ route.stop();
855
+ };
856
+ void Promise.resolve(ctx.resume?.()).catch(() => {
857
+ }).finally(() => {
858
+ if (stopped) return;
859
+ const t0 = ctx.currentTime;
860
+ for (const { hz, at } of NOTES) {
861
+ const gain = ctx.createGain();
862
+ gain.gain.value = 0;
863
+ gain.connect(bus);
864
+ const osc = ctx.createOscillator();
865
+ osc.type = "sine";
866
+ osc.frequency.value = hz;
867
+ osc.connect(gain);
868
+ const start = t0 + at;
869
+ gain.gain.setValueAtTime(0, start);
870
+ gain.gain.linearRampToValueAtTime(LEVEL, start + 0.015);
871
+ gain.gain.setValueAtTime(LEVEL, start + 0.25);
872
+ gain.gain.linearRampToValueAtTime(0, start + 0.32);
873
+ osc.start(start);
874
+ osc.stop(start + 0.34);
875
+ oscs.push(osc);
876
+ }
877
+ timer = setTimeout(stop, TEST_TONE_MS);
878
+ });
879
+ return { stop };
880
+ }
881
+ function synthesisedVoice(ctx, bus) {
882
+ const { input, output } = classicRingGraph(ctx, bus);
883
+ const scheduled = /* @__PURE__ */ new Set();
884
+ const track = (o) => {
885
+ scheduled.add(o);
886
+ o.onended = () => scheduled.delete(o);
636
887
  };
637
888
  const LOOKAHEAD = 0.5;
638
889
  const TICK_MS = 150;
@@ -641,7 +892,7 @@ function playRingtone() {
641
892
  let timer = null;
642
893
  let nextAt = 0;
643
894
  const pump = () => {
644
- while (nextAt < ctx.currentTime + LOOKAHEAD) nextAt += scheduleCycle(nextAt);
895
+ while (nextAt < ctx.currentTime + LOOKAHEAD) nextAt += scheduleClassicCycle(ctx, input, nextAt, track);
645
896
  };
646
897
  void Promise.resolve(ctx.resume?.()).catch(() => {
647
898
  }).finally(() => {
@@ -654,12 +905,15 @@ function playRingtone() {
654
905
  stop() {
655
906
  stopped = true;
656
907
  if (timer) clearInterval(timer);
657
- try {
658
- osc.stop();
659
- } catch {
908
+ for (const o of scheduled) {
909
+ try {
910
+ o.stop();
911
+ } catch {
912
+ }
660
913
  }
914
+ scheduled.clear();
661
915
  try {
662
- gain.disconnect();
916
+ output.disconnect();
663
917
  } catch {
664
918
  }
665
919
  }
@@ -805,18 +1059,378 @@ function playRingback() {
805
1059
  }
806
1060
  };
807
1061
  }
1062
+ var stores = /* @__PURE__ */ new WeakMap();
1063
+ function outboundNumberStore(softphone) {
1064
+ const source = softphone.sources?.outboundNumbers;
1065
+ if (!source) return null;
1066
+ let store = stores.get(softphone);
1067
+ if (!store) {
1068
+ store = createOutboundNumberStore(source);
1069
+ stores.set(softphone, store);
1070
+ }
1071
+ return store;
1072
+ }
1073
+ function createOutboundNumberStore(source) {
1074
+ let settings = null;
1075
+ let inFlight = null;
1076
+ const listeners = /* @__PURE__ */ new Set();
1077
+ const apply = (next) => {
1078
+ settings = next;
1079
+ for (const l of listeners) l();
1080
+ };
1081
+ return {
1082
+ getSettings: () => settings,
1083
+ refresh() {
1084
+ inFlight ??= (async () => {
1085
+ if (!settings && source.cached) {
1086
+ const early = await source.cached().catch(() => null);
1087
+ if (early && !settings) apply(early);
1088
+ }
1089
+ try {
1090
+ apply(await source.get());
1091
+ } catch (error) {
1092
+ console.warn("[softphone] outbound numbers could not be loaded; keeping the previous ones", error);
1093
+ } finally {
1094
+ inFlight = null;
1095
+ }
1096
+ })();
1097
+ return inFlight;
1098
+ },
1099
+ async setMine(id) {
1100
+ apply(await source.setMine(id));
1101
+ },
1102
+ subscribe(listener) {
1103
+ listeners.add(listener);
1104
+ return () => listeners.delete(listener);
1105
+ }
1106
+ };
1107
+ }
1108
+ var noSubscribe = () => () => {
1109
+ };
1110
+ var noSettings = () => null;
1111
+ function useOutboundNumbers(store) {
1112
+ const settings = useSyncExternalStore(store?.subscribe ?? noSubscribe, store?.getSettings ?? noSettings);
1113
+ const [failed, setFailed] = useState(false);
1114
+ const load = useCallback(() => {
1115
+ if (!store) return () => {
1116
+ };
1117
+ let live = true;
1118
+ setFailed(false);
1119
+ void store.refresh().then(() => {
1120
+ if (live) setFailed(store.getSettings() === null);
1121
+ });
1122
+ return () => {
1123
+ live = false;
1124
+ };
1125
+ }, [store]);
1126
+ useEffect(load, [load]);
1127
+ return { settings, failed, retry: load };
1128
+ }
1129
+ function useKnownOutboundNumbers(store) {
1130
+ return useSyncExternalStore(store?.subscribe ?? noSubscribe, store?.getSettings ?? noSettings);
1131
+ }
1132
+ function useCountdown(until) {
1133
+ const [, setTick] = useState(0);
1134
+ useEffect(() => {
1135
+ if (!until) return void 0;
1136
+ const timer = setInterval(() => {
1137
+ setTick((n) => n + 1);
1138
+ if (secondsLeft(until) === 0) clearInterval(timer);
1139
+ }, 1e3);
1140
+ return () => clearInterval(timer);
1141
+ }, [until]);
1142
+ return secondsLeft(until);
1143
+ }
1144
+ function secondsLeft(until) {
1145
+ return until ? Math.max(0, Math.ceil((until - Date.now()) / 1e3)) : 0;
1146
+ }
1147
+ function numberName(n, format = formatIntl) {
1148
+ if (!n) return "N\xFAmero de la organizaci\xF3n";
1149
+ return n.label || format(`+${n.number}`);
1150
+ }
1151
+ function effectiveNumber(settings) {
1152
+ return settings?.numbers.find((n) => n.id === settings.effective);
1153
+ }
1154
+
1155
+ // src/ui/outbound/outbound-change.ts
1156
+ var KIND_TAG = {
1157
+ mobile: { tag: "M\xF3vil", hint: "No usar para atenci\xF3n al cliente ni para llamadas comerciales no solicitadas." },
1158
+ commercial: { tag: "Comercial (400)", hint: "Solo para llamadas comerciales. No recibe llamadas." }
1159
+ };
1160
+ function noticeText(situation, seconds, current) {
1161
+ switch (situation) {
1162
+ case "platform":
1163
+ return `El cambio de n\xFAmero de salida no est\xE1 disponible temporalmente. Vuelve a intentarlo en ${seconds} s. Hasta entonces, tus llamadas seguir\xE1n saliendo desde ${current}.`;
1164
+ case "user":
1165
+ return `Acabas de cambiar el n\xFAmero de salida. Podr\xE1s cambiarlo de nuevo en ${seconds} s. Hasta entonces, tus llamadas saldr\xE1n desde ${current}.`;
1166
+ case "wait":
1167
+ return `Podr\xE1s cambiar el n\xFAmero de salida en ${seconds} s.`;
1168
+ case "pending":
1169
+ return seconds > 0 ? `Tu n\xFAmero de salida todav\xEDa no se ha aplicado. Podr\xE1s volver a elegirlo en ${seconds} s.` : "Tu n\xFAmero de salida todav\xEDa no se ha aplicado. Vuelve a elegirlo para aplicarlo.";
1170
+ }
1171
+ }
1172
+ function useOutboundNumberChange(store, settings, current) {
1173
+ const [busy, setBusy] = useState(false);
1174
+ const [notice, setNotice] = useState(null);
1175
+ const [waitUntil, setWaitUntil] = useState(null);
1176
+ useEffect(() => {
1177
+ const s = settings?.changeAvailableInSeconds ?? 0;
1178
+ setWaitUntil(s > 0 ? Date.now() + s * 1e3 : null);
1179
+ }, [settings]);
1180
+ const refusedUntil = notice?.kind === "refused" ? notice.until : null;
1181
+ const blockedFor = useCountdown(refusedUntil ?? waitUntil);
1182
+ useEffect(() => {
1183
+ if (notice?.kind === "refused" && blockedFor === 0) setNotice(null);
1184
+ }, [notice, blockedFor]);
1185
+ const locked = busy || blockedFor > 0;
1186
+ const choose = async (id) => {
1187
+ if (!store || !settings || locked) return false;
1188
+ if (id === settings.mine) return true;
1189
+ setBusy(true);
1190
+ setNotice(null);
1191
+ try {
1192
+ await store.setMine(id);
1193
+ return true;
1194
+ } catch (error) {
1195
+ setNotice(
1196
+ isOutboundNumberRefused(error) ? { kind: "refused", reason: error.refusal.reason, until: Date.now() + error.refusal.retryAfterSeconds * 1e3 } : { kind: "error", text: errorText(error) }
1197
+ );
1198
+ return false;
1199
+ } finally {
1200
+ setBusy(false);
1201
+ }
1202
+ };
1203
+ return {
1204
+ busy,
1205
+ locked,
1206
+ choose,
1207
+ /** What to say about the choice right now, if anything. */
1208
+ message: settings ? currentMessage(notice, settings.state, blockedFor, current) : null,
1209
+ /** The message is an error (the change failed), not information (wait, pending). */
1210
+ isError: notice?.kind === "error"
1211
+ };
1212
+ }
1213
+ function currentMessage(notice, state, blockedFor, current) {
1214
+ if (notice?.kind === "refused") return blockedFor > 0 ? noticeText(notice.reason, blockedFor, current) : null;
1215
+ if (notice?.kind === "error") return notice.text;
1216
+ if (state === "pending" || state === "failed") return noticeText("pending", blockedFor, current);
1217
+ return blockedFor > 0 ? noticeText("wait", blockedFor, current) : null;
1218
+ }
1219
+ function errorText(error) {
1220
+ const message = error instanceof Error ? error.message : "";
1221
+ return message && !/HTTP \d+/.test(message) ? message : "No se ha podido cambiar el n\xFAmero de salida. Int\xE9ntalo de nuevo.";
1222
+ }
1223
+ var CALLOUT_MS = 6e3;
1224
+ function OutboundNumberPicker({ onBusyChange }) {
1225
+ const softphone = useSoftphone();
1226
+ const store = outboundNumberStore(softphone);
1227
+ const { settings } = useOutboundNumbers(store);
1228
+ const formatIntl2 = useIntlFormatter();
1229
+ const current = settings ? numberName(effectiveNumber(settings), formatIntl2) : "";
1230
+ const { busy, locked, choose: apply, message, isError } = useOutboundNumberChange(store, settings, current);
1231
+ const [open, setOpen] = useState(false);
1232
+ const [applying, setApplying] = useState(void 0);
1233
+ const [callout, setCallout] = useState(false);
1234
+ const rootRef = useRef(null);
1235
+ const chipRef = useRef(null);
1236
+ const keys = useListKeyboard(open);
1237
+ useEffect(() => onBusyChange?.(busy), [busy, onBusyChange]);
1238
+ useEffect(() => {
1239
+ if (!message) setCallout(false);
1240
+ }, [message]);
1241
+ useEffect(() => {
1242
+ if (!callout) return void 0;
1243
+ const timer = setTimeout(() => setCallout(false), CALLOUT_MS);
1244
+ return () => clearTimeout(timer);
1245
+ }, [callout]);
1246
+ const floating = open || callout;
1247
+ useEffect(() => {
1248
+ if (!floating) return;
1249
+ const close = () => {
1250
+ setOpen(false);
1251
+ setCallout(false);
1252
+ };
1253
+ const root = rootRef.current?.getRootNode() ?? document;
1254
+ const onDown = (e) => {
1255
+ if (!rootRef.current?.contains(e.target)) close();
1256
+ };
1257
+ const onKey = (e) => {
1258
+ if (e.key !== "Escape") return;
1259
+ if (rootRef.current?.contains(e.target)) chipRef.current?.focus();
1260
+ close();
1261
+ };
1262
+ root.addEventListener("pointerdown", onDown);
1263
+ document.addEventListener("keydown", onKey);
1264
+ return () => {
1265
+ root.removeEventListener("pointerdown", onDown);
1266
+ document.removeEventListener("keydown", onKey);
1267
+ };
1268
+ }, [floating]);
1269
+ if (!store || !settings || settings.numbers.length < 2) return null;
1270
+ const orgDefault = settings.numbers.find((n) => n.id === settings.orgDefault);
1271
+ const applyingNumber = applying === null ? orgDefault : settings.numbers.find((n) => n.id === applying);
1272
+ const shown = applying === void 0 ? current : numberName(applyingNumber, formatIntl2);
1273
+ const choose = async (id) => {
1274
+ if (locked) return;
1275
+ setOpen(false);
1276
+ chipRef.current?.focus();
1277
+ if (id === settings.mine) return;
1278
+ setCallout(false);
1279
+ setApplying(id);
1280
+ const applied = await apply(id);
1281
+ setApplying(void 0);
1282
+ if (!applied) setCallout(true);
1283
+ };
1284
+ return /* @__PURE__ */ jsxs("div", { className: "cv-on", ref: rootRef, children: [
1285
+ /* @__PURE__ */ jsxs(
1286
+ "button",
1287
+ {
1288
+ ref: chipRef,
1289
+ type: "button",
1290
+ className: "cv-on-chip",
1291
+ onClick: () => {
1292
+ setCallout(false);
1293
+ setOpen((o) => !o);
1294
+ },
1295
+ "aria-haspopup": "listbox",
1296
+ "aria-expanded": open,
1297
+ "aria-label": `Llamar desde: ${shown}${busy ? " (aplicando)" : ""}. Cambiar n\xFAmero de salida`,
1298
+ "aria-busy": busy,
1299
+ part: "outbound-number-chip",
1300
+ children: [
1301
+ /* @__PURE__ */ jsx("span", { className: "cv-on-label", children: "Llamar desde:" }),
1302
+ busy ? /* @__PURE__ */ jsx(LoaderCircle, { size: 13, className: "cv-on-icon cv-on-spin", "aria-hidden": true }) : null,
1303
+ /* @__PURE__ */ jsx("span", { className: "cv-on-current", children: shown }),
1304
+ /* @__PURE__ */ jsx(ChevronDown, { size: 13, className: "cv-cp-caret", "data-open": open, "aria-hidden": true })
1305
+ ]
1306
+ }
1307
+ ),
1308
+ open ? /* @__PURE__ */ jsxs(
1309
+ "div",
1310
+ {
1311
+ className: "cv-cp-pop cv-on-pop",
1312
+ role: "dialog",
1313
+ "aria-label": "Llamar desde",
1314
+ part: "outbound-number-list",
1315
+ onBlur: (e) => {
1316
+ if (!e.currentTarget.contains(e.relatedTarget)) setOpen(false);
1317
+ },
1318
+ children: [
1319
+ /* @__PURE__ */ jsx("p", { className: "cv-on-title", children: "Llamar desde" }),
1320
+ /* @__PURE__ */ jsxs("div", { className: "cv-cp-list", role: "listbox", "aria-disabled": locked, ref: keys.listRef, onKeyDown: keys.onKeyDown, children: [
1321
+ /* @__PURE__ */ jsx(
1322
+ OptionRow,
1323
+ {
1324
+ selected: settings.mine === null,
1325
+ disabled: locked,
1326
+ title: "Por defecto de la organizaci\xF3n",
1327
+ sub: orgDefault ? numberName(orgDefault, formatIntl2) : "Sin n\xFAmero por defecto",
1328
+ onSelect: () => void choose(null)
1329
+ }
1330
+ ),
1331
+ settings.numbers.map((n) => /* @__PURE__ */ jsx(
1332
+ OptionRow,
1333
+ {
1334
+ selected: settings.mine === n.id,
1335
+ disabled: locked,
1336
+ title: numberName(n, formatIntl2),
1337
+ sub: n.label ? formatIntl2(`+${n.number}`) : null,
1338
+ tag: KIND_TAG[n.kind],
1339
+ onSelect: () => void choose(n.id)
1340
+ },
1341
+ n.id
1342
+ ))
1343
+ ] }),
1344
+ message ? /* @__PURE__ */ jsx("p", { className: "cv-on-notice", role: "status", "data-kind": isError ? "error" : "info", children: message }) : null
1345
+ ]
1346
+ }
1347
+ ) : null,
1348
+ callout && !open && message ? /* @__PURE__ */ jsx("p", { className: "cv-on-callout", role: "status", "data-kind": isError ? "error" : "info", part: "outbound-number-notice", children: message }) : null
1349
+ ] });
1350
+ }
1351
+ function OptionRow({
1352
+ selected,
1353
+ disabled,
1354
+ title,
1355
+ sub,
1356
+ tag,
1357
+ onSelect
1358
+ }) {
1359
+ return /* @__PURE__ */ jsxs(
1360
+ "button",
1361
+ {
1362
+ type: "button",
1363
+ role: "option",
1364
+ "aria-selected": selected,
1365
+ "aria-disabled": disabled,
1366
+ className: "cv-cp-item cv-on-item",
1367
+ tabIndex: -1,
1368
+ onClick: onSelect,
1369
+ children: [
1370
+ /* @__PURE__ */ jsxs("span", { className: "cv-on-text", children: [
1371
+ /* @__PURE__ */ jsxs("span", { className: "cv-on-line", children: [
1372
+ /* @__PURE__ */ jsx("span", { className: "cv-cp-name", children: title }),
1373
+ tag ? /* @__PURE__ */ jsx("span", { className: "cv-on-tag", children: tag.tag }) : null
1374
+ ] }),
1375
+ sub ? /* @__PURE__ */ jsx("span", { className: "cv-on-sub", children: sub }) : null,
1376
+ tag ? /* @__PURE__ */ jsx("span", { className: "cv-on-hint", children: tag.hint }) : null
1377
+ ] }),
1378
+ /* @__PURE__ */ jsx(Check, { size: 14, className: "cv-cp-check", "data-on": selected, "aria-hidden": true })
1379
+ ]
1380
+ }
1381
+ );
1382
+ }
1383
+ var optionsOf = (list) => list ? [...list.querySelectorAll('[role="option"]')] : [];
1384
+ var fold = (text) => text.normalize("NFD").replace(/\p{Diacritic}/gu, "").toLowerCase();
1385
+ var TYPEAHEAD_MS = 700;
1386
+ function useListKeyboard(open) {
1387
+ const listRef = useRef(null);
1388
+ const typed = useRef({ text: "", at: 0 });
1389
+ useEffect(() => {
1390
+ if (!open) return;
1391
+ const options = optionsOf(listRef.current);
1392
+ (options.find((o) => o.getAttribute("aria-selected") === "true") ?? options[0])?.focus();
1393
+ }, [open]);
1394
+ const onKeyDown = (e) => {
1395
+ const options = optionsOf(listRef.current);
1396
+ const at = options.indexOf(e.target);
1397
+ const move = {
1398
+ ArrowDown: Math.min(at + 1, options.length - 1),
1399
+ ArrowUp: Math.max(at - 1, 0),
1400
+ Home: 0,
1401
+ End: options.length - 1
1402
+ };
1403
+ let next = move[e.key];
1404
+ if (next === void 0 && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
1405
+ const now = Date.now();
1406
+ const text = now - typed.current.at < TYPEAHEAD_MS ? typed.current.text + e.key : e.key;
1407
+ typed.current = { text, at: now };
1408
+ const wanted = fold(text);
1409
+ const match = options.findIndex((o) => fold(o.querySelector(".cv-cp-name")?.textContent ?? "").startsWith(wanted));
1410
+ next = match >= 0 ? match : void 0;
1411
+ }
1412
+ if (next === void 0) return;
1413
+ e.preventDefault();
1414
+ options[next]?.focus();
1415
+ };
1416
+ return { listRef, onKeyDown };
1417
+ }
808
1418
  var NOT_DIALABLE = /* @__PURE__ */ new Set(["*", "#"]);
1419
+ var DISPLAY_SIZES = ["1.375rem", "1.25rem", "1.125rem", "1rem"];
809
1420
  function Dialpad({ outcome = null, onIntent, keyTones = true } = {}) {
810
1421
  const softphone = useSoftphone();
811
1422
  const state = useSoftphoneState();
812
1423
  const [value, setValue] = useState("");
813
1424
  const [country, setCountry] = useState(DEFAULT_COUNTRY);
1425
+ const [changingNumber, setChangingNumber] = useState(false);
814
1426
  const format = useNumberFormatter();
815
1427
  const inputRef = useRef(null);
816
1428
  const caretRef = useRef(null);
817
1429
  const display = format(country, value);
818
1430
  const target = toE164(country, value);
819
- const canCall = state === "online" && target.length > 1;
1431
+ const canCall = state === "online" && target.length > 1 && !changingNumber;
1432
+ const typedInternational = value.trim().startsWith("+");
1433
+ const numberRegion = regionOfInternational(value);
820
1434
  useLayoutEffect(() => {
821
1435
  const caret = caretRef.current;
822
1436
  if (caret === null || !inputRef.current) return;
@@ -824,6 +1438,14 @@ function Dialpad({ outcome = null, onIntent, keyTones = true } = {}) {
824
1438
  const pos = caretAfterDigits(display, caret);
825
1439
  inputRef.current.setSelectionRange(pos, pos);
826
1440
  }, [display]);
1441
+ useLayoutEffect(() => {
1442
+ const input = inputRef.current;
1443
+ if (!input) return;
1444
+ for (const size of DISPLAY_SIZES) {
1445
+ input.style.fontSize = size;
1446
+ if (input.scrollWidth <= input.clientWidth) break;
1447
+ }
1448
+ }, [display]);
827
1449
  const append = (key) => {
828
1450
  onIntent?.();
829
1451
  setValue((v) => v + key);
@@ -839,9 +1461,16 @@ function Dialpad({ outcome = null, onIntent, keyTones = true } = {}) {
839
1461
  void softphone.call(target);
840
1462
  };
841
1463
  return /* @__PURE__ */ jsxs("div", { className: "cv-view", children: [
842
- /* @__PURE__ */ jsx("div", { className: "cv-dial-prefix", children: /* @__PURE__ */ jsx(CountryPicker, { value: country, onChange: setCountry }) }),
1464
+ /* @__PURE__ */ jsx(OutboundNumberPicker, { onBusyChange: setChangingNumber }),
843
1465
  /* @__PURE__ */ jsxs("div", { className: "cv-dial-head", children: [
844
- /* @__PURE__ */ jsx("span", { className: "cv-dial-side", "aria-hidden": true }),
1466
+ /* @__PURE__ */ jsx(
1467
+ CountryPicker,
1468
+ {
1469
+ value: numberRegion ?? country,
1470
+ onChange: setCountry,
1471
+ fromNumber: typedInternational
1472
+ }
1473
+ ),
845
1474
  /* @__PURE__ */ jsx(
846
1475
  "input",
847
1476
  {
@@ -865,6 +1494,8 @@ function Dialpad({ outcome = null, onIntent, keyTones = true } = {}) {
865
1494
  type: "button",
866
1495
  className: "cv-dial-side",
867
1496
  "data-hidden": value.length === 0,
1497
+ tabIndex: value.length === 0 ? -1 : void 0,
1498
+ "aria-hidden": value.length === 0 || void 0,
868
1499
  onClick: backspace,
869
1500
  "aria-label": "Borrar",
870
1501
  children: /* @__PURE__ */ jsx(Delete, { size: 19 })
@@ -1289,6 +1920,9 @@ function CallScreen({
1289
1920
  }) {
1290
1921
  const view = useCall(call);
1291
1922
  const softphone = useSoftphone();
1923
+ const outbound = useOutboundNumbers(call.direction === "outbound" ? outboundNumberStore(softphone) : null).settings;
1924
+ const formatIntl2 = useIntlFormatter();
1925
+ const fromName = outbound && outbound.numbers.length > 1 ? numberName(effectiveNumber(outbound), formatIntl2) : null;
1292
1926
  const [panel, setPanel] = useState(null);
1293
1927
  const showTransfer = panel === "transfer";
1294
1928
  const showKeypad = panel === "keypad";
@@ -1499,6 +2133,13 @@ function CallScreen({
1499
2133
  ] }),
1500
2134
  /* @__PURE__ */ jsx("div", { className: "cv-call-name", children: name }),
1501
2135
  showNumber ? /* @__PURE__ */ jsx("div", { className: "cv-call-number", children: number }) : null,
2136
+ fromName ? /* @__PURE__ */ jsxs("div", { className: "cv-call-from", children: [
2137
+ /* @__PURE__ */ jsx(PhoneOutgoing, { size: 11, "aria-hidden": true }),
2138
+ /* @__PURE__ */ jsxs("span", { children: [
2139
+ "Desde ",
2140
+ fromName
2141
+ ] })
2142
+ ] }) : null,
1502
2143
  s === "active" ? /* @__PURE__ */ jsxs("div", { className: "cv-timer", "aria-label": "Duraci\xF3n de la llamada", children: [
1503
2144
  /* @__PURE__ */ jsx("b", { children: mm }),
1504
2145
  /* @__PURE__ */ jsx("i", { children: ":" }),
@@ -1914,12 +2555,23 @@ function HandlerChain({ handlers }) {
1914
2555
  ] }, `${h.extension}-${i}`);
1915
2556
  }) });
1916
2557
  }
2558
+ function outboundFrom(entry, known, formatIntl2) {
2559
+ if (entry.direction !== "outbound" || !entry.from) return null;
2560
+ const number = formatIntl2(`+${entry.from}`);
2561
+ const label = known?.numbers.find((n) => n.number === entry.from)?.label;
2562
+ if (!label) return number;
2563
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
2564
+ label,
2565
+ /* @__PURE__ */ jsx("span", { className: "cv-detail-row-sub", children: number })
2566
+ ] });
2567
+ }
1917
2568
  function HistoryDetail({
1918
2569
  entry,
1919
2570
  onBack
1920
2571
  }) {
1921
2572
  const softphone = useSoftphone();
1922
2573
  const formatIntl2 = useIntlFormatter();
2574
+ const knownNumbers = useKnownOutboundNumbers(outboundNumberStore(softphone));
1923
2575
  const [detail, setDetail] = useState(null);
1924
2576
  const [loadingDetail, setLoadingDetail] = useState(false);
1925
2577
  useEffect(() => {
@@ -1935,6 +2587,7 @@ function HistoryDetail({
1935
2587
  }, [entry]);
1936
2588
  const formatted = formatIntl2(entry.number);
1937
2589
  const name = entry.name ?? formatted;
2590
+ const from = outboundFrom(entry, knownNumbers, formatIntl2);
1938
2591
  const handledBy = detail?.handledBy ?? entry.handledBy;
1939
2592
  const recordingUrls = detail?.recordingUrls ?? [];
1940
2593
  const loadingRecording = entry.recorded && loadingDetail && recordingUrls.length === 0;
@@ -1971,7 +2624,8 @@ function HistoryDetail({
1971
2624
  entry.durationSec > 0 ? /* @__PURE__ */ jsx(DetailRow, { icon: Timer, eyebrow: "Duraci\xF3n", text: formatDuration(entry.durationSec) }) : null,
1972
2625
  /* @__PURE__ */ jsx(DetailRow, { icon: Phone, eyebrow: "N\xFAmero", text: formatted }),
1973
2626
  handledBy.length > 0 ? /* @__PURE__ */ jsx(DetailRow, { icon: User, eyebrow: handlerLabel, text: /* @__PURE__ */ jsx(HandlerChain, { handlers: handledBy }) }) : null,
1974
- entry.direction === "inbound" && entry.did ? /* @__PURE__ */ jsx(DetailRow, { icon: Hash, eyebrow: "N\xFAmero marcado (DID)", text: formatIntl2(entry.did) }) : null
2627
+ entry.direction === "inbound" && entry.did ? /* @__PURE__ */ jsx(DetailRow, { icon: Hash, eyebrow: "N\xFAmero marcado (DID)", text: formatIntl2(entry.did) }) : null,
2628
+ from ? /* @__PURE__ */ jsx(DetailRow, { icon: PhoneOutgoing, eyebrow: "Llamada desde", text: from }) : null
1975
2629
  ] })
1976
2630
  ] }),
1977
2631
  /* @__PURE__ */ jsx("div", { className: "cv-detail-foot", children: /* @__PURE__ */ jsxs(
@@ -2164,6 +2818,110 @@ function HistoryTab({ source }) {
2164
2818
  selected ? /* @__PURE__ */ jsx(HistoryDetail, { entry: selected, onBack: () => setSelected(null) }) : null
2165
2819
  ] });
2166
2820
  }
2821
+
2822
+ // src/ui/audio/ringtone-store.ts
2823
+ var stores2 = /* @__PURE__ */ new WeakMap();
2824
+ function ringtoneStore(softphone) {
2825
+ const source = softphone.sources?.ringtones;
2826
+ if (!source) return null;
2827
+ let store = stores2.get(softphone);
2828
+ if (!store) {
2829
+ store = createRingtoneStore(source);
2830
+ stores2.set(softphone, store);
2831
+ }
2832
+ return store;
2833
+ }
2834
+ function createRingtoneStore(source, decodeImpl = decodeRingtone) {
2835
+ let settings = null;
2836
+ let audioWanted = false;
2837
+ let loaded = null;
2838
+ let loading2 = null;
2839
+ let inFlight = null;
2840
+ const listeners = /* @__PURE__ */ new Set();
2841
+ const notify = () => {
2842
+ for (const l of listeners) l();
2843
+ };
2844
+ const effectiveUrl = () => {
2845
+ if (!settings) return null;
2846
+ return settings.catalogue.find((r) => r.id === settings.effective)?.url ?? null;
2847
+ };
2848
+ const loadEffective = async () => {
2849
+ const url = effectiveUrl();
2850
+ if (!audioWanted || !url || loaded?.url === url || loading2 === url) return;
2851
+ loading2 = url;
2852
+ try {
2853
+ const buffer = await decodeImpl(url);
2854
+ if (audioWanted && effectiveUrl() === url) {
2855
+ loaded = { url, buffer };
2856
+ notify();
2857
+ }
2858
+ } catch (error) {
2859
+ console.warn(`[softphone] ringtone could not be loaded, using the built-in tone: ${url}`, error);
2860
+ } finally {
2861
+ if (loading2 === url) loading2 = null;
2862
+ }
2863
+ };
2864
+ const apply = (next) => {
2865
+ settings = next;
2866
+ if (loaded && loaded.url !== effectiveUrl()) loaded = null;
2867
+ notify();
2868
+ };
2869
+ return {
2870
+ getSettings: () => settings,
2871
+ readyBuffer: () => loaded && loaded.url === effectiveUrl() ? loaded.buffer : null,
2872
+ readyLoop: () => {
2873
+ if (!loaded || loaded.url !== effectiveUrl() || !settings) return null;
2874
+ return settings.catalogue.find((r) => r.id === settings.effective)?.loop ?? null;
2875
+ },
2876
+ refresh() {
2877
+ inFlight ??= (async () => {
2878
+ if (!settings && source.cached) {
2879
+ const early = await source.cached().catch(() => null);
2880
+ if (early && !settings) {
2881
+ apply(early);
2882
+ void loadEffective();
2883
+ }
2884
+ }
2885
+ try {
2886
+ apply(await source.get());
2887
+ } catch (error) {
2888
+ console.warn("[softphone] ringtone settings could not be loaded; keeping the previous ones", error);
2889
+ } finally {
2890
+ inFlight = null;
2891
+ }
2892
+ await loadEffective();
2893
+ })();
2894
+ return inFlight;
2895
+ },
2896
+ async setMine(id) {
2897
+ apply(await source.setMine(id));
2898
+ void loadEffective();
2899
+ },
2900
+ wantAudio(on) {
2901
+ if (audioWanted === on) return;
2902
+ audioWanted = on;
2903
+ if (!on) {
2904
+ loaded = null;
2905
+ notify();
2906
+ } else {
2907
+ void loadEffective();
2908
+ }
2909
+ },
2910
+ decode: (url) => decodeImpl(url),
2911
+ subscribe(listener) {
2912
+ listeners.add(listener);
2913
+ return () => listeners.delete(listener);
2914
+ }
2915
+ };
2916
+ }
2917
+ async function decodeRingtone(url) {
2918
+ const Offline = typeof OfflineAudioContext !== "undefined" ? OfflineAudioContext : globalThis.webkitOfflineAudioContext;
2919
+ if (!Offline) throw new Error("this browser cannot decode audio files");
2920
+ const res = await fetch(url, { credentials: "omit" });
2921
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
2922
+ const bytes = await res.arrayBuffer();
2923
+ return new Offline(1, 1, 48e3).decodeAudioData(bytes);
2924
+ }
2167
2925
  function SettingsSection({
2168
2926
  title,
2169
2927
  children
@@ -2173,11 +2931,641 @@ function SettingsSection({
2173
2931
  /* @__PURE__ */ jsx("div", { className: "cv-settings-section-body", children })
2174
2932
  ] });
2175
2933
  }
2934
+ function SettingsNavRow({
2935
+ icon: Icon3,
2936
+ title,
2937
+ value,
2938
+ onOpen,
2939
+ rowRef
2940
+ }) {
2941
+ const inner = /* @__PURE__ */ jsxs(Fragment, { children: [
2942
+ /* @__PURE__ */ jsx("span", { className: "cv-settings-row-icon", "aria-hidden": true, children: /* @__PURE__ */ jsx(Icon3, { size: 16 }) }),
2943
+ /* @__PURE__ */ jsxs("span", { className: "cv-settings-nav-text", children: [
2944
+ /* @__PURE__ */ jsx("span", { className: "cv-settings-nav-title", children: title }),
2945
+ /* @__PURE__ */ jsx("span", { className: "cv-settings-nav-value", children: value })
2946
+ ] }),
2947
+ onOpen ? /* @__PURE__ */ jsx(ChevronRight, { size: 16, className: "cv-settings-nav-chevron", "aria-hidden": true }) : null
2948
+ ] });
2949
+ return onOpen ? /* @__PURE__ */ jsx("button", { type: "button", className: "cv-settings-nav", onClick: onOpen, ref: rowRef, children: inner }) : /* @__PURE__ */ jsx("div", { className: "cv-settings-nav cv-settings-nav--static", children: inner });
2950
+ }
2951
+ function ChoiceList({
2952
+ label,
2953
+ choices,
2954
+ value,
2955
+ onSelect,
2956
+ onReselect,
2957
+ disabled = false
2958
+ }) {
2959
+ const name = useId();
2960
+ const pointer = useRef(false);
2961
+ return /* @__PURE__ */ jsx("div", { className: "cv-choices", role: "radiogroup", "aria-label": label, "aria-disabled": disabled || void 0, children: choices.map((c) => {
2962
+ const checked = c.value === value;
2963
+ return /* @__PURE__ */ jsxs(
2964
+ "label",
2965
+ {
2966
+ className: "cv-choice",
2967
+ "data-checked": checked || void 0,
2968
+ "data-disabled": disabled || void 0,
2969
+ onPointerDown: () => {
2970
+ pointer.current = true;
2971
+ },
2972
+ children: [
2973
+ /* @__PURE__ */ jsx(
2974
+ "input",
2975
+ {
2976
+ type: "radio",
2977
+ className: "cv-choice-input",
2978
+ name,
2979
+ value: c.value,
2980
+ checked,
2981
+ onChange: () => {
2982
+ const via = pointer.current ? "pointer" : "keyboard";
2983
+ pointer.current = false;
2984
+ if (!disabled) onSelect(c.value, via);
2985
+ },
2986
+ onClick: () => {
2987
+ if (c.value === value) {
2988
+ pointer.current = false;
2989
+ if (!disabled) onReselect?.(c.value);
2990
+ }
2991
+ }
2992
+ }
2993
+ ),
2994
+ /* @__PURE__ */ jsxs("span", { className: "cv-choice-text", children: [
2995
+ c.tag ? /* @__PURE__ */ jsxs("span", { className: "cv-choice-line", children: [
2996
+ /* @__PURE__ */ jsx("span", { className: "cv-choice-label", children: c.label }),
2997
+ /* @__PURE__ */ jsx("span", { className: "cv-choice-tag", children: c.tag })
2998
+ ] }) : /* @__PURE__ */ jsx("span", { className: "cv-choice-label", children: c.label }),
2999
+ c.sub ? /* @__PURE__ */ jsx("span", { className: "cv-choice-sub", children: c.sub }) : null,
3000
+ c.hint ? /* @__PURE__ */ jsx("span", { className: "cv-choice-hint", children: c.hint }) : null
3001
+ ] }),
3002
+ c.trailing,
3003
+ /* @__PURE__ */ jsx(Check, { size: 16, className: "cv-choice-check", "aria-hidden": true })
3004
+ ]
3005
+ },
3006
+ c.value
3007
+ );
3008
+ }) });
3009
+ }
3010
+ function SettingsPage({
3011
+ title,
3012
+ intro,
3013
+ onBack,
3014
+ children
3015
+ }) {
3016
+ const titleId = useId();
3017
+ const heading = useRef(null);
3018
+ useEffect(() => heading.current?.focus(), []);
3019
+ return /* @__PURE__ */ jsxs(
3020
+ "div",
3021
+ {
3022
+ className: "cv-detail cv-settings-page",
3023
+ role: "region",
3024
+ "aria-labelledby": titleId,
3025
+ onKeyDown: (e) => {
3026
+ if (e.key === "Escape") {
3027
+ e.stopPropagation();
3028
+ onBack();
3029
+ }
3030
+ },
3031
+ children: [
3032
+ /* @__PURE__ */ jsx("div", { className: "cv-detail-head", children: /* @__PURE__ */ jsxs("button", { type: "button", className: "cv-detail-back", onClick: onBack, children: [
3033
+ /* @__PURE__ */ jsx(ArrowLeft, { size: 16, "aria-hidden": true }),
3034
+ "Ajustes"
3035
+ ] }) }),
3036
+ /* @__PURE__ */ jsxs("div", { className: "cv-settings-page-body", children: [
3037
+ /* @__PURE__ */ jsx("h2", { className: "cv-settings-page-title", id: titleId, tabIndex: -1, ref: heading, children: title }),
3038
+ intro ? /* @__PURE__ */ jsx("p", { className: "cv-settings-page-intro", children: intro }) : null,
3039
+ children
3040
+ ] })
3041
+ ]
3042
+ }
3043
+ );
3044
+ }
3045
+ var SYSTEM_DEFAULT = "";
3046
+ var PAGES = {
3047
+ input: {
3048
+ title: "Micr\xF3fono",
3049
+ intro: "El micr\xF3fono que usa el tel\xE9fono en las llamadas.",
3050
+ empty: "No se encontr\xF3 ning\xFAn micr\xF3fono."
3051
+ },
3052
+ output: {
3053
+ title: "Altavoces",
3054
+ intro: "D\xF3nde oyes a la otra persona durante una llamada.",
3055
+ empty: "No se encontr\xF3 ning\xFAn altavoz."
3056
+ },
3057
+ ringtone: {
3058
+ title: "Salida del tono",
3059
+ intro: "D\xF3nde suena el tono cuando entra una llamada. La llamada se oye por \xABAltavoces\xBB.",
3060
+ empty: "No se encontr\xF3 ning\xFAn altavoz."
3061
+ }
3062
+ };
3063
+ function deviceSummary(kind, list, id) {
3064
+ const i = id ? list.findIndex((d) => d.deviceId === id) : -1;
3065
+ return i >= 0 ? deviceLabel(list[i], i, kind) : "Predeterminado del sistema";
3066
+ }
3067
+ function DevicePage({ kind, onBack }) {
3068
+ const devices = useDevices();
3069
+ const page = PAGES[kind];
3070
+ const list = kind === "input" ? devices.inputs : devices.outputs;
3071
+ const current = kind === "input" ? devices.currentInputId : kind === "output" ? devices.currentOutputId : devices.currentRingtoneOutputId;
3072
+ const apply = (id) => kind === "input" ? devices.setInput(id) : kind === "output" ? devices.setOutput(id) : devices.setRingtoneOutput(id);
3073
+ const choices = [
3074
+ { value: SYSTEM_DEFAULT, label: "Predeterminado del sistema" },
3075
+ ...list.map((d, i) => ({ value: d.deviceId, label: deviceLabel(d, i, kind) }))
3076
+ ];
3077
+ return /* @__PURE__ */ jsxs(SettingsPage, { title: page.title, intro: page.intro, onBack, children: [
3078
+ list.length > 0 ? /* @__PURE__ */ jsx(
3079
+ ChoiceList,
3080
+ {
3081
+ label: page.title,
3082
+ choices,
3083
+ value: current ?? SYSTEM_DEFAULT,
3084
+ onSelect: (v) => void apply(v === SYSTEM_DEFAULT ? null : v)
3085
+ }
3086
+ ) : /* @__PURE__ */ jsx("p", { className: "cv-settings-page-empty", children: page.empty }),
3087
+ kind === "output" ? /* @__PURE__ */ jsx(SpeakerTest, { sinkId: devices.currentOutputId }) : null
3088
+ ] });
3089
+ }
3090
+ function SpeakerTest({ sinkId }) {
3091
+ const [testing, setTesting] = useState(false);
3092
+ const tone = useRef(null);
3093
+ const timer = useRef(null);
3094
+ useEffect(
3095
+ () => () => {
3096
+ tone.current?.stop();
3097
+ if (timer.current) clearTimeout(timer.current);
3098
+ },
3099
+ []
3100
+ );
3101
+ const test = () => {
3102
+ primeAudio();
3103
+ tone.current?.stop();
3104
+ tone.current = playTestTone(sinkId);
3105
+ setTesting(true);
3106
+ if (timer.current) clearTimeout(timer.current);
3107
+ timer.current = setTimeout(() => setTesting(false), TEST_TONE_MS);
3108
+ };
3109
+ return /* @__PURE__ */ jsxs("button", { type: "button", className: "cv-btn cv-settings-test", onClick: test, disabled: testing, children: [
3110
+ /* @__PURE__ */ jsx(Volume2, { size: 16, "aria-hidden": true }),
3111
+ testing ? "Sonando\u2026" : "Probar altavoz"
3112
+ ] });
3113
+ }
3114
+
3115
+ // src/ui/audio/preview.ts
3116
+ var PREVIEW_MAX_MS = 1e4;
3117
+ var BUILT_IN_PREVIEW_MS = 6e3;
3118
+ function previewDurationMs(buffer, loop) {
3119
+ if (!buffer) return BUILT_IN_PREVIEW_MS;
3120
+ const seconds = loop ? loop.end - loop.start : buffer.duration;
3121
+ return Math.min(seconds * 1e3, PREVIEW_MAX_MS);
3122
+ }
3123
+ function previewRingtone(ringtone, options = {}) {
3124
+ primeAudio();
3125
+ let tone = null;
3126
+ let timer = null;
3127
+ let over = false;
3128
+ let settle;
3129
+ let fail;
3130
+ const done = new Promise((resolve, reject) => {
3131
+ settle = resolve;
3132
+ fail = reject;
3133
+ });
3134
+ done.catch(() => {
3135
+ });
3136
+ const stop = () => {
3137
+ if (over) return;
3138
+ over = true;
3139
+ if (timer) clearTimeout(timer);
3140
+ tone?.stop();
3141
+ settle();
3142
+ };
3143
+ const play = (buffer) => {
3144
+ if (over) return;
3145
+ const loop = buffer ? ringtone.loop ?? null : null;
3146
+ tone = playRingtone({ buffer, loop, sinkId: options.sinkId ?? null });
3147
+ timer = setTimeout(stop, previewDurationMs(buffer, loop));
3148
+ };
3149
+ if (!ringtone.url) {
3150
+ play(null);
3151
+ } else {
3152
+ decodeRingtone(ringtone.url).then(play, (error) => {
3153
+ if (over) return;
3154
+ over = true;
3155
+ fail(error);
3156
+ });
3157
+ }
3158
+ return { stop, done };
3159
+ }
3160
+
3161
+ // src/ui/views/settings/ringtone-state.ts
3162
+ var noSubscribe2 = () => () => {
3163
+ };
3164
+ var noSettings2 = () => null;
3165
+ function useRingtoneSettings(store) {
3166
+ const settings = useSyncExternalStore(store?.subscribe ?? noSubscribe2, store?.getSettings ?? noSettings2);
3167
+ const [failed, setFailed] = useState(false);
3168
+ const load = useCallback(() => {
3169
+ if (!store) return () => {
3170
+ };
3171
+ let live = true;
3172
+ setFailed(false);
3173
+ void store.refresh().then(() => {
3174
+ if (live) setFailed(store.getSettings() === null);
3175
+ });
3176
+ return () => {
3177
+ live = false;
3178
+ };
3179
+ }, [store]);
3180
+ useEffect(load, [load]);
3181
+ return { settings, failed, retry: load };
3182
+ }
3183
+ function ringtoneSummary(settings, failed) {
3184
+ if (!settings) return failed ? "No disponible" : "Cargando\u2026";
3185
+ const byId = (id) => id ? settings.catalogue.find((r) => r.id === id) : void 0;
3186
+ const mine = byId(settings.mine);
3187
+ if (mine) return mine.name;
3188
+ const org = byId(settings.orgDefault);
3189
+ if (org) return `${org.name} \xB7 organizaci\xF3n`;
3190
+ return settings.catalogue.find((r) => r.url === null)?.name ?? "Predeterminado";
3191
+ }
3192
+ function useRingtonePreview(store, sinkId) {
3193
+ const [playing, setPlaying] = useState(null);
3194
+ const [error, setError] = useState(null);
3195
+ const current = useRef(null);
3196
+ const generation = useRef(0);
3197
+ const stop = useCallback(() => {
3198
+ generation.current += 1;
3199
+ const c = current.current;
3200
+ current.current = null;
3201
+ if (c?.timer) clearTimeout(c.timer);
3202
+ c?.tone?.stop();
3203
+ setPlaying(null);
3204
+ }, []);
3205
+ useEffect(() => stop, [stop]);
3206
+ const start = (settings, choice) => {
3207
+ stop();
3208
+ primeAudio();
3209
+ setError(null);
3210
+ const gen = generation.current;
3211
+ const orgDefault = settings.catalogue.some((r) => r.id === settings.orgDefault) ? settings.orgDefault : null;
3212
+ const id = choice || orgDefault;
3213
+ const option = settings.catalogue.find((r) => r.id === id) ?? settings.catalogue.find((r) => r.url === null);
3214
+ setPlaying(choice);
3215
+ current.current = { tone: null, timer: null };
3216
+ const play = (buffer) => {
3217
+ if (gen !== generation.current) return;
3218
+ const loop = buffer ? option?.loop ?? null : null;
3219
+ const tone = playRingtone({ buffer, loop, sinkId });
3220
+ current.current = { tone, timer: setTimeout(stop, previewDurationMs(buffer, loop)) };
3221
+ };
3222
+ if (!option?.url) {
3223
+ play(null);
3224
+ return;
3225
+ }
3226
+ store.decode(option.url).then(play, () => {
3227
+ if (gen !== generation.current) return;
3228
+ stop();
3229
+ setError("No se pudo reproducir el tono.");
3230
+ });
3231
+ };
3232
+ return { playing, error, start, stop };
3233
+ }
3234
+ var ORG_DEFAULT = "";
3235
+ var KEYBOARD_SETTLE_MS = 300;
3236
+ function RingtonePage({
3237
+ store,
3238
+ settings,
3239
+ failed,
3240
+ onRetry,
3241
+ onBack
3242
+ }) {
3243
+ const softphone = useSoftphone();
3244
+ const devices = useDevices();
3245
+ const preview = useRingtonePreview(store, devices.canSelectRingtoneOutput ? devices.currentRingtoneOutputId : null);
3246
+ const [pending, setPending] = useState(void 0);
3247
+ const [error, setError] = useState(null);
3248
+ const save = useSerialSave(store, {
3249
+ onSettled: () => setPending(void 0),
3250
+ onFailed: () => {
3251
+ setPending(void 0);
3252
+ setError("No se pudo guardar el tono. Int\xE9ntalo de nuevo.");
3253
+ }
3254
+ });
3255
+ const latest = useRef(settings);
3256
+ latest.current = settings;
3257
+ const settle = useRef(null);
3258
+ const { stop: stopPreview } = preview;
3259
+ useEffect(() => softphone.on("incomingCall" /* IncomingCall */, stopPreview), [softphone, stopPreview]);
3260
+ useEffect(
3261
+ () => () => {
3262
+ if (settle.current) {
3263
+ clearTimeout(settle.current.timer);
3264
+ save(settle.current.value || null);
3265
+ }
3266
+ },
3267
+ [save]
3268
+ );
3269
+ const commit = (value) => {
3270
+ save(value || null);
3271
+ if (latest.current) preview.start(latest.current, value);
3272
+ };
3273
+ const onSelect = (value, via) => {
3274
+ setError(null);
3275
+ setPending(value);
3276
+ if (settle.current) clearTimeout(settle.current.timer);
3277
+ settle.current = null;
3278
+ if (via === "pointer") {
3279
+ commit(value);
3280
+ return;
3281
+ }
3282
+ preview.stop();
3283
+ settle.current = {
3284
+ value,
3285
+ timer: setTimeout(() => {
3286
+ settle.current = null;
3287
+ commit(value);
3288
+ }, KEYBOARD_SETTLE_MS)
3289
+ };
3290
+ };
3291
+ const onReselect = (value) => {
3292
+ if (!settings) return;
3293
+ if (preview.playing === value) preview.stop();
3294
+ else preview.start(settings, value);
3295
+ };
3296
+ return /* @__PURE__ */ jsx(
3297
+ SettingsPage,
3298
+ {
3299
+ title: "Tono de llamada",
3300
+ intro: "Lo que suena cuando entra una llamada. Se guarda en tu cuenta, as\xED que es el mismo en todos tus dispositivos.",
3301
+ onBack,
3302
+ children: settings ? /* @__PURE__ */ jsxs(Fragment, { children: [
3303
+ /* @__PURE__ */ jsx(
3304
+ ChoiceList,
3305
+ {
3306
+ label: "Tono de llamada",
3307
+ choices: choicesFor(settings, preview.playing),
3308
+ value: pending !== void 0 ? pending : currentValue(settings),
3309
+ onSelect,
3310
+ onReselect
3311
+ }
3312
+ ),
3313
+ error ?? preview.error ? /* @__PURE__ */ jsx("p", { className: "cv-settings-error", role: "alert", children: error ?? preview.error }) : null
3314
+ ] }) : failed ? /* @__PURE__ */ jsxs("div", { className: "cv-empty", children: [
3315
+ /* @__PURE__ */ jsx("p", { className: "cv-empty-title", children: "No se pudieron cargar los tonos." }),
3316
+ /* @__PURE__ */ jsx("button", { type: "button", className: "cv-btn cv-settings-retry", onClick: onRetry, children: "Reintentar" })
3317
+ ] }) : /* @__PURE__ */ jsxs("div", { className: "cv-empty", role: "status", children: [
3318
+ /* @__PURE__ */ jsx(LoaderCircle, { className: "cv-empty-spinner", size: 18, "aria-hidden": true }),
3319
+ /* @__PURE__ */ jsx("span", { className: "cv-empty-title", children: "Cargando\u2026" })
3320
+ ] })
3321
+ }
3322
+ );
3323
+ }
3324
+ function currentValue(settings) {
3325
+ return settings.mine && settings.catalogue.some((r) => r.id === settings.mine) ? settings.mine : ORG_DEFAULT;
3326
+ }
3327
+ function choicesFor(settings, playing) {
3328
+ const org = settings.orgDefault ? settings.catalogue.find((r) => r.id === settings.orgDefault) : void 0;
3329
+ const builtIn = settings.catalogue.find((r) => r.url === null);
3330
+ const mark = (value) => playing === value ? /* @__PURE__ */ jsxs("span", { className: "cv-choice-playing", children: [
3331
+ /* @__PURE__ */ jsx(Volume2, { size: 15, "aria-hidden": true }),
3332
+ /* @__PURE__ */ jsx("span", { className: "cv-sr-only", children: "Sonando" })
3333
+ ] }) : void 0;
3334
+ const orgChoice = {
3335
+ value: ORG_DEFAULT,
3336
+ label: org ? "Predeterminado de la organizaci\xF3n" : "Predeterminado",
3337
+ sub: (org ?? builtIn)?.name ?? "",
3338
+ trailing: mark(ORG_DEFAULT)
3339
+ };
3340
+ return [orgChoice, ...settings.catalogue.map((r) => ({ value: r.id, label: r.name, trailing: mark(r.id) }))];
3341
+ }
3342
+ function useSerialSave(store, handlers) {
3343
+ const wanted = useRef(null);
3344
+ const busy = useRef(false);
3345
+ const on = useRef(handlers);
3346
+ on.current = handlers;
3347
+ const [save] = useState(() => (id) => {
3348
+ wanted.current = { id };
3349
+ if (busy.current) return;
3350
+ busy.current = true;
3351
+ void (async () => {
3352
+ while (wanted.current) {
3353
+ const { id: next } = wanted.current;
3354
+ wanted.current = null;
3355
+ try {
3356
+ await store.setMine(next);
3357
+ if (!wanted.current) on.current.onSettled();
3358
+ } catch {
3359
+ if (!wanted.current) on.current.onFailed();
3360
+ }
3361
+ }
3362
+ busy.current = false;
3363
+ })();
3364
+ });
3365
+ return save;
3366
+ }
3367
+ var ORG_DEFAULT2 = "";
3368
+ var KEYBOARD_SETTLE_MS2 = 800;
3369
+ function OutboundNumberPage({
3370
+ store,
3371
+ settings,
3372
+ onBack
3373
+ }) {
3374
+ const formatIntl2 = useIntlFormatter();
3375
+ const current = numberName(effectiveNumber(settings), formatIntl2);
3376
+ const change = useOutboundNumberChange(store, settings, current);
3377
+ const [pending, setPending] = useState(void 0);
3378
+ const settle = useRef(null);
3379
+ const choose = useRef(change.choose);
3380
+ choose.current = change.choose;
3381
+ useEffect(
3382
+ () => () => {
3383
+ if (settle.current) clearTimeout(settle.current);
3384
+ },
3385
+ []
3386
+ );
3387
+ const notice = useRef(null);
3388
+ const apply = async (value) => {
3389
+ setPending(value);
3390
+ const applied = await choose.current(value || null);
3391
+ setPending(void 0);
3392
+ if (!applied) requestAnimationFrame(() => notice.current?.scrollIntoView?.({ block: "nearest" }));
3393
+ };
3394
+ const onSelect = (value, via) => {
3395
+ if (settle.current) clearTimeout(settle.current);
3396
+ settle.current = null;
3397
+ if (via === "pointer") {
3398
+ void apply(value);
3399
+ return;
3400
+ }
3401
+ setPending(value);
3402
+ settle.current = setTimeout(() => {
3403
+ settle.current = null;
3404
+ void apply(value);
3405
+ }, KEYBOARD_SETTLE_MS2);
3406
+ };
3407
+ return /* @__PURE__ */ jsxs(
3408
+ SettingsPage,
3409
+ {
3410
+ title: "N\xFAmero de salida",
3411
+ intro: "El n\xFAmero que ve la persona a la que llamas. Es el mismo en todos tus dispositivos y tambi\xE9n puedes cambiarlo desde el teclado.",
3412
+ onBack,
3413
+ children: [
3414
+ change.message ? /* @__PURE__ */ jsx("p", { className: "cv-settings-notice", role: "status", "data-kind": change.isError ? "error" : "info", ref: notice, children: change.message }) : null,
3415
+ /* @__PURE__ */ jsx(
3416
+ ChoiceList,
3417
+ {
3418
+ label: "N\xFAmero de salida",
3419
+ choices: choicesFor2(settings, formatIntl2, change.busy ? pending : void 0),
3420
+ value: pending ?? currentValue2(settings),
3421
+ onSelect,
3422
+ disabled: change.locked
3423
+ }
3424
+ )
3425
+ ]
3426
+ }
3427
+ );
3428
+ }
3429
+ function currentValue2(settings) {
3430
+ return settings.mine && settings.numbers.some((n) => n.id === settings.mine) ? settings.mine : ORG_DEFAULT2;
3431
+ }
3432
+ function choicesFor2(settings, formatIntl2, applying) {
3433
+ const orgDefault = settings.numbers.find((n) => n.id === settings.orgDefault);
3434
+ const spinner = (value) => applying === value ? /* @__PURE__ */ jsxs("span", { className: "cv-choice-applying", children: [
3435
+ /* @__PURE__ */ jsx(LoaderCircle, { size: 15, className: "cv-choice-applying-icon", "aria-hidden": true }),
3436
+ /* @__PURE__ */ jsx("span", { className: "cv-sr-only", children: "Aplicando" })
3437
+ ] }) : void 0;
3438
+ return [
3439
+ {
3440
+ value: ORG_DEFAULT2,
3441
+ label: "Por defecto de la organizaci\xF3n",
3442
+ sub: orgDefault ? numberName(orgDefault, formatIntl2) : "Sin n\xFAmero por defecto",
3443
+ trailing: spinner(ORG_DEFAULT2)
3444
+ },
3445
+ ...settings.numbers.map((n) => {
3446
+ const kind = KIND_TAG[n.kind];
3447
+ return {
3448
+ value: n.id,
3449
+ label: numberName(n, formatIntl2),
3450
+ ...n.label ? { sub: formatIntl2(`+${n.number}`) } : {},
3451
+ ...kind ? { tag: kind.tag, hint: kind.hint } : {},
3452
+ trailing: spinner(n.id)
3453
+ };
3454
+ })
3455
+ ];
3456
+ }
3457
+ function outboundSummary(settings, formatIntl2) {
3458
+ const effective = effectiveNumber(settings);
3459
+ const name = numberName(effective, formatIntl2);
3460
+ return settings.mine || !effective ? name : `${name} \xB7 organizaci\xF3n`;
3461
+ }
2176
3462
  function SettingsTab() {
2177
- return /* @__PURE__ */ jsx("div", { className: "cv-view cv-settings", children: /* @__PURE__ */ jsxs("div", { className: "cv-settings-body", children: [
2178
- /* @__PURE__ */ jsx(SettingsSection, { title: "Dispositivos", children: /* @__PURE__ */ jsx(DevicePickers, {}) }),
2179
- /* @__PURE__ */ jsx("p", { className: "cv-settings-more", children: "M\xE1s ajustes se ir\xE1n a\xF1adiendo pr\xF3ximamente." })
2180
- ] }) });
3463
+ const softphone = useSoftphone();
3464
+ const devices = useDevices();
3465
+ const store = ringtoneStore(softphone);
3466
+ const ringtone = useRingtoneSettings(store);
3467
+ const outStore = outboundNumberStore(softphone);
3468
+ const outbound = useOutboundNumbers(outStore).settings;
3469
+ const formatIntl2 = useIntlFormatter();
3470
+ const [page, setPage] = useState(null);
3471
+ const list = useRef(null);
3472
+ const rows = useRef({});
3473
+ const returnTo = useRef(null);
3474
+ useEffect(() => {
3475
+ void softphone.audio.refresh();
3476
+ }, [softphone]);
3477
+ useEffect(() => {
3478
+ list.current?.toggleAttribute("inert", page !== null);
3479
+ if (page === null && returnTo.current) {
3480
+ rows.current[returnTo.current]?.focus();
3481
+ returnTo.current = null;
3482
+ }
3483
+ }, [page]);
3484
+ const open = (p) => () => {
3485
+ returnTo.current = p;
3486
+ setPage(p);
3487
+ };
3488
+ const back = () => setPage(null);
3489
+ const rowRef = (p) => (el) => {
3490
+ rows.current[p] = el;
3491
+ };
3492
+ return /* @__PURE__ */ jsxs("div", { className: "cv-view cv-settings", children: [
3493
+ /* @__PURE__ */ jsxs("div", { className: "cv-settings-body", ref: list, children: [
3494
+ store ? /* @__PURE__ */ jsx(SettingsSection, { title: "Llamadas entrantes", children: /* @__PURE__ */ jsx(
3495
+ SettingsNavRow,
3496
+ {
3497
+ icon: Music,
3498
+ title: "Tono de llamada",
3499
+ value: ringtoneSummary(ringtone.settings, ringtone.failed),
3500
+ onOpen: open("tone"),
3501
+ rowRef: rowRef("tone")
3502
+ }
3503
+ ) }) : null,
3504
+ outStore && outbound && outbound.numbers.length > 1 ? /* @__PURE__ */ jsx(SettingsSection, { title: "Llamadas salientes", children: /* @__PURE__ */ jsx(
3505
+ SettingsNavRow,
3506
+ {
3507
+ icon: PhoneOutgoing,
3508
+ title: "N\xFAmero de salida",
3509
+ value: outboundSummary(outbound, formatIntl2),
3510
+ onOpen: open("outbound"),
3511
+ rowRef: rowRef("outbound")
3512
+ }
3513
+ ) }) : null,
3514
+ /* @__PURE__ */ jsxs(SettingsSection, { title: "Dispositivos", children: [
3515
+ /* @__PURE__ */ jsx(
3516
+ SettingsNavRow,
3517
+ {
3518
+ icon: Mic,
3519
+ title: "Micr\xF3fono",
3520
+ value: deviceSummary("input", devices.inputs, devices.currentInputId),
3521
+ onOpen: open("input"),
3522
+ rowRef: rowRef("input")
3523
+ }
3524
+ ),
3525
+ devices.canSelectOutput ? /* @__PURE__ */ jsx(
3526
+ SettingsNavRow,
3527
+ {
3528
+ icon: Headphones,
3529
+ title: "Altavoces",
3530
+ value: deviceSummary("output", devices.outputs, devices.currentOutputId),
3531
+ onOpen: open("output"),
3532
+ rowRef: rowRef("output")
3533
+ }
3534
+ ) : (
3535
+ // Safari and most of Firefox have no `setSinkId`: offering a page whose choice silently does
3536
+ // nothing is worse than saying where the choice really lives.
3537
+ /* @__PURE__ */ jsx(
3538
+ SettingsNavRow,
3539
+ {
3540
+ icon: Headphones,
3541
+ title: "Altavoces",
3542
+ value: "Tu navegador no permite elegirlos desde aqu\xED \u2014 c\xE1mbialos en los ajustes del sistema."
3543
+ }
3544
+ )
3545
+ ),
3546
+ devices.canSelectRingtoneOutput ? /* @__PURE__ */ jsx(
3547
+ SettingsNavRow,
3548
+ {
3549
+ icon: BellRing,
3550
+ title: "Salida del tono",
3551
+ value: deviceSummary("ringtone", devices.outputs, devices.currentRingtoneOutputId),
3552
+ onOpen: open("ringtone"),
3553
+ rowRef: rowRef("ringtone")
3554
+ }
3555
+ ) : null
3556
+ ] })
3557
+ ] }),
3558
+ page === "tone" && store ? /* @__PURE__ */ jsx(
3559
+ RingtonePage,
3560
+ {
3561
+ store,
3562
+ settings: ringtone.settings,
3563
+ failed: ringtone.failed,
3564
+ onRetry: ringtone.retry,
3565
+ onBack: back
3566
+ }
3567
+ ) : page === "outbound" && outStore && outbound ? /* @__PURE__ */ jsx(OutboundNumberPage, { store: outStore, settings: outbound, onBack: back }) : page !== null && page !== "tone" && page !== "outbound" ? /* @__PURE__ */ jsx(DevicePage, { kind: page, onBack: back }) : null
3568
+ ] });
2181
3569
  }
2182
3570
  function useMicPermission() {
2183
3571
  const [state, setState] = useState("checking");
@@ -2406,14 +3794,29 @@ function useCallAudio(enabled) {
2406
3794
  const offDeviceChange = softphone.on("deviceChange" /* DeviceChange */, () => {
2407
3795
  void remote.current?.setOutputDevice(softphone.audio.currentOutputId);
2408
3796
  });
3797
+ const ringtones = ringtoneStore(softphone);
3798
+ ringtones?.wantAudio(true);
3799
+ const refreshRingtone = () => {
3800
+ if (softphone.isLeader) void ringtones?.refresh();
3801
+ };
3802
+ refreshRingtone();
3803
+ const offLeader = softphone.on("leaderChanged" /* LeaderChanged */, ({ isLeader }) => {
3804
+ if (isLeader) refreshRingtone();
3805
+ });
2409
3806
  const stopTone = () => {
2410
3807
  tone.current?.stop();
2411
3808
  tone.current = null;
2412
3809
  };
2413
3810
  const setTone = (next) => {
2414
3811
  stopTone();
2415
- if (next === "ringtone") tone.current = playRingtone();
2416
- else if (next === "ringback") tone.current = playRingback();
3812
+ if (next === "ringtone") {
3813
+ const audio = softphone.audio;
3814
+ tone.current = playRingtone({
3815
+ buffer: ringtones?.readyBuffer() ?? null,
3816
+ loop: ringtones?.readyLoop() ?? null,
3817
+ sinkId: audio.canSelectRingtoneOutput ? audio.currentRingtoneOutputId : null
3818
+ });
3819
+ } else if (next === "ringback") tone.current = playRingback();
2417
3820
  };
2418
3821
  const perCall = [];
2419
3822
  const followedCalls = /* @__PURE__ */ new Set();
@@ -2444,6 +3847,7 @@ function useCallAudio(enabled) {
2444
3847
  stopTone();
2445
3848
  remote.current?.detach();
2446
3849
  if (wasConnected && (reason === "local" || reason === "remote")) playCallEndedTone();
3850
+ refreshRingtone();
2447
3851
  })
2448
3852
  );
2449
3853
  };
@@ -2461,6 +3865,8 @@ function useCallAudio(enabled) {
2461
3865
  offIncoming();
2462
3866
  offActive();
2463
3867
  offDeviceChange();
3868
+ offLeader();
3869
+ ringtones?.wantAudio(false);
2464
3870
  for (const off of perCall) off();
2465
3871
  stopTone();
2466
3872
  remote.current?.destroy();
@@ -2635,6 +4041,7 @@ lucide-react/dist/esm/createLucideIcon.js:
2635
4041
  lucide-react/dist/esm/icons/arrow-left-right.js:
2636
4042
  lucide-react/dist/esm/icons/arrow-left.js:
2637
4043
  lucide-react/dist/esm/icons/arrow-right.js:
4044
+ lucide-react/dist/esm/icons/bell-ring.js:
2638
4045
  lucide-react/dist/esm/icons/calendar-clock.js:
2639
4046
  lucide-react/dist/esm/icons/check.js:
2640
4047
  lucide-react/dist/esm/icons/chevron-down.js:
@@ -2650,6 +4057,7 @@ lucide-react/dist/esm/icons/loader-circle.js:
2650
4057
  lucide-react/dist/esm/icons/mic-off.js:
2651
4058
  lucide-react/dist/esm/icons/mic.js:
2652
4059
  lucide-react/dist/esm/icons/monitor-smartphone.js:
4060
+ lucide-react/dist/esm/icons/music.js:
2653
4061
  lucide-react/dist/esm/icons/pause.js:
2654
4062
  lucide-react/dist/esm/icons/phone-incoming.js:
2655
4063
  lucide-react/dist/esm/icons/phone-missed.js:
@@ -2663,6 +4071,7 @@ lucide-react/dist/esm/icons/timer.js:
2663
4071
  lucide-react/dist/esm/icons/triangle-alert.js:
2664
4072
  lucide-react/dist/esm/icons/user.js:
2665
4073
  lucide-react/dist/esm/icons/users.js:
4074
+ lucide-react/dist/esm/icons/volume-2.js:
2666
4075
  lucide-react/dist/esm/icons/x.js:
2667
4076
  lucide-react/dist/esm/lucide-react.js:
2668
4077
  (**
@@ -2673,6 +4082,6 @@ lucide-react/dist/esm/lucide-react.js:
2673
4082
  *)
2674
4083
  */
2675
4084
 
2676
- export { CallScreen, ContactsTab, Dialpad, HistoryTab, MicPermissionGate, SoftphoneWidget };
4085
+ export { CallScreen, ContactsTab, Dialpad, HistoryTab, MicPermissionGate, SoftphoneWidget, previewRingtone };
2677
4086
  //# sourceMappingURL=index.js.map
2678
4087
  //# sourceMappingURL=index.js.map