[251014] docs(views): [251014] VoicePanel

🕐 커밋 시간: 2025. 10. 14. 14:56:58

📊 변경 통계:
  • 총 파일: 21개
  • 추가: +714줄
  • 삭제: -69줄

📁 추가된 파일:
  + com.twin.app.shoptime/luna.md
  + com.twin.app.shoptime/src/actions/voiceActions.js
  + com.twin.app.shoptime/src/lunaSend/voice.js
  + com.twin.app.shoptime/src/reducers/voiceReducer.js
  + com.twin.app.shoptime/src/views/VoicePanel/mockLogData.js
  + com.twin.app.shoptime/vui.md
  + com.twin.app.shoptime/webos-meta/appinfo.bakcup.json

📝 수정된 파일:
  ~ com.twin.app.shoptime/src/actions/actionTypes.js
  ~ com.twin.app.shoptime/src/actions/mediaActions.js
  ~ com.twin.app.shoptime/src/components/VideoPlayer/Video.js
  ~ com.twin.app.shoptime/src/lunaSend/index.js
  ~ com.twin.app.shoptime/src/store/store.js
  ~ com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx
  ~ com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductVideo/ProductVideo.jsx
  ~ com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductVideo/ProductVideo.module.less
  ~ com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductVideo/ProductVideo.v2.jsx
  ~ com.twin.app.shoptime/src/views/MediaPanel/MediaPanel.jsx
  ~ com.twin.app.shoptime/src/views/MediaPanel/MediaPanel.module.less
  ~ com.twin.app.shoptime/src/views/VoicePanel/VoicePanel.jsx
  ~ com.twin.app.shoptime/src/views/VoicePanel/VoicePanel.module.less
  ~ com.twin.app.shoptime/webos-meta/appinfo.json

🔧 함수 변경 내용:
  📄 com.twin.app.shoptime/src/actions/mediaActions.js (javascript):
     Added: switchMediaToModal()
  📄 com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx (javascript):
    🔄 Modified: extractProductMeta()
  📄 com.twin.app.shoptime/src/views/VoicePanel/VoicePanel.module.less (unknown):
     Added: gradient()
  📄 com.twin.app.shoptime/luna.md (md파일):
     Added: Layer(), Functions(), LS2Request(), PalmServiceBridge(), Bus(), function(), instance(), cancel(), deleteInstance(), dispatch(), createToast(), getSystemSettings(), onSuccess(), getConnectionStatus(), useEffect()
  📄 com.twin.app.shoptime/src/actions/voiceActions.js (javascript):
     Added: addLog(), handleSelectIntent(), handleScrollIntent()
  📄 com.twin.app.shoptime/src/views/VoicePanel/mockLogData.js (javascript):
     Added: getRandomElement(), generateMockLogs()
  📄 com.twin.app.shoptime/vui.md (md파일):
     Added: Interface(), Commands(), Controls(), Format()

🔧 주요 변경 내용:
  • 타입 시스템 안정성 강화
  • 핵심 비즈니스 로직 개선
  • UI 컴포넌트 아키텍처 개선
  • 개발 문서 및 가이드 개선
  • 로깅 시스템 개선
This commit is contained in:
2025-10-14 14:57:02 +09:00
parent 997415f836
commit c823587eaf
21 changed files with 2891 additions and 404 deletions

View File

@@ -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 시스템 서비스와의 안정적이고 효율적인 통신을 구현하고 있습니다.

View File

@@ -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',
};

View File

@@ -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');
}
};

View File

@@ -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,
});

View File

