Files
shoptime/com.twin.app.shoptime/vui-implement.md
optrader 9dd5897c24 [251016] fix: VoiceInputOverlay ShopperHouse connect
🕐 커밋 시간: 2025. 10. 16. 10:26:16

📊 변경 통계:
  • 총 파일: 7개
  • 추가: +65줄
  • 삭제: -36줄

📁 추가된 파일:
  + com.twin.app.shoptime/vui-implement.md
  + com.twin.app.shoptime/vui-react.md

📝 수정된 파일:
  ~ com.twin.app.shoptime/src/actions/actionTypes.js
  ~ com.twin.app.shoptime/src/actions/searchActions.js
  ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx
  ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceListening.module.less
  ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoicePromptScreen.jsx

🔧 함수 변경 내용:
  📄 com.twin.app.shoptime/src/actions/searchActions.js (javascript):
    🔄 Modified: updateSearchTimestamp()
  📄 com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceListening.module.less (unknown):
     Added: scaleY()
  📄 com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoicePromptScreen.jsx (javascript):
     Added: VoicePromptScreen()
     Deleted: SpotlightContainerDecorator()
  📄 com.twin.app.shoptime/vui-implement.md (md파일):
     Added: dispatch(), Date(), useDispatch(), useSelector(), useEffect(), onSTTText(), SearchPanel(), useState(), useCallback(), setSearchQuery(), getSearch(), setIsVoiceOverlayVisible(), useSearchVoice(), setVoiceMode(), handleSearchSubmit(), onSearchChange(), setCurrentMode(), setTimeout(), onClose(), stopPropagation(), Search(), App(), getLaunchParams(), clearLaunchParams(), pushPanel(), Input(), registerVoiceFramework(), performAction(), handleSTTText()
  📄 com.twin.app.shoptime/vui-react.md (md파일):
     Added: Interface(), Search(), Input(), function(), register(), App(), useDispatch(), useCallback(), getLaunchParams(), clearLaunchParams(), dispatch(), pushPanel(), useEffect(), SearchPanel(), getSearch(), onSuccess(), cancel(), LS2Request(), onCommand(), onFailure(), instance(), deleteInstance(), useRef(), onVoiceInput(), reportActionResult(), registerVoiceConductor(), setVoiceContext(), unregisterVoiceConductor(), setSearchQuery(), useVoiceConductor(), handleSearchSubmit(), setContext()

🔧 주요 변경 내용:
  • 타입 시스템 안정성 강화
  • 핵심 비즈니스 로직 개선
  • 개발 문서 및 가이드 개선
2025-10-16 10:26:18 +09:00

17 KiB
Raw Blame History

📋 구현 계획

● 이제 각 단계별로 자세히 설명드리겠습니다.


🔧 구체적인 구현 방법

1 voiceActions.js에 UseIME Intent 추가

현재 문제: sendVoiceIntents 함수가 Select, Scroll만 등록하고 UseIME는 없음

해결 방법: UseIME Intent를 추가하고, STT 텍스트를 Redux로 dispatch

// src/actions/voiceActions.js - sendVoiceIntents 수정

export const sendVoiceIntents = (voiceTicket) => (dispatch, getState) => { console.log('[Voice] Sending voice intents...');

const inAppIntents = [
  // ⭐ UseIME Intent 추가 - 음성 입력 텍스트 받기
  {
    intent: 'UseIME',
    supportAsrOnly: true,  // STT만 사용 (NLP 불필요)
  },
  // 기존 intents...
  {
    intent: 'Select',
    supportOrdinal: true,
    items: [
      // ... 기존 items
    ],
  },
  {
    intent: 'Scroll',
    supportOrdinal: false,
    items: [
      // ... 기존 items
    ],
  },
];

// ... 나머지 코드 동일

};

handleVoiceAction 수정 - UseIME 처리 추가:

// src/actions/voiceActions.js

