diff --git a/com.twin.app.shoptime/luna.md b/com.twin.app.shoptime/luna.md new file mode 100644 index 00000000..bb3d1fb9 --- /dev/null +++ b/com.twin.app.shoptime/luna.md @@ -0,0 +1,439 @@ +# [251014] webOS Luna Service 호출 메커니즘 분석 + +## 1. 개요 + +이 프로젝트는 webOS TV 애플리케이션으로, **Luna Service**를 통해 webOS 시스템의 다양한 기능과 통신합니다. Luna Service는 webOS의 서비스 버스 아키텍처로, 애플리케이션이 시스템 서비스에 접근할 수 있게 해주는 IPC(Inter-Process Communication) 메커니즘입니다. + +## 2. 아키텍처 구조 + +### 2.1 핵심 컴포넌트 + +``` +src/lunaSend/ +├── LS2Request.js # Enact LS2Request 래퍼 +├── LS2RequestSingleton.js # 싱글톤 패턴 구현 +├── index.js # 모듈 export 및 취소 함수 +├── common.js # 공통 Luna Service 호출 함수들 +├── account.js # 계정 관련 Luna Service 호출 함수들 +└── lunaTest.js # 테스트용 파일 +``` + +### 2.2 계층 구조 + +``` +Application Layer (React Components/Actions) + ↓ +Wrapper Functions (lunaSend/common.js, lunaSend/account.js) + ↓ +LS2Request Layer (LS2Request.js) + ↓ +@enact/webos/LS2Request (Enact Framework) + ↓ +PalmServiceBridge (webOS Native Bridge) + ↓ +Luna Service Bus (webOS System Services) +``` + +## 3. Luna Service 호출 메커니즘 + +### 3.1 LS2Request 래퍼 (`LS2Request.js`) + +```javascript +import LS2Request from '@enact/webos/LS2Request'; + +let request = LS2Request; +export {request}; +export default request; +``` + +- **역할**: Enact 프레임워크의 `@enact/webos/LS2Request` 모듈을 import하여 재export +- **목적**: 향후 mock 구현이나 개발 환경에서의 대체가 용이하도록 추상화 계층 제공 + +### 3.2 LS2RequestSingleton (`LS2RequestSingleton.js`) + +```javascript +import LS2Request from './LS2Request'; + +const ls2instances = {}; + +export const LS2RequestSingleton = { + instance: function (skey) { + ls2instances[skey] = ls2instances[skey] || new LS2Request(); + return ls2instances[skey]; + }, + deleteInstance: function (skey) { + ls2instances[skey] = null; + } +}; +``` + +- **패턴**: Singleton Factory 패턴 +- **기능**: + - 키별로 LS2Request 인스턴스를 관리 + - 동일한 키에 대해 재사용 가능한 인스턴스 제공 + - 인스턴스 삭제를 통한 메모리 관리 + +### 3.3 기본 호출 패턴 + +Luna Service 호출의 기본 구조는 다음과 같습니다: + +```javascript +new LS2Request().send({ + service: "luna://[service-name]", + method: "[method-name]", + parameters: { /* 파라미터 객체 */ }, + subscribe: true/false, // 구독 여부 + onSuccess: (response) => { /* 성공 콜백 */ }, + onFailure: (error) => { /* 실패 콜백 */ }, + onComplete: (response) => { /* 완료 콜백 */ } +}); +``` + +### 3.4 환경 감지 및 Mock 처리 + +모든 Luna Service 호출 함수는 다음과 같은 환경 감지 로직을 포함합니다: + +```javascript +if (typeof window === "object" && window.PalmSystem && + process.env.REACT_APP_MODE !== "DEBUG") { + // 실제 webOS 환경에서 Luna Service 호출 + return new LS2Request().send({ ... }); +} else { + // 개발 환경에서 mock 데이터 반환 + console.log("LUNA SEND [function-name]", ...); + return mockData; +} +``` + +- **`window.PalmSystem`**: webOS TV 환경에서만 존재하는 전역 객체 +- **`process.env.REACT_APP_MODE !== "DEBUG"`**: DEBUG 모드가 아닐 때만 실제 호출 + +## 4. 호출되는 Luna Service 목록 + +### 4.1 시스템 정보 및 설정 + +| Service URI | Method | 목적 | 파일 | +|------------|--------|------|------| +| `luna://com.webos.service.tv.systemproperty` | `getSystemInfo` | 시스템 정보 조회 | account.js | +| `luna://com.webos.settingsservice` | `getSystemSettings` | 시스템 설정 조회 (자막 등) | common.js | +| `luna://com.webos.service.sm` | `deviceid/getIDs` | 디바이스 ID 조회 | account.js | +| `luna://com.webos.service.sdx` | `getHttpHeaderForServiceRequest` | HTTP 헤더 정보 조회 (구독) | common.js | + +### 4.2 계정 관리 + +| Service URI | Method | 목적 | 파일 | +|------------|--------|------|------| +| `luna://com.webos.service.accountmanager` | `getLoginID` | 로그인 사용자 정보 조회 | account.js | + +### 4.3 네트워크 연결 + +| Service URI | Method | 목적 | 파일 | +|------------|--------|------|------| +| `luna://com.webos.service.connectionmanager` | `getStatus` | 연결 상태 조회 (구독) | common.js | +| `luna://com.webos.service.connectionmanager` | `getinfo` | 연결 정보 조회 | common.js | + +### 4.4 알림 (Notification) + +| Service URI | Method | 목적 | 파일 | +|------------|--------|------|------| +| `luna://com.webos.notification` | `createToast` | 토스트 메시지 생성 | common.js | +| `luna://com.webos.notification` | `enable` | 알림 활성화 | common.js | +| `luna://com.webos.notification` | `disable` | 알림 비활성화 | common.js | + +### 4.5 자막 (Subtitle) + +| Service URI | Method | 목적 | 파일 | +|------------|--------|------|------| +| `luna://com.webos.service.tv.subtitle` | `enableSubtitle` | 자막 활성화 (3.0~4.5) | common.js | +| `luna://com.webos.service.tv.subtitle` | `disableSubtitle` | 자막 비활성화 (3.0~4.5) | common.js | +| `luna://com.webos.media` | `setSubtitleEnable` | 자막 설정 (5.0+) | common.js | + +### 4.6 예약 (Reservation) + +| Service URI | Method | 목적 | 파일 | +|------------|--------|------|------| +| `luna://com.webos.service.tvReservationAgent` | `insert` | 예약 추가 | common.js | +| `luna://com.webos.service.tvReservationAgent` | `delete` | 예약 삭제 | common.js | + +### 4.7 데이터베이스 (DB8) + +| Service URI | Method | 목적 | 파일 | +|------------|--------|------|------| +| `luna://com.webos.service.db` | `find` | 데이터 조회 | common.js | +| `luna://com.webos.service.db` | `put` | 데이터 저장 | common.js | +| `luna://com.webos.service.db` | `delKind` | Kind 삭제 | common.js | +| `luna://com.palm.db` | `search` | 데이터 검색 | common.js | + +### 4.8 애플리케이션 실행 + +| Service URI | Method | 목적 | 파일 | +|------------|--------|------|------| +| `luna://com.webos.applicationManager` | `launch` | 멤버십 앱 실행 | account.js | + +## 5. 주요 사용 예제 + +### 5.1 단순 호출 (One-time Request) + +토스트 메시지 생성: + +```javascript +export const createToast = (message) => { + if (typeof window === "object" && !window.PalmSystem) { + console.log("LUNA SEND createToast message", message); + return; + } + return new LS2Request().send({ + service: "luna://com.webos.notification", + method: "createToast", + parameters: { + message: message, + iconUrl: "", + noaction: true, + }, + onSuccess: (res) => { + console.log("LUNA SEND createToast success", message); + }, + onFailure: (err) => { + console.log("LUNA SEND createToast failed", err); + }, + }); +}; +``` + +### 5.2 구독 (Subscribe) + +연결 상태 모니터링: + +```javascript +export const getConnectionStatus = ({ onSuccess, onFailure, onComplete }) => { + if (typeof window === "object" && !window.PalmSystem) { + return "Some Hard Coded Mock Data"; + } else { + return new LS2Request().send({ + service: "luna://com.webos.service.connectionmanager", + method: "getStatus", + subscribe: true, // 구독 모드 + parameters: {}, + onSuccess, + onFailure, + onComplete, + }); + } +}; +``` + +**특징**: +- `subscribe: true` 설정 시 상태 변화 시마다 onSuccess 콜백 호출 +- 반환된 핸들러를 통해 `.cancel()` 메서드로 구독 취소 가능 + +### 5.3 조건부 호출 + +자막 활성화/비활성화: + +```javascript +export const setSubtitleEnable = ( + mediaId, + captionEnable, + { onSuccess, onFailure, onComplete } +) => { + if (typeof window === "object" && window.PalmSystem && + process.env.REACT_APP_MODE !== "DEBUG") { + if (captionEnable) { + return new LS2Request().send({ + service: "luna://com.webos.service.tv.subtitle", + method: "enableSubtitle", + parameters: { pipelineId: mediaId }, + onSuccess, onFailure, onComplete, + }); + } else { + return new LS2Request().send({ + service: "luna://com.webos.service.tv.subtitle", + method: "disableSubtitle", + parameters: { pipelineId: mediaId }, + onSuccess, onFailure, onComplete, + }); + } + } +}; +``` + +### 5.4 구독 취소 + +```javascript +export const cancelReq = (instanceName) => { + let r = LS2RequestSingleton.instance(instanceName); + if (r) { + r.cancel(); + r.cancelled = false; + LS2RequestSingleton.deleteInstance(instanceName); + } +}; +``` + +### 5.5 Redux Action에서 사용 + +```javascript +export const alertToast = (payload) => (dispatch, getState) => { + if (typeof window === "object" && !window.PalmSystem) { + dispatch(changeAppStatus({ toast: true, toastText: payload })); + } else { + lunaSend.createToast(payload); + } +}; + +export const getSystemSettings = () => (dispatch, getState) => { + lunaSend.getSystemSettings( + { category: "caption", keys: ["captionEnable"] }, + { + onSuccess: (res) => {}, + onFailure: (err) => {}, + onComplete: (res) => { + if (res && res.settings) { + if (typeof res.settings.captionEnable !== "undefined") { + dispatch(changeAppStatus({ + captionEnable: res.settings.captionEnable === "on" || + res.settings.captionEnable === true, + })); + } + } + }, + } + ); +}; +``` + +## 6. 콜백 함수 패턴 + +Luna Service 호출은 3가지 콜백을 지원합니다: + +### 6.1 onSuccess +- **호출 시점**: 서비스 호출이 성공했을 때 +- **용도**: 성공 응답 데이터 처리 +- **구독 모드**: 데이터가 업데이트될 때마다 호출됨 + +### 6.2 onFailure +- **호출 시점**: 서비스 호출이 실패했을 때 +- **용도**: 에러 처리 및 로깅 + +### 6.3 onComplete +- **호출 시점**: 서비스 호출이 완료되었을 때 (성공/실패 무관) +- **용도**: 로딩 상태 해제, 리소스 정리 등 + +## 7. 개발 환경 지원 + +### 7.1 환경 감지 + +모든 Luna Service 호출 함수는 다음 조건을 확인합니다: + +```javascript +typeof window === "object" && window.PalmSystem && +process.env.REACT_APP_MODE !== "DEBUG" +``` + +- **webOS 실제 환경**: 모든 조건 충족 → 실제 Luna Service 호출 +- **개발 환경**: 조건 불충족 → Mock 데이터 반환 또는 콘솔 로그 + +### 7.2 Mock 데이터 예시 + +```javascript +// getLoginUserData에서 개발 환경용 mock 데이터 +const mockRes = { + HOST: "qt2-US.nextlgsdp.com", + "X-User-Number": "US2412306099093", + Authorization: "eyJ0eXAiOiJKV1QiLCJhbGci...", + // ... 기타 헤더 정보 +}; +onSuccess(mockRes); +``` + +## 8. 프로젝트 의존성 + +### 8.1 Enact Framework + +```json +"@enact/webos": "^3.3.0" +``` + +- **역할**: webOS 플랫폼 API 접근을 위한 Enact 프레임워크의 webOS 모듈 +- **제공**: LS2Request 클래스 및 webOS 관련 유틸리티 + +### 8.2 주요 특징 + +- React 기반 webOS TV 애플리케이션 +- Redux를 통한 상태 관리 +- Sandstone 테마 사용 + +## 9. 베스트 프랙티스 + +### 9.1 에러 처리 + +```javascript +lunaSend.getSystemSettings(parameters, { + onSuccess: (res) => { + // 성공 처리 + }, + onFailure: (err) => { + console.error("Luna Service Error:", err); + // 사용자에게 에러 메시지 표시 + }, + onComplete: (res) => { + // 로딩 상태 해제 + } +}); +``` + +### 9.2 구독 관리 + +```javascript +// 구독 시작 +let handler = lunaSend.getConnectionStatus({ + onSuccess: (res) => { + // 상태 업데이트 처리 + } +}); + +// 컴포넌트 언마운트 시 구독 취소 +useEffect(() => { + return () => { + if (handler) { + handler.cancel(); + } + }; +}, []); +``` + +### 9.3 싱글톤 사용 + +특정 서비스에 대해 중복 호출을 방지해야 하는 경우: + +```javascript +let httpHeaderHandler = null; + +export const getHttpHeaderForServiceRequest = ({ onSuccess }) => { + if (httpHeaderHandler) { + httpHeaderHandler.cancel(); // 기존 요청 취소 + } + httpHeaderHandler = new LS2Request().send({ + service: "luna://com.webos.service.sdx", + method: "getHttpHeaderForServiceRequest", + subscribe: true, + parameters: {}, + onSuccess, + }); + return httpHeaderHandler; +}; +``` + +## 10. 요약 + +이 프로젝트의 Luna Service 호출 메커니즘은 다음과 같은 특징을 가집니다: + +1. **계층화된 아키텍처**: Enact LS2Request → 커스텀 래퍼 → 비즈니스 로직 +2. **환경 분리**: webOS 실제 환경과 개발 환경을 자동으로 감지하여 처리 +3. **싱글톤 패턴**: 인스턴스 재사용을 통한 메모리 효율성 +4. **콜백 기반**: onSuccess, onFailure, onComplete 콜백으로 비동기 처리 +5. **구독 지원**: subscribe 옵션으로 실시간 데이터 업데이트 수신 +6. **타입 안전성**: 각 서비스 호출을 전용 함수로 래핑하여 타입 안전성 확보 +7. **재사용성**: common.js, account.js로 기능별 모듈화 + +이러한 구조를 통해 webOS 시스템 서비스와의 안정적이고 효율적인 통신을 구현하고 있습니다. diff --git a/com.twin.app.shoptime/src/actions/actionTypes.js b/com.twin.app.shoptime/src/actions/actionTypes.js index b9c8d5e1..0aeb2d3f 100644 --- a/com.twin.app.shoptime/src/actions/actionTypes.js +++ b/com.twin.app.shoptime/src/actions/actionTypes.js @@ -278,4 +278,18 @@ export const types = { GET_RECENTLY_SAW_ITEM: 'GET_RECENTLY_SAW_ITEM', GET_LIKE_BRAND_PRODUCT: 'GET_LIKE_BRAND_PRODUCT', GET_MORE_TO_CONCIDER_AT_THIS_PRICE: 'GET_MORE_TO_CONCIDER_AT_THIS_PRICE', + + // 🔽 Voice Conductor 관련 액션 타입 + 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_PERFORM_ACTION: 'VOICE_PERFORM_ACTION', + VOICE_REPORT_RESULT_SUCCESS: 'VOICE_REPORT_RESULT_SUCCESS', + VOICE_REPORT_RESULT_FAILURE: 'VOICE_REPORT_RESULT_FAILURE', + VOICE_UPDATE_INTENTS: 'VOICE_UPDATE_INTENTS', + VOICE_CLEAR_STATE: 'VOICE_CLEAR_STATE', + VOICE_ADD_LOG: 'VOICE_ADD_LOG', + VOICE_CLEAR_LOGS: 'VOICE_CLEAR_LOGS', }; diff --git a/com.twin.app.shoptime/src/actions/mediaActions.js b/com.twin.app.shoptime/src/actions/mediaActions.js index 402483c6..32d5ca84 100644 --- a/com.twin.app.shoptime/src/actions/mediaActions.js +++ b/com.twin.app.shoptime/src/actions/mediaActions.js @@ -230,3 +230,105 @@ export const switchMediaToModal = (modalContainerId, modalClassName) => (dispatc ); } }; + +/** + * Modal MediaPanel을 최소화합니다 (1px 크기로 축소, 재생은 계속) + * modal=false로 변경하여 background 클래스 적용 (modalContainerId는 복원을 위해 유지) + */ +export const minimizeModalMedia = () => (dispatch, getState) => { + const panels = getState().panels.panels; + + console.log('[minimizeModalMedia] ========== Called =========='); + console.log('[minimizeModalMedia] Total panels:', panels.length); + console.log( + '[minimizeModalMedia] All panels:', + JSON.stringify( + panels.map((p) => ({ name: p.name, modal: p.panelInfo?.modal })), + null, + 2 + ) + ); + + const modalMediaPanel = panels.find( + (panel) => panel.name === panel_names.MEDIA_PANEL && panel.panelInfo?.modal + ); + + console.log('[minimizeModalMedia] Found modalMediaPanel:', !!modalMediaPanel); + if (modalMediaPanel) { + console.log( + '[minimizeModalMedia] modalMediaPanel.panelInfo:', + JSON.stringify(modalMediaPanel.panelInfo, null, 2) + ); + console.log( + '[minimizeModalMedia] ✅ Minimizing modal MediaPanel (modal=false, isMinimized=true)' + ); + dispatch( + updatePanel({ + name: panel_names.MEDIA_PANEL, + panelInfo: { + ...modalMediaPanel.panelInfo, + modal: false, // fullscreen 모드로 전환 + isMinimized: true, // modal-minimized 클래스 적용 (1px 크기) + // modalContainerId, modalClassName 등은 복원을 위해 유지 + // isPaused는 변경하지 않음 - 재생은 계속됨 + }, + }) + ); + } else { + console.log('[minimizeModalMedia] ❌ No modal MediaPanel found - cannot minimize'); + } +}; + +/** + * Modal MediaPanel을 복원합니다 (최소화 해제) + * modal=true, isMinimized=false로 변경하여 원래 modal 위치로 복원 + */ +export const restoreModalMedia = () => (dispatch, getState) => { + const panels = getState().panels.panels; + + console.log('[restoreModalMedia] ========== Called =========='); + console.log('[restoreModalMedia] Total panels:', panels.length); + console.log( + '[restoreModalMedia] All panels:', + JSON.stringify( + panels.map((p) => ({ + name: p.name, + modal: p.panelInfo?.modal, + isMinimized: p.panelInfo?.isMinimized, + })), + null, + 2 + ) + ); + + // modal=false AND isMinimized=true인 MediaPanel을 찾음 (최소화 상태) + const minimizedMediaPanel = panels.find( + (panel) => + panel.name === panel_names.MEDIA_PANEL && + !panel.panelInfo?.modal && + panel.panelInfo?.isMinimized + ); + + console.log('[restoreModalMedia] Found minimizedMediaPanel:', !!minimizedMediaPanel); + if (minimizedMediaPanel) { + console.log( + '[restoreModalMedia] minimizedMediaPanel.panelInfo:', + JSON.stringify(minimizedMediaPanel.panelInfo, null, 2) + ); + console.log( + '[restoreModalMedia] ✅ Restoring modal MediaPanel (modal=true, isMinimized=false)' + ); + dispatch( + updatePanel({ + name: panel_names.MEDIA_PANEL, + panelInfo: { + ...minimizedMediaPanel.panelInfo, + modal: true, // modal 모드로 복원 (원래 위치로 복귀) + isMinimized: false, // 최소화 해제 + }, + }) + ); + } else { + console.log('[restoreModalMedia] ❌ No minimized MediaPanel found - cannot restore'); + } +}; diff --git a/com.twin.app.shoptime/src/actions/voiceActions.js b/com.twin.app.shoptime/src/actions/voiceActions.js new file mode 100644 index 00000000..8d4ddc08 --- /dev/null +++ b/com.twin.app.shoptime/src/actions/voiceActions.js @@ -0,0 +1,370 @@ +// src/actions/voiceActions.js + +import { types } from './actionTypes'; +import * as lunaSend from '../lunaSend/voice'; + +/** + * Helper function to add log entries + */ +const addLog = (type, title, data, success = true) => { + return { + type: types.VOICE_ADD_LOG, + payload: { + timestamp: new Date().toISOString(), + type, + title, + data, + success, + }, + }; +}; + +/** + * Register app with voice framework + * This will establish a subscription to receive voice commands + */ +export const registerVoiceFramework = () => (dispatch, getState) => { + // Platform check: Voice framework only works on TV (webOS) + const isTV = typeof window === 'object' && window.PalmSystem; + + if (!isTV) { + console.warn('[Voice] Voice framework is only available on webOS TV platform'); + dispatch( + addLog( + 'ERROR', + 'Platform Not Supported', + { + message: 'Voice framework requires webOS TV platform', + platform: 'web', + }, + false + ) + ); + dispatch({ + type: types.VOICE_REGISTER_FAILURE, + payload: { message: 'Voice framework is only available on webOS TV' }, + }); + return null; + } + + console.log('[Voice] Registering with voice framework...'); + + // Log the request + dispatch( + addLog('REQUEST', 'Register Voice Framework', { + service: 'luna://com.webos.service.voiceconductor', + method: 'interactor/register', + parameters: { + type: 'foreground', + subscribe: true, + }, + }) + ); + + let voiceHandler = null; + + voiceHandler = lunaSend.registerVoiceConductor({ + onSuccess: (res) => { + console.log('[Voice] Response from voice framework:', res); + + // Log all responses + dispatch(addLog('RESPONSE', 'Voice Framework Response', res, true)); + + // Initial registration response + if (res.subscribed && res.returnValue && !res.command) { + console.log('[Voice] Registration successful'); + dispatch({ + type: types.VOICE_REGISTER_SUCCESS, + payload: { handler: voiceHandler }, + }); + } + + // setContext command received + if (res.command === 'setContext' && res.voiceTicket) { + console.log('[Voice] setContext command received, ticket:', res.voiceTicket); + dispatch( + addLog('COMMAND', 'setContext Command Received', { + command: res.command, + voiceTicket: res.voiceTicket, + }) + ); + dispatch({ + type: types.VOICE_SET_TICKET, + payload: res.voiceTicket, + }); + + // Automatically send supported intents + dispatch(sendVoiceIntents(res.voiceTicket)); + } + + // performAction command received + if (res.command === 'performAction' && res.action) { + console.log('[Voice] performAction command received:', res.action); + dispatch( + addLog('COMMAND', 'performAction Command Received', { + command: res.command, + action: res.action, + }) + ); + dispatch({ + type: types.VOICE_PERFORM_ACTION, + payload: res.action, + }); + + // Process the action and report result + dispatch(handleVoiceAction(res.voiceTicket, res.action)); + } + }, + + onFailure: (err) => { + console.error('[Voice] Registration failed:', err); + dispatch(addLog('ERROR', 'Registration Failed', err, false)); + dispatch({ + type: types.VOICE_REGISTER_FAILURE, + payload: err, + }); + }, + + onComplete: (res) => { + console.log('[Voice] Registration completed:', res); + }, + }); + + return voiceHandler; +}; + +/** + * Send supported voice intents to the framework + * This should be called when setContext command is received + */ +export const sendVoiceIntents = (voiceTicket) => (dispatch, getState) => { + console.log('[Voice] Sending voice intents...'); + + // Define the intents that this app supports + // This is a sample configuration - customize based on your app's features + const inAppIntents = [ + { + intent: 'Select', + supportOrdinal: true, + items: [ + { + itemId: 'voice-search-button', + value: ['Search', 'Search Products', 'Find Items'], + title: 'Search', + }, + { + itemId: 'voice-cart-button', + value: ['Cart', 'Shopping Cart', 'My Cart'], + title: 'Cart', + }, + { + itemId: 'voice-home-button', + value: ['Home', 'Go Home', 'Main Page'], + title: 'Home', + }, + { + itemId: 'voice-mypage-button', + value: ['My Page', 'Account', 'Profile'], + title: 'My Page', + }, + ], + }, + { + intent: 'Scroll', + supportOrdinal: false, + items: [ + { + itemId: 'voice-scroll-up', + value: ['Scroll Up', 'Page Up'], + }, + { + itemId: 'voice-scroll-down', + value: ['Scroll Down', 'Page Down'], + }, + ], + }, + // Add more intents as needed + // See vui.md for complete list of available intents + ]; + + dispatch({ + type: types.VOICE_UPDATE_INTENTS, + payload: inAppIntents, + }); + + lunaSend.setVoiceContext(voiceTicket, inAppIntents, { + onSuccess: (res) => { + console.log('[Voice] Voice context set successfully:', res); + dispatch({ + type: types.VOICE_SET_CONTEXT_SUCCESS, + payload: res, + }); + }, + + onFailure: (err) => { + console.error('[Voice] Failed to set voice context:', err); + dispatch({ + type: types.VOICE_SET_CONTEXT_FAILURE, + payload: err, + }); + }, + + onComplete: (res) => { + console.log('[Voice] setContext completed'); + }, + }); +}; + +/** + * Handle voice action received from framework + * Process the action and report the result + */ +export const handleVoiceAction = (voiceTicket, action) => (dispatch, getState) => { + console.log('[Voice] Handling voice action:', action); + + let result = false; + let feedback = null; + + try { + // Process action based on intent and itemId + 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 or missing itemId:', action); + result = false; + feedback = { + voiceUi: { + systemUtterance: 'This action is not supported', + }, + }; + } + } catch (error) { + console.error('[Voice] Error processing action:', error); + result = false; + feedback = { + voiceUi: { + systemUtterance: 'An error occurred while processing your request', + }, + }; + } + + // Report result to voice framework + dispatch(reportActionResult(voiceTicket, result, feedback)); +}; + +/** + * Handle Select intent actions + */ +const handleSelectIntent = (itemId) => (dispatch, getState) => { + console.log('[Voice] Processing Select intent for:', itemId); + + // TODO: Implement actual navigation/action logic + switch (itemId) { + case 'voice-search-button': + console.log('[Voice] Navigate to Search'); + // dispatch(navigateToSearch()); + return true; + + case 'voice-cart-button': + console.log('[Voice] Navigate to Cart'); + // dispatch(navigateToCart()); + return true; + + case 'voice-home-button': + console.log('[Voice] Navigate to Home'); + // dispatch(navigateToHome()); + return true; + + case 'voice-mypage-button': + console.log('[Voice] Navigate to My Page'); + // dispatch(navigateToMyPage()); + return true; + + default: + console.warn('[Voice] Unknown Select itemId:', itemId); + return false; + } +}; + +/** + * Handle Scroll intent actions + */ +const handleScrollIntent = (itemId) => (dispatch, getState) => { + console.log('[Voice] Processing Scroll intent for:', itemId); + + // TODO: Implement actual scroll logic + switch (itemId) { + case 'voice-scroll-up': + console.log('[Voice] Scroll Up'); + // Implement scroll up logic + return true; + + case 'voice-scroll-down': + console.log('[Voice] Scroll Down'); + // Implement scroll down logic + return true; + + default: + console.warn('[Voice] Unknown Scroll itemId:', itemId); + return false; + } +}; + +/** + * Report action result to voice framework + */ +export const reportActionResult = + (voiceTicket, result, feedback = null) => + (dispatch, getState) => { + console.log('[Voice] Reporting action result:', { result, feedback }); + + lunaSend.reportVoiceActionResult(voiceTicket, result, feedback, { + onSuccess: (res) => { + console.log('[Voice] Action result reported successfully:', res); + dispatch({ + type: types.VOICE_REPORT_RESULT_SUCCESS, + payload: { result, feedback }, + }); + }, + + onFailure: (err) => { + console.error('[Voice] Failed to report action result:', err); + dispatch({ + type: types.VOICE_REPORT_RESULT_FAILURE, + payload: err, + }); + }, + + onComplete: (res) => { + console.log('[Voice] reportActionResult completed'); + }, + }); + }; + +/** + * Unregister from voice framework + * Cancel the subscription when app goes to background or unmounts + */ +export const unregisterVoiceFramework = () => (dispatch, getState) => { + const { voiceHandler } = getState().voice; + const isTV = typeof window === 'object' && window.PalmSystem; + + if (voiceHandler && isTV) { + console.log('[Voice] Unregistering from voice framework'); + lunaSend.cancelVoiceRegistration(voiceHandler); + } + + // Always clear state on unmount, regardless of platform + dispatch({ + type: types.VOICE_CLEAR_STATE, + }); +}; + +/** + * Clear voice state + */ +export const clearVoiceState = () => ({ + type: types.VOICE_CLEAR_STATE, +}); diff --git a/com.twin.app.shoptime/src/components/VideoPlayer/Video.js b/com.twin.app.shoptime/src/components/VideoPlayer/Video.js index e76fc9fe..c7c6c3d6 100644 --- a/com.twin.app.shoptime/src/components/VideoPlayer/Video.js +++ b/com.twin.app.shoptime/src/components/VideoPlayer/Video.js @@ -1,324 +1,319 @@ -import {forward} from '@enact/core/handle'; -import ForwardRef from '@enact/ui/ForwardRef'; -import {Media, getKeyFromSource} from '@enact/ui/Media'; -import EnactPropTypes from '@enact/core/internal/prop-types'; -import Slottable from '@enact/ui/Slottable'; -import compose from 'ramda/src/compose'; -import React from 'react'; - -import css from './VideoPlayer.module.less'; - -import PropTypes from 'prop-types'; - -/** - * Adds support for preloading a video source for `VideoPlayer`. - * - * @class VideoBase - * @memberof sandstone/VideoPlayer - * @ui - * @private - */ -const VideoBase = class extends React.Component { - static displayName = 'Video'; - - static propTypes = /** @lends sandstone/VideoPlayer.Video.prototype */ { - /** - * Video plays automatically. - * - * @type {Boolean} - * @default false - * @public - */ - autoPlay: PropTypes.bool, - - /** - * Video component to use. - * - * The default (`'video'`) renders an `HTMLVideoElement`. Custom video components must have - * a similar API structure, exposing the following APIs: - * - * Properties: - * * `currentTime` {Number} - Playback index of the media in seconds - * * `duration` {Number} - Media's entire duration in seconds - * * `error` {Boolean} - `true` if video playback has errored. - * * `loading` {Boolean} - `true` if video playback is loading. - * * `paused` {Boolean} - Playing vs paused state. `true` means the media is paused - * * `playbackRate` {Number} - Current playback rate, as a number - * * `proportionLoaded` {Number} - A value between `0` and `1` - * representing the proportion of the media that has loaded - * * `proportionPlayed` {Number} - A value between `0` and `1` representing the - * proportion of the media that has already been shown - * - * Events: - * * `onLoadStart` - Called when the video starts to load - * * `onPlay` - Sent when playback of the media starts after having been paused - * * `onUpdate` - Sent when any of the properties were updated - * - * Methods: - * * `play()` - play video - * * `pause()` - pause video - * * `load()` - load video - * - * The [`source`]{@link sandstone/VideoPlayer.Video.source} property is passed to - * the video component as a child node. - * - * @type {String|Component|Element} - * @default 'video' - * @public - */ - mediaComponent: EnactPropTypes.renderableOverride, - - /** - * The video source to be preloaded. Expects a `` node. - * - * @type {Node} - * @public - */ - preloadSource: PropTypes.node, - - /** - * Called with a reference to the active [Media]{@link ui/Media.Media} component. - * - * @type {Function} - * @private - */ - setMedia: PropTypes.func, - - /** - * The video source to be played. - * - * Any children `` elements will be sent directly to the `mediaComponent` as video - * sources. - * - * See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/source - * - * @type {String|Node} - * @public - */ - source: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), - track: PropTypes.oneOfType([PropTypes.string, PropTypes.node]) - }; - - static defaultProps = { - mediaComponent: 'video' - }; - - componentDidUpdate (prevProps) { - const {source, preloadSource} = this.props; - const {source: prevSource, preloadSource: prevPreloadSource} = prevProps; - - const key = getKeyFromSource(source); - const prevKey = getKeyFromSource(prevSource); - const preloadKey = getKeyFromSource(preloadSource); - const prevPreloadKey = getKeyFromSource(prevPreloadSource); - - if (this.props.setMedia !== prevProps.setMedia) { - this.clearMedia(prevProps); - this.setMedia(); - } - - if (source) { - if (key === prevPreloadKey && preloadKey !== prevPreloadKey) { - // if there's source and it was the preload source - - // if the preloaded video didn't error, notify VideoPlayer it is ready to reset - if (this.preloadLoadStart) { - forward('onLoadStart', this.preloadLoadStart, this.props); - } - - // emit onUpdate to give VideoPlayer an opportunity to updates its internal state - // since it won't receive the onLoadStart or onError event - forward('onUpdate', {type: 'onUpdate'}, this.props); - - this.autoPlay(); - } else if (key !== prevKey) { - // if there's source and it has changed. - this.autoPlay(); - } - } - - if (preloadSource && preloadKey !== prevPreloadKey) { - this.preloadLoadStart = null; - - // In the case that the previous source equalled the previous preload (causing the - // preload video node to not be created) and then the preload source was changed, we - // need to guard against accessing the preloadVideo node. - if (this.preloadVideo) { - this.preloadVideo.load(); - } - } - } - - componentWillUnmount () { - this.clearMedia(); - } - - keys = ['media-1', 'media-2']; - prevSourceKey = null; - prevPreloadKey = null; - - handlePreloadLoadStart = (ev) => { - // persist the event so we can cache it to re-emit when the preload becomes active - ev.persist(); - this.preloadLoadStart = ev; - - // prevent the from bubbling to upstream handlers - ev.stopPropagation(); - }; - - clearMedia ({setMedia} = this.props) { - if (setMedia) { - setMedia(null); - } - } - - setMedia ({setMedia} = this.props) { - if (setMedia) { - setMedia(this.video); - } - } - - autoPlay () { - if (!this.props.autoPlay) return; - - this.video.play(); - } - - setVideoRef = (node) => { - this.video = node; - this.setMedia(); - }; - - setPreloadRef = (node) => { - if (node) { - node.load(); - } - this.preloadVideo = node; - }; - - getKeys () { - const {source, preloadSource} = this.props; - - const sourceKey = source && getKeyFromSource(source); - let preloadKey = preloadSource && getKeyFromSource(preloadSource); - - // If the same source is used for both, clear the preload key to avoid rendering duplicate - // video elements. - if (sourceKey === preloadKey) { - preloadKey = null; - } - - // if either the source or preload existed previously in the other "slot", swap the keys so - // the preload video becomes the active video and vice versa - if ( - (sourceKey === this.prevPreloadKey && this.prevPreloadKey) || - (preloadKey === this.prevSourceKey && this.prevSourceKey) - ) { - this.keys.reverse(); - } - - // cache the previous keys so we know if the sources change the next time - this.prevSourceKey = sourceKey; - this.prevPreloadKey = preloadKey; - - // if preload is unset, clear the key so we don't render that media node at all - return preloadKey ? this.keys : this.keys.slice(0, 1); - } - - render () { - const { - preloadSource, - source, - track, - mediaComponent, - ...rest - } = this.props; - - delete rest.setMedia; - - const [sourceKey, preloadKey] = this.getKeys(); - - return ( - - {sourceKey ? ( - - )} - source={React.isValidElement(source) ? source : ( - - )} - /> - ) : null} - {preloadKey ? ( - - )} - source={React.isValidElement(preloadSource) ? preloadSource : ( - - )} - /> - ) : null} - - ); - } -}; - -const VideoDecorator = compose( - ForwardRef({prop: 'setMedia'}), - Slottable({slots: ['source', 'track','preloadSource']}) -); - -/** - * Provides support for more advanced video configurations for `VideoPlayer`. - * - * Custom Video Tag - * - * ``` - * - * - * - * ``` - * - * Preload Video Source - * - * ``` - * - * - * - * ``` - * - * @class Video - * @mixes ui/Slottable.Slottable - * @memberof sandstone/VideoPlayer - * @ui - * @public - */ -const Video = VideoDecorator(VideoBase); -Video.defaultSlot = 'videoComponent'; - -export default Video; -export { - Video -}; +import { forward } from '@enact/core/handle'; +import ForwardRef from '@enact/ui/ForwardRef'; +import { Media, getKeyFromSource } from '@enact/ui/Media'; +import EnactPropTypes from '@enact/core/internal/prop-types'; +import Slottable from '@enact/ui/Slottable'; +import compose from 'ramda/src/compose'; +import React from 'react'; + +import css from './VideoPlayer.module.less'; + +import PropTypes from 'prop-types'; + +/** + * Adds support for preloading a video source for `VideoPlayer`. + * + * @class VideoBase + * @memberof sandstone/VideoPlayer + * @ui + * @private + */ +const VideoBase = class extends React.Component { + static displayName = 'Video'; + + static propTypes = /** @lends sandstone/VideoPlayer.Video.prototype */ { + /** + * Video plays automatically. + * + * @type {Boolean} + * @default false + * @public + */ + autoPlay: PropTypes.bool, + + /** + * Video loops continuously. + * + * @type {Boolean} + * @default false + * @public + */ + loop: PropTypes.bool, + + /** + * Video component to use. + * + * The default (`'video'`) renders an `HTMLVideoElement`. Custom video components must have + * a similar API structure, exposing the following APIs: + * + * Properties: + * * `currentTime` {Number} - Playback index of the media in seconds + * * `duration` {Number} - Media's entire duration in seconds + * * `error` {Boolean} - `true` if video playback has errored. + * * `loading` {Boolean} - `true` if video playback is loading. + * * `paused` {Boolean} - Playing vs paused state. `true` means the media is paused + * * `playbackRate` {Number} - Current playback rate, as a number + * * `proportionLoaded` {Number} - A value between `0` and `1` + * representing the proportion of the media that has loaded + * * `proportionPlayed` {Number} - A value between `0` and `1` representing the + * proportion of the media that has already been shown + * + * Events: + * * `onLoadStart` - Called when the video starts to load + * * `onPlay` - Sent when playback of the media starts after having been paused + * * `onUpdate` - Sent when any of the properties were updated + * + * Methods: + * * `play()` - play video + * * `pause()` - pause video + * * `load()` - load video + * + * The [`source`]{@link sandstone/VideoPlayer.Video.source} property is passed to + * the video component as a child node. + * + * @type {String|Component|Element} + * @default 'video' + * @public + */ + mediaComponent: EnactPropTypes.renderableOverride, + + /** + * The video source to be preloaded. Expects a `` node. + * + * @type {Node} + * @public + */ + preloadSource: PropTypes.node, + + /** + * Called with a reference to the active [Media]{@link ui/Media.Media} component. + * + * @type {Function} + * @private + */ + setMedia: PropTypes.func, + + /** + * The video source to be played. + * + * Any children `` elements will be sent directly to the `mediaComponent` as video + * sources. + * + * See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/source + * + * @type {String|Node} + * @public + */ + source: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + track: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + }; + + static defaultProps = { + mediaComponent: 'video', + }; + + componentDidUpdate(prevProps) { + const { source, preloadSource } = this.props; + const { source: prevSource, preloadSource: prevPreloadSource } = prevProps; + + const key = getKeyFromSource(source); + const prevKey = getKeyFromSource(prevSource); + const preloadKey = getKeyFromSource(preloadSource); + const prevPreloadKey = getKeyFromSource(prevPreloadSource); + + if (this.props.setMedia !== prevProps.setMedia) { + this.clearMedia(prevProps); + this.setMedia(); + } + + if (source) { + if (key === prevPreloadKey && preloadKey !== prevPreloadKey) { + // if there's source and it was the preload source + + // if the preloaded video didn't error, notify VideoPlayer it is ready to reset + if (this.preloadLoadStart) { + forward('onLoadStart', this.preloadLoadStart, this.props); + } + + // emit onUpdate to give VideoPlayer an opportunity to updates its internal state + // since it won't receive the onLoadStart or onError event + forward('onUpdate', { type: 'onUpdate' }, this.props); + + this.autoPlay(); + } else if (key !== prevKey) { + // if there's source and it has changed. + this.autoPlay(); + } + } + + if (preloadSource && preloadKey !== prevPreloadKey) { + this.preloadLoadStart = null; + + // In the case that the previous source equalled the previous preload (causing the + // preload video node to not be created) and then the preload source was changed, we + // need to guard against accessing the preloadVideo node. + if (this.preloadVideo) { + this.preloadVideo.load(); + } + } + } + + componentWillUnmount() { + this.clearMedia(); + } + + keys = ['media-1', 'media-2']; + prevSourceKey = null; + prevPreloadKey = null; + + handlePreloadLoadStart = (ev) => { + // persist the event so we can cache it to re-emit when the preload becomes active + ev.persist(); + this.preloadLoadStart = ev; + + // prevent the from bubbling to upstream handlers + ev.stopPropagation(); + }; + + clearMedia({ setMedia } = this.props) { + if (setMedia) { + setMedia(null); + } + } + + setMedia({ setMedia } = this.props) { + if (setMedia) { + setMedia(this.video); + } + } + + autoPlay() { + if (!this.props.autoPlay) return; + + this.video.play(); + } + + setVideoRef = (node) => { + this.video = node; + this.setMedia(); + }; + + setPreloadRef = (node) => { + if (node) { + node.load(); + } + this.preloadVideo = node; + }; + + getKeys() { + const { source, preloadSource } = this.props; + + const sourceKey = source && getKeyFromSource(source); + let preloadKey = preloadSource && getKeyFromSource(preloadSource); + + // If the same source is used for both, clear the preload key to avoid rendering duplicate + // video elements. + if (sourceKey === preloadKey) { + preloadKey = null; + } + + // if either the source or preload existed previously in the other "slot", swap the keys so + // the preload video becomes the active video and vice versa + if ( + (sourceKey === this.prevPreloadKey && this.prevPreloadKey) || + (preloadKey === this.prevSourceKey && this.prevSourceKey) + ) { + this.keys.reverse(); + } + + // cache the previous keys so we know if the sources change the next time + this.prevSourceKey = sourceKey; + this.prevPreloadKey = preloadKey; + + // if preload is unset, clear the key so we don't render that media node at all + return preloadKey ? this.keys : this.keys.slice(0, 1); + } + + render() { + const { preloadSource, source, track, mediaComponent, ...rest } = this.props; + + delete rest.setMedia; + + const [sourceKey, preloadKey] = this.getKeys(); + + return ( + + {sourceKey ? ( + } + source={React.isValidElement(source) ? source : } + /> + ) : null} + {preloadKey ? ( + } + source={ + React.isValidElement(preloadSource) ? preloadSource : + } + /> + ) : null} + + ); + } +}; + +const VideoDecorator = compose( + ForwardRef({ prop: 'setMedia' }), + Slottable({ slots: ['source', 'track', 'preloadSource'] }) +); + +/** + * Provides support for more advanced video configurations for `VideoPlayer`. + * + * Custom Video Tag + * + * ``` + * + * + * + * ``` + * + * Preload Video Source + * + * ``` + * + * + * + * ``` + * + * @class Video + * @mixes ui/Slottable.Slottable + * @memberof sandstone/VideoPlayer + * @ui + * @public + */ +const Video = VideoDecorator(VideoBase); +Video.defaultSlot = 'videoComponent'; + +export default Video; +export { Video }; diff --git a/com.twin.app.shoptime/src/lunaSend/index.js b/com.twin.app.shoptime/src/lunaSend/index.js index f9084536..7675eb57 100644 --- a/com.twin.app.shoptime/src/lunaSend/index.js +++ b/com.twin.app.shoptime/src/lunaSend/index.js @@ -1,13 +1,14 @@ -import {LS2RequestSingleton} from './LS2RequestSingleton'; +import { LS2RequestSingleton } from './LS2RequestSingleton'; export * from './account'; export * from './common'; +export * from './voice'; export const cancelReq = (instanceName) => { - let r = LS2RequestSingleton.instance(instanceName); - if (r) { - r.cancel(); - r.cancelled = false; - LS2RequestSingleton.deleteInstance(instanceName); - } -}; \ No newline at end of file + let r = LS2RequestSingleton.instance(instanceName); + if (r) { + r.cancel(); + r.cancelled = false; + LS2RequestSingleton.deleteInstance(instanceName); + } +}; diff --git a/com.twin.app.shoptime/src/lunaSend/voice.js b/com.twin.app.shoptime/src/lunaSend/voice.js new file mode 100644 index 00000000..ab04b887 --- /dev/null +++ b/com.twin.app.shoptime/src/lunaSend/voice.js @@ -0,0 +1,164 @@ +import LS2Request from './LS2Request'; + +/** + * Register app with voice framework to receive voice commands + * This is a subscription-based service that will continuously receive commands + * + * Commands received: + * - setContext: Request to set the voice intents that the app supports + * - performAction: Request to perform a voice action + */ +export const registerVoiceConductor = ({ onSuccess, onFailure, onComplete }) => { + if (typeof window === 'object' && !window.PalmSystem) { + console.log('LUNA SEND registerVoiceConductor - Not available on web platform'); + + // Do NOT run mock mode on web to prevent performance issues + // Voice framework should only be used on TV + if (onFailure) { + onFailure({ + returnValue: false, + errorText: 'Voice framework is only available on webOS TV', + }); + } + + return { + cancel: () => { + console.log('LUNA SEND registerVoiceConductor - Cancel (no-op on web)'); + }, + }; + } + + return new LS2Request().send({ + service: 'luna://com.webos.service.voiceconductor', + method: 'interactor/register', + parameters: { + type: 'foreground', + subscribe: true, + }, + onSuccess, + onFailure, + onComplete, + }); +}; + +/** + * Set the voice intents that the app supports + * Must be called after receiving setContext command from registerVoiceConductor + * + * @param {string} voiceTicket - The ticket received from registerVoiceConductor + * @param {Array} inAppIntents - Array of intent objects that the app supports + * + * Intent object structure: + * { + * intent: "Select" | "Scroll" | "PlayContent" | "ControlMedia" | etc., + * supportOrdinal: boolean, // Whether to support ordinal speech (e.g., "select the first one") + * items: [ + * { + * itemId: string, // Unique identifier for this item (must be globally unique) + * value: string[], // Array of voice command variants + * title: string // Optional display title + * } + * ] + * } + */ +export const setVoiceContext = ( + voiceTicket, + inAppIntents, + { onSuccess, onFailure, onComplete } +) => { + if (typeof window === 'object' && !window.PalmSystem) { + console.log('LUNA SEND setVoiceContext', { + voiceTicket, + intentCount: inAppIntents.length, + intents: inAppIntents, + }); + + setTimeout(() => { + onSuccess && onSuccess({ returnValue: true }); + }, 100); + + return; + } + + return new LS2Request().send({ + service: 'luna://com.webos.service.voiceconductor', + method: 'interactor/setContext', + parameters: { + voiceTicket: voiceTicket, + inAppIntents: inAppIntents, + }, + onSuccess, + onFailure, + onComplete, + }); +}; + +/** + * Report the result of processing a voice command + * Must be called after receiving performAction command from registerVoiceConductor + * + * @param {string} voiceTicket - The ticket received from registerVoiceConductor + * @param {boolean} result - true if command was processed successfully, false otherwise + * @param {object} feedback - Optional feedback object + * + * Feedback object structure: + * { + * general: { + * responseCode: string, + * responseMessage: string + * }, + * voiceUi: { + * systemUtterance: string, // Message to display to user + * exception: string // Predefined exception ID (e.g., "alreadyCompleted") + * } + * } + */ +export const reportVoiceActionResult = ( + voiceTicket, + result, + feedback, + { onSuccess, onFailure, onComplete } +) => { + if (typeof window === 'object' && !window.PalmSystem) { + console.log('LUNA SEND reportVoiceActionResult', { + voiceTicket, + result, + feedback, + }); + + setTimeout(() => { + onSuccess && onSuccess({ returnValue: true }); + }, 100); + + return; + } + + const parameters = { + voiceTicket: voiceTicket, + result: result, + }; + + if (feedback) { + parameters.feedback = feedback; + } + + return new LS2Request().send({ + service: 'luna://com.webos.service.voiceconductor', + method: 'interactor/reportActionResult', + parameters: parameters, + onSuccess, + onFailure, + onComplete, + }); +}; + +/** + * Cancel voice conductor subscription + * Helper function to cancel the subscription handler + */ +export const cancelVoiceRegistration = (handler) => { + if (handler && handler.cancel) { + handler.cancel(); + console.log('Voice conductor subscription cancelled'); + } +}; diff --git a/com.twin.app.shoptime/src/reducers/voiceReducer.js b/com.twin.app.shoptime/src/reducers/voiceReducer.js new file mode 100644 index 00000000..455bd416 --- /dev/null +++ b/com.twin.app.shoptime/src/reducers/voiceReducer.js @@ -0,0 +1,130 @@ +// src/reducers/voiceReducer.js + +import { types } from '../actions/actionTypes'; + +const initialState = { + // Registration state + isRegistered: false, + registrationError: null, + voiceTicket: null, + voiceHandler: null, // LS2Request handler for subscription + + // Context state + supportedIntents: [], + contextSetSuccess: false, + contextError: null, + + // Action state + lastCommand: null, // "setContext" | "performAction" + lastAction: null, // Last performAction object received + lastActionResult: null, // Last action processing result + + // Processing state + isProcessingAction: false, + actionError: null, + + // Logging for debugging + logs: [], + logIdCounter: 0, +}; + +export const voiceReducer = (state = initialState, action) => { + switch (action.type) { + case types.VOICE_REGISTER_SUCCESS: + return { + ...state, + isRegistered: true, + registrationError: null, + voiceHandler: action.payload.handler || null, + }; + + case types.VOICE_REGISTER_FAILURE: + return { + ...state, + isRegistered: false, + registrationError: action.payload, + voiceHandler: null, + }; + + case types.VOICE_SET_TICKET: + return { + ...state, + voiceTicket: action.payload, + lastCommand: 'setContext', + }; + + case types.VOICE_SET_CONTEXT_SUCCESS: + return { + ...state, + contextSetSuccess: true, + contextError: null, + }; + + case types.VOICE_SET_CONTEXT_FAILURE: + return { + ...state, + contextSetSuccess: false, + contextError: action.payload, + }; + + case types.VOICE_UPDATE_INTENTS: + return { + ...state, + supportedIntents: action.payload, + }; + + case types.VOICE_PERFORM_ACTION: + return { + ...state, + lastCommand: 'performAction', + lastAction: action.payload, + isProcessingAction: true, + actionError: null, + }; + + case types.VOICE_REPORT_RESULT_SUCCESS: + return { + ...state, + lastActionResult: action.payload, + isProcessingAction: false, + actionError: null, + }; + + case types.VOICE_REPORT_RESULT_FAILURE: + return { + ...state, + isProcessingAction: false, + actionError: action.payload, + }; + + case types.VOICE_CLEAR_STATE: + return { + ...initialState, + }; + + case types.VOICE_ADD_LOG: + return { + ...state, + logs: [ + ...state.logs, + { + id: state.logIdCounter + 1, + ...action.payload, + }, + ], + logIdCounter: state.logIdCounter + 1, + }; + + case types.VOICE_CLEAR_LOGS: + return { + ...state, + logs: [], + logIdCounter: 0, + }; + + default: + return state; + } +}; + +export default voiceReducer; diff --git a/com.twin.app.shoptime/src/store/store.js b/com.twin.app.shoptime/src/store/store.js index 14b837a2..1cdc81e8 100644 --- a/com.twin.app.shoptime/src/store/store.js +++ b/com.twin.app.shoptime/src/store/store.js @@ -29,6 +29,7 @@ import { searchReducer } from '../reducers/searchReducer'; import { shippingReducer } from '../reducers/shippingReducer'; import { toastReducer } from '../reducers/toastReducer'; import { videoPlayReducer } from '../reducers/videoPlayReducer'; +import { voiceReducer } from '../reducers/voiceReducer'; const rootReducer = combineReducers({ panels: panelsReducer, @@ -58,6 +59,7 @@ const rootReducer = combineReducers({ foryou: foryouReducer, toast: toastReducer, videoPlay: videoPlayReducer, + voice: voiceReducer, }); export const store = createStore(rootReducer, applyMiddleware(thunk)); diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx b/com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx index 8fd67ebc..f8909f52 100644 --- a/com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx +++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx @@ -15,6 +15,7 @@ import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDeco import arrowDown from '../../../../assets/images/icons/ic_arrow_down_3x_new.png'; import indicatorDefaultImage from '../../../../assets/images/img-thumb-empty-144@3x.png'; // import { pushPanel } from '../../../actions/panelActions'; +import { minimizeModalMedia } from '../../../actions/mediaActions'; import { resetShowAllReviews } from '../../../actions/productActions'; import { showToast } from '../../../actions/toastActions'; // ProductInfoSection imports @@ -143,7 +144,7 @@ export default function ProductAllSection({ const youmaylikeData = useSelector((state) => state.main.youmaylikeData); // ProductVideo 버전 관리 (1: 기존 modal 방식, 2: 내장 방식) - const [productVideoVersion, setProductVideoVersion] = useState(1); + const [productVideoVersion, setProductVideoVersion] = useState(3); // const [currentHeight, setCurrentHeight] = useState(0); //하단부분까지 갔을때 체크용 @@ -308,8 +309,12 @@ export default function ProductAllSection({ [scrollToSection] ); - // 비디오 다음 이미지로 스크롤하는 핸들러 - const handleScrollToImages = useCallback(() => { + // ProductVideo V1 전용 - MediaPanel minimize 포함 + const handleScrollToImagesV1 = useCallback(() => { + // 1. MediaPanel을 1px로 축소하여 포커스 충돌 방지 + dispatch(minimizeModalMedia()); + + // 2. 스크롤 이동 scrollToSection('scroll-marker-after-video'); // 기존 timeout이 있으면 클리어 @@ -317,13 +322,45 @@ export default function ProductAllSection({ clearTimeout(scrollToImagesTimeoutRef.current); } - // 250ms 후 ProductDetail로 포커스 이동 + // 3. 100ms 후 명시적으로 첫 번째 ProductDetail(이미지)로 포커스 이동 scrollToImagesTimeoutRef.current = setTimeout(() => { - Spotlight.move('down'); + Spotlight.focus('product-img-1'); scrollToImagesTimeoutRef.current = null; - }, 250); + }, 100); + }, [scrollToSection, dispatch]); + + // ProductVideoV2 전용 - minimize 없음 (내장 비디오 방식) + const handleScrollToImagesV2 = useCallback(() => { + // 1. 스크롤 이동 + scrollToSection('scroll-marker-after-video'); + + // 기존 timeout이 있으면 클리어 + if (scrollToImagesTimeoutRef.current) { + clearTimeout(scrollToImagesTimeoutRef.current); + } + + // 2. 100ms 후 명시적으로 첫 번째 ProductDetail(이미지)로 포커스 이동 + scrollToImagesTimeoutRef.current = setTimeout(() => { + Spotlight.focus('product-img-1'); + scrollToImagesTimeoutRef.current = null; + }, 100); }, [scrollToSection]); + // ProductVideoVersion 3 전용 - 비디오 없이 이미지만 사용 (minimize 액션 없음) + const handleScrollToImagesV3 = useCallback(() => { + // 비디오가 없으므로 scroll-marker-after-video 대신 첫 이미지로 직접 이동 + // 기존 timeout이 있으면 클리어 + if (scrollToImagesTimeoutRef.current) { + clearTimeout(scrollToImagesTimeoutRef.current); + } + + // 즉시 첫 번째 ProductDetail(이미지)로 포커스 이동 + scrollToImagesTimeoutRef.current = setTimeout(() => { + Spotlight.focus('product-img-1'); + scrollToImagesTimeoutRef.current = null; + }, 100); + }, []); + const scrollContainerRef = useRef(null); const productDetailRef = useRef(null); //높이값 변경때문 const descriptionRef = useRef(null); @@ -334,8 +371,8 @@ export default function ProductAllSection({ const renderItems = useMemo(() => { const items = []; - // 동영상이 있으면 첫 번째에 추가 (Indicator.jsx와 동일한 로직) - if (productData && productData.prdtMediaUrl) { + // 동영상이 있으면 첫 번째에 추가 (productVideoVersion이 3이 아닐 때만) + if (productData && productData.prdtMediaUrl && productVideoVersion !== 3) { items.push({ type: 'video', url: productData.prdtMediaUrl, @@ -350,13 +387,17 @@ export default function ProductAllSection({ items.push({ type: 'image', url: image, - index: productData && productData.prdtMediaUrl ? imgIndex + 1 : imgIndex, + // productVideoVersion === 3이면 비디오가 없으므로 index는 0부터 시작 + index: + productData && productData.prdtMediaUrl && productVideoVersion !== 3 + ? imgIndex + 1 + : imgIndex, }); }); } return items; - }, [productData]); + }, [productData, productVideoVersion]); // renderItems에 Video가 존재하는지 확인하는 boolean 상태 const hasVideo = useMemo(() => { @@ -696,8 +737,8 @@ export default function ProductAllSection({ onFocus={() => handleButtonFocus('product')} onBlur={handleButtonBlur} > - {/* 비디오가 있으면 먼저 렌더링 */} - {hasVideo && renderItems[0].type === 'video' && ( + {/* 비디오가 있으면 먼저 렌더링 (productVideoVersion이 3이 아닐 때만) */} + {hasVideo && renderItems[0].type === 'video' && productVideoVersion !== 3 && ( <> {productVideoVersion === 1 ? ( ) : ( )}
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductVideo/ProductVideo.jsx b/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductVideo/ProductVideo.jsx index 2723da6c..128a074e 100644 --- a/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductVideo/ProductVideo.jsx +++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductVideo/ProductVideo.jsx @@ -5,6 +5,8 @@ import { startMediaPlayer, finishMediaPreview, switchMediaToFullscreen, + minimizeModalMedia, + restoreModalMedia, } from '../../../../actions/mediaActions'; import CustomImage from '../../../../components/CustomImage/CustomImage'; import { panel_names } from '../../../../utils/Config'; @@ -13,7 +15,14 @@ import css from './ProductVideo.module.less'; const SpottableComponent = Spottable('div'); -export default function ProductVideo({ productInfo, videoUrl, thumbnailUrl, onScrollToImages }) { +export default function ProductVideo({ + productInfo, + videoUrl, + thumbnailUrl, + onScrollToImages, + autoPlay = false, // 자동 재생 여부 + continuousPlay = false, // 반복 재생 여부 +}) { const dispatch = useDispatch(); // MediaPanel 상태 체크를 위한 selectors 추가 @@ -21,6 +30,7 @@ export default function ProductVideo({ productInfo, videoUrl, thumbnailUrl, onSc const [isLaunchedFromPlayer, setIsLaunchedFromPlayer] = useState(false); const [focused, setFocused] = useState(false); const [modalState, setModalState] = useState(true); // 모달 상태 관리 추가 + const [hasAutoPlayed, setHasAutoPlayed] = useState(false); // 자동 재생 완료 여부 const topPanel = panels[panels.length - 1]; @@ -40,6 +50,51 @@ export default function ProductVideo({ productInfo, videoUrl, thumbnailUrl, onSc } }, [topPanel]); + // autoPlay 기능: 컴포넌트 마운트 시 자동으로 비디오 재생 + useEffect(() => { + if (autoPlay && canPlayVideo && !hasAutoPlayed && productInfo) { + console.log('[ProductVideo] Auto-playing video'); + setHasAutoPlayed(true); + + // 짧은 딸레이 후 재생 시작 (컴포넌트 마운트 완료 후) + setTimeout(() => { + dispatch( + startMediaPlayer({ + qrCurrentItem: productInfo, + showUrl: productInfo?.prdtMediaUrl, + showNm: productInfo?.prdtNm, + patnrNm: productInfo?.patncNm, + patncLogoPath: productInfo?.patncLogoPath, + orderPhnNo: productInfo?.orderPhnNo, + disclaimer: productInfo?.disclaimer, + subtitle: productInfo?.prdtMediaSubtitlUrl, + lgCatCd: productInfo?.catCd, + patnrId: productInfo?.patnrId, + lgCatNm: productInfo?.catNm, + prdtId: productInfo?.prdtId, + patncNm: productInfo?.patncNm, + prdtNm: productInfo?.prdtNm, + thumbnailUrl: productInfo?.thumbnailUrl960, + shptmBanrTpNm: 'MEDIA', + modal: true, + modalContainerId: 'product-video-player', + modalClassName: modalClassNameChange(), + spotlightDisable: true, + continuousPlay, // 반복 재생 옵션 전달 + }) + ); + }, 100); + } + }, [ + autoPlay, + canPlayVideo, + hasAutoPlayed, + productInfo, + dispatch, + modalClassNameChange, + continuousPlay, + ]); + // 비디오 재생 가능 여부 체크 const canPlayVideo = useMemo(() => { return Boolean(productInfo?.prdtMediaUrl); @@ -57,18 +112,20 @@ export default function ProductVideo({ productInfo, videoUrl, thumbnailUrl, onSc const videoContainerOnFocus = useCallback(() => { if (canPlayVideo) { setFocused(true); + console.log('[ProductVideo] Calling restoreModalMedia'); + // ProductVideo에 포커스가 돌아오면 비디오 복원 + dispatch(restoreModalMedia()); } - }, [canPlayVideo]); + }, [canPlayVideo, dispatch]); const videoContainerOnBlur = useCallback(() => { console.log('[ProductVideo] onBlur called - canPlayVideo:', canPlayVideo); if (canPlayVideo) { setFocused(false); - console.log('[ProductVideo] Calling finishMediaPreview'); - // ProductVideo에서 포커스가 벗어나면 비디오 재생 종료 - dispatch(finishMediaPreview()); + // minimize는 handleScrollToImages에서 명시적으로 처리 + // 여기서는 focused 상태만 변경 } - }, [canPlayVideo, dispatch]); + }, [canPlayVideo]); // Spotlight Down 키 핸들러 - 비디오 다음 이미지로 스크롤 const handleSpotlightDown = useCallback( @@ -136,6 +193,7 @@ export default function ProductVideo({ productInfo, videoUrl, thumbnailUrl, onSc modalContainerId: 'product-video-player', modalClassName: modalClassNameChange(), spotlightDisable: true, + continuousPlay, // 반복 재생 옵션 전달 }) ); } diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductVideo/ProductVideo.module.less b/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductVideo/ProductVideo.module.less index 418b6459..208c72b3 100644 --- a/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductVideo/ProductVideo.module.less +++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductVideo/ProductVideo.module.less @@ -63,7 +63,7 @@ &::after { overflow: hidden; .position(@position: absolute, @top: 0, @left: 0, @right: 0, @bottom: 0); - z-index: 19; + z-index: 23; // MediaPanel(z-index: 22)보다 위에 표시되어야 비디오 재생 중에도 포커스 테두리가 보임 border: 6px solid @PRIMARY_COLOR_RED; box-shadow: 0 0 22px 0 rgba(0, 0, 0, 0.5); border-radius: 12px; diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductVideo/ProductVideo.v2.jsx b/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductVideo/ProductVideo.v2.jsx index b254fedf..9acede89 100644 --- a/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductVideo/ProductVideo.v2.jsx +++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductVideo/ProductVideo.v2.jsx @@ -38,7 +38,13 @@ const YOUTUBECONFIG = { }, }; -export default function ProductVideoV2({ productInfo, videoUrl, thumbnailUrl, autoPlay = false }) { +export default function ProductVideoV2({ + productInfo, + videoUrl, + thumbnailUrl, + autoPlay = false, + onScrollToImages, +}) { const [isPlaying, setIsPlaying] = useState(false); const [focused, setFocused] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); @@ -126,6 +132,20 @@ export default function ProductVideoV2({ productInfo, videoUrl, thumbnailUrl, au setIsFullscreen(false); // 전체화면도 해제 }, []); + // Spotlight Down 키 핸들러 - 비디오 다음 이미지로 스크롤 + const handleSpotlightDown = useCallback( + (e) => { + if (canPlayVideo && onScrollToImages) { + e.preventDefault(); + e.stopPropagation(); + onScrollToImages(); + return true; // 이벤트 처리 완료 + } + return false; // Spotlight가 기본 동작 수행 + }, + [canPlayVideo, onScrollToImages] + ); + // Back 버튼 핸들러 - 전체화면 해제 또는 비디오 종료 const handleBackButton = useCallback(() => { if (isFullscreen) { @@ -226,12 +246,14 @@ export default function ProductVideoV2({ productInfo, videoUrl, thumbnailUrl, au ? { spotlightRestrict: 'self-only', // 포커스가 밖으로 나가지 않도록 spotlightId: 'product-video-v2-fullscreen', + onSpotlightDown: handleSpotlightDown, // 전체화면에서도 Down 키 동작 // 전체화면 모드: window 레벨에서 이벤트 처리 } : isPlaying ? { spotlightId: 'product-video-v2-playing', onKeyDown: handleContainerKeyDown, // 일반 모드: 컨테이너에서 직접 처리 + onSpotlightDown: handleSpotlightDown, // 일반 재생에서도 Down 키 동작 // 일반 재생 모드: 컨테이너가 포커스 받음 } : {}; @@ -250,6 +272,7 @@ export default function ProductVideoV2({ productInfo, videoUrl, thumbnailUrl, au onClick={handleThumbnailClick} onFocus={videoContainerOnFocus} onBlur={videoContainerOnBlur} + onSpotlightDown={handleSpotlightDown} spotlightId="product-video-v2-thumbnail" aria-label={`${productInfo?.prdtNm} 동영상 재생`} > diff --git a/com.twin.app.shoptime/src/views/MediaPanel/MediaPanel.jsx b/com.twin.app.shoptime/src/views/MediaPanel/MediaPanel.jsx index 15faa28b..649d93d0 100644 --- a/com.twin.app.shoptime/src/views/MediaPanel/MediaPanel.jsx +++ b/com.twin.app.shoptime/src/views/MediaPanel/MediaPanel.jsx @@ -113,28 +113,43 @@ const MediaPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props // modal 스타일 설정 useEffect(() => { if (panelInfo.modal && panelInfo.modalContainerId) { + // modal 모드: modalContainerId 기반으로 위치와 크기 계산 const node = document.querySelector(`[data-spotlight-id="${panelInfo.modalContainerId}"]`); if (node) { const { width, height, top, left } = node.getBoundingClientRect(); + + // ProductVideo의 padding(6px * 2)과 추가 여유를 고려하여 크기 조정 + // 비디오가 오른쪽으로 넘치지 않도록 충분한 여유 확보 + const paddingOffset = 6 * 2; // padding 양쪽 + const extraMargin = 6 * 2; // 추가 여유 (포커스 테두리 + 비디오 비율 고려) + const totalOffset = paddingOffset + extraMargin; // 24px + + const adjustedWidth = width - totalOffset; + const adjustedHeight = height - totalOffset; + const adjustedTop = top + totalOffset / 2; + const adjustedLeft = left + totalOffset / 2; + const style = { - width: width + 'px', - height: height + 'px', - top: top + 'px', - left: left + 'px', + width: adjustedWidth + 'px', + height: adjustedHeight + 'px', + maxWidth: adjustedWidth + 'px', + maxHeight: adjustedHeight + 'px', + top: adjustedTop + 'px', + left: adjustedLeft + 'px', position: 'fixed', - overflow: 'visible', + overflow: 'hidden', // visible → hidden으로 변경하여 넘치는 부분 숨김 }; setModalStyle(style); let scale = 1; if (typeof window === 'object') { - scale = width / window.innerWidth; + scale = adjustedWidth / window.innerWidth; setModalScale(scale); } } else { setModalStyle(panelInfo.modalStyle || {}); setModalScale(panelInfo.modalScale || 1); } - } else if (isOnTop && !panelInfo.modal && videoPlayer.current) { + } else if (isOnTop && !panelInfo.modal && !panelInfo.isMinimized && videoPlayer.current) { if (videoPlayer.current?.getMediaState()?.paused) { videoPlayer.current.play(); } @@ -263,8 +278,9 @@ const MediaPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props const onEnded = useCallback( (e) => { - // console.log('[MediaPanel] Video ended'); - // 비디오 종료 시 패널 닫기 + console.log('[MediaPanel] Video ended'); + // continuousPlay는 MediaPlayer(VideoPlayer) 컴포넌트 내부에서 loop 속성으로 처리 + // onEnded가 호출되면 loop=false 인 경우이므로 패널을 닫음 Spotlight.pause(); setTimeout(() => { Spotlight.resume(); @@ -290,23 +306,48 @@ const MediaPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props // console.log('[MediaPanel] ========== Rendering =========='); // console.log('[MediaPanel] isOnTop:', isOnTop); - // console.log('[MediaPanel] panelInfo:', JSON.stringify(panelInfo, null, 2)); + // console.log('[MediaPanel] panelInfo.modal:', panelInfo.modal); + // console.log('[MediaPanel] panelInfo.isMinimized:', panelInfo.isMinimized); + // console.log('[MediaPanel] panelInfo.isPaused:', panelInfo.isPaused); // console.log('[MediaPanel] currentPlayingUrl:', currentPlayingUrl); // console.log('[MediaPanel] hasVideoPlayer:', !!videoPlayer.current); + // classNames 적용 상태 확인 + // console.log('[MediaPanel] ========== ClassNames Analysis =========='); + // console.log('[MediaPanel] css.videoContainer:', css.videoContainer); + // console.log('[MediaPanel] Condition [panelInfo.modal && !panelInfo.isMinimized]:', panelInfo.modal && !panelInfo.isMinimized); + // console.log('[MediaPanel] css.modal:', css.modal); + // console.log('[MediaPanel] Condition [panelInfo.isMinimized]:', panelInfo.isMinimized); + // console.log('[MediaPanel] css["modal-minimized"]:', css['modal-minimized']); + // console.log('[MediaPanel] Condition [!isOnTop]:', !isOnTop); + // console.log('[MediaPanel] css.background:', css.background); + + const appliedClassNames = classNames( + css.videoContainer, + panelInfo.modal && !panelInfo.isMinimized && css.modal, + panelInfo.isMinimized && css['modal-minimized'], + !isOnTop && css.background + ); + // console.log('[MediaPanel] Final Applied ClassNames:', appliedClassNames); + // console.log('[MediaPanel] modalStyle:', modalStyle); + // console.log('[MediaPanel] modalScale:', modalScale); + // console.log('[MediaPanel] ==============================================='); + + // minimized 상태일 때는 spotlightRestrict 해제 (포커스 이동 허용) + const containerSpotlightRestrict = panelInfo.isMinimized ? 'none' : 'self-only'; + return ( - + {currentPlayingUrl && ( state.common?.loadingComplete); + const logViewerRef = useRef(null); + + // Platform detection: Luna/Voice framework only works on TV + const isTV = typeof window === 'object' && window.PalmSystem; + + // Voice state from Redux + const voiceState = useSelector((state) => state.voice); + const { isRegistered, voiceTicket, logs, registrationError } = voiceState; useEffect(() => { if (isOnTop) { @@ -24,11 +31,83 @@ export default function VoicePanel({ panelInfo, isOnTop, spotlightId }) { } }, [isOnTop, dispatch]); + // Cleanup on unmount + useEffect(() => { + return () => { + dispatch(unregisterVoiceFramework()); + }; + }, [dispatch]); + const handleBackButton = useCallback(() => { console.log(`[VoicePanel] Back button clicked - returning to previous panel`); dispatch(popPanel()); }, [dispatch]); + const handleRegister = useCallback(() => { + if (!isTV) { + console.warn('[VoicePanel] Voice framework is only available on TV platform'); + dispatch({ + type: types.VOICE_ADD_LOG, + payload: { + timestamp: new Date().toISOString(), + type: 'ERROR', + title: 'Platform Not Supported', + data: { message: 'Voice framework is only available on webOS TV platform' }, + success: false, + }, + }); + return; + } + console.log('[VoicePanel] Register button clicked'); + dispatch(registerVoiceFramework()); + }, [dispatch, isTV]); + + const handleClearLogs = useCallback(() => { + console.log('[VoicePanel] Clear logs button clicked'); + dispatch({ type: types.VOICE_CLEAR_LOGS }); + }, [dispatch]); + + const handleLoadMockData = useCallback(() => { + console.log('[VoicePanel] Loading 200 mock log entries for scroll test'); + // Add all mock logs to Redux + mockLogs.forEach((log) => { + dispatch({ + type: types.VOICE_ADD_LOG, + payload: log, + }); + }); + }, [dispatch]); + + const formatTime = useCallback((timestamp) => { + const date = new Date(timestamp); + return date.toLocaleTimeString('en-US', { hour12: false }); + }, []); + + const getTypeColor = useCallback((type) => { + const colors = { + REQUEST: '#4A90E2', + RESPONSE: '#7ED321', + COMMAND: '#F5A623', + ERROR: '#D0021B', + ACTION: '#9013FE', + }; + return colors[type] || '#FFFFFF'; + }, []); + + const handleScrollUp = useCallback(() => { + if (!logViewerRef.current) return; + const scrollAmount = 200; // Scroll 200px up + logViewerRef.current.scrollTop -= scrollAmount; + console.log('[VoicePanel] Scroll Up clicked'); + }, []); + + const handleScrollDown = useCallback(() => { + if (!logViewerRef.current) return; + const scrollAmount = 200; // Scroll 200px down + logViewerRef.current.scrollTop += scrollAmount; + console.log('[VoicePanel] Scroll Down clicked'); + }, []); + return ( - - {loadingComplete && ( - -
-

Voice Panel

-

- Voice search functionality will be implemented here. -

+ {loadingComplete && ( +
+ {/* Buttons - All in one row */} +
+ + {isRegistered ? 'Registered ✓' : 'Register'} + + + Clear + + + Mock + + + ↑ Up + + + ↓ Down + +
+ + {/* Status and Logs */} +
+ {/* Status Panel */} +
+
+ Platform: + + {isTV ? '✓ TV (webOS)' : '✗ Web Browser'} + +
+
+ Status: + + {isRegistered ? '✓ Registered' : '✗ Not Registered'} + +
+
+ Ticket: + {voiceTicket || 'N/A'} +
+ {registrationError && ( +
+ Error: + {JSON.stringify(registrationError)} +
+ )}
- - )} - + + {/* Log Viewer */} +
+
+ Event Logs ({logs.length}) +
+
+ {logs.length === 0 ? ( +
No logs yet. Click "Register" to start.
+ ) : ( + logs.map((log) => ( +
+
+ [{formatTime(log.timestamp)}] + + {log.type} + + {log.title} +
+
{JSON.stringify(log.data, null, 2)}
+
+ )) + )} +
+
+
+
+ )} ); } diff --git a/com.twin.app.shoptime/src/views/VoicePanel/VoicePanel.module.less b/com.twin.app.shoptime/src/views/VoicePanel/VoicePanel.module.less index ab2a315e..c1c1b84c 100644 --- a/com.twin.app.shoptime/src/views/VoicePanel/VoicePanel.module.less +++ b/com.twin.app.shoptime/src/views/VoicePanel/VoicePanel.module.less @@ -14,28 +14,224 @@ overflow: hidden; } -.voiceContainer { +// Content Wrapper - Main container +.contentWrapper { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + overflow: hidden; +} + +// Button Area - Single row, compact buttons +.buttonArea { + flex-shrink: 0; + padding: 30px 60px 15px; + display: flex; + + flex-wrap: nowrap; // Force single row +} + +.compactButton { + min-width: auto; + max-width: auto; + padding: 6px 8px; + font-size: 22px; + line-height: 1.2; + white-space: nowrap; + flex-shrink: 1; display: flex; - justify-content: center; align-items: center; - min-height: 600px; - padding: 60px; + justify-content: center; + margin-right: 12px; + + &:last-child { + margin-right: 0; + } } -.voiceContent { - text-align: center; - max-width: 800px; +// Info Container - Status and Logs (increased height) +.infoContainer { + flex: 1; + display: flex; + flex-direction: column; + padding: 0 60px 30px; + overflow: hidden; } -.title { - font-size: 48px; +// Status Panel - Dark theme (more compact) +.statusPanel { + background: rgba(0, 0, 0, 0.6); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 12px; + padding: 20px 30px; + margin-bottom: 20px; + flex-shrink: 0; +} + +.statusItem { + display: flex; + align-items: center; + margin-bottom: 12px; + font-size: 20px; + + &:last-child { + margin-bottom: 0; + } +} + +.statusLabel { + color: #c0c0c0; + margin-right: 15px; + min-width: 100px; + font-weight: 500; +} + +.statusValue { + color: #f0f0f0; + font-family: 'Courier New', monospace; +} + +.statusSuccess { + color: #7ED321; font-weight: bold; - color: #ffffff; - margin-bottom: 30px; } -.description { - font-size: 24px; - color: #cccccc; - line-height: 1.6; +.statusInactive { + color: #999999; +} + +.statusWarning { + color: #FFB84D; + font-weight: bold; +} + +.statusError { + color: #FF4D4D; + font-family: 'Courier New', monospace; + font-size: 18px; +} + +// Log Section - Dark theme with better visibility +.logSection { + flex: 1; + display: flex; + flex-direction: column; + background: #0a0a0a; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 12px; + overflow: hidden; + min-height: 0; +} + +.logHeader { + background: linear-gradient(135deg, #1a1a1a 0%, #0f0f0f 100%); + padding: 20px 25px; + font-size: 22px; + font-weight: bold; + color: #f0f0f0; + border-bottom: 2px solid rgba(255, 255, 255, 0.3); + flex-shrink: 0; +} + +.logViewer { + flex: 1; + padding: 20px; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + background: #000000; + + // Custom scrollbar styling for TV - Brighter + &::-webkit-scrollbar { + width: 14px; + } + + &::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.08); + border-radius: 7px; + } + + &::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.4); + border-radius: 7px; + + &:hover { + background: rgba(255, 255, 255, 0.6); + } + } +} + +.emptyLog { + text-align: center; + color: #aaaaaa; + font-size: 20px; + padding: 60px 20px; +} + +// Log Entry - Enhanced dark theme +.logEntry { + background: rgba(255, 255, 255, 0.03); + border-radius: 10px; + padding: 20px; + margin-bottom: 16px; + border-left: 4px solid rgba(255, 255, 255, 0.4); + border: 1px solid rgba(255, 255, 255, 0.1); + border-left: 4px solid rgba(255, 255, 255, 0.4); + transition: all 0.2s ease; + + &:hover { + background: rgba(255, 255, 255, 0.06); + border-left-color: #4A90E2; + box-shadow: 0 2px 8px rgba(74, 144, 226, 0.3); + } + + &:last-child { + margin-bottom: 0; + } +} + +.logEntryHeader { + display: flex; + align-items: center; + gap: 15px; + margin-bottom: 15px; + font-size: 18px; +} + +.timestamp { + color: #b0b0b0; + font-family: 'Courier New', monospace; + font-size: 16px; +} + +.logType { + font-weight: bold; + font-family: 'Courier New', monospace; + font-size: 16px; + padding: 6px 14px; + background: rgba(0, 0, 0, 0.6); + border-radius: 6px; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); +} + +.logTitle { + color: #f0f0f0; + font-weight: 500; + flex: 1; +} + +.logData { + background: #000000; + padding: 20px; + border-radius: 8px; + overflow-x: auto; + font-family: 'Courier New', monospace; + font-size: 16px; + color: #00ff88; + line-height: 1.6; + white-space: pre-wrap; + word-wrap: break-word; + margin: 0; + border: 1px solid rgba(255, 255, 255, 0.2); } diff --git a/com.twin.app.shoptime/src/views/VoicePanel/mockLogData.js b/com.twin.app.shoptime/src/views/VoicePanel/mockLogData.js new file mode 100644 index 00000000..410f4ab2 --- /dev/null +++ b/com.twin.app.shoptime/src/views/VoicePanel/mockLogData.js @@ -0,0 +1,171 @@ +// Mock log data for VoicePanel testing +// 200 log entries simulating various voice framework interactions + +const LOG_TYPES = ['REQUEST', 'RESPONSE', 'COMMAND', 'ERROR', 'ACTION']; + +const SAMPLE_REQUESTS = [ + { + service: 'luna://com.webos.service.voiceconductor', + method: 'interactor/register', + parameters: { type: 'foreground', subscribe: true }, + }, + { + service: 'luna://com.webos.service.voiceconductor', + method: 'interactor/setContext', + parameters: { + voiceTicket: 'ticket-12345', + inAppIntents: [{ intent: 'Select', supportOrdinal: true, items: [] }], + }, + }, + { + service: 'luna://com.webos.service.voiceconductor', + method: 'interactor/reportActionResult', + parameters: { voiceTicket: 'ticket-12345', result: true }, + }, +]; + +const SAMPLE_RESPONSES = [ + { + subscribed: true, + returnValue: true, + }, + { + command: 'setContext', + voiceTicket: 'ticket-abc123', + subscribed: true, + returnValue: true, + }, + { + command: 'performAction', + voiceTicket: 'ticket-abc123', + action: { + type: 'IntentMatch', + intent: 'Select', + itemId: 'voice-search-button', + }, + subscribed: true, + returnValue: true, + }, + { + returnValue: true, + message: 'Action result reported successfully', + }, +]; + +const SAMPLE_COMMANDS = [ + { + command: 'setContext', + voiceTicket: 'ticket-xyz789', + timestamp: new Date().toISOString(), + }, + { + command: 'performAction', + action: { + intent: 'Scroll', + direction: 'down', + }, + }, +]; + +const SAMPLE_ERRORS = [ + { + returnValue: false, + errorCode: -1, + errorText: 'Service not available', + }, + { + returnValue: false, + errorCode: 404, + errorText: 'Method not found', + }, + { + message: 'Voice framework registration failed', + reason: 'Platform not supported', + }, +]; + +const SAMPLE_ACTIONS = [ + { + intent: 'Select', + itemId: 'voice-register-btn', + processed: true, + }, + { + intent: 'Scroll', + direction: 'up', + processed: true, + }, +]; + +function getRandomElement(array) { + return array[Math.floor(Math.random() * array.length)]; +} + +function generateMockLogs(count = 200) { + const logs = []; + let baseTime = Date.now() - count * 1000; // Start from count seconds ago + + for (let i = 0; i < count; i++) { + const logType = getRandomElement(LOG_TYPES); + let title, data; + + switch (logType) { + case 'REQUEST': { + const request = getRandomElement(SAMPLE_REQUESTS); + title = `Luna Request: ${request.method}`; + data = request; + break; + } + + case 'RESPONSE': { + const response = getRandomElement(SAMPLE_RESPONSES); + title = response.command + ? `Voice Framework Response: ${response.command}` + : 'Luna Response'; + data = response; + break; + } + + case 'COMMAND': { + const command = getRandomElement(SAMPLE_COMMANDS); + title = `Voice Command: ${command.command}`; + data = command; + break; + } + + case 'ERROR': { + const error = getRandomElement(SAMPLE_ERRORS); + title = 'Error Occurred'; + data = error; + break; + } + + case 'ACTION': { + const action = getRandomElement(SAMPLE_ACTIONS); + title = `Action Processed: ${action.intent}`; + data = action; + break; + } + + default: + title = 'Unknown Log Entry'; + data = { message: 'No data available' }; + } + + logs.push({ + id: i + 1, + timestamp: new Date(baseTime + i * 1000).toISOString(), + type: logType, + title: title, + data: data, + success: logType !== 'ERROR', + }); + } + + return logs; +} + +// Generate 200 mock logs +export const mockLogs = generateMockLogs(200); + +export default mockLogs; diff --git a/com.twin.app.shoptime/vui.md b/com.twin.app.shoptime/vui.md new file mode 100644 index 00000000..68178422 --- /dev/null +++ b/com.twin.app.shoptime/vui.md @@ -0,0 +1,531 @@ +# webOS Voice User Interface (VUI) Guide + +## Table of Contents +- [webOS Voice User Interface (VUI) Guide](#webos-voice-user-interface-vui-guide) + - [Table of Contents](#table-of-contents) + - [1. Search and Play Commands (Global Actions)](#1-search-and-play-commands-global-actions) + - [1.1 Scenario](#11-scenario) + - [1.2 What You Will Receive](#12-what-you-will-receive) + - [1.3 Implementation Requirements](#13-implementation-requirements) + - [2. Media Controls and UI Controls (Foreground App Control)](#2-media-controls-and-ui-controls-foreground-app-control) + - [2.1 Scenario](#21-scenario) + - [Media Controls](#media-controls) + - [UI Controls](#ui-controls) + - [2.2 Implementation Guide](#22-implementation-guide) + - [2.3 API Reference: `com.webos.service.voiceconductor`](#23-api-reference-comwebosservicevoiceconductor) + - [Available APIs for InAppControl](#available-apis-for-inappcontrol) + - [API: `/interactor/register`](#api-interactorregister) + - [API: `/interactor/setContext`](#api-interactorsetcontext) + - [API: `/reportActionResult`](#api-reportactionresult) + - [2.4 Registration Example](#24-registration-example) + - [2.5 Voice Command Flow Diagram](#25-voice-command-flow-diagram) + - [2.6 Registering In-App Intents](#26-registering-in-app-intents) + - [Step 1: Receive setContext Command](#step-1-receive-setcontext-command) + - [Step 2: Send Intent List to Voice Framework](#step-2-send-intent-list-to-voice-framework) + - [rhk](#rhk) + - [2.7 Executing Voice Commands](#27-executing-voice-commands) + - [Step 3: Receive performAction Command](#step-3-receive-performaction-command) + - [Step 4: Process and Report Result](#step-4-process-and-report-result) + - [2.8 Feedback Object Format (Optional)](#28-feedback-object-format-optional) + - [Feedback Properties](#feedback-properties) + - [`general` Property Example](#general-property-example) + - [`voiceUi` Property Examples](#voiceui-property-examples) + - [2.9 Predefined Exception IDs](#29-predefined-exception-ids) + - [2.10 Complete In-App Intent Reference](#210-complete-in-app-intent-reference) + - [Understanding In-App Actions](#understanding-in-app-actions) + - [Intent List Table](#intent-list-table) + - [3. In-App Intent Examples](#3-in-app-intent-examples) + - [3.1 Basic Policy](#31-basic-policy) + - [3.2 Basic Payload Format](#32-basic-payload-format) + - [3.3 Detailed Intent Payload Examples](#33-detailed-intent-payload-examples) + +--- + +## 1. Search and Play Commands (Global Actions) + +### 1.1 Scenario + +This feature allows your app to support search and play commands through voice. You will receive intent and keyword arguments when implementing your app according to this guide. + +**Key Characteristics:** +- These commands work as **Global Actions** - they can be triggered even when your app is not in the foreground +- If the user mentions the app name, the command works globally +- If the user doesn't mention the app name, the command only works when the app is in the foreground + +**Examples:** + +| User Speech | Behavior | +|-------------|----------| +| "Search for Avengers on Netflix" | The keyword "Avengers" is passed to Netflix even if the user is watching Live TV. Netflix launches in the foreground. | +| "Play Avengers" | The keyword "Avengers" goes to the foreground app. If the app doesn't support webOS VUI, results are shown through LG Voice app. | +| "Search Avengers" | Same behavior as "Play Avengers" | + +--- + +### 1.2 What You Will Receive + +Your app will receive the following object as arguments with the **"relaunch"** event. + +**Available Intents:** +- `SearchContent` +- `PlayContent` + +**Parameter Syntax:** + +```json +"params": { + "intent": "SearchContent", + "intentParam": "Avengers", + "languageCode": "en-US" +} +``` + +**Parameter Details:** + +| Parameter | Required | Type | Description | +|-----------|----------|------|-------------| +| `intent` | Yes | string | User intent: `SearchContent` or `PlayContent` | +| `intentParam` | Yes | string | Keyword for searching or playing | +| `languageCode` | Yes | string | Language code of NLP (e.g., `en-US`, `ko-KR`) | +| `voiceEngine` | No | string | Information about the voice assistant used by the user
• `amazonAlexa`
• `googleAssistant`
• `thinQtv`
**Note:** Supported from webOS 6.0+ (2021 products) | + +--- + +### 1.3 Implementation Requirements + +Add the `inAppVoiceIntent` property to your app's `appinfo.json` file to receive keywords and intents from user voice commands. + +**Configuration Syntax:** + +```json +"inAppVoiceIntent": { + "contentTarget": { + "intent": "$INTENT", + "intentParam": "$INTENT_PARAM", + "languageCode": "$LANG_CODE", + "voiceEngine": "$VOICE_ENGINE" + }, + "voiceConfig": { + "supportedIntent": ["SearchContent", "PlayContent"], + "supportedVoiceLanguage": [] + } +} +``` + +**Configuration Parameters:** + +| Parameter | Required | Type | Description | +|-----------|----------|------|-------------| +| **contentTarget** | | | **Parameters to receive from voice commands** | +| `intent` | Yes | string | Parameter to receive the user's intent | +| `intentParam` | Yes | string | Parameter to receive the search/play keyword | +| `languageCode` | Yes | string | Parameter to receive the NLP language code | +| `voiceEngine` | No | string | Parameter to receive voice assistant information
**Note:** Supported from webOS 6.0+ (2021 products) | +| **voiceConfig** | | | **App capabilities configuration** | +| `supportedIntent` | No | array | Intents supported by your app
**Examples:**
• `["SearchContent"]`
• `["PlayContent"]`
• `["SearchContent", "PlayContent"]` | +| `supportedVoiceLanguage` | No | array | Languages supported by your app
**Format:** BCP-47 (e.g., `["en-US", "ko-KR"]`) | + +--- + +## 2. Media Controls and UI Controls (Foreground App Control) + +### 2.1 Scenario + +Your app can receive voice intents to control functionality through user speech. + +**Key Characteristics:** +- These controls **only work when your app is in the foreground** +- You must register only the intents that your app can actually process +- Do not register commands that your app cannot handle + +**Supported Control Types:** + +#### Media Controls + +**Category i - Playback Controls:** +- Play previous/next content +- Skip intro +- Forward 30 seconds +- Backward 30 seconds +- Start over +- OK, Select, Toggle + +**Category ii - Content Management:** +- Play N times (e.g., "Play 2 times") +- Change profile +- Add profile +- Add this content to my list +- Delete this content from my list +- Like/Dislike this content *(expected to be supported in the future)* + +#### UI Controls +- OK +- Select +- Toggle +- Check +- And more... + +--- + +### 2.2 Implementation Guide + +Refer to the flow chart below to understand the sequence. Focus on the **Foreground app** block for implementation details. + +![alt text](image-1.png) + +ii. API: com.webos.service.voiceconductor + +### 2.3 API Reference: `com.webos.service.voiceconductor` + +#### Available APIs for InAppControl + +- `com.webos.service.voiceconductor/interactor/register` +- `com.webos.service.voiceconductor/interactor/setContext` +- `com.webos.service.voiceconductor/interactor/reportActionResult` + +--- + +#### API: `/interactor/register` + +Register your app with the voice framework to receive voice commands. + +**Parameters:** + +| Parameter | Required | Type | Values | Description | +|-----------|----------|------|--------|-------------| +| `subscribe` | Yes | boolean | `true` | Must be `true` (false is not allowed) | +| `type` | Yes | string | `"foreground"` | Type cannot be customized, only `"foreground"` is supported | + +--- + +#### API: `/interactor/setContext` + +Set the voice intents that your app supports at the current time. + +**Parameters:** + +| Parameter | Required | Type | Description | +|-----------|----------|------|-------------| +| `voiceTicket` | Yes | string | The value returned from `/interactor/register` | +| `inAppIntents` | Yes | array | List of intents to support at the time
Refer to [In App Intent List](#c-in-app-intent-list) | + +--- + +#### API: `/reportActionResult` + +Report the result of processing a voice command. + +**Parameters:** + +| Parameter | Required | Type | Description | +|-----------|----------|------|-------------| +| `voiceTicket` | Yes | string | The value returned from `/interactor/register` | +| `result` | Yes | boolean | `true` if the command was processed successfully
`false` if processing failed | +| `feedback` | No | object | Optional details about the result | + +--- + +### 2.4 Registration Example + +**Request:** + +```javascript +com.webos.service.voiceconductor/interactor/register +{ + "type": "foreground", + "subscribe": true +} +``` + +**Response:** + +```json +{ + "subscribed": true, + "returnValue": true +} +``` + +**Return Value Details:** + +| Return Type | Payload | Description | +|-------------|---------|-------------| +| Initial Response | `{"subscribed": true, "returnValue": true}` | Registration successful | + +--- + +### 2.5 Voice Command Flow Diagram + +```mermaid +sequenceDiagram + participant MRCU as MRCU (keyfilter) + participant VoiceConductor as com.webos.service.voiceconductor
(voice framework) + participant VoiceEngine as com.webos.service.voiceconductor
(Voice Engine Plugin) + participant NLP as NLP Server + participant ForegroundApp as Foreground App + + MRCU->>VoiceConductor: 1. start NLP speechStarted + VoiceConductor->>VoiceEngine: 4. recognizeIntentByVoice + VoiceEngine->>NLP: 5. request & result + NLP->>VoiceEngine: + VoiceEngine->>VoiceConductor: 6. return (recognizeIntentByVoice) + VoiceConductor->>ForegroundApp: 2. /interactor/register ("command": "setContext") + ForegroundApp->>VoiceConductor: 3. /command/setContext + VoiceConductor->>ForegroundApp: 7. /interactor/register ("command": "performAction") + ForegroundApp->>VoiceConductor: 8. /interactor/reportActionResult + VoiceConductor->>ForegroundApp: 9. update handle result + + Note left of MRCU: Subscribe-reply ---->
Call ----> +``` + + +### 2.6 Registering In-App Intents + +#### Step 1: Receive setContext Command + +When your app is in the foreground, you'll receive: + +```json +{ + "command": "setContext", + "voiceTicket": "{{STRING}}", + "subscribed": true, + "returnValue": true +} +``` + +#### Step 2: Send Intent List to Voice Framework + +Gather all intents your app supports and send them to the voice conductor: + +**Request:** + +```javascript +com.webos.service.voiceconductor/interactor/setContext +{ + "voiceTicket": "{{STRING}}", + "inAppIntents": [ + { + "intent": "Select", + "supportOrdinal": false, + "items": [ + { + "title": "", // optional + "itemId": "{{STRING}}", + "value": ["SEE ALL"] + }, + { + "itemId": "{{STRING}}", + "value": ["RECENTLY OPEN PAGES"] + } + ] + } + ] +} +``` +rhk +--- + +### 2.7 Executing Voice Commands + +#### Step 3: Receive performAction Command + +When a user speaks a command, your app receives: + +```json +{ + "command": "performAction", + "voiceTicket": "{{STRING}}", + "action": { + "type": "IntentMatch", + "intent": "Select", + "itemId": "{{STRING}}" + }, + "subscribed": true, + "returnValue": true +} +``` + +#### Step 4: Process and Report Result + +After processing the command, report the result: + +**Request:** + +```javascript +com.webos.service.voiceconductor/interactor/reportActionResult +{ + "voiceTicket": "{{STRING}}", + "result": true // true: success, false: failure +} +``` + +--- + +### 2.8 Feedback Object Format (Optional) + +You can provide additional feedback when reporting action results. + +#### Feedback Properties + +| Property | Required | Type | Description | +|----------|----------|------|-------------| +| `general` | No | object | Reserved (Currently unsupported) | +| `voiceUi` | No | object | Declare system utterance or exception | + +#### `general` Property Example + +```json +{ + "responseCode": "0000", + "responseMessage": "OK" +} +``` + +#### `voiceUi` Property Examples + +**Exception:** + +```json +{ + "exception": "alreadyCompleted" +} +``` + +**System Utterance:** + +```json +{ + "systemUtterance": "The function is not supported" +} +``` + +**voiceUi Sub-Properties:** +- `systemUtterance`: Message to display to the user +- `exception`: Predefined exception type (see below) + +--- + +### 2.9 Predefined Exception IDs + +| Exception ID | Description | Use Case Example | +|--------------|-------------|------------------| +| `alreadyCompleted` | Action was processed correctly but no further action is needed | Scroll command received but page is already at the end | + +--- + +### 2.10 Complete In-App Intent Reference + +#### Understanding In-App Actions + +**Control Actions:** +- You must pass both `intent` and `control` values for the actions you want to support +- The system will respond with the corresponding action data +- Request: `requests.control` +- Response: `ActionData.control` + +**Ordinal Speech Support:** +- Only certain intents support ordinal speech (e.g., "Select the first one", "Play the second video") +- To enable ordinal speech, set `"supportOrdinal": true` in your intent configuration +- Supported intents: `Select`, `SelectRadioItem`, `SelectCheckItem`, `SetToggleItem`, `PlayContent`, `Delete`, `Show`, `Hide` + +#### Intent List Table + +| # | Intent Type | Control Action | Ordinal | Multi-Item | Example User Speech | Status | +|---|-------------|----------------|---------|------------|---------------------|--------| +| **1** | `Select` | - | ✅ | ✅ | "Okay", "Select Okay" | ✅ | +| **2** | `Scroll` | - | ❌ | ✅ | "Scroll up/down" | ✅ | +| **3** | `SelectRadioItem` | - | ✅ | ✅ | "Select [Radio Button Name]" | ✅ | +| **4** | `SelectCheckItem` | - | ✅ | ✅ | "Select/Deselect [Checkbox Name]" | ✅ | +| **5** | `SetToggleItem` | - | ✅ | ✅ | "Turn on/off [Toggle Item Name]" | ✅ | +| **6** | `PlayContent` | - | ✅ | ✅ | "Play [Content Name]" | ✅ | +| **7** | `PlayListControl` | - | ❌ | ❌ | "Play previous/next content" | ✅ | +| **8** | `Delete` | - | ✅ | ✅ | "Delete [Content Name]" | ✅ | +| **9** | `Zoom` | - | ❌ | ❌ | "Zoom in/out" | ✅ | +| **10** | *(Reserved)* | - | - | - | - | - | +| **11** | `ControlMedia` | `skipIntro` | ❌ | ❌ | "Skip Intro" | ✅ | +| **12** | `ControlMedia` | `forward` | ❌ | ❌ | "Forward 30 seconds"
"Fast forward 1 minute" | ✅ | +| **13** | `ControlMedia` | `backward` | ❌ | ❌ | "Backward 30 seconds"
"Rewind 30 seconds" | ✅ | +| **14** | `ControlMedia` | `move` | ❌ | ❌ | "Start over"
"Play from 2:30"
"Skip to 1:30" | ✅ | +| **15** | `ControlMedia` | `speed` | ❌ | ❌ | "Play at 1.5x speed"
"Play 2x speed" | ✅ | +| **16** | `ControlMedia` | `defaultSpeed` | ❌ | ❌ | "Play at default speed" | ✅ | +| **17** | `ControlMedia` | `playLikeList` | ❌ | ❌ | "Play liked songs/videos" | ✅ | +| **18** | `ControlMedia` | `playSubscriptionList` | ❌ | ❌ | "Play subscriptions" | ✅ | +| **19** | `ControlMedia` | `playWatchLaterList` | ❌ | ❌ | "Play watch later" | ✅ | +| **20** | `ControlMedia` | `playMyPlaylist` | ❌ | ❌ | "Play my [playlist name]" | ✅ | +| **21** | `ControlMedia` | `sendToDevice` | ❌ | ❌ | "Send this to my phone" | ✅ | +| **22** | `ControlMedia` | `skipAd` | ❌ | ❌ | "Skip ad" | ✅ | +| **23** | `ControlMedia` | `play` | ❌ | ❌ | "Play" | ✅ | +| **24** | `ControlMedia` | `pause` | ❌ | ❌ | "Pause" | ✅ | +| **25** | `ControlMedia` | `nextChapter` | ❌ | ❌ | "Next chapter" | ✅ | +| **26** | `ControlMedia` | `previousChapter` | ❌ | ❌ | "Previous chapter" | ✅ | +| **27** | `ControlMedia` | `shuffle` | ❌ | ❌ | "Shuffle" | ✅ | +| **28** | `ControlMedia` | `repeat` | ❌ | ❌ | "Repeat" | ✅ | +| **29** | `SetMediaOption` | `turnCaptionOn` | ❌ | ❌ | "Turn on caption" | ✅ | +| **30** | `SetMediaOption` | `turnCaptionOff` | ❌ | ❌ | "Turn off caption" | ✅ | +| **31** | `SetMediaOption` | `selectLanguage` | ❌ | ❌ | "Set caption language to English"
"Set audio to default" | ✅ | +| **32** | `RateContents` | `likeContents` | ❌ | ❌ | "Like this content/song/video"
"Thumbs up" | ✅ | +| **33** | `RateContents` | `cancelLike` | ❌ | ❌ | "Remove like from this content" | ✅ | +| **34** | `RateContents` | `dislikeContents` | ❌ | ❌ | "Dislike this content/song/video" | ✅ | +| **35** | `RateContents` | `cancelDislike` | ❌ | ❌ | "Remove dislike from this content" | ✅ | +| **36** | `RateContents` | `rateContents` | ❌ | ❌ | "Rate this content"
"Rate this 5 points" | ✅ | +| **37** | `RateContents` | `cancelRating` | ❌ | ❌ | "Cancel the rating"
"Remove this rating" | ✅ | +| **38** | `RateContents` | `addToMyList` | ❌ | ❌ | "Add this to My list"
"Add to favorites"
"Save to watch later" | ✅ | +| **39** | `RateContents` | `removeFromMyList` | ❌ | ❌ | "Delete from My list"
"Remove from favorites" | ✅ | +| **40** | `RateContents` | `likeAndSubscribe` | ❌ | ❌ | "Like and subscribe" | ✅ | +| **41** | `RateContents` | `subscribe` | ❌ | ❌ | "Subscribe"
"Subscribe to this channel" | ✅ | +| **42** | `RateContents` | `unsubscribe` | ❌ | ❌ | "Unsubscribe"
"Unsubscribe from this channel" | ✅ | +| **43** | `DisplayList` | `displayMyList` | ❌ | ❌ | "Show me my favorites"
"Show my playlists" | ✅ | +| **44** | `DisplayList` | `displayRecentHistory` | ❌ | ❌ | "What have I watched lately?" | ✅ | +| **45** | `DisplayList` | `displayPurchaseHistory` | ❌ | ❌ | "What have I purchased lately?" | ✅ | +| **46** | `DisplayList` | `displayRecommendedContents` | ❌ | ❌ | "Recommend me something to watch" | ✅ | +| **47** | `DisplayList` | `displaySimilarContents` | ❌ | ❌ | "Search for something similar" | ✅ | +| **48** | `DisplayList` | `displayLikeList` | ❌ | ❌ | "Browse liked videos" | ✅ | +| **49** | `DisplayList` | `displaySubscriptionList` | ❌ | ❌ | "Browse subscriptions"
"Show my subscriptions" | ✅ | +| **50** | `DisplayList` | `displayWatchLaterList` | ❌ | ❌ | "Browse watch later"
"Show Watch Later playlist" | ✅ | +| **51** | `ControlStorage` | `addToWatchLater` | ❌ | ❌ | "Add to watch later"
"Add to Watch Later Playlist" | ✅ | +| **52** | `ControlStorage` | `removeFromWatchLater` | ❌ | ❌ | "Remove from watch later" | ✅ | +| **53** | `ControlStorage` | `addToMyPlaylist` | ❌ | ❌ | "Add video to my [playlist name]"
"Add song to [playlist name]" | ✅ | +| **54** | `ControlStorage` | `removeFromMyPlaylist` | ❌ | ❌ | "Remove video from [playlist name]" | ✅ | +| **55** | `UseIME` | - | ❌ | ❌ | N/A | - | +| **56** | `Show` | - | ✅ | ✅ | "Show [Content Name]" | ✅ | +| **57** | `Hide` | - | ✅ | ✅ | "Hide [Content Name]" | ✅ | + +**Legend:** +- **Ordinal**: Supports ordinal speech (e.g., "Select the first one") +- **Multi-Item**: Supports multiple items (2+ items) + + +--- + +## 3. In-App Intent Examples + +### 3.1 Basic Policy + +**Important Rules:** +- `itemId` within each Intent must be a globally unique value +- `PlayListControl` and `Zoom` intents can only register one instance +- Ordinal speech is only supported for: `Select`, `SelectRadioItem`, `SelectCheckItem`, `SetToggleItem`, `PlayContent`, `Delete`, `Show`, `Hide` + +--- + +### 3.2 Basic Payload Format + +**setContext Request Structure:** + +```json +{ + "voiceTicket": "{{STRING}}", // Ticket value from voice framework + "inAppIntents": [ // Array of in-app intents + { + // ... Intent configuration ... + } + ] +} +``` + +--- + +### 3.3 Detailed Intent Payload Examples + +The following section provides detailed payload examples for each intent type, showing both the request (app → voiceframework) and response (voiceframework → app) formats. diff --git a/com.twin.app.shoptime/webos-meta/appinfo.bakcup.json b/com.twin.app.shoptime/webos-meta/appinfo.bakcup.json new file mode 100644 index 00000000..00b24d7b --- /dev/null +++ b/com.twin.app.shoptime/webos-meta/appinfo.bakcup.json @@ -0,0 +1,15 @@ +{ + "id": "com.lgshop.app", + "version": "2.0.0", + "vendor": "T-Win", + "type": "web", + "main": "index.html", + "title": "Shop Time", + "icon": "icon.png", + "miniicon": "icon-mini.png", + "largeIcon": "icon-large.png", + "iconColor": "#ffffff", + "disableBackHistoryAPI": true, + "deeplinkingParams": "{\"contentTarget\":\"$CONTENTID\"}", + "uiRevision": 2 +} diff --git a/com.twin.app.shoptime/webos-meta/appinfo.json b/com.twin.app.shoptime/webos-meta/appinfo.json index 00b24d7b..249e554b 100644 --- a/com.twin.app.shoptime/webos-meta/appinfo.json +++ b/com.twin.app.shoptime/webos-meta/appinfo.json @@ -10,6 +10,26 @@ "largeIcon": "icon-large.png", "iconColor": "#ffffff", "disableBackHistoryAPI": true, + "handlesRelaunch": true, "deeplinkingParams": "{\"contentTarget\":\"$CONTENTID\"}", - "uiRevision": 2 + "uiRevision": 2, + "requiredPermissions": [ + "time.query", + "device.info", + "applications.query", + "settings.read", + "applications.operation" + ], + "inAppVoiceIntent": { + "contentTarget": { + "intent": "$INTENT", + "intentParam": "$INTENT_PARAM", + "languageCode": "$LANG_CODE", + "voiceEngine": "$VOICE_ENGINE" + }, + "voiceConfig": { + "supportedIntent": ["SearchContent", "PlayContent"], + "supportedVoiceLanguage": ["ko-KR", "en-US"] + } + } }