[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_LIKE_BRAND_PRODUCT: 'GET_LIKE_BRAND_PRODUCT',
|
||||
GET_MORE_TO_CONCIDER_AT_THIS_PRICE: 'GET_MORE_TO_CONCIDER_AT_THIS_PRICE',
|
||||
|
||||
// 🔽 Voice Conductor 관련 액션 타입
|
||||
VOICE_REGISTER_SUCCESS: 'VOICE_REGISTER_SUCCESS',
|
||||
VOICE_REGISTER_FAILURE: 'VOICE_REGISTER_FAILURE',
|
||||
VOICE_SET_TICKET: 'VOICE_SET_TICKET',
|
||||
VOICE_SET_CONTEXT_SUCCESS: 'VOICE_SET_CONTEXT_SUCCESS',
|
||||
VOICE_SET_CONTEXT_FAILURE: 'VOICE_SET_CONTEXT_FAILURE',
|
||||
VOICE_PERFORM_ACTION: 'VOICE_PERFORM_ACTION',
|
||||
VOICE_REPORT_RESULT_SUCCESS: 'VOICE_REPORT_RESULT_SUCCESS',
|
||||
VOICE_REPORT_RESULT_FAILURE: 'VOICE_REPORT_RESULT_FAILURE',
|
||||
VOICE_UPDATE_INTENTS: 'VOICE_UPDATE_INTENTS',
|
||||
VOICE_CLEAR_STATE: 'VOICE_CLEAR_STATE',
|
||||
VOICE_ADD_LOG: 'VOICE_ADD_LOG',
|
||||
VOICE_CLEAR_LOGS: 'VOICE_CLEAR_LOGS',
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -1,324 +1,319 @@
|
||||
import {forward} from '@enact/core/handle';
|
||||
import ForwardRef from '@enact/ui/ForwardRef';
|
||||
import {Media, getKeyFromSource} from '@enact/ui/Media';
|
||||
import EnactPropTypes from '@enact/core/internal/prop-types';
|
||||
import Slottable from '@enact/ui/Slottable';
|
||||
import compose from 'ramda/src/compose';
|
||||
import React from 'react';
|
||||
|
||||
import css from './VideoPlayer.module.less';
|
||||
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
/**
|
||||
* Adds support for preloading a video source for `VideoPlayer`.
|
||||
*
|
||||
* @class VideoBase
|
||||
* @memberof sandstone/VideoPlayer
|
||||
* @ui
|
||||
* @private
|
||||
*/
|
||||
const VideoBase = class extends React.Component {
|
||||
static displayName = 'Video';
|
||||
|
||||
static propTypes = /** @lends sandstone/VideoPlayer.Video.prototype */ {
|
||||
/**
|
||||
* Video plays automatically.
|
||||
*
|
||||
* @type {Boolean}
|
||||
* @default false
|
||||
* @public
|
||||
*/
|
||||
autoPlay: PropTypes.bool,
|
||||
|
||||
/**
|
||||
* Video component to use.
|
||||
*
|
||||
* The default (`'video'`) renders an `HTMLVideoElement`. Custom video components must have
|
||||
* a similar API structure, exposing the following APIs:
|
||||
*
|
||||
* Properties:
|
||||
* * `currentTime` {Number} - Playback index of the media in seconds
|
||||
* * `duration` {Number} - Media's entire duration in seconds
|
||||
* * `error` {Boolean} - `true` if video playback has errored.
|
||||
* * `loading` {Boolean} - `true` if video playback is loading.
|
||||
* * `paused` {Boolean} - Playing vs paused state. `true` means the media is paused
|
||||
* * `playbackRate` {Number} - Current playback rate, as a number
|
||||
* * `proportionLoaded` {Number} - A value between `0` and `1`
|
||||
* representing the proportion of the media that has loaded
|
||||
* * `proportionPlayed` {Number} - A value between `0` and `1` representing the
|
||||
* proportion of the media that has already been shown
|
||||
*
|
||||
* Events:
|
||||
* * `onLoadStart` - Called when the video starts to load
|
||||
* * `onPlay` - Sent when playback of the media starts after having been paused
|
||||
* * `onUpdate` - Sent when any of the properties were updated
|
||||
*
|
||||
* Methods:
|
||||
* * `play()` - play video
|
||||
* * `pause()` - pause video
|
||||
* * `load()` - load video
|
||||
*
|
||||
* The [`source`]{@link sandstone/VideoPlayer.Video.source} property is passed to
|
||||
* the video component as a child node.
|
||||
*
|
||||
* @type {String|Component|Element}
|
||||
* @default 'video'
|
||||
* @public
|
||||
*/
|
||||
mediaComponent: EnactPropTypes.renderableOverride,
|
||||
|
||||
/**
|
||||
* The video source to be preloaded. Expects a `<source>` node.
|
||||
*
|
||||
* @type {Node}
|
||||
* @public
|
||||
*/
|
||||
preloadSource: PropTypes.node,
|
||||
|
||||
/**
|
||||
* Called with a reference to the active [Media]{@link ui/Media.Media} component.
|
||||
*
|
||||
* @type {Function}
|
||||
* @private
|
||||
*/
|
||||
setMedia: PropTypes.func,
|
||||
|
||||
/**
|
||||
* The video source to be played.
|
||||
*
|
||||
* Any children `<source>` elements will be sent directly to the `mediaComponent` as video
|
||||
* sources.
|
||||
*
|
||||
* See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/source
|
||||
*
|
||||
* @type {String|Node}
|
||||
* @public
|
||||
*/
|
||||
source: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
|
||||
track: PropTypes.oneOfType([PropTypes.string, PropTypes.node])
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
mediaComponent: 'video'
|
||||
};
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
const {source, preloadSource} = this.props;
|
||||
const {source: prevSource, preloadSource: prevPreloadSource} = prevProps;
|
||||
|
||||
const key = getKeyFromSource(source);
|
||||
const prevKey = getKeyFromSource(prevSource);
|
||||
const preloadKey = getKeyFromSource(preloadSource);
|
||||
const prevPreloadKey = getKeyFromSource(prevPreloadSource);
|
||||
|
||||
if (this.props.setMedia !== prevProps.setMedia) {
|
||||
this.clearMedia(prevProps);
|
||||
this.setMedia();
|
||||
}
|
||||
|
||||
if (source) {
|
||||
if (key === prevPreloadKey && preloadKey !== prevPreloadKey) {
|
||||
// if there's source and it was the preload source
|
||||
|
||||
// if the preloaded video didn't error, notify VideoPlayer it is ready to reset
|
||||
if (this.preloadLoadStart) {
|
||||
forward('onLoadStart', this.preloadLoadStart, this.props);
|
||||
}
|
||||
|
||||
// emit onUpdate to give VideoPlayer an opportunity to updates its internal state
|
||||
// since it won't receive the onLoadStart or onError event
|
||||
forward('onUpdate', {type: 'onUpdate'}, this.props);
|
||||
|
||||
this.autoPlay();
|
||||
} else if (key !== prevKey) {
|
||||
// if there's source and it has changed.
|
||||
this.autoPlay();
|
||||
}
|
||||
}
|
||||
|
||||
if (preloadSource && preloadKey !== prevPreloadKey) {
|
||||
this.preloadLoadStart = null;
|
||||
|
||||
// In the case that the previous source equalled the previous preload (causing the
|
||||
// preload video node to not be created) and then the preload source was changed, we
|
||||
// need to guard against accessing the preloadVideo node.
|
||||
if (this.preloadVideo) {
|
||||
this.preloadVideo.load();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
this.clearMedia();
|
||||
}
|
||||
|
||||
keys = ['media-1', 'media-2'];
|
||||
prevSourceKey = null;
|
||||
prevPreloadKey = null;
|
||||
|
||||
handlePreloadLoadStart = (ev) => {
|
||||
// persist the event so we can cache it to re-emit when the preload becomes active
|
||||
ev.persist();
|
||||
this.preloadLoadStart = ev;
|
||||
|
||||
// prevent the from bubbling to upstream handlers
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
||||
clearMedia ({setMedia} = this.props) {
|
||||
if (setMedia) {
|
||||
setMedia(null);
|
||||
}
|
||||
}
|
||||
|
||||
setMedia ({setMedia} = this.props) {
|
||||
if (setMedia) {
|
||||
setMedia(this.video);
|
||||
}
|
||||
}
|
||||
|
||||
autoPlay () {
|
||||
if (!this.props.autoPlay) return;
|
||||
|
||||
this.video.play();
|
||||
}
|
||||
|
||||
setVideoRef = (node) => {
|
||||
this.video = node;
|
||||
this.setMedia();
|
||||
};
|
||||
|
||||
setPreloadRef = (node) => {
|
||||
if (node) {
|
||||
node.load();
|
||||
}
|
||||
this.preloadVideo = node;
|
||||
};
|
||||
|
||||
getKeys () {
|
||||
const {source, preloadSource} = this.props;
|
||||
|
||||
const sourceKey = source && getKeyFromSource(source);
|
||||
let preloadKey = preloadSource && getKeyFromSource(preloadSource);
|
||||
|
||||
// If the same source is used for both, clear the preload key to avoid rendering duplicate
|
||||
// video elements.
|
||||
if (sourceKey === preloadKey) {
|
||||
preloadKey = null;
|
||||
}
|
||||
|
||||
// if either the source or preload existed previously in the other "slot", swap the keys so
|
||||
// the preload video becomes the active video and vice versa
|
||||
if (
|
||||
(sourceKey === this.prevPreloadKey && this.prevPreloadKey) ||
|
||||
(preloadKey === this.prevSourceKey && this.prevSourceKey)
|
||||
) {
|
||||
this.keys.reverse();
|
||||
}
|
||||
|
||||
// cache the previous keys so we know if the sources change the next time
|
||||
this.prevSourceKey = sourceKey;
|
||||
this.prevPreloadKey = preloadKey;
|
||||
|
||||
// if preload is unset, clear the key so we don't render that media node at all
|
||||
return preloadKey ? this.keys : this.keys.slice(0, 1);
|
||||
}
|
||||
|
||||
render () {
|
||||
const {
|
||||
preloadSource,
|
||||
source,
|
||||
track,
|
||||
mediaComponent,
|
||||
...rest
|
||||
} = this.props;
|
||||
|
||||
delete rest.setMedia;
|
||||
|
||||
const [sourceKey, preloadKey] = this.getKeys();
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{sourceKey ? (
|
||||
<Media
|
||||
{...rest}
|
||||
className={css.video}
|
||||
controls={false}
|
||||
key={sourceKey}
|
||||
mediaComponent={mediaComponent}
|
||||
preload="none"
|
||||
ref={this.setVideoRef}
|
||||
track={React.isValidElement(track) ? track : (
|
||||
<track src={track} />
|
||||
)}
|
||||
source={React.isValidElement(source) ? source : (
|
||||
<source src={source} />
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
{preloadKey ? (
|
||||
<Media
|
||||
autoPlay={false}
|
||||
className={css.preloadVideo}
|
||||
controls={false}
|
||||
key={preloadKey}
|
||||
mediaComponent={mediaComponent}
|
||||
onLoadStart={this.handlePreloadLoadStart}
|
||||
preload="none"
|
||||
ref={this.setPreloadRef}
|
||||
track={React.isValidElement(track) ? track : (
|
||||
<track src={track} />
|
||||
)}
|
||||
source={React.isValidElement(preloadSource) ? preloadSource : (
|
||||
<source src={preloadSource} />
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const VideoDecorator = compose(
|
||||
ForwardRef({prop: 'setMedia'}),
|
||||
Slottable({slots: ['source', 'track','preloadSource']})
|
||||
);
|
||||
|
||||
/**
|
||||
* Provides support for more advanced video configurations for `VideoPlayer`.
|
||||
*
|
||||
* Custom Video Tag
|
||||
*
|
||||
* ```
|
||||
* <VideoPlayer>
|
||||
* <Video mediaComponent="custom-video-element">
|
||||
* <source src="path/to/source.mp4" />
|
||||
* </Video>
|
||||
* </VideoPlayer>
|
||||
* ```
|
||||
*
|
||||
* Preload Video Source
|
||||
*
|
||||
* ```
|
||||
* <VideoPlayer>
|
||||
* <Video>
|
||||
* <source src="path/to/source.mp4" />
|
||||
* <source src="path/to/preload-source.mp4" slot="preloadSource" />
|
||||
* </Video>
|
||||
* </VideoPlayer>
|
||||
* ```
|
||||
*
|
||||
* @class Video
|
||||
* @mixes ui/Slottable.Slottable
|
||||
* @memberof sandstone/VideoPlayer
|
||||
* @ui
|
||||
* @public
|
||||
*/
|
||||
const Video = VideoDecorator(VideoBase);
|
||||
Video.defaultSlot = 'videoComponent';
|
||||
|
||||
export default Video;
|
||||
export {
|
||||
Video
|
||||
};
|
||||
import { forward } from '@enact/core/handle';
|
||||
import ForwardRef from '@enact/ui/ForwardRef';
|
||||
import { Media, getKeyFromSource } from '@enact/ui/Media';
|
||||
import EnactPropTypes from '@enact/core/internal/prop-types';
|
||||
import Slottable from '@enact/ui/Slottable';
|
||||
import compose from 'ramda/src/compose';
|
||||
import React from 'react';
|
||||
|
||||
import css from './VideoPlayer.module.less';
|
||||
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
/**
|
||||
* Adds support for preloading a video source for `VideoPlayer`.
|
||||
*
|
||||
* @class VideoBase
|
||||
* @memberof sandstone/VideoPlayer
|
||||
* @ui
|
||||
* @private
|
||||
*/
|
||||
const VideoBase = class extends React.Component {
|
||||
static displayName = 'Video';
|
||||
|
||||
static propTypes = /** @lends sandstone/VideoPlayer.Video.prototype */ {
|
||||
/**
|
||||
* Video plays automatically.
|
||||
*
|
||||
* @type {Boolean}
|
||||
* @default false
|
||||
* @public
|
||||
*/
|
||||
autoPlay: PropTypes.bool,
|
||||
|
||||
/**
|
||||
* Video loops continuously.
|
||||
*
|
||||
* @type {Boolean}
|
||||
* @default false
|
||||
* @public
|
||||
*/
|
||||
loop: PropTypes.bool,
|
||||
|
||||
/**
|
||||
* Video component to use.
|
||||
*
|
||||
* The default (`'video'`) renders an `HTMLVideoElement`. Custom video components must have
|
||||
* a similar API structure, exposing the following APIs:
|
||||
*
|
||||
* Properties:
|
||||
* * `currentTime` {Number} - Playback index of the media in seconds
|
||||
* * `duration` {Number} - Media's entire duration in seconds
|
||||
* * `error` {Boolean} - `true` if video playback has errored.
|
||||
* * `loading` {Boolean} - `true` if video playback is loading.
|
||||
* * `paused` {Boolean} - Playing vs paused state. `true` means the media is paused
|
||||
* * `playbackRate` {Number} - Current playback rate, as a number
|
||||
* * `proportionLoaded` {Number} - A value between `0` and `1`
|
||||
* representing the proportion of the media that has loaded
|
||||
* * `proportionPlayed` {Number} - A value between `0` and `1` representing the
|
||||
* proportion of the media that has already been shown
|
||||
*
|
||||
* Events:
|
||||
* * `onLoadStart` - Called when the video starts to load
|
||||
* * `onPlay` - Sent when playback of the media starts after having been paused
|
||||
* * `onUpdate` - Sent when any of the properties were updated
|
||||
*
|
||||
* Methods:
|
||||
* * `play()` - play video
|
||||
* * `pause()` - pause video
|
||||
* * `load()` - load video
|
||||
*
|
||||
* The [`source`]{@link sandstone/VideoPlayer.Video.source} property is passed to
|
||||
* the video component as a child node.
|
||||
*
|
||||
* @type {String|Component|Element}
|
||||
* @default 'video'
|
||||
* @public
|
||||
*/
|
||||
mediaComponent: EnactPropTypes.renderableOverride,
|
||||
|
||||
/**
|
||||
* The video source to be preloaded. Expects a `<source>` node.
|
||||
*
|
||||
* @type {Node}
|
||||
* @public
|
||||
*/
|
||||
preloadSource: PropTypes.node,
|
||||
|
||||
/**
|
||||
* Called with a reference to the active [Media]{@link ui/Media.Media} component.
|
||||
*
|
||||
* @type {Function}
|
||||
* @private
|
||||
*/
|
||||
setMedia: PropTypes.func,
|
||||
|
||||
/**
|
||||
* The video source to be played.
|
||||
*
|
||||
* Any children `<source>` elements will be sent directly to the `mediaComponent` as video
|
||||
* sources.
|
||||
*
|
||||
* See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/source
|
||||
*
|
||||
* @type {String|Node}
|
||||
* @public
|
||||
*/
|
||||
source: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
|
||||
track: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
mediaComponent: 'video',
|
||||
};
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { source, preloadSource } = this.props;
|
||||
const { source: prevSource, preloadSource: prevPreloadSource } = prevProps;
|
||||
|
||||
const key = getKeyFromSource(source);
|
||||
const prevKey = getKeyFromSource(prevSource);
|
||||
const preloadKey = getKeyFromSource(preloadSource);
|
||||
const prevPreloadKey = getKeyFromSource(prevPreloadSource);
|
||||
|
||||
if (this.props.setMedia !== prevProps.setMedia) {
|
||||
this.clearMedia(prevProps);
|
||||
this.setMedia();
|
||||
}
|
||||
|
||||
if (source) {
|
||||
if (key === prevPreloadKey && preloadKey !== prevPreloadKey) {
|
||||
// if there's source and it was the preload source
|
||||
|
||||
// if the preloaded video didn't error, notify VideoPlayer it is ready to reset
|
||||
if (this.preloadLoadStart) {
|
||||
forward('onLoadStart', this.preloadLoadStart, this.props);
|
||||
}
|
||||
|
||||
// emit onUpdate to give VideoPlayer an opportunity to updates its internal state
|
||||
// since it won't receive the onLoadStart or onError event
|
||||
forward('onUpdate', { type: 'onUpdate' }, this.props);
|
||||
|
||||
this.autoPlay();
|
||||
} else if (key !== prevKey) {
|
||||
// if there's source and it has changed.
|
||||
this.autoPlay();
|
||||
}
|
||||
}
|
||||
|
||||
if (preloadSource && preloadKey !== prevPreloadKey) {
|
||||
this.preloadLoadStart = null;
|
||||
|
||||
// In the case that the previous source equalled the previous preload (causing the
|
||||
// preload video node to not be created) and then the preload source was changed, we
|
||||
// need to guard against accessing the preloadVideo node.
|
||||
if (this.preloadVideo) {
|
||||
this.preloadVideo.load();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.clearMedia();
|
||||
}
|
||||
|
||||
keys = ['media-1', 'media-2'];
|
||||
prevSourceKey = null;
|
||||
prevPreloadKey = null;
|
||||
|
||||
handlePreloadLoadStart = (ev) => {
|
||||
// persist the event so we can cache it to re-emit when the preload becomes active
|
||||
ev.persist();
|
||||
this.preloadLoadStart = ev;
|
||||
|
||||
// prevent the from bubbling to upstream handlers
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
||||
clearMedia({ setMedia } = this.props) {
|
||||
if (setMedia) {
|
||||
setMedia(null);
|
||||
}
|
||||
}
|
||||
|
||||
setMedia({ setMedia } = this.props) {
|
||||
if (setMedia) {
|
||||
setMedia(this.video);
|
||||
}
|
||||
}
|
||||
|
||||
autoPlay() {
|
||||
if (!this.props.autoPlay) return;
|
||||
|
||||
this.video.play();
|
||||
}
|
||||
|
||||
setVideoRef = (node) => {
|
||||
this.video = node;
|
||||
this.setMedia();
|
||||
};
|
||||
|
||||
setPreloadRef = (node) => {
|
||||
if (node) {
|
||||
node.load();
|
||||
}
|
||||
this.preloadVideo = node;
|
||||
};
|
||||
|
||||
getKeys() {
|
||||
const { source, preloadSource } = this.props;
|
||||
|
||||
const sourceKey = source && getKeyFromSource(source);
|
||||
let preloadKey = preloadSource && getKeyFromSource(preloadSource);
|
||||
|
||||
// If the same source is used for both, clear the preload key to avoid rendering duplicate
|
||||
// video elements.
|
||||
if (sourceKey === preloadKey) {
|
||||
preloadKey = null;
|
||||
}
|
||||
|
||||
// if either the source or preload existed previously in the other "slot", swap the keys so
|
||||
// the preload video becomes the active video and vice versa
|
||||
if (
|
||||
(sourceKey === this.prevPreloadKey && this.prevPreloadKey) ||
|
||||
(preloadKey === this.prevSourceKey && this.prevSourceKey)
|
||||
) {
|
||||
this.keys.reverse();
|
||||
}
|
||||
|
||||
// cache the previous keys so we know if the sources change the next time
|
||||
this.prevSourceKey = sourceKey;
|
||||
this.prevPreloadKey = preloadKey;
|
||||
|
||||
// if preload is unset, clear the key so we don't render that media node at all
|
||||
return preloadKey ? this.keys : this.keys.slice(0, 1);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { preloadSource, source, track, mediaComponent, ...rest } = this.props;
|
||||
|
||||
delete rest.setMedia;
|
||||
|
||||
const [sourceKey, preloadKey] = this.getKeys();
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{sourceKey ? (
|
||||
<Media
|
||||
{...rest}
|
||||
className={css.video}
|
||||
controls={false}
|
||||
key={sourceKey}
|
||||
mediaComponent={mediaComponent}
|
||||
preload="none"
|
||||
ref={this.setVideoRef}
|
||||
track={React.isValidElement(track) ? track : <track src={track} />}
|
||||
source={React.isValidElement(source) ? source : <source src={source} />}
|
||||
/>
|
||||
) : null}
|
||||
{preloadKey ? (
|
||||
<Media
|
||||
autoPlay={false}
|
||||
className={css.preloadVideo}
|
||||
controls={false}
|
||||
key={preloadKey}
|
||||
mediaComponent={mediaComponent}
|
||||
onLoadStart={this.handlePreloadLoadStart}
|
||||
preload="none"
|
||||
ref={this.setPreloadRef}
|
||||
track={React.isValidElement(track) ? track : <track src={track} />}
|
||||
source={
|
||||
React.isValidElement(preloadSource) ? preloadSource : <source src={preloadSource} />
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const VideoDecorator = compose(
|
||||
ForwardRef({ prop: 'setMedia' }),
|
||||
Slottable({ slots: ['source', 'track', 'preloadSource'] })
|
||||
);
|
||||
|
||||
/**
|
||||
* Provides support for more advanced video configurations for `VideoPlayer`.
|
||||
*
|
||||
* Custom Video Tag
|
||||
*
|
||||
* ```
|
||||
* <VideoPlayer>
|
||||
* <Video mediaComponent="custom-video-element">
|
||||
* <source src="path/to/source.mp4" />
|
||||
* </Video>
|
||||
* </VideoPlayer>
|
||||
* ```
|
||||
*
|
||||
* Preload Video Source
|
||||
*
|
||||
* ```
|
||||
* <VideoPlayer>
|
||||
* <Video>
|
||||
* <source src="path/to/source.mp4" />
|
||||
* <source src="path/to/preload-source.mp4" slot="preloadSource" />
|
||||
* </Video>
|
||||
* </VideoPlayer>
|
||||
* ```
|
||||
*
|
||||
* @class Video
|
||||
* @mixes ui/Slottable.Slottable
|
||||
* @memberof sandstone/VideoPlayer
|
||||
* @ui
|
||||
* @public
|
||||
*/
|
||||
const Video = VideoDecorator(VideoBase);
|
||||
Video.defaultSlot = 'videoComponent';
|
||||
|
||||
export default Video;
|
||||
export { Video };
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import {LS2RequestSingleton} from './LS2RequestSingleton';
|
||||
import { LS2RequestSingleton } from './LS2RequestSingleton';
|
||||
|
||||
export * from './account';
|
||||
export * from './common';
|
||||
export * from './voice';
|
||||
|
||||
export const cancelReq = (instanceName) => {
|
||||
let r = LS2RequestSingleton.instance(instanceName);
|
||||
if (r) {
|
||||
r.cancel();
|
||||
r.cancelled = false;
|
||||
LS2RequestSingleton.deleteInstance(instanceName);
|
||||
}
|
||||
};
|
||||
let r = LS2RequestSingleton.instance(instanceName);
|
||||
if (r) {
|
||||
r.cancel();
|
||||
r.cancelled = false;
|
||||
LS2RequestSingleton.deleteInstance(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 { toastReducer } from '../reducers/toastReducer';
|
||||
import { videoPlayReducer } from '../reducers/videoPlayReducer';
|
||||
import { voiceReducer } from '../reducers/voiceReducer';
|
||||
|
||||
const rootReducer = combineReducers({
|
||||
panels: panelsReducer,
|
||||
@@ -58,6 +59,7 @@ const rootReducer = combineReducers({
|
||||
foryou: foryouReducer,
|
||||
toast: toastReducer,
|
||||
videoPlay: videoPlayReducer,
|
||||
voice: voiceReducer,
|
||||
});
|
||||
|
||||
export const store = createStore(rootReducer, applyMiddleware(thunk));
|
||||
|
||||
@@ -15,6 +15,7 @@ import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDeco
|
||||
import arrowDown from '../../../../assets/images/icons/ic_arrow_down_3x_new.png';
|
||||
import indicatorDefaultImage from '../../../../assets/images/img-thumb-empty-144@3x.png';
|
||||
// import { pushPanel } from '../../../actions/panelActions';
|
||||
import { minimizeModalMedia } from '../../../actions/mediaActions';
|
||||
import { resetShowAllReviews } from '../../../actions/productActions';
|
||||
import { showToast } from '../../../actions/toastActions';
|
||||
// ProductInfoSection imports
|
||||
@@ -143,7 +144,7 @@ export default function ProductAllSection({
|
||||
const youmaylikeData = useSelector((state) => state.main.youmaylikeData);
|
||||
|
||||
// ProductVideo 버전 관리 (1: 기존 modal 방식, 2: 내장 방식)
|
||||
const [productVideoVersion, setProductVideoVersion] = useState(1);
|
||||
const [productVideoVersion, setProductVideoVersion] = useState(3);
|
||||
|
||||
// const [currentHeight, setCurrentHeight] = useState(0);
|
||||
//하단부분까지 갔을때 체크용
|
||||
@@ -308,8 +309,12 @@ export default function ProductAllSection({
|
||||
[scrollToSection]
|
||||
);
|
||||
|
||||
// 비디오 다음 이미지로 스크롤하는 핸들러
|
||||
const handleScrollToImages = useCallback(() => {
|
||||
// ProductVideo V1 전용 - MediaPanel minimize 포함
|
||||
const handleScrollToImagesV1 = useCallback(() => {
|
||||
// 1. MediaPanel을 1px로 축소하여 포커스 충돌 방지
|
||||
dispatch(minimizeModalMedia());
|
||||
|
||||
// 2. 스크롤 이동
|
||||
scrollToSection('scroll-marker-after-video');
|
||||
|
||||
// 기존 timeout이 있으면 클리어
|
||||
@@ -317,13 +322,45 @@ export default function ProductAllSection({
|
||||
clearTimeout(scrollToImagesTimeoutRef.current);
|
||||
}
|
||||
|
||||
// 250ms 후 ProductDetail로 포커스 이동
|
||||
// 3. 100ms 후 명시적으로 첫 번째 ProductDetail(이미지)로 포커스 이동
|
||||
scrollToImagesTimeoutRef.current = setTimeout(() => {
|
||||
Spotlight.move('down');
|
||||
Spotlight.focus('product-img-1');
|
||||
scrollToImagesTimeoutRef.current = null;
|
||||
}, 250);
|
||||
}, 100);
|
||||
}, [scrollToSection, dispatch]);
|
||||
|
||||
// ProductVideoV2 전용 - minimize 없음 (내장 비디오 방식)
|
||||
const handleScrollToImagesV2 = useCallback(() => {
|
||||
// 1. 스크롤 이동
|
||||
scrollToSection('scroll-marker-after-video');
|
||||
|
||||
// 기존 timeout이 있으면 클리어
|
||||
if (scrollToImagesTimeoutRef.current) {
|
||||
clearTimeout(scrollToImagesTimeoutRef.current);
|
||||
}
|
||||
|
||||
// 2. 100ms 후 명시적으로 첫 번째 ProductDetail(이미지)로 포커스 이동
|
||||
scrollToImagesTimeoutRef.current = setTimeout(() => {
|
||||
Spotlight.focus('product-img-1');
|
||||
scrollToImagesTimeoutRef.current = null;
|
||||
}, 100);
|
||||
}, [scrollToSection]);
|
||||
|
||||
// ProductVideoVersion 3 전용 - 비디오 없이 이미지만 사용 (minimize 액션 없음)
|
||||
const handleScrollToImagesV3 = useCallback(() => {
|
||||
// 비디오가 없으므로 scroll-marker-after-video 대신 첫 이미지로 직접 이동
|
||||
// 기존 timeout이 있으면 클리어
|
||||
if (scrollToImagesTimeoutRef.current) {
|
||||
clearTimeout(scrollToImagesTimeoutRef.current);
|
||||
}
|
||||
|
||||
// 즉시 첫 번째 ProductDetail(이미지)로 포커스 이동
|
||||
scrollToImagesTimeoutRef.current = setTimeout(() => {
|
||||
Spotlight.focus('product-img-1');
|
||||
scrollToImagesTimeoutRef.current = null;
|
||||
}, 100);
|
||||
}, []);
|
||||
|
||||
const scrollContainerRef = useRef(null);
|
||||
const productDetailRef = useRef(null); //높이값 변경때문
|
||||
const descriptionRef = useRef(null);
|
||||
@@ -334,8 +371,8 @@ export default function ProductAllSection({
|
||||
const renderItems = useMemo(() => {
|
||||
const items = [];
|
||||
|
||||
// 동영상이 있으면 첫 번째에 추가 (Indicator.jsx와 동일한 로직)
|
||||
if (productData && productData.prdtMediaUrl) {
|
||||
// 동영상이 있으면 첫 번째에 추가 (productVideoVersion이 3이 아닐 때만)
|
||||
if (productData && productData.prdtMediaUrl && productVideoVersion !== 3) {
|
||||
items.push({
|
||||
type: 'video',
|
||||
url: productData.prdtMediaUrl,
|
||||
@@ -350,13 +387,17 @@ export default function ProductAllSection({
|
||||
items.push({
|
||||
type: 'image',
|
||||
url: image,
|
||||
index: productData && productData.prdtMediaUrl ? imgIndex + 1 : imgIndex,
|
||||
// productVideoVersion === 3이면 비디오가 없으므로 index는 0부터 시작
|
||||
index:
|
||||
productData && productData.prdtMediaUrl && productVideoVersion !== 3
|
||||
? imgIndex + 1
|
||||
: imgIndex,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [productData]);
|
||||
}, [productData, productVideoVersion]);
|
||||
|
||||
// renderItems에 Video가 존재하는지 확인하는 boolean 상태
|
||||
const hasVideo = useMemo(() => {
|
||||
@@ -696,8 +737,8 @@ export default function ProductAllSection({
|
||||
onFocus={() => handleButtonFocus('product')}
|
||||
onBlur={handleButtonBlur}
|
||||
>
|
||||
{/* 비디오가 있으면 먼저 렌더링 */}
|
||||
{hasVideo && renderItems[0].type === 'video' && (
|
||||
{/* 비디오가 있으면 먼저 렌더링 (productVideoVersion이 3이 아닐 때만) */}
|
||||
{hasVideo && renderItems[0].type === 'video' && productVideoVersion !== 3 && (
|
||||
<>
|
||||
{productVideoVersion === 1 ? (
|
||||
<ProductVideo
|
||||
@@ -705,7 +746,9 @@ export default function ProductAllSection({
|
||||
productInfo={productData}
|
||||
videoUrl={renderItems[0].url}
|
||||
thumbnailUrl={renderItems[0].thumbnail}
|
||||
onScrollToImages={handleScrollToImages}
|
||||
autoPlay={true}
|
||||
continuousPlay={true}
|
||||
onScrollToImages={handleScrollToImagesV1}
|
||||
/>
|
||||
) : (
|
||||
<ProductVideoV2
|
||||
@@ -714,7 +757,7 @@ export default function ProductAllSection({
|
||||
videoUrl={renderItems[0].url}
|
||||
thumbnailUrl={renderItems[0].thumbnail}
|
||||
autoPlay={true}
|
||||
onScrollToImages={handleScrollToImages}
|
||||
onScrollToImages={handleScrollToImagesV2}
|
||||
/>
|
||||
)}
|
||||
<div id="scroll-marker-after-video" className={css.scrollMarker}></div>
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
startMediaPlayer,
|
||||
finishMediaPreview,
|
||||
switchMediaToFullscreen,
|
||||
minimizeModalMedia,
|
||||
restoreModalMedia,
|
||||
} from '../../../../actions/mediaActions';
|
||||
import CustomImage from '../../../../components/CustomImage/CustomImage';
|
||||
import { panel_names } from '../../../../utils/Config';
|
||||
@@ -13,7 +15,14 @@ import css from './ProductVideo.module.less';
|
||||
|
||||
const SpottableComponent = Spottable('div');
|
||||
|
||||
export default function ProductVideo({ productInfo, videoUrl, thumbnailUrl, onScrollToImages }) {
|
||||
export default function ProductVideo({
|
||||
productInfo,
|
||||
videoUrl,
|
||||
thumbnailUrl,
|
||||
onScrollToImages,
|
||||
autoPlay = false, // 자동 재생 여부
|
||||
continuousPlay = false, // 반복 재생 여부
|
||||
}) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// MediaPanel 상태 체크를 위한 selectors 추가
|
||||
@@ -21,6 +30,7 @@ export default function ProductVideo({ productInfo, videoUrl, thumbnailUrl, onSc
|
||||
const [isLaunchedFromPlayer, setIsLaunchedFromPlayer] = useState(false);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [modalState, setModalState] = useState(true); // 모달 상태 관리 추가
|
||||
const [hasAutoPlayed, setHasAutoPlayed] = useState(false); // 자동 재생 완료 여부
|
||||
|
||||
const topPanel = panels[panels.length - 1];
|
||||
|
||||
@@ -40,6 +50,51 @@ export default function ProductVideo({ productInfo, videoUrl, thumbnailUrl, onSc
|
||||
}
|
||||
}, [topPanel]);
|
||||
|
||||
// autoPlay 기능: 컴포넌트 마운트 시 자동으로 비디오 재생
|
||||
useEffect(() => {
|
||||
if (autoPlay && canPlayVideo && !hasAutoPlayed && productInfo) {
|
||||
console.log('[ProductVideo] Auto-playing video');
|
||||
setHasAutoPlayed(true);
|
||||
|
||||
// 짧은 딸레이 후 재생 시작 (컴포넌트 마운트 완료 후)
|
||||
setTimeout(() => {
|
||||
dispatch(
|
||||
startMediaPlayer({
|
||||
qrCurrentItem: productInfo,
|
||||
showUrl: productInfo?.prdtMediaUrl,
|
||||
showNm: productInfo?.prdtNm,
|
||||
patnrNm: productInfo?.patncNm,
|
||||
patncLogoPath: productInfo?.patncLogoPath,
|
||||
orderPhnNo: productInfo?.orderPhnNo,
|
||||
disclaimer: productInfo?.disclaimer,
|
||||
subtitle: productInfo?.prdtMediaSubtitlUrl,
|
||||
lgCatCd: productInfo?.catCd,
|
||||
patnrId: productInfo?.patnrId,
|
||||
lgCatNm: productInfo?.catNm,
|
||||
prdtId: productInfo?.prdtId,
|
||||
patncNm: productInfo?.patncNm,
|
||||
prdtNm: productInfo?.prdtNm,
|
||||
thumbnailUrl: productInfo?.thumbnailUrl960,
|
||||
shptmBanrTpNm: 'MEDIA',
|
||||
modal: true,
|
||||
modalContainerId: 'product-video-player',
|
||||
modalClassName: modalClassNameChange(),
|
||||
spotlightDisable: true,
|
||||
continuousPlay, // 반복 재생 옵션 전달
|
||||
})
|
||||
);
|
||||
}, 100);
|
||||
}
|
||||
}, [
|
||||
autoPlay,
|
||||
canPlayVideo,
|
||||
hasAutoPlayed,
|
||||
productInfo,
|
||||
dispatch,
|
||||
modalClassNameChange,
|
||||
continuousPlay,
|
||||
]);
|
||||
|
||||
// 비디오 재생 가능 여부 체크
|
||||
const canPlayVideo = useMemo(() => {
|
||||
return Boolean(productInfo?.prdtMediaUrl);
|
||||
@@ -57,18 +112,20 @@ export default function ProductVideo({ productInfo, videoUrl, thumbnailUrl, onSc
|
||||
const videoContainerOnFocus = useCallback(() => {
|
||||
if (canPlayVideo) {
|
||||
setFocused(true);
|
||||
console.log('[ProductVideo] Calling restoreModalMedia');
|
||||
// ProductVideo에 포커스가 돌아오면 비디오 복원
|
||||
dispatch(restoreModalMedia());
|
||||
}
|
||||
}, [canPlayVideo]);
|
||||
}, [canPlayVideo, dispatch]);
|
||||
|
||||
const videoContainerOnBlur = useCallback(() => {
|
||||
console.log('[ProductVideo] onBlur called - canPlayVideo:', canPlayVideo);
|
||||
if (canPlayVideo) {
|
||||
setFocused(false);
|
||||
console.log('[ProductVideo] Calling finishMediaPreview');
|
||||
// ProductVideo에서 포커스가 벗어나면 비디오 재생 종료
|
||||
dispatch(finishMediaPreview());
|
||||
// minimize는 handleScrollToImages에서 명시적으로 처리
|
||||
// 여기서는 focused 상태만 변경
|
||||
}
|
||||
}, [canPlayVideo, dispatch]);
|
||||
}, [canPlayVideo]);
|
||||
|
||||
// Spotlight Down 키 핸들러 - 비디오 다음 이미지로 스크롤
|
||||
const handleSpotlightDown = useCallback(
|
||||
@@ -136,6 +193,7 @@ export default function ProductVideo({ productInfo, videoUrl, thumbnailUrl, onSc
|
||||
modalContainerId: 'product-video-player',
|
||||
modalClassName: modalClassNameChange(),
|
||||
spotlightDisable: true,
|
||||
continuousPlay, // 반복 재생 옵션 전달
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
&::after {
|
||||
overflow: hidden;
|
||||
.position(@position: absolute, @top: 0, @left: 0, @right: 0, @bottom: 0);
|
||||
z-index: 19;
|
||||
z-index: 23; // MediaPanel(z-index: 22)보다 위에 표시되어야 비디오 재생 중에도 포커스 테두리가 보임
|
||||
border: 6px solid @PRIMARY_COLOR_RED;
|
||||
box-shadow: 0 0 22px 0 rgba(0, 0, 0, 0.5);
|
||||
border-radius: 12px;
|
||||
|
||||
@@ -38,7 +38,13 @@ const YOUTUBECONFIG = {
|
||||
},
|
||||
};
|
||||
|
||||
export default function ProductVideoV2({ productInfo, videoUrl, thumbnailUrl, autoPlay = false }) {
|
||||
export default function ProductVideoV2({
|
||||
productInfo,
|
||||
videoUrl,
|
||||
thumbnailUrl,
|
||||
autoPlay = false,
|
||||
onScrollToImages,
|
||||
}) {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
@@ -126,6 +132,20 @@ export default function ProductVideoV2({ productInfo, videoUrl, thumbnailUrl, au
|
||||
setIsFullscreen(false); // 전체화면도 해제
|
||||
}, []);
|
||||
|
||||
// Spotlight Down 키 핸들러 - 비디오 다음 이미지로 스크롤
|
||||
const handleSpotlightDown = useCallback(
|
||||
(e) => {
|
||||
if (canPlayVideo && onScrollToImages) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onScrollToImages();
|
||||
return true; // 이벤트 처리 완료
|
||||
}
|
||||
return false; // Spotlight가 기본 동작 수행
|
||||
},
|
||||
[canPlayVideo, onScrollToImages]
|
||||
);
|
||||
|
||||
// Back 버튼 핸들러 - 전체화면 해제 또는 비디오 종료
|
||||
const handleBackButton = useCallback(() => {
|
||||
if (isFullscreen) {
|
||||
@@ -226,12 +246,14 @@ export default function ProductVideoV2({ productInfo, videoUrl, thumbnailUrl, au
|
||||
? {
|
||||
spotlightRestrict: 'self-only', // 포커스가 밖으로 나가지 않도록
|
||||
spotlightId: 'product-video-v2-fullscreen',
|
||||
onSpotlightDown: handleSpotlightDown, // 전체화면에서도 Down 키 동작
|
||||
// 전체화면 모드: window 레벨에서 이벤트 처리
|
||||
}
|
||||
: isPlaying
|
||||
? {
|
||||
spotlightId: 'product-video-v2-playing',
|
||||
onKeyDown: handleContainerKeyDown, // 일반 모드: 컨테이너에서 직접 처리
|
||||
onSpotlightDown: handleSpotlightDown, // 일반 재생에서도 Down 키 동작
|
||||
// 일반 재생 모드: 컨테이너가 포커스 받음
|
||||
}
|
||||
: {};
|
||||
@@ -250,6 +272,7 @@ export default function ProductVideoV2({ productInfo, videoUrl, thumbnailUrl, au
|
||||
onClick={handleThumbnailClick}
|
||||
onFocus={videoContainerOnFocus}
|
||||
onBlur={videoContainerOnBlur}
|
||||
onSpotlightDown={handleSpotlightDown}
|
||||
spotlightId="product-video-v2-thumbnail"
|
||||
aria-label={`${productInfo?.prdtNm} 동영상 재생`}
|
||||
>
|
||||
|
||||
@@ -113,28 +113,43 @@ const MediaPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props
|
||||
// modal 스타일 설정
|
||||
useEffect(() => {
|
||||
if (panelInfo.modal && panelInfo.modalContainerId) {
|
||||
// modal 모드: modalContainerId 기반으로 위치와 크기 계산
|
||||
const node = document.querySelector(`[data-spotlight-id="${panelInfo.modalContainerId}"]`);
|
||||
if (node) {
|
||||
const { width, height, top, left } = node.getBoundingClientRect();
|
||||
|
||||
// ProductVideo의 padding(6px * 2)과 추가 여유를 고려하여 크기 조정
|
||||
// 비디오가 오른쪽으로 넘치지 않도록 충분한 여유 확보
|
||||
const paddingOffset = 6 * 2; // padding 양쪽
|
||||
const extraMargin = 6 * 2; // 추가 여유 (포커스 테두리 + 비디오 비율 고려)
|
||||
const totalOffset = paddingOffset + extraMargin; // 24px
|
||||
|
||||
const adjustedWidth = width - totalOffset;
|
||||
const adjustedHeight = height - totalOffset;
|
||||
const adjustedTop = top + totalOffset / 2;
|
||||
const adjustedLeft = left + totalOffset / 2;
|
||||
|
||||
const style = {
|
||||
width: width + 'px',
|
||||
height: height + 'px',
|
||||
top: top + 'px',
|
||||
left: left + 'px',
|
||||
width: adjustedWidth + 'px',
|
||||
height: adjustedHeight + 'px',
|
||||
maxWidth: adjustedWidth + 'px',
|
||||
maxHeight: adjustedHeight + 'px',
|
||||
top: adjustedTop + 'px',
|
||||
left: adjustedLeft + 'px',
|
||||
position: 'fixed',
|
||||
overflow: 'visible',
|
||||
overflow: 'hidden', // visible → hidden으로 변경하여 넘치는 부분 숨김
|
||||
};
|
||||
setModalStyle(style);
|
||||
let scale = 1;
|
||||
if (typeof window === 'object') {
|
||||
scale = width / window.innerWidth;
|
||||
scale = adjustedWidth / window.innerWidth;
|
||||
setModalScale(scale);
|
||||
}
|
||||
} else {
|
||||
setModalStyle(panelInfo.modalStyle || {});
|
||||
setModalScale(panelInfo.modalScale || 1);
|
||||
}
|
||||
} else if (isOnTop && !panelInfo.modal && videoPlayer.current) {
|
||||
} else if (isOnTop && !panelInfo.modal && !panelInfo.isMinimized && videoPlayer.current) {
|
||||
if (videoPlayer.current?.getMediaState()?.paused) {
|
||||
videoPlayer.current.play();
|
||||
}
|
||||
@@ -263,8 +278,9 @@ const MediaPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props
|
||||
|
||||
const onEnded = useCallback(
|
||||
(e) => {
|
||||
// console.log('[MediaPanel] Video ended');
|
||||
// 비디오 종료 시 패널 닫기
|
||||
console.log('[MediaPanel] Video ended');
|
||||
// continuousPlay는 MediaPlayer(VideoPlayer) 컴포넌트 내부에서 loop 속성으로 처리
|
||||
// onEnded가 호출되면 loop=false 인 경우이므로 패널을 닫음
|
||||
Spotlight.pause();
|
||||
setTimeout(() => {
|
||||
Spotlight.resume();
|
||||
@@ -290,23 +306,48 @@ const MediaPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props
|
||||
|
||||
// console.log('[MediaPanel] ========== Rendering ==========');
|
||||
// console.log('[MediaPanel] isOnTop:', isOnTop);
|
||||
// console.log('[MediaPanel] panelInfo:', JSON.stringify(panelInfo, null, 2));
|
||||
// console.log('[MediaPanel] panelInfo.modal:', panelInfo.modal);
|
||||
// console.log('[MediaPanel] panelInfo.isMinimized:', panelInfo.isMinimized);
|
||||
// console.log('[MediaPanel] panelInfo.isPaused:', panelInfo.isPaused);
|
||||
// console.log('[MediaPanel] currentPlayingUrl:', currentPlayingUrl);
|
||||
// console.log('[MediaPanel] hasVideoPlayer:', !!videoPlayer.current);
|
||||
|
||||
// classNames 적용 상태 확인
|
||||
// console.log('[MediaPanel] ========== ClassNames Analysis ==========');
|
||||
// console.log('[MediaPanel] css.videoContainer:', css.videoContainer);
|
||||
// console.log('[MediaPanel] Condition [panelInfo.modal && !panelInfo.isMinimized]:', panelInfo.modal && !panelInfo.isMinimized);
|
||||
// console.log('[MediaPanel] css.modal:', css.modal);
|
||||
// console.log('[MediaPanel] Condition [panelInfo.isMinimized]:', panelInfo.isMinimized);
|
||||
// console.log('[MediaPanel] css["modal-minimized"]:', css['modal-minimized']);
|
||||
// console.log('[MediaPanel] Condition [!isOnTop]:', !isOnTop);
|
||||
// console.log('[MediaPanel] css.background:', css.background);
|
||||
|
||||
const appliedClassNames = classNames(
|
||||
css.videoContainer,
|
||||
panelInfo.modal && !panelInfo.isMinimized && css.modal,
|
||||
panelInfo.isMinimized && css['modal-minimized'],
|
||||
!isOnTop && css.background
|
||||
);
|
||||
// console.log('[MediaPanel] Final Applied ClassNames:', appliedClassNames);
|
||||
// console.log('[MediaPanel] modalStyle:', modalStyle);
|
||||
// console.log('[MediaPanel] modalScale:', modalScale);
|
||||
// console.log('[MediaPanel] ===============================================');
|
||||
|
||||
// minimized 상태일 때는 spotlightRestrict 해제 (포커스 이동 허용)
|
||||
const containerSpotlightRestrict = panelInfo.isMinimized ? 'none' : 'self-only';
|
||||
|
||||
return (
|
||||
<TPanel
|
||||
isTabActivated={false}
|
||||
{...props}
|
||||
className={classNames(
|
||||
css.videoContainer,
|
||||
panelInfo.modal && css.modal,
|
||||
!isOnTop && css.background
|
||||
)}
|
||||
className={appliedClassNames}
|
||||
handleCancel={onClickBack}
|
||||
spotlightId={spotlightId}
|
||||
>
|
||||
<Container spotlightRestrict="self-only" spotlightId="spotlightId-media-video-container">
|
||||
<Container
|
||||
spotlightRestrict={containerSpotlightRestrict}
|
||||
spotlightId="spotlightId-media-video-container"
|
||||
>
|
||||
{currentPlayingUrl && (
|
||||
<VideoPlayer
|
||||
setApiProvider={getPlayer}
|
||||
@@ -319,6 +360,7 @@ const MediaPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props
|
||||
spotlightDisabled={panelInfo.modal}
|
||||
isYoutube={isYoutube}
|
||||
src={currentPlayingUrl}
|
||||
loop={panelInfo.continuousPlay || false}
|
||||
style={panelInfo.modal ? modalStyle : {}}
|
||||
modalScale={panelInfo.modal ? modalScale : 1}
|
||||
modalClassName={panelInfo.modal && panelInfo.modalClassName}
|
||||
|
||||
@@ -21,6 +21,13 @@
|
||||
z-index: 22; /* DetailPanel보다 위 */
|
||||
background-color: @videoBackgroundColor;
|
||||
overflow: visible;
|
||||
|
||||
/* video element가 컨테이너를 넘지 않도록 크기 제한 */
|
||||
video {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain; /* 비율 유지하면서 컨테이너 안에 맞춤 */
|
||||
}
|
||||
}
|
||||
|
||||
&.modal-minimized,
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDecorator';
|
||||
|
||||
import TBody from '../../components/TBody/TBody';
|
||||
import TButton from '../../components/TButton/TButton';
|
||||
import TPanel from '../../components/TPanel/TPanel';
|
||||
import { types } from '../../actions/actionTypes';
|
||||
import { sendLogGNB } from '../../actions/logActions';
|
||||
import { popPanel } from '../../actions/panelActions';
|
||||
import { registerVoiceFramework, unregisterVoiceFramework } from '../../actions/voiceActions';
|
||||
import { LOG_MENU } from '../../utils/Config';
|
||||
import VoiceHeader from './VoiceHeader';
|
||||
import mockLogs from './mockLogData';
|
||||
|
||||
import css from './VoicePanel.module.less';
|
||||
|
||||
const ContainerBasic = SpotlightContainerDecorator({ enterTo: 'last-focused' }, 'div');
|
||||
|
||||
export default function VoicePanel({ panelInfo, isOnTop, spotlightId }) {
|
||||
const dispatch = useDispatch();
|
||||
const loadingComplete = useSelector((state) => state.common?.loadingComplete);
|
||||
const logViewerRef = useRef(null);
|
||||
|
||||
// Platform detection: Luna/Voice framework only works on TV
|
||||
const isTV = typeof window === 'object' && window.PalmSystem;
|
||||
|
||||
// Voice state from Redux
|
||||
const voiceState = useSelector((state) => state.voice);
|
||||
const { isRegistered, voiceTicket, logs, registrationError } = voiceState;
|
||||
|
||||
useEffect(() => {
|
||||
if (isOnTop) {
|
||||
@@ -24,11 +31,83 @@ export default function VoicePanel({ panelInfo, isOnTop, spotlightId }) {
|
||||
}
|
||||
}, [isOnTop, dispatch]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
dispatch(unregisterVoiceFramework());
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
const handleBackButton = useCallback(() => {
|
||||
console.log(`[VoicePanel] Back button clicked - returning to previous panel`);
|
||||
dispatch(popPanel());
|
||||
}, [dispatch]);
|
||||
|
||||
const handleRegister = useCallback(() => {
|
||||
if (!isTV) {
|
||||
console.warn('[VoicePanel] Voice framework is only available on TV platform');
|
||||
dispatch({
|
||||
type: types.VOICE_ADD_LOG,
|
||||
payload: {
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'ERROR',
|
||||
title: 'Platform Not Supported',
|
||||
data: { message: 'Voice framework is only available on webOS TV platform' },
|
||||
success: false,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
console.log('[VoicePanel] Register button clicked');
|
||||
dispatch(registerVoiceFramework());
|
||||
}, [dispatch, isTV]);
|
||||
|
||||
const handleClearLogs = useCallback(() => {
|
||||
console.log('[VoicePanel] Clear logs button clicked');
|
||||
dispatch({ type: types.VOICE_CLEAR_LOGS });
|
||||
}, [dispatch]);
|
||||
|
||||
const handleLoadMockData = useCallback(() => {
|
||||
console.log('[VoicePanel] Loading 200 mock log entries for scroll test');
|
||||
// Add all mock logs to Redux
|
||||
mockLogs.forEach((log) => {
|
||||
dispatch({
|
||||
type: types.VOICE_ADD_LOG,
|
||||
payload: log,
|
||||
});
|
||||
});
|
||||
}, [dispatch]);
|
||||
|
||||
const formatTime = useCallback((timestamp) => {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleTimeString('en-US', { hour12: false });
|
||||
}, []);
|
||||
|
||||
const getTypeColor = useCallback((type) => {
|
||||
const colors = {
|
||||
REQUEST: '#4A90E2',
|
||||
RESPONSE: '#7ED321',
|
||||
COMMAND: '#F5A623',
|
||||
ERROR: '#D0021B',
|
||||
ACTION: '#9013FE',
|
||||
};
|
||||
return colors[type] || '#FFFFFF';
|
||||
}, []);
|
||||
|
||||
const handleScrollUp = useCallback(() => {
|
||||
if (!logViewerRef.current) return;
|
||||
const scrollAmount = 200; // Scroll 200px up
|
||||
logViewerRef.current.scrollTop -= scrollAmount;
|
||||
console.log('[VoicePanel] Scroll Up clicked');
|
||||
}, []);
|
||||
|
||||
const handleScrollDown = useCallback(() => {
|
||||
if (!logViewerRef.current) return;
|
||||
const scrollAmount = 200; // Scroll 200px down
|
||||
logViewerRef.current.scrollTop += scrollAmount;
|
||||
console.log('[VoicePanel] Scroll Down clicked');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<TPanel
|
||||
panelInfo={panelInfo}
|
||||
@@ -37,23 +116,108 @@ export default function VoicePanel({ panelInfo, isOnTop, spotlightId }) {
|
||||
spotlightId={spotlightId}
|
||||
>
|
||||
<VoiceHeader
|
||||
title="Voice Search"
|
||||
title="Voice Conductor Test"
|
||||
onBackButton={handleBackButton}
|
||||
onClick={handleBackButton}
|
||||
className={css.header}
|
||||
/>
|
||||
<TBody spotlightId={spotlightId} className={css.tbody}>
|
||||
{loadingComplete && (
|
||||
<ContainerBasic className={css.voiceContainer}>
|
||||
<div className={css.voiceContent}>
|
||||
<h1 className={css.title}>Voice Panel</h1>
|
||||
<p className={css.description}>
|
||||
Voice search functionality will be implemented here.
|
||||
</p>
|
||||
{loadingComplete && (
|
||||
<div className={css.contentWrapper}>
|
||||
{/* Buttons - All in one row */}
|
||||
<div className={css.buttonArea}>
|
||||
<TButton
|
||||
onClick={handleRegister}
|
||||
spotlightId="voice-register-btn"
|
||||
disabled={isRegistered}
|
||||
className={css.compactButton}
|
||||
>
|
||||
{isRegistered ? 'Registered ✓' : 'Register'}
|
||||
</TButton>
|
||||
<TButton
|
||||
onClick={handleClearLogs}
|
||||
spotlightId="voice-clear-logs-btn"
|
||||
className={css.compactButton}
|
||||
>
|
||||
Clear
|
||||
</TButton>
|
||||
<TButton
|
||||
onClick={handleLoadMockData}
|
||||
spotlightId="voice-mock-data-btn"
|
||||
className={css.compactButton}
|
||||
>
|
||||
Mock
|
||||
</TButton>
|
||||
<TButton
|
||||
onClick={handleScrollUp}
|
||||
spotlightId="voice-scroll-up-btn"
|
||||
className={css.compactButton}
|
||||
>
|
||||
↑ Up
|
||||
</TButton>
|
||||
<TButton
|
||||
onClick={handleScrollDown}
|
||||
spotlightId="voice-scroll-down-btn"
|
||||
className={css.compactButton}
|
||||
>
|
||||
↓ Down
|
||||
</TButton>
|
||||
</div>
|
||||
|
||||
{/* Status and Logs */}
|
||||
<div className={css.infoContainer}>
|
||||
{/* Status Panel */}
|
||||
<div className={css.statusPanel}>
|
||||
<div className={css.statusItem}>
|
||||
<span className={css.statusLabel}>Platform:</span>
|
||||
<span className={isTV ? css.statusSuccess : css.statusWarning}>
|
||||
{isTV ? '✓ TV (webOS)' : '✗ Web Browser'}
|
||||
</span>
|
||||
</div>
|
||||
<div className={css.statusItem}>
|
||||
<span className={css.statusLabel}>Status:</span>
|
||||
<span className={isRegistered ? css.statusSuccess : css.statusInactive}>
|
||||
{isRegistered ? '✓ Registered' : '✗ Not Registered'}
|
||||
</span>
|
||||
</div>
|
||||
<div className={css.statusItem}>
|
||||
<span className={css.statusLabel}>Ticket:</span>
|
||||
<span className={css.statusValue}>{voiceTicket || 'N/A'}</span>
|
||||
</div>
|
||||
{registrationError && (
|
||||
<div className={css.statusItem}>
|
||||
<span className={css.statusLabel}>Error:</span>
|
||||
<span className={css.statusError}>{JSON.stringify(registrationError)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ContainerBasic>
|
||||
)}
|
||||
</TBody>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
</TPanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,28 +14,224 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.voiceContainer {
|
||||
// Content Wrapper - Main container
|
||||
.contentWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Button Area - Single row, compact buttons
|
||||
.buttonArea {
|
||||
flex-shrink: 0;
|
||||
padding: 30px 60px 15px;
|
||||
display: flex;
|
||||
|
||||
flex-wrap: nowrap; // Force single row
|
||||
}
|
||||
|
||||
.compactButton {
|
||||
min-width: auto;
|
||||
max-width: auto;
|
||||
padding: 6px 8px;
|
||||
font-size: 22px;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 600px;
|
||||
padding: 60px;
|
||||
justify-content: center;
|
||||
margin-right: 12px;
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.voiceContent {
|
||||
text-align: center;
|
||||
max-width: 800px;
|
||||
// Info Container - Status and Logs (increased height)
|
||||
.infoContainer {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 60px 30px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 48px;
|
||||
// Status Panel - Dark theme (more compact)
|
||||
.statusPanel {
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 12px;
|
||||
padding: 20px 30px;
|
||||
margin-bottom: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.statusItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
font-size: 20px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.statusLabel {
|
||||
color: #c0c0c0;
|
||||
margin-right: 15px;
|
||||
min-width: 100px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.statusValue {
|
||||
color: #f0f0f0;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.statusSuccess {
|
||||
color: #7ED321;
|
||||
font-weight: bold;
|
||||
color: #ffffff;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 24px;
|
||||
color: #cccccc;
|
||||
line-height: 1.6;
|
||||
.statusInactive {
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
.statusWarning {
|
||||
color: #FFB84D;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.statusError {
|
||||
color: #FF4D4D;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
// Log Section - Dark theme with better visibility
|
||||
.logSection {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #0a0a0a;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.logHeader {
|
||||
background: linear-gradient(135deg, #1a1a1a 0%, #0f0f0f 100%);
|
||||
padding: 20px 25px;
|
||||
font-size: 22px;
|
||||
font-weight: bold;
|
||||
color: #f0f0f0;
|
||||
border-bottom: 2px solid rgba(255, 255, 255, 0.3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logViewer {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
background: #000000;
|
||||
|
||||
// Custom scrollbar styling for TV - Brighter
|
||||
&::-webkit-scrollbar {
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
border-radius: 7px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.emptyLog {
|
||||
text-align: center;
|
||||
color: #aaaaaa;
|
||||
font-size: 20px;
|
||||
padding: 60px 20px;
|
||||
}
|
||||
|
||||
// Log Entry - Enhanced dark theme
|
||||
.logEntry {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
border-left: 4px solid rgba(255, 255, 255, 0.4);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-left: 4px solid rgba(255, 255, 255, 0.4);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-left-color: #4A90E2;
|
||||
box-shadow: 0 2px 8px rgba(74, 144, 226, 0.3);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.logEntryHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
margin-bottom: 15px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
color: #b0b0b0;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.logType {
|
||||
font-weight: bold;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 16px;
|
||||
padding: 6px 14px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 6px;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.logTitle {
|
||||
color: #f0f0f0;
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.logData {
|
||||
background: #000000;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 16px;
|
||||
color: #00ff88;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
margin: 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
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",
|
||||
"iconColor": "#ffffff",
|
||||
"disableBackHistoryAPI": true,
|
||||
"handlesRelaunch": true,
|
||||
"deeplinkingParams": "{\"contentTarget\":\"$CONTENTID\"}",
|
||||
"uiRevision": 2
|
||||
"uiRevision": 2,
|
||||
"requiredPermissions": [
|
||||
"time.query",
|
||||
"device.info",
|
||||
"applications.query",
|
||||
"settings.read",
|
||||
"applications.operation"
|
||||
],
|
||||
"inAppVoiceIntent": {
|
||||
"contentTarget": {
|
||||
"intent": "$INTENT",
|
||||
"intentParam": "$INTENT_PARAM",
|
||||
"languageCode": "$LANG_CODE",
|
||||
"voiceEngine": "$VOICE_ENGINE"
|
||||
},
|
||||
"voiceConfig": {
|
||||
"supportedIntent": ["SearchContent", "PlayContent"],
|
||||
"supportedVoiceLanguage": ["ko-KR", "en-US"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user