export const handleVoiceAction = (voiceTicket, action) => (dispatch, getState) => { console.log('[Voice] Handling voice action:', action);

let result = false;
let feedback = null;

try {
  // ⭐ UseIME Intent 처리 추가
  if (action.intent === 'UseIME' && action.value) {
    console.log('[Voice] STT Text received:', action.value);

    // STT 텍스트를 Redux로 dispatch
    dispatch({
      type: types.VOICE_STT_TEXT_RECEIVED,
      payload: action.value,
    });

    result = true;
    feedback = {
      voiceUi: {
        systemUtterance: `Searching for ${action.value}`,
      },
    };
  }
  else if (action.intent === 'Select' && action.itemId) {
    result = dispatch(handleSelectIntent(action.itemId));
  }
  else if (action.intent === 'Scroll' && action.itemId) {
    result = dispatch(handleScrollIntent(action.itemId));
  }
  else {
    console.warn('[Voice] Unknown intent:', action);
    result = false;
    feedback = {
      voiceUi: {
        systemUtterance: 'This action is not supported',
      },
    };
  }
} catch (error) {
  console.error('[Voice] Error processing action:', error);
  result = false;
}

dispatch(reportActionResult(voiceTicket, result, feedback));

};


2 actionTypes.js에 새 타입 추가

// src/actions/actionTypes.js

export const types = { // ... 기존 types

// Voice 관련
VOICE_REGISTER_SUCCESS: 'VOICE_REGISTER_SUCCESS',
VOICE_REGISTER_FAILURE: 'VOICE_REGISTER_FAILURE',
VOICE_SET_TICKET: 'VOICE_SET_TICKET',
VOICE_SET_CONTEXT_SUCCESS: 'VOICE_SET_CONTEXT_SUCCESS',
VOICE_SET_CONTEXT_FAILURE: 'VOICE_SET_CONTEXT_FAILURE',
VOICE_UPDATE_INTENTS: 'VOICE_UPDATE_INTENTS',
VOICE_PERFORM_ACTION: 'VOICE_PERFORM_ACTION',
VOICE_REPORT_RESULT_SUCCESS: 'VOICE_REPORT_RESULT_SUCCESS',
VOICE_REPORT_RESULT_FAILURE: 'VOICE_REPORT_RESULT_FAILURE',
VOICE_CLEAR_STATE: 'VOICE_CLEAR_STATE',
VOICE_ADD_LOG: 'VOICE_ADD_LOG',
VOICE_CLEAR_LOGS: 'VOICE_CLEAR_LOGS',

// ⭐ 새로 추가
VOICE_STT_TEXT_RECEIVED: 'VOICE_STT_TEXT_RECEIVED',

};


3 voiceReducer.js 수정

// src/reducers/voiceReducer.js

const initialState = { // ... 기존 state

// ⭐ STT 텍스트 state 추가
lastSTTText: null,
sttTimestamp: null,

};

export const voiceReducer = (state = initialState, action) => { switch (action.type) { // ... 기존 cases

  // ⭐ STT 텍스트 수신 처리
  case types.VOICE_STT_TEXT_RECEIVED:
    return {
      ...state,
      lastSTTText: action.payload,
      sttTimestamp: new Date().toISOString(),
    };

  case types.VOICE_CLEAR_STATE:
    return {
      ...initialState,
    };

  default:
    return state;
}

};


4 SearchPanel에서 VUI 통합

방법 1: Custom Hook 생성 (권장)

// src/hooks/useSearchVoice.js

import { useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { registerVoiceFramework, unregisterVoiceFramework } from '../actions/voiceActions';

/**

  • SearchPanel용 음성 입력 Hook
    • SearchPanel이 foreground일 때 voice framework 등록
    • STT 텍스트를 자동으로 searchQuery로 설정 */ export const useSearchVoice = (isOnTop, onSTTText) => { const dispatch = useDispatch(); const { lastSTTText, sttTimestamp } = useSelector((state) => state.voice);
// SearchPanel이 foreground일 때만 voice 등록
useEffect(() => {
  if (isOnTop) {
    console.log('[useSearchVoice] Registering voice framework');
    dispatch(registerVoiceFramework());
  } else {
    console.log('[useSearchVoice] Unregistering voice framework');
    dispatch(unregisterVoiceFramework());
  }

  // Cleanup on unmount
  return () => {
    dispatch(unregisterVoiceFramework());
  };
}, [isOnTop, dispatch]);

// STT 텍스트 수신 처리
useEffect(() => {
  if (lastSTTText && sttTimestamp) {
    console.log('[useSearchVoice] STT text received:', lastSTTText);
    onSTTText && onSTTText(lastSTTText);
  }
}, [lastSTTText, sttTimestamp, onSTTText]);

};

SearchPanel.jsx 수정:

// src/views/SearchPanel/SearchPanel.jsx

import React, { useCallback, useState } from 'react'; import { useSearchVoice } from '../../hooks/useSearchVoice'; import VoiceInputOverlay, { VOICE_MODES } from './VoiceInputOverlay/VoiceInputOverlay';

export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) { const dispatch = useDispatch(); const [searchQuery, setSearchQuery] = useState(panelInfo.searchVal || '');

// ⭐ Voice Overlay 상태 관리
const [isVoiceOverlayVisible, setIsVoiceOverlayVisible] = useState(false);
const [voiceMode, setVoiceMode] = useState(VOICE_MODES.PROMPT);

// ⭐ STT 텍스트 수신 핸들러
const handleSTTText = useCallback((sttText) => {
  console.log('[SearchPanel] STT text received:', sttText);

  // 1. searchQuery 업데이트
  setSearchQuery(sttText);

  // 2. 자동 검색 실행
  if (sttText && sttText.trim()) {
    dispatch(
      getSearch({
        service: 'com.lgshop.app',
        query: sttText.trim(),
        domain: 'theme,show,item',
      })
    );
  }

  // 3. Voice Overlay 닫기 (선택사항)
  setIsVoiceOverlayVisible(false);
}, [dispatch]);

// ⭐ Voice Hook 활성화
useSearchVoice(isOnTop, handleSTTText);

// 마이크 버튼 클릭 핸들러
const handleMicButtonClick = useCallback(() => {
  console.log('[SearchPanel] Mic button clicked');
  setVoiceMode(VOICE_MODES.PROMPT);
  setIsVoiceOverlayVisible(true);
}, []);

const handleVoiceOverlayClose = useCallback(() => {
  console.log('[SearchPanel] Voice overlay closed');
  setIsVoiceOverlayVisible(false);
}, []);

return (
  <TPanel className={css.container} handleCancel={onCancel} spotlightId={spotlightId}>
    <TBody className={css.tBody}>
      {/* 기존 SearchPanel UI */}
      <TInput
        value={searchQuery}
        onChange={handleSearchChange}
        onIconClick={() => handleSearchSubmit(searchQuery)}
        // ... props
      />

      {/* ⭐ 마이크 버튼 추가 (선택사항) */}
      <button onClick={handleMicButtonClick}>
        🎤 Voice Search
      </button>

      {/* ⭐ Voice Overlay */}
      <VoiceInputOverlay
        isVisible={isVoiceOverlayVisible}
        onClose={handleVoiceOverlayClose}
        mode={voiceMode}
        searchQuery={searchQuery}
        onSearchChange={(e) => setSearchQuery(e.value)}
        onSearchSubmit={handleSearchSubmit}
        suggestions={paginatedKeywords?.map(k => k.keyword) || []}
      />

      {/* 검색 결과 등 나머지 UI */}
    </TBody>
  </TPanel>
);

}


5 VoiceInputOverlay 개선

마이크 버튼 클릭 시 실제 음성 입력 시작:

// src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx

import { useSelector } from 'react-redux';

