@ihabdevteam/core 0.291.0 → 0.293.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.
- package/CHANGELOG.md +65 -0
- package/dist/components.cjs.js +11 -11
- package/dist/hooks.cjs.js +1 -1
- package/dist/i18n.cjs.js +1 -1
- package/dist/ihabdevteam-core.cjs.js +18 -18
- package/dist/ihabdevteam-core.d.ts +1 -1
- package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +78 -10
- package/dist/packages/core/src/components/Training/usePlaybackSequencer.d.ts +20 -9
- package/dist/packages/core/src/components/Training/usePlaybackSequencer.js +11 -5
- package/dist/packages/core/src/version.d.ts +1 -1
- package/dist/packages/core/src/version.js +1 -1
- package/dist/utils.cjs.js +1 -1
- package/package.json +1 -1
|
@@ -1651,7 +1651,7 @@ export declare function copyText(text: string): Promise<boolean>;
|
|
|
1651
1651
|
*
|
|
1652
1652
|
* 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
|
|
1653
1653
|
*/
|
|
1654
|
-
export declare const CORE_VERSION = "0.
|
|
1654
|
+
export declare const CORE_VERSION = "0.293.0";
|
|
1655
1655
|
|
|
1656
1656
|
/** @public */
|
|
1657
1657
|
declare const coreDefault: Record<string, unknown>;
|
|
@@ -315,7 +315,33 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
315
315
|
a.load();
|
|
316
316
|
빠르기걸기(a); // 되돌린 뒤 한 번 더 — 기본값을 못 세우는 환경에서도 선다
|
|
317
317
|
}, [빠르기걸기]);
|
|
318
|
-
|
|
318
|
+
/**
|
|
319
|
+
* **완료 연출을 걷는다** — 새 재생이 서면 지난 완료의 자취가 그 위에 남아선 안 된다.
|
|
320
|
+
*
|
|
321
|
+
* 다 들으면 `handleEnd` 가 마커를 `hold`(200ms) → `out`(340ms) 로 접고, 그 끝에서
|
|
322
|
+
* `onDone(true)` 를 낸다. 그 **540ms 안에** 다시듣기나 ▶ 를 누르면(사람이 가장 흔히 누르는
|
|
323
|
+
* 때다) 새 재생이 이미 마커를 0 에서 움직이는데, 접는 연출이 그 위에 `is-out` 을 붙여
|
|
324
|
+
* `opacity: 0 · scaleY: 0` 으로 **숨겼다가**(`forwards` 라 그 상태에 머문다) 끝나면 **툭
|
|
325
|
+
* 나타난다** — 사용자가 "튀면서 움직인다" 고 본 것이 이것이다(말귀 실측: 뒤뜸 끝 +60 ·
|
|
326
|
+
* +180 · +320ms 어느 때 눌러도 난다).
|
|
327
|
+
*
|
|
328
|
+
* 그리고 그 타이머 끝의 `onDone(true)` 가 **다시 듣는 중에** 한 번 더 나갔다. 이 갈래에서는
|
|
329
|
+
* 눈에 안 띄지만 단계를 넘기는 자리라면 **다시 듣는 중에 넘어간다.**
|
|
330
|
+
*
|
|
331
|
+
* 걷는 자리를 하나로 둔다 — 새 소리는 전부 `tryPlayWithRetry` 를 지나므로 거기서 한 번
|
|
332
|
+
* 부른다(다시듣기 · ▶ · 번호 단추 · 불러오기가 다 그 길이다). 걷는 곳이 여럿이면
|
|
333
|
+
* **한 곳이 안 따라간다** — 오늘 밤 내내 그 결이었다.
|
|
334
|
+
*/
|
|
335
|
+
const 완료연출걷기 = useCallback(() => {
|
|
336
|
+
if (fillOutResetRef.current) {
|
|
337
|
+
clearTimeout(fillOutResetRef.current);
|
|
338
|
+
fillOutResetRef.current = null;
|
|
339
|
+
}
|
|
340
|
+
setFillCompletionPhase('idle');
|
|
341
|
+
}, []);
|
|
342
|
+
const tryPlayWithRetry = useCallback(async (a, attempts = 3, delayMs = 50, 앞뜸흐른 = 0) => {
|
|
343
|
+
/* 지난 완료의 접는 연출을 먼저 걷는다 — 위 설명 참고 */
|
|
344
|
+
완료연출걷기();
|
|
319
345
|
const 내세대 = 진행기.generation();
|
|
320
346
|
/* **이어 트는 것에는 앞뜸을 다시 안 준다**(사용자 신고, 2026-10-06).
|
|
321
347
|
앞뜸은 *소리 앞*의 구간이다. 소리가 **이미 시작된** 것을 이어 트는데 앞뜸을 또 주면
|
|
@@ -331,7 +357,10 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
331
357
|
> 있다. 그러면 시간표를 *앞뜸 + 남은 소리 + 뒤뜸* 으로 다시 그려야 어긋나지 않는다.
|
|
332
358
|
> 그것은 **사장님께 여쭐 일**이라 지금은 "앞뜸은 소리 앞" 이라는 뜻 그대로 둔다. */
|
|
333
359
|
const 이어틀기 = !!a && a.currentTime > 0 && !a.ended;
|
|
334
|
-
|
|
360
|
+
/* 앞뜸에서 멈췄다 이으면 **남은 만큼만** 채운다 — 뜸도 시간표의 일부라 처음부터 다시
|
|
361
|
+
세면 그 자리에서 이어지지 않는다(사용자 결정, 2026-10-06). */
|
|
362
|
+
const 남은앞뜸 = Math.max(0, leadInMsRef.current - Math.max(0, 앞뜸흐른));
|
|
363
|
+
if (남은앞뜸 > 0 && !이어틀기) {
|
|
335
364
|
/* **스피커는 뜸이 시작될 때 잡는다**((85) 2b, 2026-10-06). 전에는 `await a.play()` 바로
|
|
336
365
|
앞에서 잡았는데, 그러면 **앞뜸 내내 주인이 없다** — 그 사이 남이 소리를 내도 이쪽을
|
|
337
366
|
못 멈추고, 뜸이 끝나면 둘이 함께 울린다. 소음은 이미 뜸부터 깔리니(0.268.0)
|
|
@@ -354,7 +383,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
354
383
|
}
|
|
355
384
|
catch (e) { }
|
|
356
385
|
}
|
|
357
|
-
if (!(await 진행기.waitPad(
|
|
386
|
+
if (!(await 진행기.waitPad(남은앞뜸, 'lead', 앞뜸흐른)))
|
|
358
387
|
return false;
|
|
359
388
|
if (!mountedRef.current || !진행기.isCurrent(내세대))
|
|
360
389
|
return false;
|
|
@@ -395,7 +424,40 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
395
424
|
}
|
|
396
425
|
catch (_) { }
|
|
397
426
|
return false;
|
|
398
|
-
}, [t, startTicker, 진행기, fadeInNoise, noiseSettingsRef]);
|
|
427
|
+
}, [t, startTicker, 진행기, fadeInNoise, noiseSettingsRef, 완료연출걷기]);
|
|
428
|
+
/**
|
|
429
|
+
* **뒤뜸(사이 뜸)에서 멈췄다 이을 때** — 남은 만큼 돌고 하던 일을 잇는다.
|
|
430
|
+
*
|
|
431
|
+
* 전에는 그 뜸을 **건너뛰고** 바로 다음 소리로 갔다(마지막이면 즉시 완료). 그러면 사이가
|
|
432
|
+
* 짧아지고 재생바가 그 순간 튄다 — 사용자가 짚은 것이 그 자리다. `onEnded` 의 사슬과
|
|
433
|
+
* **같은 모양**이어야 하므로(진행바 켜기 · 소음 · 세대 가림 · `leave()`) 여기도 그대로 둔다.
|
|
434
|
+
*/
|
|
435
|
+
const 뒤뜸이어가기 = useCallback((흐른) => {
|
|
436
|
+
const 내세대 = 진행기.generation();
|
|
437
|
+
const 남은 = Math.max(0, tailMsRef.current - Math.max(0, 흐른));
|
|
438
|
+
startTicker();
|
|
439
|
+
const ns = noiseSettingsRef.current;
|
|
440
|
+
if (ns?.enabled && (ns.volume ?? 0) > 0) {
|
|
441
|
+
try {
|
|
442
|
+
fadeInNoise(ns.volume);
|
|
443
|
+
}
|
|
444
|
+
catch (e) { }
|
|
445
|
+
}
|
|
446
|
+
const 잇기 = () => { try {
|
|
447
|
+
이어가기RefBox.current?.();
|
|
448
|
+
}
|
|
449
|
+
catch (e) { } };
|
|
450
|
+
if (남은 <= 0) {
|
|
451
|
+
진행기.leave();
|
|
452
|
+
잇기();
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
진행기.waitPad(남은, 'tail', 흐른).then((이어도되나) => {
|
|
456
|
+
진행기.leave();
|
|
457
|
+
if (이어도되나 && 진행기.isCurrent(내세대))
|
|
458
|
+
잇기();
|
|
459
|
+
}).catch(() => { });
|
|
460
|
+
}, [진행기, startTicker, fadeInNoise, noiseSettingsRef]);
|
|
399
461
|
const attachAudioListeners = useCallback(() => {
|
|
400
462
|
const a = audioRef.current;
|
|
401
463
|
if (!a)
|
|
@@ -900,19 +962,23 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
900
962
|
/* 뒤뜸에서 멈췄던 것이면 그 소리를 다시 틀지 않고 **그다음으로** 잇는다 — 끝난 요소에
|
|
901
963
|
`play()` 를 걸면 방금 들은 소리가 처음부터 또 난다. 뜸은 이미 한 번 기다렸으니
|
|
902
964
|
다시 세지 않는다. */
|
|
903
|
-
/* **멈춘 자리에서 이어 간다** — 어디서
|
|
904
|
-
|
|
905
|
-
const
|
|
906
|
-
if (
|
|
965
|
+
/* **멈춘 자리에서 이어 간다** — 어디서 몇 ms 였는지는 진행기가 안다.
|
|
966
|
+
뜸도 시간표의 일부라 **남은 만큼만** 채운다(사용자 결정, 2026-10-06). */
|
|
967
|
+
const 멈췄던곳 = 진행기.resume();
|
|
968
|
+
if (멈췄던곳) {
|
|
907
969
|
setPlaybackState("playing");
|
|
908
|
-
|
|
970
|
+
if (멈췄던곳.phase === 'tail') {
|
|
971
|
+
뒤뜸이어가기(멈췄던곳.흐른);
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
await tryPlayWithRetry(a, 3, 50, 멈췄던곳.흐른);
|
|
909
975
|
return;
|
|
910
976
|
}
|
|
911
977
|
await tryPlayWithRetry(a);
|
|
912
978
|
setPlaybackState("playing");
|
|
913
979
|
}
|
|
914
980
|
else {
|
|
915
|
-
진행기.pause(
|
|
981
|
+
진행기.pause();
|
|
916
982
|
멈춤의도();
|
|
917
983
|
a.pause();
|
|
918
984
|
setPlaybackState("paused");
|
|
@@ -1243,6 +1309,8 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
1243
1309
|
if (disabledRef.current)
|
|
1244
1310
|
return;
|
|
1245
1311
|
try {
|
|
1312
|
+
/* 앞 연출이 돌고 있으면 걷고 새로 건다 — 걷는 일은 `완료연출걷기` 하나가 안다
|
|
1313
|
+
(여기서는 바로 아래에서 `hold` 를 다시 세우므로 `idle` 로 돌아가는 한 틱은 안 보인다). */
|
|
1246
1314
|
if (fillOutResetRef.current) {
|
|
1247
1315
|
clearTimeout(fillOutResetRef.current);
|
|
1248
1316
|
fillOutResetRef.current = null;
|
|
@@ -28,6 +28,11 @@ export interface SequencerPosition {
|
|
|
28
28
|
/** 그 구간이 시작한 때(`Date.now()`). `sound` 이거나 안 돌면 0 */
|
|
29
29
|
at: number;
|
|
30
30
|
}
|
|
31
|
+
/** 뜸에서 멈춘 자리 — **어느 뜸의 몇 ms 지점**이었나. 이을 때 남은 만큼만 채우는 데 쓴다 */
|
|
32
|
+
export interface SequencerPause {
|
|
33
|
+
phase: Exclude<PadPhase, 'sound'>;
|
|
34
|
+
흐른: number;
|
|
35
|
+
}
|
|
31
36
|
export interface PlaybackSequencer {
|
|
32
37
|
/** 틱·소음·토글이 읽는다 — 매 틀 읽히므로 ref 다 */
|
|
33
38
|
positionRef: React.RefObject<SequencerPosition>;
|
|
@@ -56,25 +61,31 @@ export interface PlaybackSequencer {
|
|
|
56
61
|
/**
|
|
57
62
|
* **멈춘 자리를 적어 둔다 — 다시 누르면 어디서부터인가.**
|
|
58
63
|
*
|
|
59
|
-
* 멈출 때 `pause(
|
|
64
|
+
* 멈출 때 `pause()` 로 적고, 다시 누를 때 `resume()` 으로 묻는다. 답은 **어느 뜸의 몇 ms
|
|
65
|
+
* 자리였나** 다 — 그러면 부르는 쪽이 **남은 만큼만** 채우고 하던 일을 잇는다.
|
|
60
66
|
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
67
|
+
* **뜸을 포함한 시간표가 한 시계다**(사용자 결정, 2026-10-06). 전에는 앞뜸이면 처음부터 다시,
|
|
68
|
+
* 사이·뒤뜸이면 그 뜸을 **건너뛰고** 다음으로였다 — 소리 구간만 제자리에서 이어지고 뜸
|
|
69
|
+
* 구간은 "다시" 아니면 "건너뜀" 이었다. 사용자가 짚은 것이 그 어긋남이다: *"앞 뒤 뜸을
|
|
70
|
+
* 고려한 시간으로 계산이 가능하도록 해야 앞 뒤에서 멈추더라도 그 자리에서 동일하게
|
|
71
|
+
* 재생될 것 같은데"*.
|
|
65
72
|
*
|
|
66
73
|
* 전에는 재생기가 ref 둘(`뒤뜸에서멈춤Ref` · `이어가기Ref`)로 들고 **멈추는 자리마다** 그
|
|
67
74
|
* 둘을 맞춰 적어야 했다 — 네 자리 중 한 곳만 빠뜨려도 틀린 데서 이어 간다.
|
|
68
75
|
* 어느 구간에서 멈췄는지는 **자리를 쥔 쪽**이 안다.
|
|
69
76
|
*/
|
|
70
|
-
pause: (
|
|
71
|
-
/** 멈춘
|
|
72
|
-
resume: () =>
|
|
77
|
+
pause: () => void;
|
|
78
|
+
/** 멈춘 뜸 자리. 뜸에서 안 멈췄으면 `null` */
|
|
79
|
+
resume: () => SequencerPause | null;
|
|
73
80
|
/**
|
|
74
81
|
* 뜸만큼 기다린다. 그 사이 세대가 바뀌거나 화면을 떠나면 **거짓**을 준다 —
|
|
75
82
|
* 부르는 쪽은 거짓이면 아무 일도 하지 않고 돌아간다.
|
|
83
|
+
*
|
|
84
|
+
* `이미흐른` 은 **이 뜸에서 이미 지나간 만큼**이다(이어 틀 때). 기다리는 시간은 `ms` 그대로고
|
|
85
|
+
* 자리만 그만큼 **뒤로 밀어 둔다** — 그래야 재생바가 멈춘 값에서 이어진다. 안 밀면 남은
|
|
86
|
+
* 시간만 기다리면서 바는 0 부터 다시 긴다(그 어긋남이 (90)·(91) 의 튐이었다).
|
|
76
87
|
*/
|
|
77
|
-
waitPad: (ms: number, phase: Exclude<PadPhase, 'sound'
|
|
88
|
+
waitPad: (ms: number, phase: Exclude<PadPhase, 'sound'>, 이미흐른?: number) => Promise<boolean>;
|
|
78
89
|
/** 소리 구간으로 들어간다 */
|
|
79
90
|
enterSound: () => void;
|
|
80
91
|
/**
|
|
@@ -18,16 +18,21 @@ export function usePlaybackSequencer({ mountedRef }) {
|
|
|
18
18
|
* (처음에는 걷는 쪽으로 썼다가, 그 때문에 **세대 비교가 죽은 코드**가 된 것을 뒤늦게 봤다 —
|
|
19
19
|
* 걷어 버리면 세대를 볼 일이 없다. 둘 중 하나를 골라야 하는 자리였다.)
|
|
20
20
|
*/
|
|
21
|
-
/** 멈출 때의 자리 —
|
|
21
|
+
/** 멈출 때의 자리 — 어느 뜸의 몇 ms 지점이었나. 뜸에서 안 멈췄으면 `null` */
|
|
22
22
|
const 멈춘자리Ref = useRef(null);
|
|
23
23
|
/* **`abort()` 보다 먼저 부른다** — `abort()` 는 자리를 비우므로 뒤에 부르면 "어느 구간에서
|
|
24
24
|
멈췄나" 를 이미 잃는다. `pause()` 가 그 자리를 보고 적고, 그다음에 `abort()` 가 걷는다.
|
|
25
25
|
(`abort()` 가 이 적바림까지 지우게 했다가 못이 울렸다 — 멈춘 자리를 바로 지워 버려
|
|
26
26
|
이을 때 앞 소리가 또 났다. `멈춤의도()` 안에서 둘이 잇따라 불린다.) */
|
|
27
|
-
const pause = useCallback((
|
|
27
|
+
const pause = useCallback(() => {
|
|
28
28
|
const p = positionRef.current;
|
|
29
29
|
const 뜸중 = p.phase !== 'sound' && p.at > 0;
|
|
30
|
-
|
|
30
|
+
/* **타이머는 안 걷는다** — 위 긴 설명 그대로다. 걷으면 약속이 안 풀려 뒷정리가 통째로
|
|
31
|
+
안 돈다. 바로 뒤따르는 `abort()` 가 세대를 올리므로 돌던 뜸은 깨어나 거짓을 받고
|
|
32
|
+
조용히 물러난다. 여기서는 **어디까지 갔었나**만 적는다. */
|
|
33
|
+
멈춘자리Ref.current = 뜸중
|
|
34
|
+
? { phase: p.phase, 흐른: Math.max(0, Date.now() - p.at) }
|
|
35
|
+
: null;
|
|
31
36
|
}, []);
|
|
32
37
|
const resume = useCallback(() => {
|
|
33
38
|
const v = 멈춘자리Ref.current;
|
|
@@ -38,11 +43,12 @@ export function usePlaybackSequencer({ mountedRef }) {
|
|
|
38
43
|
세대Ref.current += 1;
|
|
39
44
|
positionRef.current = { phase: 'sound', at: 0 };
|
|
40
45
|
}, []);
|
|
41
|
-
const waitPad = useCallback((ms, phase) => {
|
|
46
|
+
const waitPad = useCallback((ms, phase, 이미흐른 = 0) => {
|
|
42
47
|
if (!(ms > 0))
|
|
43
48
|
return Promise.resolve(true);
|
|
44
49
|
const 내세대 = 세대Ref.current;
|
|
45
|
-
|
|
50
|
+
/* 자리를 **이미 흐른 만큼 뒤로 밀어 둔다** — 재생바가 그 값에서 이어진다(위 설명) */
|
|
51
|
+
positionRef.current = { phase, at: Date.now() - Math.max(0, 이미흐른) };
|
|
46
52
|
return new Promise((res) => {
|
|
47
53
|
const 내타이머 = setTimeout(() => {
|
|
48
54
|
if (뜸타이머Ref.current === 내타이머)
|