@@ -1,6 +1,6 @@
import {forward} from '@enact/core/handle';
import { forward } from '@enact/core/handle';
import ForwardRef from '@enact/ui/ForwardRef';
import {Media, getKeyFromSource} from '@enact/ui/Media';
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';
@@ -31,6 +31,15 @@ const VideoBase = class extends React.Component {
*/
autoPlay: PropTypes.bool,
/**
* Video loops continuously.
*
* @type {Boolean}
* @default false
* @public
*/
loop: PropTypes.bool,
/**
* Video component to use.
*
@@ -96,16 +105,16 @@ const VideoBase = class extends React.Component {
* @public
*/
source: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
track: PropTypes.oneOfType([PropTypes.string, PropTypes.node])
track: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
};
static defaultProps = {
mediaComponent: 'video'
mediaComponent: 'video',
};
componentDidUpdate (prevProps) {
const {source, preloadSource} = this.props;
const {source: prevSource, preloadSource: prevPreloadSource} = prevProps;
componentDidUpdate(prevProps) {
const { source, preloadSource } = this.props;
const { source: prevSource, preloadSource: prevPreloadSource } = prevProps;
const key = getKeyFromSource(source);
const prevKey = getKeyFromSource(prevSource);
@@ -128,7 +137,7 @@ const VideoBase = class extends React.Component {
// 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);
forward('onUpdate', { type: 'onUpdate' }, this.props);
this.autoPlay();
} else if (key !== prevKey) {
@@ -149,7 +158,7 @@ const VideoBase = class extends React.Component {
}
}
componentWillUnmount () {
componentWillUnmount() {
this.clearMedia();
}
@@ -166,19 +175,19 @@ const VideoBase = class extends React.Component {
ev.stopPropagation();
};
clearMedia ({setMedia} = this.props) {
clearMedia({ setMedia } = this.props) {
if (setMedia) {
setMedia(null);
}
}
setMedia ({setMedia} = this.props) {
setMedia({ setMedia } = this.props) {
if (setMedia) {
setMedia(this.video);
}
}
autoPlay () {
autoPlay() {
if (!this.props.autoPlay) return;
this.video.play();
@@ -196,8 +205,8 @@ const VideoBase = class extends React.Component {
this.preloadVideo = node;
};
getKeys () {
const {source, preloadSource} = this.props;
getKeys() {
const { source, preloadSource } = this.props;
const sourceKey = source && getKeyFromSource(source);
let preloadKey = preloadSource && getKeyFromSource(preloadSource);
@@ -225,14 +234,8 @@ const VideoBase = class extends React.Component {
return preloadKey ? this.keys : this.keys.slice(0, 1);
}
render () {
const {
preloadSource,
source,
track,
mediaComponent,
...rest
} = this.props;
render() {
const { preloadSource, source, track, mediaComponent, ...rest } = this.props;
delete rest.setMedia;
@@ -249,12 +252,8 @@ const VideoBase = class extends React.Component {
mediaComponent={mediaComponent}
preload="none"
ref={this.setVideoRef}
track={React.isValidElement(track) ? track : (
<track src={track} />
)}
source={React.isValidElement(source) ? source : (
<source src={source} />
)}
track={React.isValidElement(track) ? track : <track src={track} />}
source={React.isValidElement(source) ? source : <source src={source} />}
/>
) : null}
{preloadKey ? (
@@ -267,12 +266,10 @@ const VideoBase = class extends React.Component {
onLoadStart={this.handlePreloadLoadStart}
preload="none"
ref={this.setPreloadRef}
track={React.isValidElement(track) ? track : (
<track src={track} />
)}
source={React.isValidElement(preloadSource) ? preloadSource : (
<source src={preloadSource} />
)}
track={React.isValidElement(track) ? track : <track src={track} />}
source={
React.isValidElement(preloadSource) ? preloadSource : <source src={preloadSource} />
}
/>
) : null}
</React.Fragment>
@@ -281,8 +278,8 @@ const VideoBase = class extends React.Component {
};
const VideoDecorator = compose(
ForwardRef({prop: 'setMedia'}),
Slottable({slots: ['source', 'track','preloadSource']})
ForwardRef({ prop: 'setMedia' }),
Slottable({ slots: ['source', 'track', 'preloadSource'] })
);
/**
@@ -319,6 +316,4 @@ const Video = VideoDecorator(VideoBase);
Video.defaultSlot = 'videoComponent';
export default Video;
export {
Video
};
export { Video };

View File

@@ -1,7 +1,8 @@
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);

View File

@@ -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');
}
};

View File

@@ -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;

View File

@@ -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));

View File

@@ -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 ? (
<ProductVideo
@@ -705,7 +746,9 @@ export default function ProductAllSection({
productInfo={productData}
videoUrl={renderItems[0].url}
thumbnailUrl={renderItems[0].thumbnail}
onScrollToImages={handleScrollToImages}
autoPlay={true}
continuousPlay={true}
onScrollToImages={handleScrollToImagesV1}
/>
) : (
<ProductVideoV2
@@ -714,7 +757,7 @@ export default function ProductAllSection({
videoUrl={renderItems[0].url}
thumbnailUrl={renderItems[0].thumbnail}
autoPlay={true}
onScrollToImages={handleScrollToImages}
onScrollToImages={handleScrollToImagesV2}
/>
)}
<div id="scroll-marker-after-video" className={css.scrollMarker}></div>

View File

@@ -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, // 반복 재생 옵션 전달
})
);
}

View File

@@ -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;

View File

@@ -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} 동영상 재생`}
>

View File

@@ -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 (
<TPanel
isTabActivated={false}
{...props}
className={classNames(
css.videoContainer,
panelInfo.modal && css.modal,
!isOnTop && css.background
)}
className={appliedClassNames}
handleCancel={onClickBack}
spotlightId={spotlightId}
>
<Container spotlightRestrict="self-only" spotlightId="spotlightId-media-video-container">
<Container
spotlightRestrict={containerSpotlightRestrict}
spotlightId="spotlightId-media-video-container"
>
{currentPlayingUrl && (
<VideoPlayer
setApiProvider={getPlayer}
@@ -319,6 +360,7 @@ const MediaPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props
spotlightDisabled={panelInfo.modal}
isYoutube={isYoutube}
src={currentPlayingUrl}
loop={panelInfo.continuousPlay || false}
style={panelInfo.modal ? modalStyle : {}}
modalScale={panelInfo.modal ? modalScale : 1}
modalClassName={panelInfo.modal && panelInfo.modalClassName}

View File

@@ -21,6 +21,13 @@
z-index: 22; /* DetailPanel보다 위 */
background-color: @videoBackgroundColor;
overflow: visible;
/* video element가 컨테이너를 넘지 않도록 크기 제한 */
video {
max-width: 100%;
max-height: 100%;
object-fit: contain; /* 비율 유지하면서 컨테이너 안에 맞춤 */
}
}
&.modal-minimized,

View File

@@ -1,22 +1,29 @@
import React, { useCallback, useEffect } from 'react';
import React, { useCallback, useEffect, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDecorator';
import TBody from '../../components/TBody/TBody';
import TButton from '../../components/TButton/TButton';
import TPanel from '../../components/TPanel/TPanel';
import { types } from '../../actions/actionTypes';
import { sendLogGNB } from '../../actions/logActions';
import { popPanel } from '../../actions/panelActions';
import { registerVoiceFramework, unregisterVoiceFramework } from '../../actions/voiceActions';
import { LOG_MENU } from '../../utils/Config';
import VoiceHeader from './VoiceHeader';
import mockLogs from './mockLogData';
import css from './VoicePanel.module.less';
const ContainerBasic = SpotlightContainerDecorator({ enterTo: 'last-focused' }, 'div');
export default function VoicePanel({ panelInfo, isOnTop, spotlightId }) {
const dispatch = useDispatch();
const loadingComplete = useSelector((state) => 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 (
<TPanel
panelInfo={panelInfo}
@@ -37,23 +116,108 @@ export default function VoicePanel({ panelInfo, isOnTop, spotlightId }) {
spotlightId={spotlightId}
>
<VoiceHeader
title="Voice Search"
title="Voice Conductor Test"
onBackButton={handleBackButton}
onClick={handleBackButton}
className={css.header}
/>
<TBody spotlightId={spotlightId} className={css.tbody}>
{loadingComplete && (
<ContainerBasic className={css.voiceContainer}>
<div className={css.voiceContent}>
<h1 className={css.title}>Voice Panel</h1>
<p className={css.description}>
Voice search functionality will be implemented here.
</p>
<div className={css.contentWrapper}>
{/* Buttons - All in one row */}
<div className={css.buttonArea}>
<TButton
onClick={handleRegister}
spotlightId="voice-register-btn"
disabled={isRegistered}
className={css.compactButton}
>
{isRegistered ? 'Registered ✓' : 'Register'}
</TButton>
<TButton
onClick={handleClearLogs}
spotlightId="voice-clear-logs-btn"
className={css.compactButton}
>
Clear
</TButton>
<TButton
onClick={handleLoadMockData}
spotlightId="voice-mock-data-btn"
className={css.compactButton}
>
Mock
</TButton>
<TButton
onClick={handleScrollUp}
spotlightId="voice-scroll-up-btn"
className={css.compactButton}
>
Up
</TButton>
<TButton
onClick={handleScrollDown}
spotlightId="voice-scroll-down-btn"
className={css.compactButton}
>
Down
</TButton>
</div>
{/* Status and Logs */}
<div className={css.infoContainer}>
{/* Status Panel */}
<div className={css.statusPanel}>
<div className={css.statusItem}>
<span className={css.statusLabel}>Platform:</span>
<span className={isTV ? css.statusSuccess : css.statusWarning}>
{isTV ? '✓ TV (webOS)' : '✗ Web Browser'}
</span>
</div>
<div className={css.statusItem}>
<span className={css.statusLabel}>Status:</span>
<span className={isRegistered ? css.statusSuccess : css.statusInactive}>
{isRegistered ? '✓ Registered' : '✗ Not Registered'}
</span>
</div>
<div className={css.statusItem}>
<span className={css.statusLabel}>Ticket:</span>
<span className={css.statusValue}>{voiceTicket || 'N/A'}</span>
</div>
{registrationError && (
<div className={css.statusItem}>
<span className={css.statusLabel}>Error:</span>
<span className={css.statusError}>{JSON.stringify(registrationError)}</span>
</div>
)}
</div>
{/* Log Viewer */}
<div className={css.logSection}>
<div className={css.logHeader}>
<span>Event Logs ({logs.length})</span>
</div>
<div ref={logViewerRef} className={css.logViewer}>
{logs.length === 0 ? (
<div className={css.emptyLog}>No logs yet. Click "Register" to start.</div>
) : (
logs.map((log) => (
<div key={log.id} className={css.logEntry}>
<div className={css.logEntryHeader}>
<span className={css.timestamp}>[{formatTime(log.timestamp)}]</span>
<span className={css.logType} style={{ color: getTypeColor(log.type) }}>
{log.type}
</span>
<span className={css.logTitle}>{log.title}</span>
</div>
<pre className={css.logData}>{JSON.stringify(log.data, null, 2)}</pre>
</div>
))
)}
</div>
</div>
</div>
</div>
</ContainerBasic>
)}
</TBody>
</TPanel>
);
}

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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<br/>`amazonAlexa`<br/>`googleAssistant`<br/>`thinQtv`<br/>**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<br/>**Note:** Supported from webOS 6.0+ (2021 products) |
| **voiceConfig** | | | **App capabilities configuration** |
| `supportedIntent` | No | array | Intents supported by your app<br/>**Examples:**<br/>`["SearchContent"]`<br/>`["PlayContent"]`<br/>`["SearchContent", "PlayContent"]` |
| `supportedVoiceLanguage` | No | array | Languages supported by your app<br/>**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<br/>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<br/>`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<br>(voice framework)
participant VoiceEngine as com.webos.service.voiceconductor<br>(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 ----><br>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"<br/>"Fast forward 1 minute" | ✅ |
| **13** | `ControlMedia` | `backward` | ❌ | ❌ | "Backward 30 seconds"<br/>"Rewind 30 seconds" | ✅ |
| **14** | `ControlMedia` | `move` | ❌ | ❌ | "Start over"<br/>"Play from 2:30"<br/>"Skip to 1:30" | ✅ |
| **15** | `ControlMedia` | `speed` | ❌ | ❌ | "Play at 1.5x speed"<br/>"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"<br/>"Set audio to default" | ✅ |
| **32** | `RateContents` | `likeContents` | ❌ | ❌ | "Like this content/song/video"<br/>"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"<br/>"Rate this 5 points" | ✅ |
| **37** | `RateContents` | `cancelRating` | ❌ | ❌ | "Cancel the rating"<br/>"Remove this rating" | ✅ |
| **38** | `RateContents` | `addToMyList` | ❌ | ❌ | "Add this to My list"<br/>"Add to favorites"<br/>"Save to watch later" | ✅ |
| **39** | `RateContents` | `removeFromMyList` | ❌ | ❌ | "Delete from My list"<br/>"Remove from favorites" | ✅ |
| **40** | `RateContents` | `likeAndSubscribe` | ❌ | ❌ | "Like and subscribe" | ✅ |
| **41** | `RateContents` | `subscribe` | ❌ | ❌ | "Subscribe"<br/>"Subscribe to this channel" | ✅ |
| **42** | `RateContents` | `unsubscribe` | ❌ | ❌ | "Unsubscribe"<br/>"Unsubscribe from this channel" | ✅ |
| **43** | `DisplayList` | `displayMyList` | ❌ | ❌ | "Show me my favorites"<br/>"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"<br/>"Show my subscriptions" | ✅ |
| **50** | `DisplayList` | `displayWatchLaterList` | ❌ | ❌ | "Browse watch later"<br/>"Show Watch Later playlist" | ✅ |
| **51** | `ControlStorage` | `addToWatchLater` | ❌ | ❌ | "Add to watch later"<br/>"Add to Watch Later Playlist" | ✅ |
| **52** | `ControlStorage` | `removeFromWatchLater` | ❌ | ❌ | "Remove from watch later" | ✅ |
| **53** | `ControlStorage` | `addToMyPlaylist` | ❌ | ❌ | "Add video to my [playlist name]"<br/>"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.

View File

@@ -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
}

View File

@@ -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"]
}
}
}