const VoiceInputOverlay = ({ isVisible, onClose, mode, suggestions, searchQuery, onSearchChange, onSearchSubmit, }) => { const [currentMode, setCurrentMode] = useState(mode);

// ⭐ Redux에서 voice 상태 가져오기
const { isRegistered, lastSTTText, sttTimestamp } = useSelector((state) => state.voice);

// ⭐ STT 텍스트 수신 시 listening 모드로 전환
useEffect(() => {
  if (lastSTTText && isVisible) {
    console.log('[VoiceInputOverlay] STT text received in overlay:', lastSTTText);

    // 입력창에 텍스트 표시 (부모 컴포넌트로 전달)
    if (onSearchChange) {
      onSearchChange({ value: lastSTTText });
    }

    // listening 모드로 전환 (시각적 피드백)
    setCurrentMode(VOICE_MODES.LISTENING);

    // 1초 후 자동 닫기 (선택사항)
    setTimeout(() => {
      onClose();
    }, 1000);
  }
}, [lastSTTText, sttTimestamp, isVisible, onSearchChange, onClose]);

// 마이크 버튼 클릭 핸들러
const handleMicClick = useCallback((e) => {
  e?.stopPropagation();

  if (currentMode === VOICE_MODES.PROMPT) {
    // prompt 모드에서 클릭 시 -> listening 모드로 전환
    console.log('[VoiceInputOverlay] Switching to LISTENING mode');
    setCurrentMode(VOICE_MODES.LISTENING);

    // 이 시점에서 webOS Voice Framework가 자동으로 음성 인식 시작
    // (이미 registerVoiceFramework()로 등록되어 있으므로)
  } else if (currentMode === VOICE_MODES.LISTENING) {
    // listening 모드에서 클릭 시 -> 종료
    console.log('[VoiceInputOverlay] Closing from LISTENING mode');
    onClose();
  }
}, [currentMode, onClose]);

return (
  <TFullPopup open={isVisible} onClose={onClose}>
    {/* 기존 UI */}

    {/* Voice 등록 상태 표시 (디버깅용, 나중에 제거 가능) */}
    {__DEV__ && (
      <div style={{ position: 'absolute', top: 10, right: 10, color: '#fff' }}>
        Voice: {isRegistered ? '✓ Ready' : '✗ Not Ready'}
      </div>
    )}

    {/* 나머지 UI */}
  </TFullPopup>
);

};


6 Global Voice Search (handleRelaunch) 구현

App.js 수정:

// src/App/App.js

import React, { useCallback, useEffect } from 'react'; import { useDispatch } from 'react-redux'; import { pushPanel } from '../actions/panelActions'; import { panel_names } from '../utils/Config';

function App() { const dispatch = useDispatch();

// ⭐ webOSRelaunch 이벤트 핸들러
const handleRelaunchEvent = useCallback(() => {
  console.log('[App] handleRelaunchEvent triggered');

  const launchParams = getLaunchParams();
  clearLaunchParams();

  if (!launchParams) return;

  // ========================================
  // ⭐ Voice Intent 처리 (최우선)
  // ========================================
  if (launchParams.intent) {
    const { intent, intentParam, languageCode } = launchParams;
    console.log('[App] Voice Intent received:', { intent, intentParam, languageCode });

    // SearchContent 또는 PlayContent intent 처리
    if (intent === 'SearchContent' || intent === 'PlayContent') {
      dispatch(
        pushPanel({
          name: panel_names.SEARCH_PANEL,
          panelInfo: {
            voiceSearch: true,      // 음성 검색 플래그
            searchVal: intentParam, // 검색어
            languageCode: languageCode,
          },
        })
      );

      console.log(`[App] Opening SearchPanel with voice query: ${intentParam}`);
      return;
    }
  }

  // ========================================
  // 기존 deeplink 처리
  // ========================================
  if (launchParams.contentTarget) {
    console.log('[App] DeepLink:', launchParams.contentTarget);
    // dispatch(handleDeepLink(launchParams.contentTarget));
  }
}, [dispatch]);

// ⭐ webOSRelaunch 이벤트 리스너 등록
useEffect(() => {
  document.addEventListener('webOSRelaunch', handleRelaunchEvent);

  return () => {
    document.removeEventListener('webOSRelaunch', handleRelaunchEvent);
  };
}, [handleRelaunchEvent]);

return (
  <div className="app">
    {/* 앱 컴포넌트 */}
  </div>
);

}

// Launch 파라미터 헬퍼 함수 function getLaunchParams() { if (window.PalmSystem) { try { const params = JSON.parse(window.PalmSystem.launchParams || '{}'); console.log('[App] Launch params:', params); return params; } catch (e) { console.error('[App] Failed to parse launch params:', e); return null; } } return null; }

function clearLaunchParams() { if (window.PalmSystem) { window.PalmSystem.launchParams = '{}'; } }

export default App;


🎯 전체 흐름 요약

