[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:
439
com.twin.app.shoptime/luna.md
Normal file
439
com.twin.app.shoptime/luna.md
Normal 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 시스템 서비스와의 안정적이고 효율적인 통신을 구현하고 있습니다.
|
||||||
@@ -278,4 +278,18 @@ export const types = {
|
|||||||
GET_RECENTLY_SAW_ITEM: 'GET_RECENTLY_SAW_ITEM',
|
GET_RECENTLY_SAW_ITEM: 'GET_RECENTLY_SAW_ITEM',
|
||||||
GET_LIKE_BRAND_PRODUCT: 'GET_LIKE_BRAND_PRODUCT',
|
GET_LIKE_BRAND_PRODUCT: 'GET_LIKE_BRAND_PRODUCT',
|
||||||
GET_MORE_TO_CONCIDER_AT_THIS_PRICE: 'GET_MORE_TO_CONCIDER_AT_THIS_PRICE',
|
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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
370
com.twin.app.shoptime/src/actions/voiceActions.js
Normal file
370
com.twin.app.shoptime/src/actions/voiceActions.js
Normal 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,
|
||||||
|
});
|
||||||
@@ -31,6 +31,15 @@ const VideoBase = class extends React.Component {
|
|||||||
*/
|
*/
|
||||||
autoPlay: PropTypes.bool,
|
autoPlay: PropTypes.bool,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Video loops continuously.
|
||||||
|
*
|
||||||
|
* @type {Boolean}
|
||||||
|
* @default false
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
loop: PropTypes.bool,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Video component to use.
|
* Video component to use.
|
||||||
*
|
*
|
||||||
@@ -96,11 +105,11 @@ const VideoBase = class extends React.Component {
|
|||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
source: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
|
source: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
|
||||||
track: PropTypes.oneOfType([PropTypes.string, PropTypes.node])
|
track: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
|
||||||
};
|
};
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
mediaComponent: 'video'
|
mediaComponent: 'video',
|
||||||
};
|
};
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
componentDidUpdate(prevProps) {
|
||||||
@@ -226,13 +235,7 @@ const VideoBase = class extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {
|
const { preloadSource, source, track, mediaComponent, ...rest } = this.props;
|
||||||
preloadSource,
|
|
||||||
source,
|
|
||||||
track,
|
|
||||||
mediaComponent,
|
|
||||||
...rest
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
delete rest.setMedia;
|
delete rest.setMedia;
|
||||||
|
|
||||||
@@ -249,12 +252,8 @@ const VideoBase = class extends React.Component {
|
|||||||
mediaComponent={mediaComponent}
|
mediaComponent={mediaComponent}
|
||||||
preload="none"
|
preload="none"
|
||||||
ref={this.setVideoRef}
|
ref={this.setVideoRef}
|
||||||
track={React.isValidElement(track) ? track : (
|
track={React.isValidElement(track) ? track : <track src={track} />}
|
||||||
<track src={track} />
|
source={React.isValidElement(source) ? source : <source src={source} />}
|
||||||
)}
|
|
||||||
source={React.isValidElement(source) ? source : (
|
|
||||||
<source src={source} />
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{preloadKey ? (
|
{preloadKey ? (
|
||||||
@@ -267,12 +266,10 @@ const VideoBase = class extends React.Component {
|
|||||||
onLoadStart={this.handlePreloadLoadStart}
|
onLoadStart={this.handlePreloadLoadStart}
|
||||||
preload="none"
|
preload="none"
|
||||||
ref={this.setPreloadRef}
|
ref={this.setPreloadRef}
|
||||||
track={React.isValidElement(track) ? track : (
|
track={React.isValidElement(track) ? track : <track src={track} />}
|
||||||
<track src={track} />
|
source={
|
||||||
)}
|
React.isValidElement(preloadSource) ? preloadSource : <source src={preloadSource} />
|
||||||
source={React.isValidElement(preloadSource) ? preloadSource : (
|
}
|
||||||
<source src={preloadSource} />
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
@@ -319,6 +316,4 @@ const Video = VideoDecorator(VideoBase);
|
|||||||
Video.defaultSlot = 'videoComponent';
|
Video.defaultSlot = 'videoComponent';
|
||||||
|
|
||||||
export default Video;
|
export default Video;
|
||||||
export {
|
export { Video };
|
||||||
Video
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {LS2RequestSingleton} from './LS2RequestSingleton';
|
|||||||
|
|
||||||
export * from './account';
|
export * from './account';
|
||||||
export * from './common';
|
export * from './common';
|
||||||
|
export * from './voice';
|
||||||
|
|
||||||
export const cancelReq = (instanceName) => {
|
export const cancelReq = (instanceName) => {
|
||||||
let r = LS2RequestSingleton.instance(instanceName);
|
let r = LS2RequestSingleton.instance(instanceName);
|
||||||
|
|||||||
164
com.twin.app.shoptime/src/lunaSend/voice.js
Normal file
164
com.twin.app.shoptime/src/lunaSend/voice.js
Normal 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
130
com.twin.app.shoptime/src/reducers/voiceReducer.js
Normal file
130
com.twin.app.shoptime/src/reducers/voiceReducer.js
Normal 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;
|
||||||
@@ -29,6 +29,7 @@ import { searchReducer } from '../reducers/searchReducer';
|
|||||||
import { shippingReducer } from '../reducers/shippingReducer';
|
import { shippingReducer } from '../reducers/shippingReducer';
|
||||||
import { toastReducer } from '../reducers/toastReducer';
|
import { toastReducer } from '../reducers/toastReducer';
|
||||||
import { videoPlayReducer } from '../reducers/videoPlayReducer';
|
import { videoPlayReducer } from '../reducers/videoPlayReducer';
|
||||||
|
import { voiceReducer } from '../reducers/voiceReducer';
|
||||||
|
|
||||||
const rootReducer = combineReducers({
|
const rootReducer = combineReducers({
|
||||||
panels: panelsReducer,
|
panels: panelsReducer,
|
||||||
@@ -58,6 +59,7 @@ const rootReducer = combineReducers({
|
|||||||
foryou: foryouReducer,
|
foryou: foryouReducer,
|
||||||
toast: toastReducer,
|
toast: toastReducer,
|
||||||
videoPlay: videoPlayReducer,
|
videoPlay: videoPlayReducer,
|
||||||
|
voice: voiceReducer,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const store = createStore(rootReducer, applyMiddleware(thunk));
|
export const store = createStore(rootReducer, applyMiddleware(thunk));
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDeco
|
|||||||
import arrowDown from '../../../../assets/images/icons/ic_arrow_down_3x_new.png';
|
import arrowDown from '../../../../assets/images/icons/ic_arrow_down_3x_new.png';
|
||||||
import indicatorDefaultImage from '../../../../assets/images/img-thumb-empty-144@3x.png';
|
import indicatorDefaultImage from '../../../../assets/images/img-thumb-empty-144@3x.png';
|
||||||
// import { pushPanel } from '../../../actions/panelActions';
|
// import { pushPanel } from '../../../actions/panelActions';
|
||||||
|
import { minimizeModalMedia } from '../../../actions/mediaActions';
|
||||||
import { resetShowAllReviews } from '../../../actions/productActions';
|
import { resetShowAllReviews } from '../../../actions/productActions';
|
||||||
import { showToast } from '../../../actions/toastActions';
|
import { showToast } from '../../../actions/toastActions';
|
||||||
// ProductInfoSection imports
|
// ProductInfoSection imports
|
||||||
@@ -143,7 +144,7 @@ export default function ProductAllSection({
|
|||||||
const youmaylikeData = useSelector((state) => state.main.youmaylikeData);
|
const youmaylikeData = useSelector((state) => state.main.youmaylikeData);
|
||||||
|
|
||||||
// ProductVideo 버전 관리 (1: 기존 modal 방식, 2: 내장 방식)
|
// ProductVideo 버전 관리 (1: 기존 modal 방식, 2: 내장 방식)
|
||||||
const [productVideoVersion, setProductVideoVersion] = useState(1);
|
const [productVideoVersion, setProductVideoVersion] = useState(3);
|
||||||
|
|
||||||
// const [currentHeight, setCurrentHeight] = useState(0);
|
// const [currentHeight, setCurrentHeight] = useState(0);
|
||||||
//하단부분까지 갔을때 체크용
|
//하단부분까지 갔을때 체크용
|
||||||
@@ -308,8 +309,12 @@ export default function ProductAllSection({
|
|||||||
[scrollToSection]
|
[scrollToSection]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 비디오 다음 이미지로 스크롤하는 핸들러
|
// ProductVideo V1 전용 - MediaPanel minimize 포함
|
||||||
const handleScrollToImages = useCallback(() => {
|
const handleScrollToImagesV1 = useCallback(() => {
|
||||||
|
// 1. MediaPanel을 1px로 축소하여 포커스 충돌 방지
|
||||||
|
dispatch(minimizeModalMedia());
|
||||||
|
|
||||||
|
// 2. 스크롤 이동
|
||||||
scrollToSection('scroll-marker-after-video');
|
scrollToSection('scroll-marker-after-video');
|
||||||
|
|
||||||
// 기존 timeout이 있으면 클리어
|
// 기존 timeout이 있으면 클리어
|
||||||
@@ -317,13 +322,45 @@ export default function ProductAllSection({
|
|||||||
clearTimeout(scrollToImagesTimeoutRef.current);
|
clearTimeout(scrollToImagesTimeoutRef.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 250ms 후 ProductDetail로 포커스 이동
|
// 3. 100ms 후 명시적으로 첫 번째 ProductDetail(이미지)로 포커스 이동
|
||||||
scrollToImagesTimeoutRef.current = setTimeout(() => {
|
scrollToImagesTimeoutRef.current = setTimeout(() => {
|
||||||
Spotlight.move('down');
|
Spotlight.focus('product-img-1');
|
||||||
scrollToImagesTimeoutRef.current = null;
|
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]);
|
}, [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 scrollContainerRef = useRef(null);
|
||||||
const productDetailRef = useRef(null); //높이값 변경때문
|
const productDetailRef = useRef(null); //높이값 변경때문
|
||||||
const descriptionRef = useRef(null);
|
const descriptionRef = useRef(null);
|
||||||
@@ -334,8 +371,8 @@ export default function ProductAllSection({
|
|||||||
const renderItems = useMemo(() => {
|
const renderItems = useMemo(() => {
|
||||||
const items = [];
|
const items = [];
|
||||||
|
|
||||||
// 동영상이 있으면 첫 번째에 추가 (Indicator.jsx와 동일한 로직)
|
// 동영상이 있으면 첫 번째에 추가 (productVideoVersion이 3이 아닐 때만)
|
||||||
if (productData && productData.prdtMediaUrl) {
|
if (productData && productData.prdtMediaUrl && productVideoVersion !== 3) {
|
||||||
items.push({
|
items.push({
|
||||||
type: 'video',
|
type: 'video',
|
||||||
url: productData.prdtMediaUrl,
|
url: productData.prdtMediaUrl,
|
||||||
@@ -350,13 +387,17 @@ export default function ProductAllSection({
|
|||||||
items.push({
|
items.push({
|
||||||
type: 'image',
|
type: 'image',
|
||||||
url: 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;
|
return items;
|
||||||
}, [productData]);
|
}, [productData, productVideoVersion]);
|
||||||
|
|
||||||
// renderItems에 Video가 존재하는지 확인하는 boolean 상태
|
// renderItems에 Video가 존재하는지 확인하는 boolean 상태
|
||||||
const hasVideo = useMemo(() => {
|
const hasVideo = useMemo(() => {
|
||||||
@@ -696,8 +737,8 @@ export default function ProductAllSection({
|
|||||||
onFocus={() => handleButtonFocus('product')}
|
onFocus={() => handleButtonFocus('product')}
|
||||||
onBlur={handleButtonBlur}
|
onBlur={handleButtonBlur}
|
||||||
>
|
>
|
||||||
{/* 비디오가 있으면 먼저 렌더링 */}
|
{/* 비디오가 있으면 먼저 렌더링 (productVideoVersion이 3이 아닐 때만) */}
|
||||||
{hasVideo && renderItems[0].type === 'video' && (
|
{hasVideo && renderItems[0].type === 'video' && productVideoVersion !== 3 && (
|
||||||
<>
|
<>
|
||||||
{productVideoVersion === 1 ? (
|
{productVideoVersion === 1 ? (
|
||||||
<ProductVideo
|
<ProductVideo
|
||||||
@@ -705,7 +746,9 @@ export default function ProductAllSection({
|
|||||||
productInfo={productData}
|
productInfo={productData}
|
||||||
videoUrl={renderItems[0].url}
|
videoUrl={renderItems[0].url}
|
||||||
thumbnailUrl={renderItems[0].thumbnail}
|
thumbnailUrl={renderItems[0].thumbnail}
|
||||||
onScrollToImages={handleScrollToImages}
|
autoPlay={true}
|
||||||
|
continuousPlay={true}
|
||||||
|
onScrollToImages={handleScrollToImagesV1}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ProductVideoV2
|
<ProductVideoV2
|
||||||
@@ -714,7 +757,7 @@ export default function ProductAllSection({
|
|||||||
videoUrl={renderItems[0].url}
|
videoUrl={renderItems[0].url}
|
||||||
thumbnailUrl={renderItems[0].thumbnail}
|
thumbnailUrl={renderItems[0].thumbnail}
|
||||||
autoPlay={true}
|
autoPlay={true}
|
||||||
onScrollToImages={handleScrollToImages}
|
onScrollToImages={handleScrollToImagesV2}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div id="scroll-marker-after-video" className={css.scrollMarker}></div>
|
<div id="scroll-marker-after-video" className={css.scrollMarker}></div>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
startMediaPlayer,
|
startMediaPlayer,
|
||||||
finishMediaPreview,
|
finishMediaPreview,
|
||||||
switchMediaToFullscreen,
|
switchMediaToFullscreen,
|
||||||
|
minimizeModalMedia,
|
||||||
|
restoreModalMedia,
|
||||||
} from '../../../../actions/mediaActions';
|
} from '../../../../actions/mediaActions';
|
||||||
import CustomImage from '../../../../components/CustomImage/CustomImage';
|
import CustomImage from '../../../../components/CustomImage/CustomImage';
|
||||||
import { panel_names } from '../../../../utils/Config';
|
import { panel_names } from '../../../../utils/Config';
|
||||||
@@ -13,7 +15,14 @@ import css from './ProductVideo.module.less';
|
|||||||
|
|
||||||
const SpottableComponent = Spottable('div');
|
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();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
// MediaPanel 상태 체크를 위한 selectors 추가
|
// MediaPanel 상태 체크를 위한 selectors 추가
|
||||||
@@ -21,6 +30,7 @@ export default function ProductVideo({ productInfo, videoUrl, thumbnailUrl, onSc
|
|||||||
const [isLaunchedFromPlayer, setIsLaunchedFromPlayer] = useState(false);
|
const [isLaunchedFromPlayer, setIsLaunchedFromPlayer] = useState(false);
|
||||||
const [focused, setFocused] = useState(false);
|
const [focused, setFocused] = useState(false);
|
||||||
const [modalState, setModalState] = useState(true); // 모달 상태 관리 추가
|
const [modalState, setModalState] = useState(true); // 모달 상태 관리 추가
|
||||||
|
const [hasAutoPlayed, setHasAutoPlayed] = useState(false); // 자동 재생 완료 여부
|
||||||
|
|
||||||
const topPanel = panels[panels.length - 1];
|
const topPanel = panels[panels.length - 1];
|
||||||
|
|
||||||
@@ -40,6 +50,51 @@ export default function ProductVideo({ productInfo, videoUrl, thumbnailUrl, onSc
|
|||||||
}
|
}
|
||||||
}, [topPanel]);
|
}, [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(() => {
|
const canPlayVideo = useMemo(() => {
|
||||||
return Boolean(productInfo?.prdtMediaUrl);
|
return Boolean(productInfo?.prdtMediaUrl);
|
||||||
@@ -57,18 +112,20 @@ export default function ProductVideo({ productInfo, videoUrl, thumbnailUrl, onSc
|
|||||||
const videoContainerOnFocus = useCallback(() => {
|
const videoContainerOnFocus = useCallback(() => {
|
||||||
if (canPlayVideo) {
|
if (canPlayVideo) {
|
||||||
setFocused(true);
|
setFocused(true);
|
||||||
|
console.log('[ProductVideo] Calling restoreModalMedia');
|
||||||
|
// ProductVideo에 포커스가 돌아오면 비디오 복원
|
||||||
|
dispatch(restoreModalMedia());
|
||||||
}
|
}
|
||||||
}, [canPlayVideo]);
|
}, [canPlayVideo, dispatch]);
|
||||||
|
|
||||||
const videoContainerOnBlur = useCallback(() => {
|
const videoContainerOnBlur = useCallback(() => {
|
||||||
console.log('[ProductVideo] onBlur called - canPlayVideo:', canPlayVideo);
|
console.log('[ProductVideo] onBlur called - canPlayVideo:', canPlayVideo);
|
||||||
if (canPlayVideo) {
|
if (canPlayVideo) {
|
||||||
setFocused(false);
|
setFocused(false);
|
||||||
console.log('[ProductVideo] Calling finishMediaPreview');
|
// minimize는 handleScrollToImages에서 명시적으로 처리
|
||||||
// ProductVideo에서 포커스가 벗어나면 비디오 재생 종료
|
// 여기서는 focused 상태만 변경
|
||||||
dispatch(finishMediaPreview());
|
|
||||||
}
|
}
|
||||||
}, [canPlayVideo, dispatch]);
|
}, [canPlayVideo]);
|
||||||
|
|
||||||
// Spotlight Down 키 핸들러 - 비디오 다음 이미지로 스크롤
|
// Spotlight Down 키 핸들러 - 비디오 다음 이미지로 스크롤
|
||||||
const handleSpotlightDown = useCallback(
|
const handleSpotlightDown = useCallback(
|
||||||
@@ -136,6 +193,7 @@ export default function ProductVideo({ productInfo, videoUrl, thumbnailUrl, onSc
|
|||||||
modalContainerId: 'product-video-player',
|
modalContainerId: 'product-video-player',
|
||||||
modalClassName: modalClassNameChange(),
|
modalClassName: modalClassNameChange(),
|
||||||
spotlightDisable: true,
|
spotlightDisable: true,
|
||||||
|
continuousPlay, // 반복 재생 옵션 전달
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@
|
|||||||
&::after {
|
&::after {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
.position(@position: absolute, @top: 0, @left: 0, @right: 0, @bottom: 0);
|
.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;
|
border: 6px solid @PRIMARY_COLOR_RED;
|
||||||
box-shadow: 0 0 22px 0 rgba(0, 0, 0, 0.5);
|
box-shadow: 0 0 22px 0 rgba(0, 0, 0, 0.5);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
|
|||||||
@@ -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 [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [focused, setFocused] = useState(false);
|
const [focused, setFocused] = useState(false);
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
@@ -126,6 +132,20 @@ export default function ProductVideoV2({ productInfo, videoUrl, thumbnailUrl, au
|
|||||||
setIsFullscreen(false); // 전체화면도 해제
|
setIsFullscreen(false); // 전체화면도 해제
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Spotlight Down 키 핸들러 - 비디오 다음 이미지로 스크롤
|
||||||
|
const handleSpotlightDown = useCallback(
|
||||||
|
(e) => {
|
||||||
|
if (canPlayVideo && onScrollToImages) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onScrollToImages();
|
||||||
|
return true; // 이벤트 처리 완료
|
||||||
|
}
|
||||||
|
return false; // Spotlight가 기본 동작 수행
|
||||||
|
},
|
||||||
|
[canPlayVideo, onScrollToImages]
|
||||||
|
);
|
||||||
|
|
||||||
// Back 버튼 핸들러 - 전체화면 해제 또는 비디오 종료
|
// Back 버튼 핸들러 - 전체화면 해제 또는 비디오 종료
|
||||||
const handleBackButton = useCallback(() => {
|
const handleBackButton = useCallback(() => {
|
||||||
if (isFullscreen) {
|
if (isFullscreen) {
|
||||||
@@ -226,12 +246,14 @@ export default function ProductVideoV2({ productInfo, videoUrl, thumbnailUrl, au
|
|||||||
? {
|
? {
|
||||||
spotlightRestrict: 'self-only', // 포커스가 밖으로 나가지 않도록
|
spotlightRestrict: 'self-only', // 포커스가 밖으로 나가지 않도록
|
||||||
spotlightId: 'product-video-v2-fullscreen',
|
spotlightId: 'product-video-v2-fullscreen',
|
||||||
|
onSpotlightDown: handleSpotlightDown, // 전체화면에서도 Down 키 동작
|
||||||
// 전체화면 모드: window 레벨에서 이벤트 처리
|
// 전체화면 모드: window 레벨에서 이벤트 처리
|
||||||
}
|
}
|
||||||
: isPlaying
|
: isPlaying
|
||||||
? {
|
? {
|
||||||
spotlightId: 'product-video-v2-playing',
|
spotlightId: 'product-video-v2-playing',
|
||||||
onKeyDown: handleContainerKeyDown, // 일반 모드: 컨테이너에서 직접 처리
|
onKeyDown: handleContainerKeyDown, // 일반 모드: 컨테이너에서 직접 처리
|
||||||
|
onSpotlightDown: handleSpotlightDown, // 일반 재생에서도 Down 키 동작
|
||||||
// 일반 재생 모드: 컨테이너가 포커스 받음
|
// 일반 재생 모드: 컨테이너가 포커스 받음
|
||||||
}
|
}
|
||||||
: {};
|
: {};
|
||||||
@@ -250,6 +272,7 @@ export default function ProductVideoV2({ productInfo, videoUrl, thumbnailUrl, au
|
|||||||
onClick={handleThumbnailClick}
|
onClick={handleThumbnailClick}
|
||||||
onFocus={videoContainerOnFocus}
|
onFocus={videoContainerOnFocus}
|
||||||
onBlur={videoContainerOnBlur}
|
onBlur={videoContainerOnBlur}
|
||||||
|
onSpotlightDown={handleSpotlightDown}
|
||||||
spotlightId="product-video-v2-thumbnail"
|
spotlightId="product-video-v2-thumbnail"
|
||||||
aria-label={`${productInfo?.prdtNm} 동영상 재생`}
|
aria-label={`${productInfo?.prdtNm} 동영상 재생`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -113,28 +113,43 @@ const MediaPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props
|
|||||||
// modal 스타일 설정
|
// modal 스타일 설정
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (panelInfo.modal && panelInfo.modalContainerId) {
|
if (panelInfo.modal && panelInfo.modalContainerId) {
|
||||||
|
// modal 모드: modalContainerId 기반으로 위치와 크기 계산
|
||||||
const node = document.querySelector(`[data-spotlight-id="${panelInfo.modalContainerId}"]`);
|
const node = document.querySelector(`[data-spotlight-id="${panelInfo.modalContainerId}"]`);
|
||||||
if (node) {
|
if (node) {
|
||||||
const { width, height, top, left } = node.getBoundingClientRect();
|
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 = {
|
const style = {
|
||||||
width: width + 'px',
|
width: adjustedWidth + 'px',
|
||||||
height: height + 'px',
|
height: adjustedHeight + 'px',
|
||||||
top: top + 'px',
|
maxWidth: adjustedWidth + 'px',
|
||||||
left: left + 'px',
|
maxHeight: adjustedHeight + 'px',
|
||||||
|
top: adjustedTop + 'px',
|
||||||
|
left: adjustedLeft + 'px',
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
overflow: 'visible',
|
overflow: 'hidden', // visible → hidden으로 변경하여 넘치는 부분 숨김
|
||||||
};
|
};
|
||||||
setModalStyle(style);
|
setModalStyle(style);
|
||||||
let scale = 1;
|
let scale = 1;
|
||||||
if (typeof window === 'object') {
|
if (typeof window === 'object') {
|
||||||
scale = width / window.innerWidth;
|
scale = adjustedWidth / window.innerWidth;
|
||||||
setModalScale(scale);
|
setModalScale(scale);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setModalStyle(panelInfo.modalStyle || {});
|
setModalStyle(panelInfo.modalStyle || {});
|
||||||
setModalScale(panelInfo.modalScale || 1);
|
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) {
|
if (videoPlayer.current?.getMediaState()?.paused) {
|
||||||
videoPlayer.current.play();
|
videoPlayer.current.play();
|
||||||
}
|
}
|
||||||
@@ -263,8 +278,9 @@ const MediaPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props
|
|||||||
|
|
||||||
const onEnded = useCallback(
|
const onEnded = useCallback(
|
||||||
(e) => {
|
(e) => {
|
||||||
// console.log('[MediaPanel] Video ended');
|
console.log('[MediaPanel] Video ended');
|
||||||
// 비디오 종료 시 패널 닫기
|
// continuousPlay는 MediaPlayer(VideoPlayer) 컴포넌트 내부에서 loop 속성으로 처리
|
||||||
|
// onEnded가 호출되면 loop=false 인 경우이므로 패널을 닫음
|
||||||
Spotlight.pause();
|
Spotlight.pause();
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
Spotlight.resume();
|
Spotlight.resume();
|
||||||
@@ -290,23 +306,48 @@ const MediaPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props
|
|||||||
|
|
||||||
// console.log('[MediaPanel] ========== Rendering ==========');
|
// console.log('[MediaPanel] ========== Rendering ==========');
|
||||||
// console.log('[MediaPanel] isOnTop:', isOnTop);
|
// 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] currentPlayingUrl:', currentPlayingUrl);
|
||||||
// console.log('[MediaPanel] hasVideoPlayer:', !!videoPlayer.current);
|
// 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 (
|
return (
|
||||||
<TPanel
|
<TPanel
|
||||||
isTabActivated={false}
|
isTabActivated={false}
|
||||||
{...props}
|
{...props}
|
||||||
className={classNames(
|
className={appliedClassNames}
|
||||||
css.videoContainer,
|
|
||||||
panelInfo.modal && css.modal,
|
|
||||||
!isOnTop && css.background
|
|
||||||
)}
|
|
||||||
handleCancel={onClickBack}
|
handleCancel={onClickBack}
|
||||||
spotlightId={spotlightId}
|
spotlightId={spotlightId}
|
||||||
>
|
>
|
||||||
<Container spotlightRestrict="self-only" spotlightId="spotlightId-media-video-container">
|
<Container
|
||||||
|
spotlightRestrict={containerSpotlightRestrict}
|
||||||
|
spotlightId="spotlightId-media-video-container"
|
||||||
|
>
|
||||||
{currentPlayingUrl && (
|
{currentPlayingUrl && (
|
||||||
<VideoPlayer
|
<VideoPlayer
|
||||||
setApiProvider={getPlayer}
|
setApiProvider={getPlayer}
|
||||||
@@ -319,6 +360,7 @@ const MediaPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props
|
|||||||
spotlightDisabled={panelInfo.modal}
|
spotlightDisabled={panelInfo.modal}
|
||||||
isYoutube={isYoutube}
|
isYoutube={isYoutube}
|
||||||
src={currentPlayingUrl}
|
src={currentPlayingUrl}
|
||||||
|
loop={panelInfo.continuousPlay || false}
|
||||||
style={panelInfo.modal ? modalStyle : {}}
|
style={panelInfo.modal ? modalStyle : {}}
|
||||||
modalScale={panelInfo.modal ? modalScale : 1}
|
modalScale={panelInfo.modal ? modalScale : 1}
|
||||||
modalClassName={panelInfo.modal && panelInfo.modalClassName}
|
modalClassName={panelInfo.modal && panelInfo.modalClassName}
|
||||||
|
|||||||
@@ -21,6 +21,13 @@
|
|||||||
z-index: 22; /* DetailPanel보다 위 */
|
z-index: 22; /* DetailPanel보다 위 */
|
||||||
background-color: @videoBackgroundColor;
|
background-color: @videoBackgroundColor;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
|
|
||||||
|
/* video element가 컨테이너를 넘지 않도록 크기 제한 */
|
||||||
|
video {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain; /* 비율 유지하면서 컨테이너 안에 맞춤 */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.modal-minimized,
|
&.modal-minimized,
|
||||||
|
|||||||
@@ -1,22 +1,29 @@
|
|||||||
import React, { useCallback, useEffect } from 'react';
|
import React, { useCallback, useEffect, useRef } from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
|
||||||
import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDecorator';
|
import TButton from '../../components/TButton/TButton';
|
||||||
|
|
||||||
import TBody from '../../components/TBody/TBody';
|
|
||||||
import TPanel from '../../components/TPanel/TPanel';
|
import TPanel from '../../components/TPanel/TPanel';
|
||||||
|
import { types } from '../../actions/actionTypes';
|
||||||
import { sendLogGNB } from '../../actions/logActions';
|
import { sendLogGNB } from '../../actions/logActions';
|
||||||
import { popPanel } from '../../actions/panelActions';
|
import { popPanel } from '../../actions/panelActions';
|
||||||
|
import { registerVoiceFramework, unregisterVoiceFramework } from '../../actions/voiceActions';
|
||||||
import { LOG_MENU } from '../../utils/Config';
|
import { LOG_MENU } from '../../utils/Config';
|
||||||
import VoiceHeader from './VoiceHeader';
|
import VoiceHeader from './VoiceHeader';
|
||||||
|
import mockLogs from './mockLogData';
|
||||||
|
|
||||||
import css from './VoicePanel.module.less';
|
import css from './VoicePanel.module.less';
|
||||||
|
|
||||||
const ContainerBasic = SpotlightContainerDecorator({ enterTo: 'last-focused' }, 'div');
|
|
||||||
|
|
||||||
export default function VoicePanel({ panelInfo, isOnTop, spotlightId }) {
|
export default function VoicePanel({ panelInfo, isOnTop, spotlightId }) {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const loadingComplete = useSelector((state) => state.common?.loadingComplete);
|
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(() => {
|
useEffect(() => {
|
||||||
if (isOnTop) {
|
if (isOnTop) {
|
||||||
@@ -24,11 +31,83 @@ export default function VoicePanel({ panelInfo, isOnTop, spotlightId }) {
|
|||||||
}
|
}
|
||||||
}, [isOnTop, dispatch]);
|
}, [isOnTop, dispatch]);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
dispatch(unregisterVoiceFramework());
|
||||||
|
};
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
const handleBackButton = useCallback(() => {
|
const handleBackButton = useCallback(() => {
|
||||||
console.log(`[VoicePanel] Back button clicked - returning to previous panel`);
|
console.log(`[VoicePanel] Back button clicked - returning to previous panel`);
|
||||||
dispatch(popPanel());
|
dispatch(popPanel());
|
||||||
}, [dispatch]);
|
}, [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 (
|
return (
|
||||||
<TPanel
|
<TPanel
|
||||||
panelInfo={panelInfo}
|
panelInfo={panelInfo}
|
||||||
@@ -37,23 +116,108 @@ export default function VoicePanel({ panelInfo, isOnTop, spotlightId }) {
|
|||||||
spotlightId={spotlightId}
|
spotlightId={spotlightId}
|
||||||
>
|
>
|
||||||
<VoiceHeader
|
<VoiceHeader
|
||||||
title="Voice Search"
|
title="Voice Conductor Test"
|
||||||
onBackButton={handleBackButton}
|
onBackButton={handleBackButton}
|
||||||
onClick={handleBackButton}
|
onClick={handleBackButton}
|
||||||
className={css.header}
|
className={css.header}
|
||||||
/>
|
/>
|
||||||
<TBody spotlightId={spotlightId} className={css.tbody}>
|
|
||||||
{loadingComplete && (
|
{loadingComplete && (
|
||||||
<ContainerBasic className={css.voiceContainer}>
|
<div className={css.contentWrapper}>
|
||||||
<div className={css.voiceContent}>
|
{/* Buttons - All in one row */}
|
||||||
<h1 className={css.title}>Voice Panel</h1>
|
<div className={css.buttonArea}>
|
||||||
<p className={css.description}>
|
<TButton
|
||||||
Voice search functionality will be implemented here.
|
onClick={handleRegister}
|
||||||
</p>
|
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>
|
</div>
|
||||||
</ContainerBasic>
|
|
||||||
)}
|
)}
|
||||||
</TBody>
|
|
||||||
</TPanel>
|
</TPanel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,28 +14,224 @@
|
|||||||
overflow: hidden;
|
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;
|
display: flex;
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: 600px;
|
justify-content: center;
|
||||||
padding: 60px;
|
margin-right: 12px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.voiceContent {
|
// Info Container - Status and Logs (increased height)
|
||||||
text-align: center;
|
.infoContainer {
|
||||||
max-width: 800px;
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0 60px 30px;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
// Status Panel - Dark theme (more compact)
|
||||||
font-size: 48px;
|
.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;
|
font-weight: bold;
|
||||||
color: #ffffff;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.description {
|
.statusInactive {
|
||||||
font-size: 24px;
|
color: #999999;
|
||||||
color: #cccccc;
|
}
|
||||||
line-height: 1.6;
|
|
||||||
|
.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);
|
||||||
}
|
}
|
||||||
|
|||||||
171
com.twin.app.shoptime/src/views/VoicePanel/mockLogData.js
Normal file
171
com.twin.app.shoptime/src/views/VoicePanel/mockLogData.js
Normal 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;
|
||||||
531
com.twin.app.shoptime/vui.md
Normal file
531
com.twin.app.shoptime/vui.md
Normal 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
15
com.twin.app.shoptime/webos-meta/appinfo.bakcup.json
Normal file
15
com.twin.app.shoptime/webos-meta/appinfo.bakcup.json
Normal 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
|
||||||
|
}
|
||||||
@@ -10,6 +10,26 @@
|
|||||||
"largeIcon": "icon-large.png",
|
"largeIcon": "icon-large.png",
|
||||||
"iconColor": "#ffffff",
|
"iconColor": "#ffffff",
|
||||||
"disableBackHistoryAPI": true,
|
"disableBackHistoryAPI": true,
|
||||||
|
"handlesRelaunch": true,
|
||||||
"deeplinkingParams": "{\"contentTarget\":\"$CONTENTID\"}",
|
"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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user