@ihabdevteam/core 0.321.0 → 0.323.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 +617 -0
- package/dist/components.cjs.js +183 -183
- package/dist/components.esm.css +1 -1
- package/dist/hooks.cjs.js +3 -3
- package/dist/i18n.cjs.js +3 -3
- package/dist/ihabdevteam-core.cjs.js +187 -187
- package/dist/ihabdevteam-core.d.ts +108 -66
- package/dist/packages/core/src/components/Accordion.js +3 -3
- package/dist/packages/core/src/components/Alert.js +2 -2
- package/dist/packages/core/src/components/BarChart.js +120 -119
- package/dist/packages/core/src/components/BottomNav.js +18 -18
- package/dist/packages/core/src/components/BulkBar.js +25 -25
- package/dist/packages/core/src/components/Button.js +16 -16
- package/dist/packages/core/src/components/Calendar.js +17 -17
- package/dist/packages/core/src/components/ChartSection.js +30 -30
- package/dist/packages/core/src/components/CountTile.js +38 -38
- package/dist/packages/core/src/components/DatePicker.js +59 -59
- package/dist/packages/core/src/components/Dialog.js +14 -14
- package/dist/packages/core/src/components/Effect/EffectPartyPopperHost.js +8 -8
- package/dist/packages/core/src/components/FilterPanel.js +6 -6
- package/dist/packages/core/src/components/FilterSectionInfo.js +6 -6
- package/dist/packages/core/src/components/GameIntro.js +1 -1
- package/dist/packages/core/src/components/HearingDeviceInfo.d.ts +5 -5
- package/dist/packages/core/src/components/HearingDeviceInfo.js +2 -2
- package/dist/packages/core/src/components/Icon.js +2 -2
- package/dist/packages/core/src/components/ItemActions.js +16 -16
- package/dist/packages/core/src/components/LandingHeader.js +6 -6
- package/dist/packages/core/src/components/LineChart.js +24 -24
- package/dist/packages/core/src/components/Loader.js +5 -5
- package/dist/packages/core/src/components/NoticeBox.js +3 -3
- package/dist/packages/core/src/components/PageTitle.js +2 -2
- package/dist/packages/core/src/components/Pagination.js +4 -4
- package/dist/packages/core/src/components/Panel.js +2 -2
- package/dist/packages/core/src/components/PanelRow.d.ts +2 -4
- package/dist/packages/core/src/components/PanelRow.js +8 -15
- package/dist/packages/core/src/components/PassCard/PassCard.js +9 -9
- package/dist/packages/core/src/components/PassCard/passCardProduct.js +2 -2
- package/dist/packages/core/src/components/PaymentPlan.js +5 -5
- package/dist/packages/core/src/components/Popup/ConfirmPopup.js +29 -29
- package/dist/packages/core/src/components/Popup/CouponInputPopup.js +2 -2
- package/dist/packages/core/src/components/Popup/DeviceListPopup.d.ts +4 -8
- package/dist/packages/core/src/components/Popup/DeviceListPopup.js +1 -1
- package/dist/packages/core/src/components/Popup/DeviceSideTable.js +19 -26
- package/dist/packages/core/src/components/Popup/ListPopup.js +6 -6
- package/dist/packages/core/src/components/Popup/LoadTablePopup.js +10 -10
- package/dist/packages/core/src/components/Popup/Popup.js +27 -27
- package/dist/packages/core/src/components/Popup/SaveTablePopup.js +5 -5
- package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +4 -4
- package/dist/packages/core/src/components/Popup/customerDetailUtils.js +18 -18
- package/dist/packages/core/src/components/Popup/useDiscardGuard.js +4 -4
- package/dist/packages/core/src/components/ProgressIndicator.js +5 -5
- package/dist/packages/core/src/components/PromoBannerBg/promoBannerBg.js +16 -16
- package/dist/packages/core/src/components/Rating.js +20 -20
- package/dist/packages/core/src/components/Report/ReportDocument.js +21 -21
- package/dist/packages/core/src/components/RingChart.js +5 -5
- package/dist/packages/core/src/components/SectionHeadFloat.js +15 -15
- package/dist/packages/core/src/components/SegmentedControl.js +34 -34
- package/dist/packages/core/src/components/SideNav.js +37 -37
- package/dist/packages/core/src/components/Slider.js +2 -2
- package/dist/packages/core/src/components/SoundCompass.js +5 -5
- package/dist/packages/core/src/components/Table.js +257 -98
- package/dist/packages/core/src/components/Tabs.js +12 -12
- package/dist/packages/core/src/components/TextField.js +34 -34
- package/dist/packages/core/src/components/Toast.js +6 -6
- package/dist/packages/core/src/components/Tooltip/MenuTooltip.js +4 -4
- package/dist/packages/core/src/components/Tooltip/Tooltip.js +22 -22
- package/dist/packages/core/src/components/Tooltip/TrainingTooltip.js +4 -4
- package/dist/packages/core/src/components/TopBanner.js +28 -28
- package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.js +11 -11
- package/dist/packages/core/src/components/Training/CategoryIcon.js +2 -2
- package/dist/packages/core/src/components/Training/NoisePlayerContext.d.ts +7 -4
- package/dist/packages/core/src/components/Training/NoisePlayerContext.js +12 -12
- package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.js +2 -2
- package/dist/packages/core/src/components/Training/TrainingBlock.js +12 -12
- package/dist/packages/core/src/components/Training/TrainingBlockButton.js +10 -10
- package/dist/packages/core/src/components/Training/TrainingBlockGrid.js +21 -21
- package/dist/packages/core/src/components/Training/TrainingCard.css +13 -1
- package/dist/packages/core/src/components/Training/TrainingCard.js +2 -2
- package/dist/packages/core/src/components/Training/TrainingCardGroup.js +6 -6
- package/dist/packages/core/src/components/Training/TrainingPagePopup.js +83 -83
- package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +17 -17
- package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +128 -128
- package/dist/packages/core/src/components/Training/TrainingStepCard.js +18 -18
- package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.js +4 -4
- package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +12 -12
- package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +13 -13
- package/dist/packages/core/src/components/Training/TrainingStepTitle.js +4 -4
- package/dist/packages/core/src/components/Training/TrainingWordCard.js +17 -17
- package/dist/packages/core/src/components/Training/WordTablePopup.js +5 -5
- package/dist/packages/core/src/components/Training/audioPlaybackRules.js +8 -8
- package/dist/packages/core/src/components/Training/audioTimeline.js +25 -25
- package/dist/packages/core/src/components/Training/oneShotSound.d.ts +1 -1
- package/dist/packages/core/src/components/Training/oneShotSound.js +14 -14
- package/dist/packages/core/src/components/Training/stepAnswerable.d.ts +2 -2
- package/dist/packages/core/src/components/Training/trainingMissStats.js +22 -22
- package/dist/packages/core/src/components/Training/trainingPopupUtils.js +7 -7
- package/dist/packages/core/src/components/Training/useAudioPreview.js +15 -15
- package/dist/packages/core/src/components/Training/useFloatingFocus.js +14 -14
- package/dist/packages/core/src/components/Training/useNoiseFade.js +22 -22
- package/dist/packages/core/src/components/Training/useObjectUrls.js +2 -2
- package/dist/packages/core/src/components/Training/usePlaybackSequencer.d.ts +2 -2
- package/dist/packages/core/src/components/Training/usePlaybackSequencer.js +23 -23
- package/dist/packages/core/src/components/Training/useProgressTicker.js +33 -33
- package/dist/packages/core/src/components/Training/useTTSPlayback.js +17 -17
- package/dist/packages/core/src/components/Training/useWaveReady.js +11 -11
- package/dist/packages/core/src/components/UpdateAlert.js +6 -6
- package/dist/packages/core/src/components/Word/PhonemeSelectBlock.js +7 -1
- package/dist/packages/core/src/components/Word/TableFilterPanel.js +3 -3
- package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +18 -18
- package/dist/packages/core/src/components/Word/WordTableBlock.js +28 -28
- package/dist/packages/core/src/components/Word/WordTableButton.css +16 -2
- package/dist/packages/core/src/components/Word/WordTableThumbnail.css +6 -1
- package/dist/packages/core/src/components/Word/generatedDraft.js +14 -14
- package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +7 -7
- package/dist/packages/core/src/components/Word/wordScoreStats.js +17 -17
- package/dist/packages/core/src/components/Word/wordTablePrint.js +10 -10
- package/dist/packages/core/src/components/Word/wordTableRecommend.js +13 -13
- package/dist/packages/core/src/components/Word/wordTableReview.js +28 -28
- package/dist/packages/core/src/components/Word/wordTableSettings.d.ts +8 -11
- package/dist/packages/core/src/components/Word/wordTableSettings.js +48 -36
- package/dist/packages/core/src/components/WordTable.js +130 -130
- package/dist/packages/core/src/components/barChartData.d.ts +16 -1
- package/dist/packages/core/src/components/barChartData.js +49 -29
- package/dist/packages/core/src/components/barChartTypes.js +8 -8
- package/dist/packages/core/src/components/tableTrackList.js +11 -11
- package/dist/packages/core/src/components/updateAlertStack.js +28 -28
- package/dist/packages/core/src/components/useBarChartControl.js +4 -6
- package/dist/packages/core/src/components/useComboboxSuggestions.js +17 -17
- package/dist/packages/core/src/components/useToastLifecycle.js +18 -18
- package/dist/packages/core/src/components/wordTableLayout.d.ts +22 -7
- package/dist/packages/core/src/components/wordTableLayout.js +5 -5
- package/dist/packages/core/src/context/I18nProvider.js +6 -6
- package/dist/packages/core/src/hooks/focusReturn.js +27 -27
- package/dist/packages/core/src/hooks/inputModality.d.ts +1 -1
- package/dist/packages/core/src/hooks/inputModality.js +14 -14
- package/dist/packages/core/src/hooks/touchToggle.js +39 -39
- package/dist/packages/core/src/hooks/useAnchoredMenu.js +30 -30
- package/dist/packages/core/src/hooks/useDailyTrainingSelection.js +28 -28
- package/dist/packages/core/src/hooks/useDeviceManagement.d.ts +5 -17
- package/dist/packages/core/src/hooks/useDeviceManagement.js +20 -8
- package/dist/packages/core/src/hooks/useDocumentTitle.js +10 -10
- package/dist/packages/core/src/hooks/useEditableList.js +4 -4
- package/dist/packages/core/src/hooks/useFocusTrap.js +2 -2
- package/dist/packages/core/src/hooks/useFontScale.js +6 -6
- package/dist/packages/core/src/hooks/useIcon.js +7 -7
- package/dist/packages/core/src/hooks/useListControls.js +2 -2
- package/dist/packages/core/src/hooks/usePageResetOnChange.d.ts +3 -3
- package/dist/packages/core/src/hooks/usePageResetOnChange.js +6 -6
- package/dist/packages/core/src/hooks/usePasswordChange.js +4 -4
- package/dist/packages/core/src/hooks/usePendingPress.js +7 -7
- package/dist/packages/core/src/hooks/useRelativeDay.js +4 -4
- package/dist/packages/core/src/hooks/useSoundCompassExam.js +6 -6
- package/dist/packages/core/src/hooks/useTablePaging.d.ts +7 -7
- package/dist/packages/core/src/hooks/useTablePaging.js +40 -34
- package/dist/packages/core/src/hooks/useTrainingSessionLog.js +35 -35
- package/dist/packages/core/src/hooks/useVisitSchedule.js +4 -4
- package/dist/packages/core/src/hooks/useWordAttributeFilter.js +14 -14
- package/dist/packages/core/src/hooks/useWordMeanings.js +5 -5
- package/dist/packages/core/src/hooks/useWordTrainingFilter.d.ts +3 -1
- package/dist/packages/core/src/hooks/useWordTrainingFilter.js +14 -12
- package/dist/packages/core/src/i18n/locales/en.json +13 -2
- package/dist/packages/core/src/i18n/locales/ko.json +12 -1
- package/dist/packages/core/src/types/wordSearch.d.ts +9 -0
- package/dist/packages/core/src/utils/audioMeter.js +5 -5
- package/dist/packages/core/src/utils/audioPeaks.d.ts +1 -1
- package/dist/packages/core/src/utils/audioPeaks.js +18 -18
- package/dist/packages/core/src/utils/blockGrid.js +4 -4
- package/dist/packages/core/src/utils/chunkSkew.js +9 -9
- package/dist/packages/core/src/utils/clipboard.js +3 -3
- package/dist/packages/core/src/utils/dailySelectionUtils.js +12 -12
- package/dist/packages/core/src/utils/date.js +12 -12
- package/dist/packages/core/src/utils/devWarn.d.ts +2 -2
- package/dist/packages/core/src/utils/devWarn.js +7 -7
- package/dist/packages/core/src/utils/deviceKind.d.ts +37 -7
- package/dist/packages/core/src/utils/deviceKind.js +53 -10
- package/dist/packages/core/src/utils/errorMessage.js +7 -7
- package/dist/packages/core/src/utils/filterPanelSummary.d.ts +1 -1
- package/dist/packages/core/src/utils/filterPanelSummary.js +16 -16
- package/dist/packages/core/src/utils/format.js +4 -4
- package/dist/packages/core/src/utils/getTrainingData.js +9 -9
- package/dist/packages/core/src/utils/imageConfig.js +17 -17
- package/dist/packages/core/src/utils/koreanSyllable.js +18 -18
- package/dist/packages/core/src/utils/navScroll.d.ts +2 -2
- package/dist/packages/core/src/utils/navScroll.js +6 -6
- package/dist/packages/core/src/utils/phonemeConvert.d.ts +7 -0
- package/dist/packages/core/src/utils/phonemeConvert.js +19 -10
- package/dist/packages/core/src/utils/phonemeGroups.js +2 -2
- package/dist/packages/core/src/utils/phonemePairSearch.d.ts +1 -0
- package/dist/packages/core/src/utils/phonemePairSearch.js +118 -102
- package/dist/packages/core/src/utils/phonemeSectionCodes.d.ts +52 -0
- package/dist/packages/core/src/utils/phonemeSectionCodes.js +76 -0
- package/dist/packages/core/src/utils/prefetchChunk.js +6 -6
- package/dist/packages/core/src/utils/pressable.d.ts +1 -1
- package/dist/packages/core/src/utils/pressable.js +3 -3
- package/dist/packages/core/src/utils/safeHref.js +4 -4
- package/dist/packages/core/src/utils/savedWordTables.js +5 -5
- package/dist/packages/core/src/utils/sessionTab.d.ts +7 -7
- package/dist/packages/core/src/utils/sessionTab.js +9 -9
- package/dist/packages/core/src/utils/speak.js +12 -12
- package/dist/packages/core/src/utils/textSort.d.ts +5 -5
- package/dist/packages/core/src/utils/textSort.js +47 -47
- package/dist/packages/core/src/utils/theme.js +3 -3
- package/dist/packages/core/src/utils/trainingItemResults.js +3 -3
- package/dist/packages/core/src/utils/ttsConfig.js +2 -2
- package/dist/packages/core/src/utils/wordAttributeFilterCodes.d.ts +2 -2
- package/dist/packages/core/src/utils/wordAttributeFilterCodes.js +48 -48
- package/dist/packages/core/src/utils/wordContrast.js +3 -3
- package/dist/packages/core/src/utils/wordPairIndex.js +4 -4
- package/dist/packages/core/src/utils/wordTrainingGenerators.js +4 -4
- package/dist/packages/core/src/utils/wordTrainingPool.js +15 -15
- package/dist/packages/core/src/utils/wordTrainingRuntime.js +6 -6
- package/dist/packages/core/src/utils/wornDate.d.ts +3 -3
- package/dist/packages/core/src/utils/wornDate.js +14 -14
- package/dist/packages/core/src/version.d.ts +1 -1
- package/dist/packages/core/src/version.js +1 -1
- package/dist/utils.cjs.js +4 -4
- package/package.json +1 -1
|
@@ -19,7 +19,7 @@ import Button from '../Button';
|
|
|
19
19
|
import Icon from '../Icon';
|
|
20
20
|
import InfoTooltip from '../Tooltip/InfoTooltip';
|
|
21
21
|
import { STATUS_MESSAGES, ANNOUNCED_STATUSES } from './trainingStepAudioPlayerTypes';
|
|
22
|
-
import {
|
|
22
|
+
import { wasKeyboardPress } from '../../hooks/inputModality';
|
|
23
23
|
import { measureAudioDuration, waitForCanPlay } from './audioWaits';
|
|
24
24
|
import { useWaveReady } from './useWaveReady';
|
|
25
25
|
import { useObjectUrls } from './useObjectUrls';
|
|
@@ -83,17 +83,17 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
83
83
|
const [stateLocal, setStateLocal] = useState('active');
|
|
84
84
|
/* 초점을 가진 큰 '듣기' 단추를 누르면 그 단추가 사라진다 — 재생 모드가 되면 초점을 '재생 / 멈춤' 으로 옮긴다
|
|
85
85
|
(옮기지 않으면 초점이 body 로 떨어져 키보드 사용자가 멈출 단추를 다시 찾아야 했다). */
|
|
86
|
-
const
|
|
86
|
+
const moveFocusToListenRef = useRef(false);
|
|
87
87
|
useEffect(() => {
|
|
88
|
-
if (stateLocal !== 'playMode' ||
|
|
88
|
+
if (stateLocal !== 'playMode' || !moveFocusToListenRef.current)
|
|
89
89
|
return;
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
if (
|
|
90
|
+
moveFocusToListenRef.current = false;
|
|
91
|
+
const active = typeof document !== 'undefined' ? document.activeElement : null;
|
|
92
|
+
if (active && active !== document.body)
|
|
93
93
|
return;
|
|
94
|
-
const
|
|
94
|
+
const playPauseBtn = btnsRef.current?.querySelectorAll('button')[1];
|
|
95
95
|
try {
|
|
96
|
-
|
|
96
|
+
playPauseBtn?.focus();
|
|
97
97
|
}
|
|
98
98
|
catch (e) { }
|
|
99
99
|
}, [stateLocal]);
|
|
@@ -107,7 +107,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
107
107
|
* 그래서 번호는 `currentIndex` 에서 읽고, 여기서는 **밝힐지 말지만** 든다.
|
|
108
108
|
* 둘을 따로 들면 언젠가 한쪽만 갱신되는 자리가 생긴다(실제로 `goToTrack` 이
|
|
109
109
|
* 번호만 바꾸고 이 값은 효과에 기대고 있었다). */
|
|
110
|
-
const [
|
|
110
|
+
const [showRange, setShowRange] = useState(false);
|
|
111
111
|
const [totalDuration, setTotalDuration] = useState(0);
|
|
112
112
|
// 문장(메인) 재생 속도·볼륨 — 하단 소음설정 툴팁과 공유(NoisePlayerContext)
|
|
113
113
|
const playbackRate = noisePlayer?.playbackRate ?? 1.0;
|
|
@@ -138,16 +138,16 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
138
138
|
/* **시간표의 자리·의도·뜸 타이머·멈춘 자리는 진행기가 쥔다**((85) 2a~2c, 2026-10-06).
|
|
139
139
|
묻는 쪽(소음 · 틱 · 토글 · 자물쇠)은 **자리를 받아 제 손으로 셈하지 않고** 진행기에게
|
|
140
140
|
묻는다 — `isRunning()` 하나다. 그 셈이 세 벌로 흩어져 있던 것이 오늘 결함 여섯의 뿌리였다. */
|
|
141
|
-
const
|
|
141
|
+
const sequencer = usePlaybackSequencer({ mountedRef });
|
|
142
142
|
/* `onEnded` 가 적어 두는 "여기서부터 이어 간다" — 토글이 멈출 때 진행기에 넘긴다 */
|
|
143
|
-
const
|
|
143
|
+
const continueRef = useRef(null);
|
|
144
144
|
/* 이벤트 손잡이는 한 번만 붙으므로 prop 을 ref 로 들고 본다 — 안 그러면 낡은 값을 쓴다
|
|
145
145
|
(이 파일이 `disabledRef`·`noiseSettingsRef` 에 쓰는 것과 같은 결). */
|
|
146
146
|
const leadInMsRef = useRef(leadInMs);
|
|
147
147
|
const tailMsRef = useRef(tailMs);
|
|
148
148
|
useEffect(() => { leadInMsRef.current = Math.max(0, leadInMs || 0); }, [leadInMs]);
|
|
149
149
|
useEffect(() => { tailMsRef.current = Math.max(0, tailMs || 0); }, [tailMs]);
|
|
150
|
-
const { start: startTicker, stop: stopTicker, settleTo: settleTicker, } = useProgressTicker({ audioRef, fillRef, waveProgressRef, sourcesRef, currentIndexRef, sequencer:
|
|
150
|
+
const { start: startTicker, stop: stopTicker, settleTo: settleTicker, } = useProgressTicker({ audioRef, fillRef, waveProgressRef, sourcesRef, currentIndexRef, sequencer: sequencer, leadInMs, tailMs });
|
|
151
151
|
/* 파형이 자리를 잡을 때까지 기다리는 규칙은 useWaveReady 가 들고 있다 —
|
|
152
152
|
ref 둘·함수 둘에 "준비 안 됨으로 되돌리기"가 일곱 군데 흩어져 있던 것을 모았다. */
|
|
153
153
|
const { onReadyChange: handleWaveReadyChange, waitForReady: waitForWaveReady, markNotReady: markWaveNotReady, } = useWaveReady({ showSoundWaveForm, showNoiseWaveForm });
|
|
@@ -174,9 +174,9 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
174
174
|
noisePlayer,
|
|
175
175
|
audioRef,
|
|
176
176
|
playbackStateRef,
|
|
177
|
-
sequencer:
|
|
177
|
+
sequencer: sequencer,
|
|
178
178
|
});
|
|
179
|
-
const { startTTS:
|
|
179
|
+
const { startTTS: beginTTS, stopTTS } = useTTSPlayback({
|
|
180
180
|
ttsText,
|
|
181
181
|
playbackRate,
|
|
182
182
|
// TTS 음성 = 문제 소리 = 전체 소리
|
|
@@ -201,11 +201,11 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
201
201
|
setNoticeMessage(t('training.ttsUnavailable', '이 기기에서는 음성을 들려줄 수 없습니다.'));
|
|
202
202
|
}
|
|
203
203
|
catch (_) { }
|
|
204
|
-
|
|
204
|
+
beginTTS(text);
|
|
205
205
|
return;
|
|
206
206
|
}
|
|
207
|
-
|
|
208
|
-
|
|
207
|
+
acquireSound('tts');
|
|
208
|
+
beginTTS(text);
|
|
209
209
|
};
|
|
210
210
|
const requestedSources = Array.isArray(soundSrc)
|
|
211
211
|
? soundSrc.filter(Boolean)
|
|
@@ -228,17 +228,17 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
228
228
|
*
|
|
229
229
|
* 이제 세대 하나다. `멈춤의도()` 는 세대를 올리고 자리를 비운다.
|
|
230
230
|
*/
|
|
231
|
-
const
|
|
231
|
+
const abortIntent = () => { sequencer.abort(); };
|
|
232
232
|
/* 스피커는 하나(currentSound.ts) — 한 화면의 다른 재생기(문장 듣기·문제 듣기)·낱말 카드·미리 듣기가 울리면 이 재생기는
|
|
233
233
|
멈춘다. 전에는 낱말 카드만 자물쇠를 잡아, 듣기 단계가 둘인 화면·듣기+카드 단계에서 두 소리가 함께 울렸다.
|
|
234
234
|
멈춤 단추와 같게 멈춘다(이어 듣기 가능). 함수는 한 벌이어야 자물쇠가 '내 것' 을 알아본다. */
|
|
235
235
|
const stopTTSRef = useRef(null);
|
|
236
|
-
const
|
|
237
|
-
const
|
|
238
|
-
const
|
|
239
|
-
if (
|
|
240
|
-
|
|
241
|
-
|
|
236
|
+
const soundKindRef = useRef(null);
|
|
237
|
+
const releaseSoundRef = useRef(null);
|
|
238
|
+
const stopSoundRef = useRef(null);
|
|
239
|
+
if (!stopSoundRef.current) {
|
|
240
|
+
stopSoundRef.current = () => {
|
|
241
|
+
releaseSoundRef.current = null;
|
|
242
242
|
const a = audioRef.current;
|
|
243
243
|
/* **뜸 중이면 요소는 멈춰 있다** — `!a.paused` 만 보면 이 문을 안 지나, 남이 스피커를
|
|
244
244
|
가져가도 **뜸은 제 것인 줄 알고 깨어나 소리를 냈다**(가짜 시계 실측, 두 길 다:
|
|
@@ -247,8 +247,8 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
247
247
|
울린다" 고 보고한 그 자리의 **뜸 판**이다.
|
|
248
248
|
오늘 같은 꼴을 여섯 번째 만난다(소음 감시 · 재생판 · 토글 · 그 토글의 두 번째 벌 ·
|
|
249
249
|
번호 단추 · 여기). 이제 `isRunning()` 하나로 묻는다. */
|
|
250
|
-
if (
|
|
251
|
-
|
|
250
|
+
if (soundKindRef.current === 'audio' && a && (!a.paused || sequencer.isRunning())) {
|
|
251
|
+
abortIntent();
|
|
252
252
|
try {
|
|
253
253
|
a.pause();
|
|
254
254
|
}
|
|
@@ -259,7 +259,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
259
259
|
fadeOutNoise();
|
|
260
260
|
}
|
|
261
261
|
}
|
|
262
|
-
else if (
|
|
262
|
+
else if (soundKindRef.current === 'tts') {
|
|
263
263
|
try {
|
|
264
264
|
stopTTSRef.current?.();
|
|
265
265
|
}
|
|
@@ -267,14 +267,14 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
267
267
|
}
|
|
268
268
|
};
|
|
269
269
|
}
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
270
|
+
const acquireSound = (kind) => {
|
|
271
|
+
soundKindRef.current = kind;
|
|
272
|
+
releaseSoundRef.current = claimSound(stopSoundRef.current);
|
|
273
273
|
};
|
|
274
274
|
// 멈춤·정지·완료 뒤에는 자리를 비운다 — 남이 울릴 때 끝난 재생기의 상태를 건드리지 않게
|
|
275
|
-
const
|
|
276
|
-
useEffect(() => () => { if (
|
|
277
|
-
stopSoundIfOwner(
|
|
275
|
+
const releaseSound = () => { releaseSoundRef.current?.(); releaseSoundRef.current = null; };
|
|
276
|
+
useEffect(() => () => { if (stopSoundRef.current)
|
|
277
|
+
stopSoundIfOwner(stopSoundRef.current); }, []);
|
|
278
278
|
/* 이벤트 손잡이·비동기 흐름 안에서 **지금 값**을 읽어야 한다 — 효과가 다시 돌 때까지
|
|
279
279
|
기다리면 이미 실은 소리에 옛 빠르기가 걸린다(이 파일의 `disabledRef`·`leadInMsRef` 와 같은 결). */
|
|
280
280
|
const playbackRateRef = useRef(playbackRate);
|
|
@@ -285,7 +285,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
285
285
|
* `load()` 는 명세대로 `playbackRate` 를 **`defaultPlaybackRate` 로 되돌린다.** 기본값을
|
|
286
286
|
* 안 세워 두면 그 되돌림이 늘 1 이라, 소리를 실을 때마다 사용자가 고른 빠르기가 지워진다.
|
|
287
287
|
*/
|
|
288
|
-
const
|
|
288
|
+
const applyPlaybackRate = useCallback((a) => {
|
|
289
289
|
const r = playbackRateRef.current || 1.0;
|
|
290
290
|
try {
|
|
291
291
|
a.defaultPlaybackRate = r;
|
|
@@ -309,12 +309,12 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
309
309
|
* 자리를 하나로 모은다 — 흩어진 다섯을 글로 단속하지 않고 **셈으로 막는다**
|
|
310
310
|
* (`test/audioPlaybackRateSurvivesLoad.test.tsx` 가 `load()` 자리 수를 센다).
|
|
311
311
|
*/
|
|
312
|
-
const
|
|
312
|
+
const loadSound = useCallback((a, src) => {
|
|
313
313
|
a.src = src;
|
|
314
|
-
|
|
314
|
+
applyPlaybackRate(a); // defaultPlaybackRate 를 먼저 세워 둔다 — load() 가 그것으로 되돌린다
|
|
315
315
|
a.load();
|
|
316
|
-
|
|
317
|
-
}, [
|
|
316
|
+
applyPlaybackRate(a); // 되돌린 뒤 한 번 더 — 기본값을 못 세우는 환경에서도 선다
|
|
317
|
+
}, [applyPlaybackRate]);
|
|
318
318
|
/**
|
|
319
319
|
* **완료 연출을 걷는다** — 새 재생이 서면 지난 완료의 자취가 그 위에 남아선 안 된다.
|
|
320
320
|
*
|
|
@@ -332,17 +332,17 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
332
332
|
* 부른다(다시듣기 · ▶ · 번호 단추 · 불러오기가 다 그 길이다). 걷는 곳이 여럿이면
|
|
333
333
|
* **한 곳이 안 따라간다** — 오늘 밤 내내 그 결이었다.
|
|
334
334
|
*/
|
|
335
|
-
const
|
|
335
|
+
const clearDoneEffect = useCallback(() => {
|
|
336
336
|
if (fillOutResetRef.current) {
|
|
337
337
|
clearTimeout(fillOutResetRef.current);
|
|
338
338
|
fillOutResetRef.current = null;
|
|
339
339
|
}
|
|
340
340
|
setFillCompletionPhase('idle');
|
|
341
341
|
}, []);
|
|
342
|
-
const tryPlayWithRetry = useCallback(async (a, attempts = 3, delayMs = 50,
|
|
342
|
+
const tryPlayWithRetry = useCallback(async (a, attempts = 3, delayMs = 50, leadElapsed = 0) => {
|
|
343
343
|
/* 지난 완료의 접는 연출을 먼저 걷는다 — 위 설명 참고 */
|
|
344
|
-
|
|
345
|
-
const
|
|
344
|
+
clearDoneEffect();
|
|
345
|
+
const myGeneration = sequencer.generation();
|
|
346
346
|
/* **이어 트는 것에는 앞뜸을 다시 안 준다**(사용자 신고, 2026-10-06).
|
|
347
347
|
앞뜸은 *소리 앞*의 구간이다. 소리가 **이미 시작된** 것을 이어 트는데 앞뜸을 또 주면
|
|
348
348
|
소리는 멈춘 자리(`currentTime`)에서 이어 가는데 **진행은 0 부터 다시 센다** — 말귀
|
|
@@ -356,16 +356,16 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
356
356
|
> 소리 중에 멈췄다 이을 때도 귀를 다시 채비시키려 **앞뜸을 주는** 쪽이 나을 수도
|
|
357
357
|
> 있다. 그러면 시간표를 *앞뜸 + 남은 소리 + 뒤뜸* 으로 다시 그려야 어긋나지 않는다.
|
|
358
358
|
> 그것은 **사장님께 여쭐 일**이라 지금은 "앞뜸은 소리 앞" 이라는 뜻 그대로 둔다. */
|
|
359
|
-
const
|
|
359
|
+
const resuming = !!a && a.currentTime > 0 && !a.ended;
|
|
360
360
|
/* 앞뜸에서 멈췄다 이으면 **남은 만큼만** 채운다 — 뜸도 시간표의 일부라 처음부터 다시
|
|
361
361
|
세면 그 자리에서 이어지지 않는다(사용자 결정, 2026-10-06). */
|
|
362
|
-
const
|
|
363
|
-
if (
|
|
362
|
+
const leadRemaining = Math.max(0, leadInMsRef.current - Math.max(0, leadElapsed));
|
|
363
|
+
if (leadRemaining > 0 && !resuming) {
|
|
364
364
|
/* **스피커는 뜸이 시작될 때 잡는다**((85) 2b, 2026-10-06). 전에는 `await a.play()` 바로
|
|
365
365
|
앞에서 잡았는데, 그러면 **앞뜸 내내 주인이 없다** — 그 사이 남이 소리를 내도 이쪽을
|
|
366
366
|
못 멈추고, 뜸이 끝나면 둘이 함께 울린다. 소음은 이미 뜸부터 깔리니(0.268.0)
|
|
367
367
|
**소리를 내고 있는 것이 맞다.** */
|
|
368
|
-
|
|
368
|
+
acquireSound('audio');
|
|
369
369
|
startTicker();
|
|
370
370
|
/* 앞뜸부터 **재생 중**이다 — 재생판이 이제 앞뜸부터 보이므로((86)) 단추도 ⏸ 로 서야
|
|
371
371
|
누르는 뜻과 맞는다. ▶ 로 두면 "멈추려고 ▶ 를 누르는" 꼴이 된다. */
|
|
@@ -383,24 +383,24 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
383
383
|
}
|
|
384
384
|
catch (e) { }
|
|
385
385
|
}
|
|
386
|
-
if (!(await
|
|
386
|
+
if (!(await sequencer.waitPad(leadRemaining, 'lead', leadElapsed)))
|
|
387
387
|
return false;
|
|
388
|
-
if (!mountedRef.current ||
|
|
388
|
+
if (!mountedRef.current || !sequencer.isCurrent(myGeneration))
|
|
389
389
|
return false;
|
|
390
390
|
}
|
|
391
|
-
|
|
391
|
+
sequencer.enterSound();
|
|
392
392
|
for (let i = 0; i < attempts; i++) {
|
|
393
|
-
if (!mountedRef.current ||
|
|
393
|
+
if (!mountedRef.current || !sequencer.isCurrent(myGeneration))
|
|
394
394
|
return false;
|
|
395
395
|
try {
|
|
396
|
-
|
|
396
|
+
acquireSound('audio');
|
|
397
397
|
await a.play();
|
|
398
398
|
return true;
|
|
399
399
|
}
|
|
400
400
|
catch (err) {
|
|
401
401
|
const errName = err instanceof Error ? err.name : (typeof err === 'object' && err && 'name' in err ? err.name : undefined);
|
|
402
402
|
if (errName === 'AbortError') {
|
|
403
|
-
if (
|
|
403
|
+
if (!sequencer.isCurrent(myGeneration))
|
|
404
404
|
return false;
|
|
405
405
|
await new Promise((res) => setTimeout(res, delayMs));
|
|
406
406
|
continue;
|
|
@@ -424,7 +424,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
424
424
|
}
|
|
425
425
|
catch (_) { }
|
|
426
426
|
return false;
|
|
427
|
-
}, [t, startTicker,
|
|
427
|
+
}, [t, startTicker, sequencer, fadeInNoise, noiseSettingsRef, clearDoneEffect]);
|
|
428
428
|
/**
|
|
429
429
|
* **뒤뜸(사이 뜸)에서 멈췄다 이을 때** — 남은 만큼 돌고 하던 일을 잇는다.
|
|
430
430
|
*
|
|
@@ -432,9 +432,9 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
432
432
|
* 짧아지고 재생바가 그 순간 튄다 — 사용자가 짚은 것이 그 자리다. `onEnded` 의 사슬과
|
|
433
433
|
* **같은 모양**이어야 하므로(진행바 켜기 · 소음 · 세대 가림 · `leave()`) 여기도 그대로 둔다.
|
|
434
434
|
*/
|
|
435
|
-
const
|
|
436
|
-
const
|
|
437
|
-
const
|
|
435
|
+
const continueAfterTail = useCallback((elapsed) => {
|
|
436
|
+
const myGeneration = sequencer.generation();
|
|
437
|
+
const remaining = Math.max(0, tailMsRef.current - Math.max(0, elapsed));
|
|
438
438
|
startTicker();
|
|
439
439
|
const ns = noiseSettingsRef.current;
|
|
440
440
|
if (ns?.enabled && (ns.volume ?? 0) > 0) {
|
|
@@ -443,21 +443,21 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
443
443
|
}
|
|
444
444
|
catch (e) { }
|
|
445
445
|
}
|
|
446
|
-
const
|
|
447
|
-
|
|
446
|
+
const resume = () => { try {
|
|
447
|
+
continueRef.current?.();
|
|
448
448
|
}
|
|
449
449
|
catch (e) { } };
|
|
450
|
-
if (
|
|
451
|
-
|
|
452
|
-
|
|
450
|
+
if (remaining <= 0) {
|
|
451
|
+
sequencer.leave();
|
|
452
|
+
resume();
|
|
453
453
|
return;
|
|
454
454
|
}
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
if (
|
|
458
|
-
|
|
455
|
+
sequencer.waitPad(remaining, 'tail', elapsed).then((mayContinue) => {
|
|
456
|
+
sequencer.leave();
|
|
457
|
+
if (mayContinue && sequencer.isCurrent(myGeneration))
|
|
458
|
+
resume();
|
|
459
459
|
}).catch(() => { });
|
|
460
|
-
}, [
|
|
460
|
+
}, [sequencer, startTicker, fadeInNoise, noiseSettingsRef]);
|
|
461
461
|
const attachAudioListeners = useCallback(() => {
|
|
462
462
|
const a = audioRef.current;
|
|
463
463
|
if (!a)
|
|
@@ -465,7 +465,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
465
465
|
const onPlay = () => {
|
|
466
466
|
setPlaybackState("playing");
|
|
467
467
|
setStateLocal("playMode");
|
|
468
|
-
|
|
468
|
+
setShowRange(true);
|
|
469
469
|
startTicker();
|
|
470
470
|
// 문장을 Web Audio 그래프(gain·미터)에 태운다 — 재생 제스처 시점에 resume, 컨텍스트가 달리고 나서야
|
|
471
471
|
// 태우므로 그 전엔 요소가 제 볼륨으로 직접 울린다(무음 방지). 태운 뒤 볼륨은 GainNode 로 걸린다.
|
|
@@ -501,17 +501,17 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
501
501
|
다음 소리가 있으면 그 소리의 **앞 뜸**이 이어 붙어 사이가 둘을 더한 만큼이 된다 —
|
|
502
502
|
누를 때의 뜸과 사이의 뜸을 가르는 자리가 여기다. */
|
|
503
503
|
const onEnded = () => {
|
|
504
|
-
const
|
|
504
|
+
const continuePlay = () => {
|
|
505
505
|
try {
|
|
506
506
|
const sref = sourcesRef.current;
|
|
507
507
|
const next = nextTrackIndex(sref, currentIndexRef.current);
|
|
508
508
|
if (next !== null) {
|
|
509
509
|
currentIndexRef.current = next;
|
|
510
510
|
setCurrentIndex(next);
|
|
511
|
-
|
|
511
|
+
setShowRange(true);
|
|
512
512
|
const nextSrc = sref?.[next] && sref[next].src;
|
|
513
513
|
if (nextSrc && a) {
|
|
514
|
-
|
|
514
|
+
loadSound(a, nextSrc);
|
|
515
515
|
tryPlayWithRetry(a).catch(() => { });
|
|
516
516
|
return;
|
|
517
517
|
}
|
|
@@ -529,7 +529,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
529
529
|
}
|
|
530
530
|
catch (e) { }
|
|
531
531
|
stopTicker();
|
|
532
|
-
|
|
532
|
+
setShowRange(false);
|
|
533
533
|
// 마지막 트랙 종료 시 소음 정지
|
|
534
534
|
fadeOutNoise();
|
|
535
535
|
// Route through handleEnd (via ref) so stateLocal transitions to 'active' before onDone fires.
|
|
@@ -541,21 +541,21 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
541
541
|
catch (e) { }
|
|
542
542
|
};
|
|
543
543
|
/* 멈췄다 다시 누를 때 **여기서부터** 잇는다 — 진행기가 그 자리를 안다 */
|
|
544
|
-
|
|
544
|
+
continueRef.current = continuePlay;
|
|
545
545
|
if (tailMsRef.current > 0) {
|
|
546
546
|
/* 기다리는 동안 진행바를 돌린다 — `pause` 가 이미 세웠을 수 있어 다시 켠다.
|
|
547
547
|
그 사이 멈추거나 떠나면 `waitPad` 가 거짓을 주고 아무 일도 안 한다. */
|
|
548
548
|
startTicker();
|
|
549
|
-
|
|
549
|
+
sequencer.waitPad(tailMsRef.current, 'tail').then((mayContinue) => {
|
|
550
550
|
/* 뒤뜸이 끝났다 — 자리는 비우되 **세대는 안 올린다**(`abort` 가 아니다). 올리면
|
|
551
551
|
바로 아래에서 이어 갈 사슬까지 제가 버린 것이 된다. */
|
|
552
|
-
|
|
553
|
-
if (
|
|
554
|
-
|
|
552
|
+
sequencer.leave();
|
|
553
|
+
if (mayContinue)
|
|
554
|
+
continuePlay();
|
|
555
555
|
}).catch(() => { });
|
|
556
556
|
return;
|
|
557
557
|
}
|
|
558
|
-
|
|
558
|
+
continuePlay();
|
|
559
559
|
};
|
|
560
560
|
a.addEventListener("play", onPlay);
|
|
561
561
|
a.addEventListener("pause", onPause);
|
|
@@ -569,7 +569,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
569
569
|
}
|
|
570
570
|
catch (e) { }
|
|
571
571
|
};
|
|
572
|
-
}, [fadeInNoise, fadeOutNoise, tryPlayWithRetry, noiseSettingsRef, startTicker, stopTicker,
|
|
572
|
+
}, [fadeInNoise, fadeOutNoise, tryPlayWithRetry, noiseSettingsRef, startTicker, stopTicker, sequencer]);
|
|
573
573
|
useEffect(() => {
|
|
574
574
|
const cleanup = attachAudioListeners();
|
|
575
575
|
return cleanup;
|
|
@@ -735,21 +735,21 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
735
735
|
*
|
|
736
736
|
* 차례는 이렇다: 돌림판 → 준비 끝 → **재생판·파형·재생바가 보임** → 앞뜸(소음) → 소리.
|
|
737
737
|
*/
|
|
738
|
-
const
|
|
738
|
+
const openPlayPanel = () => {
|
|
739
739
|
setStateLocal('playMode');
|
|
740
740
|
setCurrentIndex(0);
|
|
741
|
-
|
|
741
|
+
setShowRange(true);
|
|
742
742
|
};
|
|
743
743
|
const loadAndPlay = async (a, src) => {
|
|
744
744
|
markCompletedByEnd(false);
|
|
745
745
|
fetchingRef.current = false;
|
|
746
|
-
|
|
746
|
+
loadSound(a, src);
|
|
747
747
|
await waitForCanPlay(a);
|
|
748
748
|
// 기다리는 사이 떠났으면(false) 이어 가지 않는다 — 떼어진 <audio> 를 틀던 것
|
|
749
749
|
if ((await waitForWaveReady()) === false)
|
|
750
750
|
return;
|
|
751
751
|
setIsFetchingUrl(false);
|
|
752
|
-
|
|
752
|
+
openPlayPanel(); // 앞뜸이 시작되기 **전에** — 뜸 동안 판이 비지 않게
|
|
753
753
|
await tryPlayWithRetry(a);
|
|
754
754
|
};
|
|
755
755
|
const fetchAndPrepareFromTTSUrl = async (ttsUrl, playAfterLoad = false) => {
|
|
@@ -896,7 +896,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
896
896
|
fetchingRef.current = false;
|
|
897
897
|
if (first.src && a) {
|
|
898
898
|
try {
|
|
899
|
-
|
|
899
|
+
loadSound(a, first.src);
|
|
900
900
|
// canplay 이벤트 대기 — load() 직후 play() 호출 시 발생하는 AbortError 방지.
|
|
901
901
|
// 짧은 파일도 canplay가 빠르게 발생하므로 재생 지연 없음.
|
|
902
902
|
// (같은 기다림이 이 파일 위쪽에 waitForCanPlay 로 이미 있었다. 여기에 한 벌
|
|
@@ -909,7 +909,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
909
909
|
// 뒤늦게 나타나는 빈 화면 구간도 생기지 않도록 한다.
|
|
910
910
|
if (showSpinner)
|
|
911
911
|
setIsFetchingUrl(false);
|
|
912
|
-
|
|
912
|
+
openPlayPanel(); // 앞뜸이 시작되기 **전에** — 뜸 동안 판이 비지 않게
|
|
913
913
|
if (a)
|
|
914
914
|
await tryPlayWithRetry(a);
|
|
915
915
|
}
|
|
@@ -924,7 +924,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
924
924
|
}
|
|
925
925
|
setStateLocal('playMode');
|
|
926
926
|
setCurrentIndex(0);
|
|
927
|
-
|
|
927
|
+
setShowRange(true);
|
|
928
928
|
}
|
|
929
929
|
return finalSources.length > 0;
|
|
930
930
|
}
|
|
@@ -944,7 +944,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
944
944
|
* 300ms 에 ⏸ → 소음 `play()` 가 또 불리고 **같은 단어가 두 번 틀어졌다**(2622ms · 2945ms).
|
|
945
945
|
* 내 못은 저장소 소리로만 돌려서 통과했다 — **고친 벌만 재고 있었다.**
|
|
946
946
|
*/
|
|
947
|
-
const
|
|
947
|
+
const toggleElement = async (a) => {
|
|
948
948
|
/* **뜸 중이면 재생 중이다**(말귀 요청 (85) 0단계에서 찾은 결함, 2026-10-06).
|
|
949
949
|
전에는 `a.paused` 만 봤다. 그런데 뜸 동안 메인 요소는 **멈춰 있다** — 앞뜸에는 아직
|
|
950
950
|
`play()` 전이고 사이·뒤뜸에는 이미 `ended` 다. 그래서 ⏸ 로 보이는 단추를 뜸 중에
|
|
@@ -953,8 +953,8 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
953
953
|
+1000ms 에 한 번, 원래 사슬이 닿는 +1500ms 에 또 한 번).
|
|
954
954
|
감시 타이머가 0.268.0 에서 배운 것과 같은 것을 이 단추가 못 배우고 있었다 —
|
|
955
955
|
뜸은 소리가 안 나는 구간이지 **멈춘 구간이 아니다.** */
|
|
956
|
-
const
|
|
957
|
-
if (a.paused &&
|
|
956
|
+
const padRunning = sequencer.isRunning();
|
|
957
|
+
if (a.paused && !padRunning) {
|
|
958
958
|
if (completedByEndRef.current) {
|
|
959
959
|
await handleReplay();
|
|
960
960
|
return;
|
|
@@ -964,26 +964,26 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
964
964
|
다시 세지 않는다. */
|
|
965
965
|
/* **멈춘 자리에서 이어 간다** — 어디서 몇 ms 였는지는 진행기가 안다.
|
|
966
966
|
뜸도 시간표의 일부라 **남은 만큼만** 채운다(사용자 결정, 2026-10-06). */
|
|
967
|
-
const
|
|
968
|
-
if (
|
|
967
|
+
const resumedAt = sequencer.resume();
|
|
968
|
+
if (resumedAt) {
|
|
969
969
|
setPlaybackState("playing");
|
|
970
|
-
if (
|
|
971
|
-
|
|
970
|
+
if (resumedAt.phase === 'tail') {
|
|
971
|
+
continueAfterTail(resumedAt.elapsed);
|
|
972
972
|
return;
|
|
973
973
|
}
|
|
974
|
-
await tryPlayWithRetry(a, 3, 50,
|
|
974
|
+
await tryPlayWithRetry(a, 3, 50, resumedAt.elapsed);
|
|
975
975
|
return;
|
|
976
976
|
}
|
|
977
977
|
await tryPlayWithRetry(a);
|
|
978
978
|
setPlaybackState("playing");
|
|
979
979
|
}
|
|
980
980
|
else {
|
|
981
|
-
|
|
982
|
-
|
|
981
|
+
sequencer.pause();
|
|
982
|
+
abortIntent();
|
|
983
983
|
a.pause();
|
|
984
984
|
setPlaybackState("paused");
|
|
985
985
|
/* 뜸 중에는 `pause` 사건이 안 온다(요소가 이미 멈춰 있다) — 그 사건이 하던 일을 여기서 한다 */
|
|
986
|
-
if (
|
|
986
|
+
if (padRunning) {
|
|
987
987
|
stopTicker();
|
|
988
988
|
fadeOutNoise();
|
|
989
989
|
}
|
|
@@ -1044,16 +1044,16 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
1044
1044
|
if (stateLocal === 'playMode') {
|
|
1045
1045
|
/* 오디오 파일로 변환된 경우 audioRef로 제어. <audio> 는 늘 있으므로 그것만 보면 음성 합성으로 말하는 중(리졸버 없음·실패)에도
|
|
1046
1046
|
이 길로 들어가 소리 없는 <audio> 를 재생하려 들고 말은 멈추지 않았다 — 합성기가 말하는 중이고 <audio> 에 소리가 없으면 합성기를 멈춘다. */
|
|
1047
|
-
const
|
|
1048
|
-
if (a &&
|
|
1049
|
-
await
|
|
1047
|
+
const speakingByTTS = !!synth && (synth.speaking || synth.paused) && !(a && (a.currentSrc || a.getAttribute('src')));
|
|
1048
|
+
if (a && !speakingByTTS) {
|
|
1049
|
+
await toggleElement(a); // 저장소 소리 갈래와 **같은 한 벌**
|
|
1050
1050
|
return;
|
|
1051
1051
|
}
|
|
1052
1052
|
// speechSynthesis fallback
|
|
1053
1053
|
try {
|
|
1054
1054
|
if (synth && synth.paused) {
|
|
1055
1055
|
try {
|
|
1056
|
-
|
|
1056
|
+
acquireSound('tts');
|
|
1057
1057
|
synth.resume();
|
|
1058
1058
|
setPlaybackState('playing');
|
|
1059
1059
|
}
|
|
@@ -1079,30 +1079,30 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
1079
1079
|
try {
|
|
1080
1080
|
if (first.src && a) {
|
|
1081
1081
|
try {
|
|
1082
|
-
|
|
1083
|
-
|
|
1082
|
+
loadSound(a, first.src);
|
|
1083
|
+
openPlayPanel(); // 앞뜸이 시작되기 **전에**
|
|
1084
1084
|
if (a)
|
|
1085
1085
|
await tryPlayWithRetry(a);
|
|
1086
1086
|
}
|
|
1087
1087
|
catch (e) { }
|
|
1088
1088
|
}
|
|
1089
|
-
|
|
1089
|
+
openPlayPanel();
|
|
1090
1090
|
return;
|
|
1091
1091
|
}
|
|
1092
1092
|
catch (e) { }
|
|
1093
1093
|
}
|
|
1094
1094
|
setStateLocal("load");
|
|
1095
1095
|
// 앱이 준 경로 규칙(resolvePath 등)이 던져도 못 얻은 것과 같이 다룬다 — 거절이 흘러 'load' 에 갇혔다
|
|
1096
|
-
let
|
|
1096
|
+
let got;
|
|
1097
1097
|
try {
|
|
1098
|
-
|
|
1098
|
+
got = await fetchAndPrepare(true);
|
|
1099
1099
|
}
|
|
1100
1100
|
catch (e) {
|
|
1101
|
-
|
|
1101
|
+
got = false;
|
|
1102
1102
|
}
|
|
1103
1103
|
/* 쓸 소스를 하나도 못 얻으면(경로를 못 정한 코드뿐 등) 'load' 에 갇혀 듣기 단추가 사라지고 아무 조작도
|
|
1104
1104
|
안 됐다 — 알리고 누를 수 있는 상태로 되돌린다. */
|
|
1105
|
-
if (
|
|
1105
|
+
if (got === false && mountedRef.current) {
|
|
1106
1106
|
try {
|
|
1107
1107
|
setNoticeMessage(t('training.audioFetchError', '사운드 소스를 가져오지 못했습니다.'));
|
|
1108
1108
|
}
|
|
@@ -1116,7 +1116,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
1116
1116
|
if (stateLocal === "playMode" || stateLocal === "active") {
|
|
1117
1117
|
try {
|
|
1118
1118
|
if (a)
|
|
1119
|
-
await
|
|
1119
|
+
await toggleElement(a);
|
|
1120
1120
|
}
|
|
1121
1121
|
catch (e) {
|
|
1122
1122
|
const errName = e instanceof Error ? e.name : (typeof e === 'object' && e && 'name' in e ? e.name : undefined);
|
|
@@ -1153,8 +1153,8 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
1153
1153
|
};
|
|
1154
1154
|
const handleStop = () => {
|
|
1155
1155
|
// 기다리던 트랙 이동·재시도가 정지 뒤에 소리를 내지 않게
|
|
1156
|
-
|
|
1157
|
-
|
|
1156
|
+
abortIntent();
|
|
1157
|
+
releaseSound();
|
|
1158
1158
|
/* **`ttsMode` 라고 `<audio>` 를 안 멈추면 안 된다**(2026-10-06). 전에는 여기서 `stopTTS()`
|
|
1159
1159
|
만 하고 **일찍 돌아갔다.** 그런데 TTS 에는 길이 둘이다 — 리졸버가 있으면 **주소를 받아
|
|
1160
1160
|
`<audio>` 로 튼다**(그 길에 뜸·파형·번호 단추가 다 달려 있다). 기기 목소리로 말할 때만
|
|
@@ -1182,7 +1182,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
1182
1182
|
setPlaybackState('stopped');
|
|
1183
1183
|
setStateLocal('active');
|
|
1184
1184
|
setProgress(0);
|
|
1185
|
-
|
|
1185
|
+
setShowRange(false);
|
|
1186
1186
|
};
|
|
1187
1187
|
/**
|
|
1188
1188
|
* 트랙을 갈아 끼우고 재생한다 — 이전·다음·번호 단추가 모두 이 한 벌을 쓴다.
|
|
@@ -1207,28 +1207,28 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
1207
1207
|
`a.src` 를 다음 소리로 바꿔 놓아서다.
|
|
1208
1208
|
그 토큰은 **세대와 같은 일**이었다((85) 2d) — `멈춤의도()` 가 둘 다 올리고 여기서
|
|
1209
1209
|
둘 다 집었다. 세대 하나로 합친다: `abort()` 로 걷고, **그 뒤에** 제 세대를 집는다. */
|
|
1210
|
-
|
|
1211
|
-
const
|
|
1210
|
+
abortIntent();
|
|
1211
|
+
const myGeneration = sequencer.generation();
|
|
1212
1212
|
const a = audioRef.current;
|
|
1213
1213
|
const sref = sourcesRef.current;
|
|
1214
1214
|
const src = sref?.[index]?.src;
|
|
1215
1215
|
try {
|
|
1216
1216
|
markCompletedByEnd(false);
|
|
1217
1217
|
setCurrentIndex(index);
|
|
1218
|
-
|
|
1218
|
+
setShowRange(true); // 재생이 시작되기 전에도 밝힌다 — 예전엔 효과가 해 주던 일
|
|
1219
1219
|
currentIndexRef.current = index;
|
|
1220
1220
|
if (!src || !a)
|
|
1221
1221
|
return;
|
|
1222
1222
|
// 같은 구간을 다시 트는 것이면 파형은 그대로다 — 괜히 기다리지 않는다.
|
|
1223
1223
|
if (!sameSegment)
|
|
1224
1224
|
markWaveNotReady();
|
|
1225
|
-
|
|
1225
|
+
loadSound(a, src);
|
|
1226
1226
|
if (!sameSegment && (await waitForWaveReady()) === false)
|
|
1227
1227
|
return;
|
|
1228
|
-
if (
|
|
1228
|
+
if (!sequencer.isCurrent(myGeneration))
|
|
1229
1229
|
return;
|
|
1230
1230
|
await tryPlayWithRetry(a);
|
|
1231
|
-
if (
|
|
1231
|
+
if (!sequencer.isCurrent(myGeneration))
|
|
1232
1232
|
return;
|
|
1233
1233
|
setPlaybackState('playing');
|
|
1234
1234
|
setStateLocal('playMode');
|
|
@@ -1269,8 +1269,8 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
1269
1269
|
const holdDelay = 200;
|
|
1270
1270
|
const outDelay = 340;
|
|
1271
1271
|
markCompletedByEnd(true);
|
|
1272
|
-
|
|
1273
|
-
|
|
1272
|
+
abortIntent();
|
|
1273
|
+
releaseSound();
|
|
1274
1274
|
try {
|
|
1275
1275
|
setProgress(1);
|
|
1276
1276
|
}
|
|
@@ -1305,7 +1305,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
1305
1305
|
setPlaybackState('stopped');
|
|
1306
1306
|
}
|
|
1307
1307
|
catch (e) { }
|
|
1308
|
-
|
|
1308
|
+
setShowRange(false);
|
|
1309
1309
|
if (disabledRef.current)
|
|
1310
1310
|
return;
|
|
1311
1311
|
try {
|
|
@@ -1360,10 +1360,10 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
1360
1360
|
try {
|
|
1361
1361
|
const a = audioRef.current;
|
|
1362
1362
|
if (a && typeof a.playbackRate === 'number')
|
|
1363
|
-
|
|
1363
|
+
applyPlaybackRate(a);
|
|
1364
1364
|
}
|
|
1365
1365
|
catch (e) { }
|
|
1366
|
-
}, [playbackRate,
|
|
1366
|
+
}, [playbackRate, applyPlaybackRate]);
|
|
1367
1367
|
// 문장(메인) 볼륨 반영 — 문제 소리 볼륨(effectiveSpeechVolume = 전체 소리). setElementVolume 이 그래프에 태운
|
|
1368
1368
|
// 요소면 GainNode, 아니면 요소 volume 에 건다(iOS 실기기는 요소 volume 이 안 먹는다). 재생 중이면 소음과 같은 150ms 램프로 옮기고, 멈춰 있으면 바로 건다.
|
|
1369
1369
|
// 백그라운드 탭에서는 RAF 가 멈춰 램프가 중간에 설 수 있으므로 끝값을 타이머로 한 번 더 박는다.
|
|
@@ -1374,7 +1374,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
1374
1374
|
return;
|
|
1375
1375
|
const target = Math.max(0, Math.min(1, speechVolume));
|
|
1376
1376
|
const ramp = speechRampRef.current;
|
|
1377
|
-
const
|
|
1377
|
+
const detach = () => {
|
|
1378
1378
|
if (ramp.raf !== null) {
|
|
1379
1379
|
cancelAnimationFrame(ramp.raf);
|
|
1380
1380
|
ramp.raf = null;
|
|
@@ -1384,7 +1384,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
1384
1384
|
ramp.timer = null;
|
|
1385
1385
|
}
|
|
1386
1386
|
};
|
|
1387
|
-
|
|
1387
|
+
detach();
|
|
1388
1388
|
const from = getElementVolume(a);
|
|
1389
1389
|
if (a.paused || Math.abs(from - target) < 0.001 || typeof requestAnimationFrame !== 'function') {
|
|
1390
1390
|
try {
|
|
@@ -1403,11 +1403,11 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
1403
1403
|
ramp.raf = t < 1 ? requestAnimationFrame(step) : null;
|
|
1404
1404
|
};
|
|
1405
1405
|
ramp.raf = requestAnimationFrame(step);
|
|
1406
|
-
ramp.timer = setTimeout(() => {
|
|
1406
|
+
ramp.timer = setTimeout(() => { detach(); try {
|
|
1407
1407
|
setElementVolume(a, target);
|
|
1408
1408
|
}
|
|
1409
1409
|
catch (e) { } }, 200);
|
|
1410
|
-
return
|
|
1410
|
+
return detach;
|
|
1411
1411
|
}, [speechVolume]);
|
|
1412
1412
|
// fill (진행 마커) 클래스 및 초기 위치 계산 — RAF 루프에서 .style.left를 직접 갱신합니다.
|
|
1413
1413
|
const isFillHolding = fillCompletionPhase === 'hold';
|
|
@@ -1426,12 +1426,12 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
1426
1426
|
누르면 소리를 받으러 가거나 "가져오지 못했습니다" 를 띄웠다 — 마우스와 같게 키보드도 막는다 */
|
|
1427
1427
|
disabled: isDisabled,
|
|
1428
1428
|
// 키보드로 눌렀을 때만 초점을 옮긴다 — 마우스로 누른 사람에게 '재생 / 멈춤' 초점 툴팁이 뜨지 않게
|
|
1429
|
-
onClick: () => {
|
|
1429
|
+
onClick: () => { moveFocusToListenRef.current = wasKeyboardPress(); hideListenTip(); handleToggle(); }, "aria-label": typeof label === 'string' ? label : t('training.listen', '듣기'), onMouseEnter: showListenTip, onMouseLeave: hideListenTip,
|
|
1430
1430
|
// 키보드로 이 버튼에 오면 마우스와 동일하게 안내를 띄운다
|
|
1431
1431
|
onFocus: showListenTip, onBlur: hideListenTip, children: _jsx("span", { ref: listenBtnRef, className: "audio-player__button-triangle-wrap", children: _jsx("svg", { className: "audio-player__button-triangle", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { ref: triPathRef, d: "M13.2 7.7 L16.5 9.85 Q20.2 12 16.5 14.15 L10 18.3 Q7 19.7 7 16.3 L7 7.7 Q7 4.3 10 5.7 L13.2 7.7" }) }) }) })) : null, listenBtnRect && (_jsx(InfoTooltip, { anchorRect: listenBtnRect, text: typeof label === 'string' && label ? label : t('training.listenLabel', '문장 듣기'), offset: 8 })), _jsxs("div", { className: `audio-player__play ${stateLocal === "playMode" || displayedPlaying ? "is-visible" : ""}`, inert: !(stateLocal === "playMode" || displayedPlaying) || undefined, children: [_jsxs("div", { ref: btnsRef, className: "audio-player__buttons", children: [_jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "skip-back", onClick: handlePrev, ariaLabel: t('training.prev', '이전'), width: "60px", variant: "transparent-white", "aria-hidden": "true", tabIndex: -1, disabled: stateLocal !== 'playMode' }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: displayedPlaying ? "pause" : "play", onClick: handleToggle, ariaLabel: t('common.playPause', '재생 / 멈춤'), "aria-pressed": displayedPlaying, width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "square", onClick: handleStop, ariaLabel: t('training.stop', '정지'), width: "60px", variant: "transparent-white", "aria-hidden": "true", tabIndex: -1, disabled: stateLocal !== 'playMode' }), sourceButtonCount > 1 ? (_jsx("div", { ref: sourceButtonsRef, className: "audio-player__source-buttons", children: Array.from({ length: sourceButtonCount }).map((_, idx) => (_jsx(Button, { htmlType: "button", size: 32, type: "dot", shape: "pill", useBorder: false, label: String(idx + 1), onClick: () => { try {
|
|
1432
1432
|
handleJumpTo(idx);
|
|
1433
1433
|
}
|
|
1434
|
-
catch (e) { } }, ariaLabel: t('training.sourcePlay', { n: idx + 1 }), variant: "transparent-white", selected:
|
|
1434
|
+
catch (e) { } }, ariaLabel: t('training.sourcePlay', { n: idx + 1 }), variant: "transparent-white", selected: showRange && currentIndex === idx, disabled: stateLocal !== 'playMode' || !(Array.isArray(sources) && sources[idx]), showTooltipInfo: true }, idx))) })) : null, (!Array.isArray(sources) || sources.length <= 1) ? (_jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "repeat", onClick: handleReplay, ariaLabel: t('common.replay', '다시듣기'), width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' })) : null, _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "skip-forward", onClick: handleNext, ariaLabel: t('training.next', '다음'), width: "60px", variant: "transparent-white", "aria-hidden": true, tabIndex: -1, disabled: stateLocal !== 'playMode' }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "check", onClick: handleEnd, ariaLabel: t('common.done', '완료'), width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' })] }), _jsxs("div", { className: "audio-player__slider", children: [_jsx("div", { className: fillClass, ref: fillRef, style: { left: `${fillLeftPercent}%` }, "aria-hidden": "true" }), (showSoundWaveForm || showNoiseWaveForm) && (_jsx(WaveForm, { segments: currentSegments, progress: progress, totalDuration: totalDuration, waveProgressRef: waveProgressRef, showSoundWaveForm: showSoundWaveForm,
|
|
1435
1435
|
/* 뜸도 파형에 그린다((74)) — 2초 단어에 앞뒤 1초면 4초가 한 폭에 들어간다 */
|
|
1436
1436
|
leadInMs: leadInMs, tailMs: tailMs, trackDurationSec: sources?.[currentIndex]?.duration ?? 0, noiseSrc: (showNoiseWaveForm && noisePlayer?.settings.enabled && noiseVolume > 0) ? activeNoiseUrl : null, noiseRatio: noisePlayer ? noiseRatioOf(noisePlayer.settings) : 0, noiseSpeed: noisePlayer?.settings.speed ?? 1,
|
|
1437
1437
|
/* 그림은 "문장 = 기준" — 문제 소리(M÷R)가 아니라 전체 소리 M 으로 문장을 그리고 소음을 R 배로 키운다 */
|