Foreground Voice Input (VoiceInputOverlay)

  1. SearchPanel이 foreground가 됨 (isOnTop = true) ↓
  2. useSearchVoice Hook이 registerVoiceFramework() dispatch ↓
  3. voiceActions.js에서 luna://com.webos.service.voiceconductor/interactor/register 호출 ↓
  4. voiceTicket 수신 ↓
  5. setContext command 수신 → sendVoiceIntents() 자동 호출 ↓
  6. UseIME Intent 등록 완료 ↓
  7. 사용자가 마이크 버튼 클릭 → VoiceInputOverlay 표시 ↓
  8. 사용자가 음성 발화 (예: "iPhone") ↓
  9. performAction command 수신: { command: "performAction", action: { intent: "UseIME", value: "iPhone" } } ↓
  10. handleVoiceAction()에서 VOICE_STT_TEXT_RECEIVED dispatch ↓
  11. useSearchVoice의 useEffect가 lastSTTText 감지 ↓
  12. onSTTText('iPhone') 호출 ↓
  13. SearchPanel의 handleSTTText에서:
    • setSearchQuery('iPhone')
    • getSearch() dispatch
    • VoiceInputOverlay 닫기

Global Voice Search

  1. 사용자가 리모컨 음성 버튼으로 "Search for iPhone on Shop Time" 발화 ↓
  2. webOS가 앱 실행하면서 webOSRelaunch 이벤트 발생 ↓
  3. App.js의 handleRelaunchEvent에서: launchParams = { intent: "SearchContent", intentParam: "iPhone", languageCode: "en-US" } ↓
  4. SearchPanel을 pushPanel하면서 panelInfo에 전달: { voiceSearch: true, searchVal: "iPhone", languageCode: "en-US" } ↓
  5. SearchPanel이 마운트되면서 panelInfo.voiceSearch 감지 ↓
  6. 자동으로 getSearch('iPhone') dispatch

📝 체크리스트

각 단계가 완료되었는지 확인해보세요:

● --- 🔑 핵심 포인트

  1. UseIME이 STT 텍스트를 받는 핵심

// Intent 등록 { intent: "UseIME", supportAsrOnly: true // 순수 STT만 (NLP 불필요) }

// performAction 수신 { action: { intent: "UseIME", value: "사용자가 말한 텍스트" // 이게 핵심! } }

  1. Voice 등록은 Foreground 패널에서만
  • SearchPanel이 isOnTop = true일 때만 registerVoiceFramework() 호출
  • 다른 패널로 이동하면 unregisterVoiceFramework() 호출
  • 이미 VoicePanel에서 기본 구조는 구현되어 있으므로, 이를 SearchPanel에 복사/적용하면 됨
  1. VoiceInputOverlay는 UI만 담당
  • VoiceInputOverlay는 시각적 피드백(prompt/listening)만 표시
  • 실제 voice framework 등록은 SearchPanel에서 담당
  • Redux의 lastSTTText 변경을 감지해서 UI 업데이트
  1. Redux를 통한 상태 공유

registerVoiceFramework() ↓ voiceTicket 수신 → Redux에 저장 ↓ performAction (UseIME) 수신 → Redux에 lastSTTText 저장 ↓ SearchPanel의 useEffect가 lastSTTText 감지 ↓ handleSTTText() 호출 → 검색 실행


🚀 다음 단계

  1. 먼저 voiceActions.js 수정 - UseIME Intent 추가 (가장 중요!)
  2. actionTypes 및 reducer 업데이트 - STT 텍스트 state 관리
  3. useSearchVoice Hook 구현 - 재사용 가능한 hook
  4. SearchPanel 통합 - useSearchVoice 사용
  5. VoiceInputOverlay 개선 - Redux 연결
  6. App.js handleRelaunch - Global Voice Search
  7. 테스트 - VoicePanel에서 먼저 테스트 후 SearchPanel 적용

💡 디버깅 팁

Voice 등록 확인

// VoicePanel에서 "Register" 버튼 클릭 // Logs에서 확인: // 1. REQUEST: Register Voice Framework // 2. RESPONSE: { returnValue: true, subscribed: true } // 3. COMMAND: setContext Command Received (voiceTicket 있음) // 4. REQUEST: Set Voice Context (UseIME intent 확인) // 5. RESPONSE: Set Voice Context Success

STT 텍스트 수신 확인

// 마이크 버튼 클릭 후 발화하면: // Logs에서 확인: // 1. COMMAND: performAction Command Received // 2. action: { intent: "UseIME", value: "발화 텍스트" } // 3. Redux devtools에서 VOICE_STT_TEXT_RECEIVED 액션 확인