From c823587eaf398d836702f0de23302bab9e5ea672 Mon Sep 17 00:00:00 2001 From: optrader Date: Tue, 14 Oct 2025 14:57:02 +0900 Subject: [PATCH] [251014] docs(views): [251014] VoicePanel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ๐Ÿ• ์ปค๋ฐ‹ ์‹œ๊ฐ„: 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 ์ปดํฌ๋„ŒํŠธ ์•„ํ‚คํ…์ฒ˜ ๊ฐœ์„  โ€ข ๊ฐœ๋ฐœ ๋ฌธ์„œ ๋ฐ ๊ฐ€์ด๋“œ ๊ฐœ์„  โ€ข ๋กœ๊น… ์‹œ์Šคํ…œ ๊ฐœ์„  --- com.twin.app.shoptime/luna.md | 439 ++++++++++++ .../src/actions/actionTypes.js | 14 + .../src/actions/mediaActions.js | 102 +++ .../src/actions/voiceActions.js | 370 ++++++++++ .../src/components/VideoPlayer/Video.js | 643 +++++++++--------- com.twin.app.shoptime/src/lunaSend/index.js | 17 +- com.twin.app.shoptime/src/lunaSend/voice.js | 164 +++++ .../src/reducers/voiceReducer.js | 130 ++++ com.twin.app.shoptime/src/store/store.js | 2 + .../ProductAllSection/ProductAllSection.jsx | 71 +- .../ProductVideo/ProductVideo.jsx | 70 +- .../ProductVideo/ProductVideo.module.less | 2 +- .../ProductVideo/ProductVideo.v2.jsx | 25 +- .../src/views/MediaPanel/MediaPanel.jsx | 74 +- .../views/MediaPanel/MediaPanel.module.less | 7 + .../src/views/VoicePanel/VoicePanel.jsx | 200 +++++- .../views/VoicePanel/VoicePanel.module.less | 226 +++++- .../src/views/VoicePanel/mockLogData.js | 171 +++++ com.twin.app.shoptime/vui.md | 531 +++++++++++++++ .../webos-meta/appinfo.bakcup.json | 15 + com.twin.app.shoptime/webos-meta/appinfo.json | 22 +- 21 files changed, 2891 insertions(+), 404 deletions(-) create mode 100644 com.twin.app.shoptime/luna.md create mode 100644 com.twin.app.shoptime/src/actions/voiceActions.js create mode 100644 com.twin.app.shoptime/src/lunaSend/voice.js create mode 100644 com.twin.app.shoptime/src/reducers/voiceReducer.js create mode 100644 com.twin.app.shoptime/src/views/VoicePanel/mockLogData.js create mode 100644 com.twin.app.shoptime/vui.md create mode 100644 com.twin.app.shoptime/webos-meta/appinfo.bakcup.json diff --git a/com.twin.app.shoptime/luna.md b/com.twin.app.shoptime/luna.md new file mode 100644 index 00000000..bb3d1fb9 --- /dev/null +++ b/com.twin.app.shoptime/luna.md @@ -0,0 +1,439 @@ +# [251014] webOS Luna Service ํ˜ธ์ถœ ๋ฉ”์ปค๋‹ˆ์ฆ˜ ๋ถ„์„ + +## 1. ๊ฐœ์š” + +์ด ํ”„๋กœ์ ํŠธ๋Š” webOS TV ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์œผ๋กœ, **Luna Service**๋ฅผ ํ†ตํ•ด webOS ์‹œ์Šคํ…œ์˜ ๋‹ค์–‘ํ•œ ๊ธฐ๋Šฅ๊ณผ ํ†ต์‹ ํ•ฉ๋‹ˆ๋‹ค. Luna Service๋Š” webOS์˜ ์„œ๋น„์Šค ๋ฒ„์Šค ์•„ํ‚คํ…์ฒ˜๋กœ, ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์ด ์‹œ์Šคํ…œ ์„œ๋น„์Šค์— ์ ‘๊ทผํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•ด์ฃผ๋Š” IPC(Inter-Process Communication) ๋ฉ”์ปค๋‹ˆ์ฆ˜์ž…๋‹ˆ๋‹ค. + +## 2. ์•„ํ‚คํ…์ฒ˜ ๊ตฌ์กฐ + +### 2.1 ํ•ต์‹ฌ ์ปดํฌ๋„ŒํŠธ + +``` +src/lunaSend/ +โ”œโ”€โ”€ LS2Request.js # Enact LS2Request ๋ž˜ํผ +โ”œโ”€โ”€ LS2RequestSingleton.js # ์‹ฑ๊ธ€ํ†ค ํŒจํ„ด ๊ตฌํ˜„ +โ”œโ”€โ”€ index.js # ๋ชจ๋“ˆ export ๋ฐ ์ทจ์†Œ ํ•จ์ˆ˜ +โ”œโ”€โ”€ common.js # ๊ณตํ†ต Luna Service ํ˜ธ์ถœ ํ•จ์ˆ˜๋“ค +โ”œโ”€โ”€ account.js # ๊ณ„์ • ๊ด€๋ จ Luna Service ํ˜ธ์ถœ ํ•จ์ˆ˜๋“ค +โ””โ”€โ”€ lunaTest.js # ํ…Œ์ŠคํŠธ์šฉ ํŒŒ์ผ +``` + +### 2.2 ๊ณ„์ธต ๊ตฌ์กฐ + +``` +Application Layer (React Components/Actions) + โ†“ +Wrapper Functions (lunaSend/common.js, lunaSend/account.js) + โ†“ +LS2Request Layer (LS2Request.js) + โ†“ +@enact/webos/LS2Request (Enact Framework) + โ†“ +PalmServiceBridge (webOS Native Bridge) + โ†“ +Luna Service Bus (webOS System Services) +``` + +## 3. Luna Service ํ˜ธ์ถœ ๋ฉ”์ปค๋‹ˆ์ฆ˜ + +### 3.1 LS2Request ๋ž˜ํผ (`LS2Request.js`) + +```javascript +import LS2Request from '@enact/webos/LS2Request'; + +let request = LS2Request; +export {request}; +export default request; +``` + +- **์—ญํ• **: Enact ํ”„๋ ˆ์ž„์›Œํฌ์˜ `@enact/webos/LS2Request` ๋ชจ๋“ˆ์„ importํ•˜์—ฌ ์žฌexport +- **๋ชฉ์ **: ํ–ฅํ›„ mock ๊ตฌํ˜„์ด๋‚˜ ๊ฐœ๋ฐœ ํ™˜๊ฒฝ์—์„œ์˜ ๋Œ€์ฒด๊ฐ€ ์šฉ์ดํ•˜๋„๋ก ์ถ”์ƒํ™” ๊ณ„์ธต ์ œ๊ณต + +### 3.2 LS2RequestSingleton (`LS2RequestSingleton.js`) + +```javascript +import LS2Request from './LS2Request'; + +const ls2instances = {}; + +export const LS2RequestSingleton = { + instance: function (skey) { + ls2instances[skey] = ls2instances[skey] || new LS2Request(); + return ls2instances[skey]; + }, + deleteInstance: function (skey) { + ls2instances[skey] = null; + } +}; +``` + +- **ํŒจํ„ด**: Singleton Factory ํŒจํ„ด +- **๊ธฐ๋Šฅ**: + - ํ‚ค๋ณ„๋กœ LS2Request ์ธ์Šคํ„ด์Šค๋ฅผ ๊ด€๋ฆฌ + - ๋™์ผํ•œ ํ‚ค์— ๋Œ€ํ•ด ์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์ธ์Šคํ„ด์Šค ์ œ๊ณต + - ์ธ์Šคํ„ด์Šค ์‚ญ์ œ๋ฅผ ํ†ตํ•œ ๋ฉ”๋ชจ๋ฆฌ ๊ด€๋ฆฌ + +### 3.3 ๊ธฐ๋ณธ ํ˜ธ์ถœ ํŒจํ„ด + +Luna Service ํ˜ธ์ถœ์˜ ๊ธฐ๋ณธ ๊ตฌ์กฐ๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์Šต๋‹ˆ๋‹ค: + +```javascript +new LS2Request().send({ + service: "luna://[service-name]", + method: "[method-name]", + parameters: { /* ํŒŒ๋ผ๋ฏธํ„ฐ ๊ฐ์ฒด */ }, + subscribe: true/false, // ๊ตฌ๋… ์—ฌ๋ถ€ + onSuccess: (response) => { /* ์„ฑ๊ณต ์ฝœ๋ฐฑ */ }, + onFailure: (error) => { /* ์‹คํŒจ ์ฝœ๋ฐฑ */ }, + onComplete: (response) => { /* ์™„๋ฃŒ ์ฝœ๋ฐฑ */ } +}); +``` + +### 3.4 ํ™˜๊ฒฝ ๊ฐ์ง€ ๋ฐ Mock ์ฒ˜๋ฆฌ + +๋ชจ๋“  Luna Service ํ˜ธ์ถœ ํ•จ์ˆ˜๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์€ ํ™˜๊ฒฝ ๊ฐ์ง€ ๋กœ์ง์„ ํฌํ•จํ•ฉ๋‹ˆ๋‹ค: + +```javascript +if (typeof window === "object" && window.PalmSystem && + process.env.REACT_APP_MODE !== "DEBUG") { + // ์‹ค์ œ webOS ํ™˜๊ฒฝ์—์„œ Luna Service ํ˜ธ์ถœ + return new LS2Request().send({ ... }); +} else { + // ๊ฐœ๋ฐœ ํ™˜๊ฒฝ์—์„œ mock ๋ฐ์ดํ„ฐ ๋ฐ˜ํ™˜ + console.log("LUNA SEND [function-name]", ...); + return mockData; +} +``` + +- **`window.PalmSystem`**: webOS TV ํ™˜๊ฒฝ์—์„œ๋งŒ ์กด์žฌํ•˜๋Š” ์ „์—ญ ๊ฐ์ฒด +- **`process.env.REACT_APP_MODE !== "DEBUG"`**: DEBUG ๋ชจ๋“œ๊ฐ€ ์•„๋‹ ๋•Œ๋งŒ ์‹ค์ œ ํ˜ธ์ถœ + +## 4. ํ˜ธ์ถœ๋˜๋Š” Luna Service ๋ชฉ๋ก + +### 4.1 ์‹œ์Šคํ…œ ์ •๋ณด ๋ฐ ์„ค์ • + +| Service URI | Method | ๋ชฉ์  | ํŒŒ์ผ | +|------------|--------|------|------| +| `luna://com.webos.service.tv.systemproperty` | `getSystemInfo` | ์‹œ์Šคํ…œ ์ •๋ณด ์กฐํšŒ | account.js | +| `luna://com.webos.settingsservice` | `getSystemSettings` | ์‹œ์Šคํ…œ ์„ค์ • ์กฐํšŒ (์ž๋ง‰ ๋“ฑ) | common.js | +| `luna://com.webos.service.sm` | `deviceid/getIDs` | ๋””๋ฐ”์ด์Šค ID ์กฐํšŒ | account.js | +| `luna://com.webos.service.sdx` | `getHttpHeaderForServiceRequest` | HTTP ํ—ค๋” ์ •๋ณด ์กฐํšŒ (๊ตฌ๋…) | common.js | + +### 4.2 ๊ณ„์ • ๊ด€๋ฆฌ + +| Service URI | Method | ๋ชฉ์  | ํŒŒ์ผ | +|------------|--------|------|------| +| `luna://com.webos.service.accountmanager` | `getLoginID` | ๋กœ๊ทธ์ธ ์‚ฌ์šฉ์ž ์ •๋ณด ์กฐํšŒ | account.js | + +### 4.3 ๋„คํŠธ์›Œํฌ ์—ฐ๊ฒฐ + +| Service URI | Method | ๋ชฉ์  | ํŒŒ์ผ | +|------------|--------|------|------| +| `luna://com.webos.service.connectionmanager` | `getStatus` | ์—ฐ๊ฒฐ ์ƒํƒœ ์กฐํšŒ (๊ตฌ๋…) | common.js | +| `luna://com.webos.service.connectionmanager` | `getinfo` | ์—ฐ๊ฒฐ ์ •๋ณด ์กฐํšŒ | common.js | + +### 4.4 ์•Œ๋ฆผ (Notification) + +| Service URI | Method | ๋ชฉ์  | ํŒŒ์ผ | +|------------|--------|------|------| +| `luna://com.webos.notification` | `createToast` | ํ† ์ŠคํŠธ ๋ฉ”์‹œ์ง€ ์ƒ์„ฑ | common.js | +| `luna://com.webos.notification` | `enable` | ์•Œ๋ฆผ ํ™œ์„ฑํ™” | common.js | +| `luna://com.webos.notification` | `disable` | ์•Œ๋ฆผ ๋น„ํ™œ์„ฑํ™” | common.js | + +### 4.5 ์ž๋ง‰ (Subtitle) + +| Service URI | Method | ๋ชฉ์  | ํŒŒ์ผ | +|------------|--------|------|------| +| `luna://com.webos.service.tv.subtitle` | `enableSubtitle` | ์ž๋ง‰ ํ™œ์„ฑํ™” (3.0~4.5) | common.js | +| `luna://com.webos.service.tv.subtitle` | `disableSubtitle` | ์ž๋ง‰ ๋น„ํ™œ์„ฑํ™” (3.0~4.5) | common.js | +| `luna://com.webos.media` | `setSubtitleEnable` | ์ž๋ง‰ ์„ค์ • (5.0+) | common.js | + +### 4.6 ์˜ˆ์•ฝ (Reservation) + +| Service URI | Method | ๋ชฉ์  | ํŒŒ์ผ | +|------------|--------|------|------| +| `luna://com.webos.service.tvReservationAgent` | `insert` | ์˜ˆ์•ฝ ์ถ”๊ฐ€ | common.js | +| `luna://com.webos.service.tvReservationAgent` | `delete` | ์˜ˆ์•ฝ ์‚ญ์ œ | common.js | + +### 4.7 ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค (DB8) + +| Service URI | Method | ๋ชฉ์  | ํŒŒ์ผ | +|------------|--------|------|------| +| `luna://com.webos.service.db` | `find` | ๋ฐ์ดํ„ฐ ์กฐํšŒ | common.js | +| `luna://com.webos.service.db` | `put` | ๋ฐ์ดํ„ฐ ์ €์žฅ | common.js | +| `luna://com.webos.service.db` | `delKind` | Kind ์‚ญ์ œ | common.js | +| `luna://com.palm.db` | `search` | ๋ฐ์ดํ„ฐ ๊ฒ€์ƒ‰ | common.js | + +### 4.8 ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์‹คํ–‰ + +| Service URI | Method | ๋ชฉ์  | ํŒŒ์ผ | +|------------|--------|------|------| +| `luna://com.webos.applicationManager` | `launch` | ๋ฉค๋ฒ„์‹ญ ์•ฑ ์‹คํ–‰ | account.js | + +## 5. ์ฃผ์š” ์‚ฌ์šฉ ์˜ˆ์ œ + +### 5.1 ๋‹จ์ˆœ ํ˜ธ์ถœ (One-time Request) + +ํ† ์ŠคํŠธ ๋ฉ”์‹œ์ง€ ์ƒ์„ฑ: + +```javascript +export const createToast = (message) => { + if (typeof window === "object" && !window.PalmSystem) { + console.log("LUNA SEND createToast message", message); + return; + } + return new LS2Request().send({ + service: "luna://com.webos.notification", + method: "createToast", + parameters: { + message: message, + iconUrl: "", + noaction: true, + }, + onSuccess: (res) => { + console.log("LUNA SEND createToast success", message); + }, + onFailure: (err) => { + console.log("LUNA SEND createToast failed", err); + }, + }); +}; +``` + +### 5.2 ๊ตฌ๋… (Subscribe) + +์—ฐ๊ฒฐ ์ƒํƒœ ๋ชจ๋‹ˆํ„ฐ๋ง: + +```javascript +export const getConnectionStatus = ({ onSuccess, onFailure, onComplete }) => { + if (typeof window === "object" && !window.PalmSystem) { + return "Some Hard Coded Mock Data"; + } else { + return new LS2Request().send({ + service: "luna://com.webos.service.connectionmanager", + method: "getStatus", + subscribe: true, // ๊ตฌ๋… ๋ชจ๋“œ + parameters: {}, + onSuccess, + onFailure, + onComplete, + }); + } +}; +``` + +**ํŠน์ง•**: +- `subscribe: true` ์„ค์ • ์‹œ ์ƒํƒœ ๋ณ€ํ™” ์‹œ๋งˆ๋‹ค onSuccess ์ฝœ๋ฐฑ ํ˜ธ์ถœ +- ๋ฐ˜ํ™˜๋œ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ํ†ตํ•ด `.cancel()` ๋ฉ”์„œ๋“œ๋กœ ๊ตฌ๋… ์ทจ์†Œ ๊ฐ€๋Šฅ + +### 5.3 ์กฐ๊ฑด๋ถ€ ํ˜ธ์ถœ + +์ž๋ง‰ ํ™œ์„ฑํ™”/๋น„ํ™œ์„ฑํ™”: + +```javascript +export const setSubtitleEnable = ( + mediaId, + captionEnable, + { onSuccess, onFailure, onComplete } +) => { + if (typeof window === "object" && window.PalmSystem && + process.env.REACT_APP_MODE !== "DEBUG") { + if (captionEnable) { + return new LS2Request().send({ + service: "luna://com.webos.service.tv.subtitle", + method: "enableSubtitle", + parameters: { pipelineId: mediaId }, + onSuccess, onFailure, onComplete, + }); + } else { + return new LS2Request().send({ + service: "luna://com.webos.service.tv.subtitle", + method: "disableSubtitle", + parameters: { pipelineId: mediaId }, + onSuccess, onFailure, onComplete, + }); + } + } +}; +``` + +### 5.4 ๊ตฌ๋… ์ทจ์†Œ + +```javascript +export const cancelReq = (instanceName) => { + let r = LS2RequestSingleton.instance(instanceName); + if (r) { + r.cancel(); + r.cancelled = false; + LS2RequestSingleton.deleteInstance(instanceName); + } +}; +``` + +### 5.5 Redux Action์—์„œ ์‚ฌ์šฉ + +```javascript +export const alertToast = (payload) => (dispatch, getState) => { + if (typeof window === "object" && !window.PalmSystem) { + dispatch(changeAppStatus({ toast: true, toastText: payload })); + } else { + lunaSend.createToast(payload); + } +}; + +export const getSystemSettings = () => (dispatch, getState) => { + lunaSend.getSystemSettings( + { category: "caption", keys: ["captionEnable"] }, + { + onSuccess: (res) => {}, + onFailure: (err) => {}, + onComplete: (res) => { + if (res && res.settings) { + if (typeof res.settings.captionEnable !== "undefined") { + dispatch(changeAppStatus({ + captionEnable: res.settings.captionEnable === "on" || + res.settings.captionEnable === true, + })); + } + } + }, + } + ); +}; +``` + +## 6. ์ฝœ๋ฐฑ ํ•จ์ˆ˜ ํŒจํ„ด + +Luna Service ํ˜ธ์ถœ์€ 3๊ฐ€์ง€ ์ฝœ๋ฐฑ์„ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค: + +### 6.1 onSuccess +- **ํ˜ธ์ถœ ์‹œ์ **: ์„œ๋น„์Šค ํ˜ธ์ถœ์ด ์„ฑ๊ณตํ–ˆ์„ ๋•Œ +- **์šฉ๋„**: ์„ฑ๊ณต ์‘๋‹ต ๋ฐ์ดํ„ฐ ์ฒ˜๋ฆฌ +- **๊ตฌ๋… ๋ชจ๋“œ**: ๋ฐ์ดํ„ฐ๊ฐ€ ์—…๋ฐ์ดํŠธ๋  ๋•Œ๋งˆ๋‹ค ํ˜ธ์ถœ๋จ + +### 6.2 onFailure +- **ํ˜ธ์ถœ ์‹œ์ **: ์„œ๋น„์Šค ํ˜ธ์ถœ์ด ์‹คํŒจํ–ˆ์„ ๋•Œ +- **์šฉ๋„**: ์—๋Ÿฌ ์ฒ˜๋ฆฌ ๋ฐ ๋กœ๊น… + +### 6.3 onComplete +- **ํ˜ธ์ถœ ์‹œ์ **: ์„œ๋น„์Šค ํ˜ธ์ถœ์ด ์™„๋ฃŒ๋˜์—ˆ์„ ๋•Œ (์„ฑ๊ณต/์‹คํŒจ ๋ฌด๊ด€) +- **์šฉ๋„**: ๋กœ๋”ฉ ์ƒํƒœ ํ•ด์ œ, ๋ฆฌ์†Œ์Šค ์ •๋ฆฌ ๋“ฑ + +## 7. ๊ฐœ๋ฐœ ํ™˜๊ฒฝ ์ง€์› + +### 7.1 ํ™˜๊ฒฝ ๊ฐ์ง€ + +๋ชจ๋“  Luna Service ํ˜ธ์ถœ ํ•จ์ˆ˜๋Š” ๋‹ค์Œ ์กฐ๊ฑด์„ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค: + +```javascript +typeof window === "object" && window.PalmSystem && +process.env.REACT_APP_MODE !== "DEBUG" +``` + +- **webOS ์‹ค์ œ ํ™˜๊ฒฝ**: ๋ชจ๋“  ์กฐ๊ฑด ์ถฉ์กฑ โ†’ ์‹ค์ œ Luna Service ํ˜ธ์ถœ +- **๊ฐœ๋ฐœ ํ™˜๊ฒฝ**: ์กฐ๊ฑด ๋ถˆ์ถฉ์กฑ โ†’ Mock ๋ฐ์ดํ„ฐ ๋ฐ˜ํ™˜ ๋˜๋Š” ์ฝ˜์†” ๋กœ๊ทธ + +### 7.2 Mock ๋ฐ์ดํ„ฐ ์˜ˆ์‹œ + +```javascript +// getLoginUserData์—์„œ ๊ฐœ๋ฐœ ํ™˜๊ฒฝ์šฉ mock ๋ฐ์ดํ„ฐ +const mockRes = { + HOST: "qt2-US.nextlgsdp.com", + "X-User-Number": "US2412306099093", + Authorization: "eyJ0eXAiOiJKV1QiLCJhbGci...", + // ... ๊ธฐํƒ€ ํ—ค๋” ์ •๋ณด +}; +onSuccess(mockRes); +``` + +## 8. ํ”„๋กœ์ ํŠธ ์˜์กด์„ฑ + +### 8.1 Enact Framework + +```json +"@enact/webos": "^3.3.0" +``` + +- **์—ญํ• **: webOS ํ”Œ๋žซํผ API ์ ‘๊ทผ์„ ์œ„ํ•œ Enact ํ”„๋ ˆ์ž„์›Œํฌ์˜ webOS ๋ชจ๋“ˆ +- **์ œ๊ณต**: LS2Request ํด๋ž˜์Šค ๋ฐ webOS ๊ด€๋ จ ์œ ํ‹ธ๋ฆฌํ‹ฐ + +### 8.2 ์ฃผ์š” ํŠน์ง• + +- React ๊ธฐ๋ฐ˜ webOS TV ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ +- Redux๋ฅผ ํ†ตํ•œ ์ƒํƒœ ๊ด€๋ฆฌ +- Sandstone ํ…Œ๋งˆ ์‚ฌ์šฉ + +## 9. ๋ฒ ์ŠคํŠธ ํ”„๋ž™ํ‹ฐ์Šค + +### 9.1 ์—๋Ÿฌ ์ฒ˜๋ฆฌ + +```javascript +lunaSend.getSystemSettings(parameters, { + onSuccess: (res) => { + // ์„ฑ๊ณต ์ฒ˜๋ฆฌ + }, + onFailure: (err) => { + console.error("Luna Service Error:", err); + // ์‚ฌ์šฉ์ž์—๊ฒŒ ์—๋Ÿฌ ๋ฉ”์‹œ์ง€ ํ‘œ์‹œ + }, + onComplete: (res) => { + // ๋กœ๋”ฉ ์ƒํƒœ ํ•ด์ œ + } +}); +``` + +### 9.2 ๊ตฌ๋… ๊ด€๋ฆฌ + +```javascript +// ๊ตฌ๋… ์‹œ์ž‘ +let handler = lunaSend.getConnectionStatus({ + onSuccess: (res) => { + // ์ƒํƒœ ์—…๋ฐ์ดํŠธ ์ฒ˜๋ฆฌ + } +}); + +// ์ปดํฌ๋„ŒํŠธ ์–ธ๋งˆ์šดํŠธ ์‹œ ๊ตฌ๋… ์ทจ์†Œ +useEffect(() => { + return () => { + if (handler) { + handler.cancel(); + } + }; +}, []); +``` + +### 9.3 ์‹ฑ๊ธ€ํ†ค ์‚ฌ์šฉ + +ํŠน์ • ์„œ๋น„์Šค์— ๋Œ€ํ•ด ์ค‘๋ณต ํ˜ธ์ถœ์„ ๋ฐฉ์ง€ํ•ด์•ผ ํ•˜๋Š” ๊ฒฝ์šฐ: + +```javascript +let httpHeaderHandler = null; + +export const getHttpHeaderForServiceRequest = ({ onSuccess }) => { + if (httpHeaderHandler) { + httpHeaderHandler.cancel(); // ๊ธฐ์กด ์š”์ฒญ ์ทจ์†Œ + } + httpHeaderHandler = new LS2Request().send({ + service: "luna://com.webos.service.sdx", + method: "getHttpHeaderForServiceRequest", + subscribe: true, + parameters: {}, + onSuccess, + }); + return httpHeaderHandler; +}; +``` + +## 10. ์š”์•ฝ + +์ด ํ”„๋กœ์ ํŠธ์˜ Luna Service ํ˜ธ์ถœ ๋ฉ”์ปค๋‹ˆ์ฆ˜์€ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ํŠน์ง•์„ ๊ฐ€์ง‘๋‹ˆ๋‹ค: + +1. **๊ณ„์ธตํ™”๋œ ์•„ํ‚คํ…์ฒ˜**: Enact LS2Request โ†’ ์ปค์Šคํ…€ ๋ž˜ํผ โ†’ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง +2. **ํ™˜๊ฒฝ ๋ถ„๋ฆฌ**: webOS ์‹ค์ œ ํ™˜๊ฒฝ๊ณผ ๊ฐœ๋ฐœ ํ™˜๊ฒฝ์„ ์ž๋™์œผ๋กœ ๊ฐ์ง€ํ•˜์—ฌ ์ฒ˜๋ฆฌ +3. **์‹ฑ๊ธ€ํ†ค ํŒจํ„ด**: ์ธ์Šคํ„ด์Šค ์žฌ์‚ฌ์šฉ์„ ํ†ตํ•œ ๋ฉ”๋ชจ๋ฆฌ ํšจ์œจ์„ฑ +4. **์ฝœ๋ฐฑ ๊ธฐ๋ฐ˜**: onSuccess, onFailure, onComplete ์ฝœ๋ฐฑ์œผ๋กœ ๋น„๋™๊ธฐ ์ฒ˜๋ฆฌ +5. **๊ตฌ๋… ์ง€์›**: subscribe ์˜ต์…˜์œผ๋กœ ์‹ค์‹œ๊ฐ„ ๋ฐ์ดํ„ฐ ์—…๋ฐ์ดํŠธ ์ˆ˜์‹  +6. **ํƒ€์ž… ์•ˆ์ „์„ฑ**: ๊ฐ ์„œ๋น„์Šค ํ˜ธ์ถœ์„ ์ „์šฉ ํ•จ์ˆ˜๋กœ ๋ž˜ํ•‘ํ•˜์—ฌ ํƒ€์ž… ์•ˆ์ „์„ฑ ํ™•๋ณด +7. **์žฌ์‚ฌ์šฉ์„ฑ**: common.js, account.js๋กœ ๊ธฐ๋Šฅ๋ณ„ ๋ชจ๋“ˆํ™” + +์ด๋Ÿฌํ•œ ๊ตฌ์กฐ๋ฅผ ํ†ตํ•ด webOS ์‹œ์Šคํ…œ ์„œ๋น„์Šค์™€์˜ ์•ˆ์ •์ ์ด๊ณ  ํšจ์œจ์ ์ธ ํ†ต์‹ ์„ ๊ตฌํ˜„ํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. diff --git a/com.twin.app.shoptime/src/actions/actionTypes.js b/com.twin.app.shoptime/src/actions/actionTypes.js index b9c8d5e1..0aeb2d3f 100644 --- a/com.twin.app.shoptime/src/actions/actionTypes.js +++ b/com.twin.app.shoptime/src/actions/actionTypes.js @@ -278,4 +278,18 @@ export const types = { GET_RECENTLY_SAW_ITEM: 'GET_RECENTLY_SAW_ITEM', GET_LIKE_BRAND_PRODUCT: 'GET_LIKE_BRAND_PRODUCT', GET_MORE_TO_CONCIDER_AT_THIS_PRICE: 'GET_MORE_TO_CONCIDER_AT_THIS_PRICE', + + // ๐Ÿ”ฝ Voice Conductor ๊ด€๋ จ ์•ก์…˜ ํƒ€์ž… + VOICE_REGISTER_SUCCESS: 'VOICE_REGISTER_SUCCESS', + VOICE_REGISTER_FAILURE: 'VOICE_REGISTER_FAILURE', + VOICE_SET_TICKET: 'VOICE_SET_TICKET', + VOICE_SET_CONTEXT_SUCCESS: 'VOICE_SET_CONTEXT_SUCCESS', + VOICE_SET_CONTEXT_FAILURE: 'VOICE_SET_CONTEXT_FAILURE', + VOICE_PERFORM_ACTION: 'VOICE_PERFORM_ACTION', + VOICE_REPORT_RESULT_SUCCESS: 'VOICE_REPORT_RESULT_SUCCESS', + VOICE_REPORT_RESULT_FAILURE: 'VOICE_REPORT_RESULT_FAILURE', + VOICE_UPDATE_INTENTS: 'VOICE_UPDATE_INTENTS', + VOICE_CLEAR_STATE: 'VOICE_CLEAR_STATE', + VOICE_ADD_LOG: 'VOICE_ADD_LOG', + VOICE_CLEAR_LOGS: 'VOICE_CLEAR_LOGS', }; diff --git a/com.twin.app.shoptime/src/actions/mediaActions.js b/com.twin.app.shoptime/src/actions/mediaActions.js index 402483c6..32d5ca84 100644 --- a/com.twin.app.shoptime/src/actions/mediaActions.js +++ b/com.twin.app.shoptime/src/actions/mediaActions.js @@ -230,3 +230,105 @@ export const switchMediaToModal = (modalContainerId, modalClassName) => (dispatc ); } }; + +/** + * Modal MediaPanel์„ ์ตœ์†Œํ™”ํ•ฉ๋‹ˆ๋‹ค (1px ํฌ๊ธฐ๋กœ ์ถ•์†Œ, ์žฌ์ƒ์€ ๊ณ„์†) + * modal=false๋กœ ๋ณ€๊ฒฝํ•˜์—ฌ background ํด๋ž˜์Šค ์ ์šฉ (modalContainerId๋Š” ๋ณต์›์„ ์œ„ํ•ด ์œ ์ง€) + */ +export const minimizeModalMedia = () => (dispatch, getState) => { + const panels = getState().panels.panels; + + console.log('[minimizeModalMedia] ========== Called =========='); + console.log('[minimizeModalMedia] Total panels:', panels.length); + console.log( + '[minimizeModalMedia] All panels:', + JSON.stringify( + panels.map((p) => ({ name: p.name, modal: p.panelInfo?.modal })), + null, + 2 + ) + ); + + const modalMediaPanel = panels.find( + (panel) => panel.name === panel_names.MEDIA_PANEL && panel.panelInfo?.modal + ); + + console.log('[minimizeModalMedia] Found modalMediaPanel:', !!modalMediaPanel); + if (modalMediaPanel) { + console.log( + '[minimizeModalMedia] modalMediaPanel.panelInfo:', + JSON.stringify(modalMediaPanel.panelInfo, null, 2) + ); + console.log( + '[minimizeModalMedia] โœ… Minimizing modal MediaPanel (modal=false, isMinimized=true)' + ); + dispatch( + updatePanel({ + name: panel_names.MEDIA_PANEL, + panelInfo: { + ...modalMediaPanel.panelInfo, + modal: false, // fullscreen ๋ชจ๋“œ๋กœ ์ „ํ™˜ + isMinimized: true, // modal-minimized ํด๋ž˜์Šค ์ ์šฉ (1px ํฌ๊ธฐ) + // modalContainerId, modalClassName ๋“ฑ์€ ๋ณต์›์„ ์œ„ํ•ด ์œ ์ง€ + // isPaused๋Š” ๋ณ€๊ฒฝํ•˜์ง€ ์•Š์Œ - ์žฌ์ƒ์€ ๊ณ„์†๋จ + }, + }) + ); + } else { + console.log('[minimizeModalMedia] โŒ No modal MediaPanel found - cannot minimize'); + } +}; + +/** + * Modal MediaPanel์„ ๋ณต์›ํ•ฉ๋‹ˆ๋‹ค (์ตœ์†Œํ™” ํ•ด์ œ) + * modal=true, isMinimized=false๋กœ ๋ณ€๊ฒฝํ•˜์—ฌ ์›๋ž˜ modal ์œ„์น˜๋กœ ๋ณต์› + */ +export const restoreModalMedia = () => (dispatch, getState) => { + const panels = getState().panels.panels; + + console.log('[restoreModalMedia] ========== Called =========='); + console.log('[restoreModalMedia] Total panels:', panels.length); + console.log( + '[restoreModalMedia] All panels:', + JSON.stringify( + panels.map((p) => ({ + name: p.name, + modal: p.panelInfo?.modal, + isMinimized: p.panelInfo?.isMinimized, + })), + null, + 2 + ) + ); + + // modal=false AND isMinimized=true์ธ MediaPanel์„ ์ฐพ์Œ (์ตœ์†Œํ™” ์ƒํƒœ) + const minimizedMediaPanel = panels.find( + (panel) => + panel.name === panel_names.MEDIA_PANEL && + !panel.panelInfo?.modal && + panel.panelInfo?.isMinimized + ); + + console.log('[restoreModalMedia] Found minimizedMediaPanel:', !!minimizedMediaPanel); + if (minimizedMediaPanel) { + console.log( + '[restoreModalMedia] minimizedMediaPanel.panelInfo:', + JSON.stringify(minimizedMediaPanel.panelInfo, null, 2) + ); + console.log( + '[restoreModalMedia] โœ… Restoring modal MediaPanel (modal=true, isMinimized=false)' + ); + dispatch( + updatePanel({ + name: panel_names.MEDIA_PANEL, + panelInfo: { + ...minimizedMediaPanel.panelInfo, + modal: true, // modal ๋ชจ๋“œ๋กœ ๋ณต์› (์›๋ž˜ ์œ„์น˜๋กœ ๋ณต๊ท€) + isMinimized: false, // ์ตœ์†Œํ™” ํ•ด์ œ + }, + }) + ); + } else { + console.log('[restoreModalMedia] โŒ No minimized MediaPanel found - cannot restore'); + } +}; diff --git a/com.twin.app.shoptime/src/actions/voiceActions.js b/com.twin.app.shoptime/src/actions/voiceActions.js new file mode 100644 index 00000000..8d4ddc08 --- /dev/null +++ b/com.twin.app.shoptime/src/actions/voiceActions.js @@ -0,0 +1,370 @@ +// src/actions/voiceActions.js + +import { types } from './actionTypes'; +import * as lunaSend from '../lunaSend/voice'; + +/** + * Helper function to add log entries + */ +const addLog = (type, title, data, success = true) => { + return { + type: types.VOICE_ADD_LOG, + payload: { + timestamp: new Date().toISOString(), + type, + title, + data, + success, + }, + }; +}; + +/** + * Register app with voice framework + * This will establish a subscription to receive voice commands + */ +export const registerVoiceFramework = () => (dispatch, getState) => { + // Platform check: Voice framework only works on TV (webOS) + const isTV = typeof window === 'object' && window.PalmSystem; + + if (!isTV) { + console.warn('[Voice] Voice framework is only available on webOS TV platform'); + dispatch( + addLog( + 'ERROR', + 'Platform Not Supported', + { + message: 'Voice framework requires webOS TV platform', + platform: 'web', + }, + false + ) + ); + dispatch({ + type: types.VOICE_REGISTER_FAILURE, + payload: { message: 'Voice framework is only available on webOS TV' }, + }); + return null; + } + + console.log('[Voice] Registering with voice framework...'); + + // Log the request + dispatch( + addLog('REQUEST', 'Register Voice Framework', { + service: 'luna://com.webos.service.voiceconductor', + method: 'interactor/register', + parameters: { + type: 'foreground', + subscribe: true, + }, + }) + ); + + let voiceHandler = null; + + voiceHandler = lunaSend.registerVoiceConductor({ + onSuccess: (res) => { + console.log('[Voice] Response from voice framework:', res); + + // Log all responses + dispatch(addLog('RESPONSE', 'Voice Framework Response', res, true)); + + // Initial registration response + if (res.subscribed && res.returnValue && !res.command) { + console.log('[Voice] Registration successful'); + dispatch({ + type: types.VOICE_REGISTER_SUCCESS, + payload: { handler: voiceHandler }, + }); + } + + // setContext command received + if (res.command === 'setContext' && res.voiceTicket) { + console.log('[Voice] setContext command received, ticket:', res.voiceTicket); + dispatch( + addLog('COMMAND', 'setContext Command Received', { + command: res.command, + voiceTicket: res.voiceTicket, + }) + ); + dispatch({ + type: types.VOICE_SET_TICKET, + payload: res.voiceTicket, + }); + + // Automatically send supported intents + dispatch(sendVoiceIntents(res.voiceTicket)); + } + + // performAction command received + if (res.command === 'performAction' && res.action) { + console.log('[Voice] performAction command received:', res.action); + dispatch( + addLog('COMMAND', 'performAction Command Received', { + command: res.command, + action: res.action, + }) + ); + dispatch({ + type: types.VOICE_PERFORM_ACTION, + payload: res.action, + }); + + // Process the action and report result + dispatch(handleVoiceAction(res.voiceTicket, res.action)); + } + }, + + onFailure: (err) => { + console.error('[Voice] Registration failed:', err); + dispatch(addLog('ERROR', 'Registration Failed', err, false)); + dispatch({ + type: types.VOICE_REGISTER_FAILURE, + payload: err, + }); + }, + + onComplete: (res) => { + console.log('[Voice] Registration completed:', res); + }, + }); + + return voiceHandler; +}; + +/** + * Send supported voice intents to the framework + * This should be called when setContext command is received + */ +export const sendVoiceIntents = (voiceTicket) => (dispatch, getState) => { + console.log('[Voice] Sending voice intents...'); + + // Define the intents that this app supports + // This is a sample configuration - customize based on your app's features + const inAppIntents = [ + { + intent: 'Select', + supportOrdinal: true, + items: [ + { + itemId: 'voice-search-button', + value: ['Search', 'Search Products', 'Find Items'], + title: 'Search', + }, + { + itemId: 'voice-cart-button', + value: ['Cart', 'Shopping Cart', 'My Cart'], + title: 'Cart', + }, + { + itemId: 'voice-home-button', + value: ['Home', 'Go Home', 'Main Page'], + title: 'Home', + }, + { + itemId: 'voice-mypage-button', + value: ['My Page', 'Account', 'Profile'], + title: 'My Page', + }, + ], + }, + { + intent: 'Scroll', + supportOrdinal: false, + items: [ + { + itemId: 'voice-scroll-up', + value: ['Scroll Up', 'Page Up'], + }, + { + itemId: 'voice-scroll-down', + value: ['Scroll Down', 'Page Down'], + }, + ], + }, + // Add more intents as needed + // See vui.md for complete list of available intents + ]; + + dispatch({ + type: types.VOICE_UPDATE_INTENTS, + payload: inAppIntents, + }); + + lunaSend.setVoiceContext(voiceTicket, inAppIntents, { + onSuccess: (res) => { + console.log('[Voice] Voice context set successfully:', res); + dispatch({ + type: types.VOICE_SET_CONTEXT_SUCCESS, + payload: res, + }); + }, + + onFailure: (err) => { + console.error('[Voice] Failed to set voice context:', err); + dispatch({ + type: types.VOICE_SET_CONTEXT_FAILURE, + payload: err, + }); + }, + + onComplete: (res) => { + console.log('[Voice] setContext completed'); + }, + }); +}; + +/** + * Handle voice action received from framework + * Process the action and report the result + */ +export const handleVoiceAction = (voiceTicket, action) => (dispatch, getState) => { + console.log('[Voice] Handling voice action:', action); + + let result = false; + let feedback = null; + + try { + // Process action based on intent and itemId + if (action.intent === 'Select' && action.itemId) { + result = dispatch(handleSelectIntent(action.itemId)); + } else if (action.intent === 'Scroll' && action.itemId) { + result = dispatch(handleScrollIntent(action.itemId)); + } else { + console.warn('[Voice] Unknown intent or missing itemId:', action); + result = false; + feedback = { + voiceUi: { + systemUtterance: 'This action is not supported', + }, + }; + } + } catch (error) { + console.error('[Voice] Error processing action:', error); + result = false; + feedback = { + voiceUi: { + systemUtterance: 'An error occurred while processing your request', + }, + }; + } + + // Report result to voice framework + dispatch(reportActionResult(voiceTicket, result, feedback)); +}; + +/** + * Handle Select intent actions + */ +const handleSelectIntent = (itemId) => (dispatch, getState) => { + console.log('[Voice] Processing Select intent for:', itemId); + + // TODO: Implement actual navigation/action logic + switch (itemId) { + case 'voice-search-button': + console.log('[Voice] Navigate to Search'); + // dispatch(navigateToSearch()); + return true; + + case 'voice-cart-button': + console.log('[Voice] Navigate to Cart'); + // dispatch(navigateToCart()); + return true; + + case 'voice-home-button': + console.log('[Voice] Navigate to Home'); + // dispatch(navigateToHome()); + return true; + + case 'voice-mypage-button': + console.log('[Voice] Navigate to My Page'); + // dispatch(navigateToMyPage()); + return true; + + default: + console.warn('[Voice] Unknown Select itemId:', itemId); + return false; + } +}; + +/** + * Handle Scroll intent actions + */ +const handleScrollIntent = (itemId) => (dispatch, getState) => { + console.log('[Voice] Processing Scroll intent for:', itemId); + + // TODO: Implement actual scroll logic + switch (itemId) { + case 'voice-scroll-up': + console.log('[Voice] Scroll Up'); + // Implement scroll up logic + return true; + + case 'voice-scroll-down': + console.log('[Voice] Scroll Down'); + // Implement scroll down logic + return true; + + default: + console.warn('[Voice] Unknown Scroll itemId:', itemId); + return false; + } +}; + +/** + * Report action result to voice framework + */ +export const reportActionResult = + (voiceTicket, result, feedback = null) => + (dispatch, getState) => { + console.log('[Voice] Reporting action result:', { result, feedback }); + + lunaSend.reportVoiceActionResult(voiceTicket, result, feedback, { + onSuccess: (res) => { + console.log('[Voice] Action result reported successfully:', res); + dispatch({ + type: types.VOICE_REPORT_RESULT_SUCCESS, + payload: { result, feedback }, + }); + }, + + onFailure: (err) => { + console.error('[Voice] Failed to report action result:', err); + dispatch({ + type: types.VOICE_REPORT_RESULT_FAILURE, + payload: err, + }); + }, + + onComplete: (res) => { + console.log('[Voice] reportActionResult completed'); + }, + }); + }; + +/** + * Unregister from voice framework + * Cancel the subscription when app goes to background or unmounts + */ +export const unregisterVoiceFramework = () => (dispatch, getState) => { + const { voiceHandler } = getState().voice; + const isTV = typeof window === 'object' && window.PalmSystem; + + if (voiceHandler && isTV) { + console.log('[Voice] Unregistering from voice framework'); + lunaSend.cancelVoiceRegistration(voiceHandler); + } + + // Always clear state on unmount, regardless of platform + dispatch({ + type: types.VOICE_CLEAR_STATE, + }); +}; + +/** + * Clear voice state + */ +export const clearVoiceState = () => ({ + type: types.VOICE_CLEAR_STATE, +}); diff --git a/com.twin.app.shoptime/src/components/VideoPlayer/Video.js b/com.twin.app.shoptime/src/components/VideoPlayer/Video.js index e76fc9fe..c7c6c3d6 100644 --- a/com.twin.app.shoptime/src/components/VideoPlayer/Video.js +++ b/com.twin.app.shoptime/src/components/VideoPlayer/Video.js @@ -1,324 +1,319 @@ -import {forward} from '@enact/core/handle'; -import ForwardRef from '@enact/ui/ForwardRef'; -import {Media, getKeyFromSource} from '@enact/ui/Media'; -import EnactPropTypes from '@enact/core/internal/prop-types'; -import Slottable from '@enact/ui/Slottable'; -import compose from 'ramda/src/compose'; -import React from 'react'; - -import css from './VideoPlayer.module.less'; - -import PropTypes from 'prop-types'; - -/** - * Adds support for preloading a video source for `VideoPlayer`. - * - * @class VideoBase - * @memberof sandstone/VideoPlayer - * @ui - * @private - */ -const VideoBase = class extends React.Component { - static displayName = 'Video'; - - static propTypes = /** @lends sandstone/VideoPlayer.Video.prototype */ { - /** - * Video plays automatically. - * - * @type {Boolean} - * @default false - * @public - */ - autoPlay: PropTypes.bool, - - /** - * Video component to use. - * - * The default (`'video'`) renders an `HTMLVideoElement`. Custom video components must have - * a similar API structure, exposing the following APIs: - * - * Properties: - * * `currentTime` {Number} - Playback index of the media in seconds - * * `duration` {Number} - Media's entire duration in seconds - * * `error` {Boolean} - `true` if video playback has errored. - * * `loading` {Boolean} - `true` if video playback is loading. - * * `paused` {Boolean} - Playing vs paused state. `true` means the media is paused - * * `playbackRate` {Number} - Current playback rate, as a number - * * `proportionLoaded` {Number} - A value between `0` and `1` - * representing the proportion of the media that has loaded - * * `proportionPlayed` {Number} - A value between `0` and `1` representing the - * proportion of the media that has already been shown - * - * Events: - * * `onLoadStart` - Called when the video starts to load - * * `onPlay` - Sent when playback of the media starts after having been paused - * * `onUpdate` - Sent when any of the properties were updated - * - * Methods: - * * `play()` - play video - * * `pause()` - pause video - * * `load()` - load video - * - * The [`source`]{@link sandstone/VideoPlayer.Video.source} property is passed to - * the video component as a child node. - * - * @type {String|Component|Element} - * @default 'video' - * @public - */ - mediaComponent: EnactPropTypes.renderableOverride, - - /** - * The video source to be preloaded. Expects a `` node. - * - * @type {Node} - * @public - */ - preloadSource: PropTypes.node, - - /** - * Called with a reference to the active [Media]{@link ui/Media.Media} component. - * - * @type {Function} - * @private - */ - setMedia: PropTypes.func, - - /** - * The video source to be played. - * - * Any children `` elements will be sent directly to the `mediaComponent` as video - * sources. - * - * See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/source - * - * @type {String|Node} - * @public - */ - source: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), - track: PropTypes.oneOfType([PropTypes.string, PropTypes.node]) - }; - - static defaultProps = { - mediaComponent: 'video' - }; - - componentDidUpdate (prevProps) { - const {source, preloadSource} = this.props; - const {source: prevSource, preloadSource: prevPreloadSource} = prevProps; - - const key = getKeyFromSource(source); - const prevKey = getKeyFromSource(prevSource); - const preloadKey = getKeyFromSource(preloadSource); - const prevPreloadKey = getKeyFromSource(prevPreloadSource); - - if (this.props.setMedia !== prevProps.setMedia) { - this.clearMedia(prevProps); - this.setMedia(); - } - - if (source) { - if (key === prevPreloadKey && preloadKey !== prevPreloadKey) { - // if there's source and it was the preload source - - // if the preloaded video didn't error, notify VideoPlayer it is ready to reset - if (this.preloadLoadStart) { - forward('onLoadStart', this.preloadLoadStart, this.props); - } - - // emit onUpdate to give VideoPlayer an opportunity to updates its internal state - // since it won't receive the onLoadStart or onError event - forward('onUpdate', {type: 'onUpdate'}, this.props); - - this.autoPlay(); - } else if (key !== prevKey) { - // if there's source and it has changed. - this.autoPlay(); - } - } - - if (preloadSource && preloadKey !== prevPreloadKey) { - this.preloadLoadStart = null; - - // In the case that the previous source equalled the previous preload (causing the - // preload video node to not be created) and then the preload source was changed, we - // need to guard against accessing the preloadVideo node. - if (this.preloadVideo) { - this.preloadVideo.load(); - } - } - } - - componentWillUnmount () { - this.clearMedia(); - } - - keys = ['media-1', 'media-2']; - prevSourceKey = null; - prevPreloadKey = null; - - handlePreloadLoadStart = (ev) => { - // persist the event so we can cache it to re-emit when the preload becomes active - ev.persist(); - this.preloadLoadStart = ev; - - // prevent the from bubbling to upstream handlers - ev.stopPropagation(); - }; - - clearMedia ({setMedia} = this.props) { - if (setMedia) { - setMedia(null); - } - } - - setMedia ({setMedia} = this.props) { - if (setMedia) { - setMedia(this.video); - } - } - - autoPlay () { - if (!this.props.autoPlay) return; - - this.video.play(); - } - - setVideoRef = (node) => { - this.video = node; - this.setMedia(); - }; - - setPreloadRef = (node) => { - if (node) { - node.load(); - } - this.preloadVideo = node; - }; - - getKeys () { - const {source, preloadSource} = this.props; - - const sourceKey = source && getKeyFromSource(source); - let preloadKey = preloadSource && getKeyFromSource(preloadSource); - - // If the same source is used for both, clear the preload key to avoid rendering duplicate - // video elements. - if (sourceKey === preloadKey) { - preloadKey = null; - } - - // if either the source or preload existed previously in the other "slot", swap the keys so - // the preload video becomes the active video and vice versa - if ( - (sourceKey === this.prevPreloadKey && this.prevPreloadKey) || - (preloadKey === this.prevSourceKey && this.prevSourceKey) - ) { - this.keys.reverse(); - } - - // cache the previous keys so we know if the sources change the next time - this.prevSourceKey = sourceKey; - this.prevPreloadKey = preloadKey; - - // if preload is unset, clear the key so we don't render that media node at all - return preloadKey ? this.keys : this.keys.slice(0, 1); - } - - render () { - const { - preloadSource, - source, - track, - mediaComponent, - ...rest - } = this.props; - - delete rest.setMedia; - - const [sourceKey, preloadKey] = this.getKeys(); - - return ( - - {sourceKey ? ( - - )} - source={React.isValidElement(source) ? source : ( - - )} - /> - ) : null} - {preloadKey ? ( - - )} - source={React.isValidElement(preloadSource) ? preloadSource : ( - - )} - /> - ) : null} - - ); - } -}; - -const VideoDecorator = compose( - ForwardRef({prop: 'setMedia'}), - Slottable({slots: ['source', 'track','preloadSource']}) -); - -/** - * Provides support for more advanced video configurations for `VideoPlayer`. - * - * Custom Video Tag - * - * ``` - * - * - * - * ``` - * - * Preload Video Source - * - * ``` - * - * - * - * ``` - * - * @class Video - * @mixes ui/Slottable.Slottable - * @memberof sandstone/VideoPlayer - * @ui - * @public - */ -const Video = VideoDecorator(VideoBase); -Video.defaultSlot = 'videoComponent'; - -export default Video; -export { - Video -}; +import { forward } from '@enact/core/handle'; +import ForwardRef from '@enact/ui/ForwardRef'; +import { Media, getKeyFromSource } from '@enact/ui/Media'; +import EnactPropTypes from '@enact/core/internal/prop-types'; +import Slottable from '@enact/ui/Slottable'; +import compose from 'ramda/src/compose'; +import React from 'react'; + +import css from './VideoPlayer.module.less'; + +import PropTypes from 'prop-types'; + +/** + * Adds support for preloading a video source for `VideoPlayer`. + * + * @class VideoBase + * @memberof sandstone/VideoPlayer + * @ui + * @private + */ +const VideoBase = class extends React.Component { + static displayName = 'Video'; + + static propTypes = /** @lends sandstone/VideoPlayer.Video.prototype */ { + /** + * Video plays automatically. + * + * @type {Boolean} + * @default false + * @public + */ + autoPlay: PropTypes.bool, + + /** + * Video loops continuously. + * + * @type {Boolean} + * @default false + * @public + */ + loop: PropTypes.bool, + + /** + * Video component to use. + * + * The default (`'video'`) renders an `HTMLVideoElement`. Custom video components must have + * a similar API structure, exposing the following APIs: + * + * Properties: + * * `currentTime` {Number} - Playback index of the media in seconds + * * `duration` {Number} - Media's entire duration in seconds + * * `error` {Boolean} - `true` if video playback has errored. + * * `loading` {Boolean} - `true` if video playback is loading. + * * `paused` {Boolean} - Playing vs paused state. `true` means the media is paused + * * `playbackRate` {Number} - Current playback rate, as a number + * * `proportionLoaded` {Number} - A value between `0` and `1` + * representing the proportion of the media that has loaded + * * `proportionPlayed` {Number} - A value between `0` and `1` representing the + * proportion of the media that has already been shown + * + * Events: + * * `onLoadStart` - Called when the video starts to load + * * `onPlay` - Sent when playback of the media starts after having been paused + * * `onUpdate` - Sent when any of the properties were updated + * + * Methods: + * * `play()` - play video + * * `pause()` - pause video + * * `load()` - load video + * + * The [`source`]{@link sandstone/VideoPlayer.Video.source} property is passed to + * the video component as a child node. + * + * @type {String|Component|Element} + * @default 'video' + * @public + */ + mediaComponent: EnactPropTypes.renderableOverride, + + /** + * The video source to be preloaded. Expects a `` node. + * + * @type {Node} + * @public + */ + preloadSource: PropTypes.node, + + /** + * Called with a reference to the active [Media]{@link ui/Media.Media} component. + * + * @type {Function} + * @private + */ + setMedia: PropTypes.func, + + /** + * The video source to be played. + * + * Any children `` elements will be sent directly to the `mediaComponent` as video + * sources. + * + * See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/source + * + * @type {String|Node} + * @public + */ + source: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + track: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + }; + + static defaultProps = { + mediaComponent: 'video', + }; + + componentDidUpdate(prevProps) { + const { source, preloadSource } = this.props; + const { source: prevSource, preloadSource: prevPreloadSource } = prevProps; + + const key = getKeyFromSource(source); + const prevKey = getKeyFromSource(prevSource); + const preloadKey = getKeyFromSource(preloadSource); + const prevPreloadKey = getKeyFromSource(prevPreloadSource); + + if (this.props.setMedia !== prevProps.setMedia) { + this.clearMedia(prevProps); + this.setMedia(); + } + + if (source) { + if (key === prevPreloadKey && preloadKey !== prevPreloadKey) { + // if there's source and it was the preload source + + // if the preloaded video didn't error, notify VideoPlayer it is ready to reset + if (this.preloadLoadStart) { + forward('onLoadStart', this.preloadLoadStart, this.props); + } + + // emit onUpdate to give VideoPlayer an opportunity to updates its internal state + // since it won't receive the onLoadStart or onError event + forward('onUpdate', { type: 'onUpdate' }, this.props); + + this.autoPlay(); + } else if (key !== prevKey) { + // if there's source and it has changed. + this.autoPlay(); + } + } + + if (preloadSource && preloadKey !== prevPreloadKey) { + this.preloadLoadStart = null; + + // In the case that the previous source equalled the previous preload (causing the + // preload video node to not be created) and then the preload source was changed, we + // need to guard against accessing the preloadVideo node. + if (this.preloadVideo) { + this.preloadVideo.load(); + } + } + } + + componentWillUnmount() { + this.clearMedia(); + } + + keys = ['media-1', 'media-2']; + prevSourceKey = null; + prevPreloadKey = null; + + handlePreloadLoadStart = (ev) => { + // persist the event so we can cache it to re-emit when the preload becomes active + ev.persist(); + this.preloadLoadStart = ev; + + // prevent the from bubbling to upstream handlers + ev.stopPropagation(); + }; + + clearMedia({ setMedia } = this.props) { + if (setMedia) { + setMedia(null); + } + } + + setMedia({ setMedia } = this.props) { + if (setMedia) { + setMedia(this.video); + } + } + + autoPlay() { + if (!this.props.autoPlay) return; + + this.video.play(); + } + + setVideoRef = (node) => { + this.video = node; + this.setMedia(); + }; + + setPreloadRef = (node) => { + if (node) { + node.load(); + } + this.preloadVideo = node; + }; + + getKeys() { + const { source, preloadSource } = this.props; + + const sourceKey = source && getKeyFromSource(source); + let preloadKey = preloadSource && getKeyFromSource(preloadSource); + + // If the same source is used for both, clear the preload key to avoid rendering duplicate + // video elements. + if (sourceKey === preloadKey) { + preloadKey = null; + } + + // if either the source or preload existed previously in the other "slot", swap the keys so + // the preload video becomes the active video and vice versa + if ( + (sourceKey === this.prevPreloadKey && this.prevPreloadKey) || + (preloadKey === this.prevSourceKey && this.prevSourceKey) + ) { + this.keys.reverse(); + } + + // cache the previous keys so we know if the sources change the next time + this.prevSourceKey = sourceKey; + this.prevPreloadKey = preloadKey; + + // if preload is unset, clear the key so we don't render that media node at all + return preloadKey ? this.keys : this.keys.slice(0, 1); + } + + render() { + const { preloadSource, source, track, mediaComponent, ...rest } = this.props; + + delete rest.setMedia; + + const [sourceKey, preloadKey] = this.getKeys(); + + return ( + + {sourceKey ? ( + } + source={React.isValidElement(source) ? source : } + /> + ) : null} + {preloadKey ? ( + } + source={ + React.isValidElement(preloadSource) ? preloadSource : + } + /> + ) : null} + + ); + } +}; + +const VideoDecorator = compose( + ForwardRef({ prop: 'setMedia' }), + Slottable({ slots: ['source', 'track', 'preloadSource'] }) +); + +/** + * Provides support for more advanced video configurations for `VideoPlayer`. + * + * Custom Video Tag + * + * ``` + * + * + * + * ``` + * + * Preload Video Source + * + * ``` + * + * + * + * ``` + * + * @class Video + * @mixes ui/Slottable.Slottable + * @memberof sandstone/VideoPlayer + * @ui + * @public + */ +const Video = VideoDecorator(VideoBase); +Video.defaultSlot = 'videoComponent'; + +export default Video; +export { Video }; diff --git a/com.twin.app.shoptime/src/lunaSend/index.js b/com.twin.app.shoptime/src/lunaSend/index.js index f9084536..7675eb57 100644 --- a/com.twin.app.shoptime/src/lunaSend/index.js +++ b/com.twin.app.shoptime/src/lunaSend/index.js @@ -1,13 +1,14 @@ -import {LS2RequestSingleton} from './LS2RequestSingleton'; +import { LS2RequestSingleton } from './LS2RequestSingleton'; export * from './account'; export * from './common'; +export * from './voice'; export const cancelReq = (instanceName) => { - let r = LS2RequestSingleton.instance(instanceName); - if (r) { - r.cancel(); - r.cancelled = false; - LS2RequestSingleton.deleteInstance(instanceName); - } -}; \ No newline at end of file + let r = LS2RequestSingleton.instance(instanceName); + if (r) { + r.cancel(); + r.cancelled = false; + LS2RequestSingleton.deleteInstance(instanceName); + } +}; diff --git a/com.twin.app.shoptime/src/lunaSend/voice.js b/com.twin.app.shoptime/src/lunaSend/voice.js new file mode 100644 index 00000000..ab04b887 --- /dev/null +++ b/com.twin.app.shoptime/src/lunaSend/voice.js @@ -0,0 +1,164 @@ +import LS2Request from './LS2Request'; + +/** + * Register app with voice framework to receive voice commands + * This is a subscription-based service that will continuously receive commands + * + * Commands received: + * - setContext: Request to set the voice intents that the app supports + * - performAction: Request to perform a voice action + */ +export const registerVoiceConductor = ({ onSuccess, onFailure, onComplete }) => { + if (typeof window === 'object' && !window.PalmSystem) { + console.log('LUNA SEND registerVoiceConductor - Not available on web platform'); + + // Do NOT run mock mode on web to prevent performance issues + // Voice framework should only be used on TV + if (onFailure) { + onFailure({ + returnValue: false, + errorText: 'Voice framework is only available on webOS TV', + }); + } + + return { + cancel: () => { + console.log('LUNA SEND registerVoiceConductor - Cancel (no-op on web)'); + }, + }; + } + + return new LS2Request().send({ + service: 'luna://com.webos.service.voiceconductor', + method: 'interactor/register', + parameters: { + type: 'foreground', + subscribe: true, + }, + onSuccess, + onFailure, + onComplete, + }); +}; + +/** + * Set the voice intents that the app supports + * Must be called after receiving setContext command from registerVoiceConductor + * + * @param {string} voiceTicket - The ticket received from registerVoiceConductor + * @param {Array} inAppIntents - Array of intent objects that the app supports + * + * Intent object structure: + * { + * intent: "Select" | "Scroll" | "PlayContent" | "ControlMedia" | etc., + * supportOrdinal: boolean, // Whether to support ordinal speech (e.g., "select the first one") + * items: [ + * { + * itemId: string, // Unique identifier for this item (must be globally unique) + * value: string[], // Array of voice command variants + * title: string // Optional display title + * } + * ] + * } + */ +export const setVoiceContext = ( + voiceTicket, + inAppIntents, + { onSuccess, onFailure, onComplete } +) => { + if (typeof window === 'object' && !window.PalmSystem) { + console.log('LUNA SEND setVoiceContext', { + voiceTicket, + intentCount: inAppIntents.length, + intents: inAppIntents, + }); + + setTimeout(() => { + onSuccess && onSuccess({ returnValue: true }); + }, 100); + + return; + } + + return new LS2Request().send({ + service: 'luna://com.webos.service.voiceconductor', + method: 'interactor/setContext', + parameters: { + voiceTicket: voiceTicket, + inAppIntents: inAppIntents, + }, + onSuccess, + onFailure, + onComplete, + }); +}; + +/** + * Report the result of processing a voice command + * Must be called after receiving performAction command from registerVoiceConductor + * + * @param {string} voiceTicket - The ticket received from registerVoiceConductor + * @param {boolean} result - true if command was processed successfully, false otherwise + * @param {object} feedback - Optional feedback object + * + * Feedback object structure: + * { + * general: { + * responseCode: string, + * responseMessage: string + * }, + * voiceUi: { + * systemUtterance: string, // Message to display to user + * exception: string // Predefined exception ID (e.g., "alreadyCompleted") + * } + * } + */ +export const reportVoiceActionResult = ( + voiceTicket, + result, + feedback, + { onSuccess, onFailure, onComplete } +) => { + if (typeof window === 'object' && !window.PalmSystem) { + console.log('LUNA SEND reportVoiceActionResult', { + voiceTicket, + result, + feedback, + }); + + setTimeout(() => { + onSuccess && onSuccess({ returnValue: true }); + }, 100); + + return; + } + + const parameters = { + voiceTicket: voiceTicket, + result: result, + }; + + if (feedback) { + parameters.feedback = feedback; + } + + return new LS2Request().send({ + service: 'luna://com.webos.service.voiceconductor', + method: 'interactor/reportActionResult', + parameters: parameters, + onSuccess, + onFailure, + onComplete, + }); +}; + +/** + * Cancel voice conductor subscription + * Helper function to cancel the subscription handler + */ +export const cancelVoiceRegistration = (handler) => { + if (handler && handler.cancel) { + handler.cancel(); + console.log('Voice conductor subscription cancelled'); + } +}; diff --git a/com.twin.app.shoptime/src/reducers/voiceReducer.js b/com.twin.app.shoptime/src/reducers/voiceReducer.js new file mode 100644 index 00000000..455bd416 --- /dev/null +++ b/com.twin.app.shoptime/src/reducers/voiceReducer.js @@ -0,0 +1,130 @@ +// src/reducers/voiceReducer.js + +import { types } from '../actions/actionTypes'; + +const initialState = { + // Registration state + isRegistered: false, + registrationError: null, + voiceTicket: null, + voiceHandler: null, // LS2Request handler for subscription + + // Context state + supportedIntents: [], + contextSetSuccess: false, + contextError: null, + + // Action state + lastCommand: null, // "setContext" | "performAction" + lastAction: null, // Last performAction object received + lastActionResult: null, // Last action processing result + + // Processing state + isProcessingAction: false, + actionError: null, + + // Logging for debugging + logs: [], + logIdCounter: 0, +}; + +export const voiceReducer = (state = initialState, action) => { + switch (action.type) { + case types.VOICE_REGISTER_SUCCESS: + return { + ...state, + isRegistered: true, + registrationError: null, + voiceHandler: action.payload.handler || null, + }; + + case types.VOICE_REGISTER_FAILURE: + return { + ...state, + isRegistered: false, + registrationError: action.payload, + voiceHandler: null, + }; + + case types.VOICE_SET_TICKET: + return { + ...state, + voiceTicket: action.payload, + lastCommand: 'setContext', + }; + + case types.VOICE_SET_CONTEXT_SUCCESS: + return { + ...state, + contextSetSuccess: true, + contextError: null, + }; + + case types.VOICE_SET_CONTEXT_FAILURE: + return { + ...state, + contextSetSuccess: false, + contextError: action.payload, + }; + + case types.VOICE_UPDATE_INTENTS: + return { + ...state, + supportedIntents: action.payload, + }; + + case types.VOICE_PERFORM_ACTION: + return { + ...state, + lastCommand: 'performAction', + lastAction: action.payload, + isProcessingAction: true, + actionError: null, + }; + + case types.VOICE_REPORT_RESULT_SUCCESS: + return { + ...state, + lastActionResult: action.payload, + isProcessingAction: false, + actionError: null, + }; + + case types.VOICE_REPORT_RESULT_FAILURE: + return { + ...state, + isProcessingAction: false, + actionError: action.payload, + }; + + case types.VOICE_CLEAR_STATE: + return { + ...initialState, + }; + + case types.VOICE_ADD_LOG: + return { + ...state, + logs: [ + ...state.logs, + { + id: state.logIdCounter + 1, + ...action.payload, + }, + ], + logIdCounter: state.logIdCounter + 1, + }; + + case types.VOICE_CLEAR_LOGS: + return { + ...state, + logs: [], + logIdCounter: 0, + }; + + default: + return state; + } +}; + +export default voiceReducer; diff --git a/com.twin.app.shoptime/src/store/store.js b/com.twin.app.shoptime/src/store/store.js index 14b837a2..1cdc81e8 100644 --- a/com.twin.app.shoptime/src/store/store.js +++ b/com.twin.app.shoptime/src/store/store.js @@ -29,6 +29,7 @@ import { searchReducer } from '../reducers/searchReducer'; import { shippingReducer } from '../reducers/shippingReducer'; import { toastReducer } from '../reducers/toastReducer'; import { videoPlayReducer } from '../reducers/videoPlayReducer'; +import { voiceReducer } from '../reducers/voiceReducer'; const rootReducer = combineReducers({ panels: panelsReducer, @@ -58,6 +59,7 @@ const rootReducer = combineReducers({ foryou: foryouReducer, toast: toastReducer, videoPlay: videoPlayReducer, + voice: voiceReducer, }); export const store = createStore(rootReducer, applyMiddleware(thunk)); diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx b/com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx index 8fd67ebc..f8909f52 100644 --- a/com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx +++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx @@ -15,6 +15,7 @@ import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDeco import arrowDown from '../../../../assets/images/icons/ic_arrow_down_3x_new.png'; import indicatorDefaultImage from '../../../../assets/images/img-thumb-empty-144@3x.png'; // import { pushPanel } from '../../../actions/panelActions'; +import { minimizeModalMedia } from '../../../actions/mediaActions'; import { resetShowAllReviews } from '../../../actions/productActions'; import { showToast } from '../../../actions/toastActions'; // ProductInfoSection imports @@ -143,7 +144,7 @@ export default function ProductAllSection({ const youmaylikeData = useSelector((state) => state.main.youmaylikeData); // ProductVideo ๋ฒ„์ „ ๊ด€๋ฆฌ (1: ๊ธฐ์กด modal ๋ฐฉ์‹, 2: ๋‚ด์žฅ ๋ฐฉ์‹) - const [productVideoVersion, setProductVideoVersion] = useState(1); + const [productVideoVersion, setProductVideoVersion] = useState(3); // const [currentHeight, setCurrentHeight] = useState(0); //ํ•˜๋‹จ๋ถ€๋ถ„๊นŒ์ง€ ๊ฐ”์„๋•Œ ์ฒดํฌ์šฉ @@ -308,8 +309,12 @@ export default function ProductAllSection({ [scrollToSection] ); - // ๋น„๋””์˜ค ๋‹ค์Œ ์ด๋ฏธ์ง€๋กœ ์Šคํฌ๋กคํ•˜๋Š” ํ•ธ๋“ค๋Ÿฌ - const handleScrollToImages = useCallback(() => { + // ProductVideo V1 ์ „์šฉ - MediaPanel minimize ํฌํ•จ + const handleScrollToImagesV1 = useCallback(() => { + // 1. MediaPanel์„ 1px๋กœ ์ถ•์†Œํ•˜์—ฌ ํฌ์ปค์Šค ์ถฉ๋Œ ๋ฐฉ์ง€ + dispatch(minimizeModalMedia()); + + // 2. ์Šคํฌ๋กค ์ด๋™ scrollToSection('scroll-marker-after-video'); // ๊ธฐ์กด timeout์ด ์žˆ์œผ๋ฉด ํด๋ฆฌ์–ด @@ -317,13 +322,45 @@ export default function ProductAllSection({ clearTimeout(scrollToImagesTimeoutRef.current); } - // 250ms ํ›„ ProductDetail๋กœ ํฌ์ปค์Šค ์ด๋™ + // 3. 100ms ํ›„ ๋ช…์‹œ์ ์œผ๋กœ ์ฒซ ๋ฒˆ์งธ ProductDetail(์ด๋ฏธ์ง€)๋กœ ํฌ์ปค์Šค ์ด๋™ scrollToImagesTimeoutRef.current = setTimeout(() => { - Spotlight.move('down'); + Spotlight.focus('product-img-1'); scrollToImagesTimeoutRef.current = null; - }, 250); + }, 100); + }, [scrollToSection, dispatch]); + + // ProductVideoV2 ์ „์šฉ - minimize ์—†์Œ (๋‚ด์žฅ ๋น„๋””์˜ค ๋ฐฉ์‹) + const handleScrollToImagesV2 = useCallback(() => { + // 1. ์Šคํฌ๋กค ์ด๋™ + scrollToSection('scroll-marker-after-video'); + + // ๊ธฐ์กด timeout์ด ์žˆ์œผ๋ฉด ํด๋ฆฌ์–ด + if (scrollToImagesTimeoutRef.current) { + clearTimeout(scrollToImagesTimeoutRef.current); + } + + // 2. 100ms ํ›„ ๋ช…์‹œ์ ์œผ๋กœ ์ฒซ ๋ฒˆ์งธ ProductDetail(์ด๋ฏธ์ง€)๋กœ ํฌ์ปค์Šค ์ด๋™ + scrollToImagesTimeoutRef.current = setTimeout(() => { + Spotlight.focus('product-img-1'); + scrollToImagesTimeoutRef.current = null; + }, 100); }, [scrollToSection]); + // ProductVideoVersion 3 ์ „์šฉ - ๋น„๋””์˜ค ์—†์ด ์ด๋ฏธ์ง€๋งŒ ์‚ฌ์šฉ (minimize ์•ก์…˜ ์—†์Œ) + const handleScrollToImagesV3 = useCallback(() => { + // ๋น„๋””์˜ค๊ฐ€ ์—†์œผ๋ฏ€๋กœ scroll-marker-after-video ๋Œ€์‹  ์ฒซ ์ด๋ฏธ์ง€๋กœ ์ง์ ‘ ์ด๋™ + // ๊ธฐ์กด timeout์ด ์žˆ์œผ๋ฉด ํด๋ฆฌ์–ด + if (scrollToImagesTimeoutRef.current) { + clearTimeout(scrollToImagesTimeoutRef.current); + } + + // ์ฆ‰์‹œ ์ฒซ ๋ฒˆ์งธ ProductDetail(์ด๋ฏธ์ง€)๋กœ ํฌ์ปค์Šค ์ด๋™ + scrollToImagesTimeoutRef.current = setTimeout(() => { + Spotlight.focus('product-img-1'); + scrollToImagesTimeoutRef.current = null; + }, 100); + }, []); + const scrollContainerRef = useRef(null); const productDetailRef = useRef(null); //๋†’์ด๊ฐ’ ๋ณ€๊ฒฝ๋•Œ๋ฌธ const descriptionRef = useRef(null); @@ -334,8 +371,8 @@ export default function ProductAllSection({ const renderItems = useMemo(() => { const items = []; - // ๋™์˜์ƒ์ด ์žˆ์œผ๋ฉด ์ฒซ ๋ฒˆ์งธ์— ์ถ”๊ฐ€ (Indicator.jsx์™€ ๋™์ผํ•œ ๋กœ์ง) - if (productData && productData.prdtMediaUrl) { + // ๋™์˜์ƒ์ด ์žˆ์œผ๋ฉด ์ฒซ ๋ฒˆ์งธ์— ์ถ”๊ฐ€ (productVideoVersion์ด 3์ด ์•„๋‹ ๋•Œ๋งŒ) + if (productData && productData.prdtMediaUrl && productVideoVersion !== 3) { items.push({ type: 'video', url: productData.prdtMediaUrl, @@ -350,13 +387,17 @@ export default function ProductAllSection({ items.push({ type: 'image', url: image, - index: productData && productData.prdtMediaUrl ? imgIndex + 1 : imgIndex, + // productVideoVersion === 3์ด๋ฉด ๋น„๋””์˜ค๊ฐ€ ์—†์œผ๋ฏ€๋กœ index๋Š” 0๋ถ€ํ„ฐ ์‹œ์ž‘ + index: + productData && productData.prdtMediaUrl && productVideoVersion !== 3 + ? imgIndex + 1 + : imgIndex, }); }); } return items; - }, [productData]); + }, [productData, productVideoVersion]); // renderItems์— Video๊ฐ€ ์กด์žฌํ•˜๋Š”์ง€ ํ™•์ธํ•˜๋Š” boolean ์ƒํƒœ const hasVideo = useMemo(() => { @@ -696,8 +737,8 @@ export default function ProductAllSection({ onFocus={() => handleButtonFocus('product')} onBlur={handleButtonBlur} > - {/* ๋น„๋””์˜ค๊ฐ€ ์žˆ์œผ๋ฉด ๋จผ์ € ๋ Œ๋”๋ง */} - {hasVideo && renderItems[0].type === 'video' && ( + {/* ๋น„๋””์˜ค๊ฐ€ ์žˆ์œผ๋ฉด ๋จผ์ € ๋ Œ๋”๋ง (productVideoVersion์ด 3์ด ์•„๋‹ ๋•Œ๋งŒ) */} + {hasVideo && renderItems[0].type === 'video' && productVideoVersion !== 3 && ( <> {productVideoVersion === 1 ? ( ) : ( )}
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductVideo/ProductVideo.jsx b/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductVideo/ProductVideo.jsx index 2723da6c..128a074e 100644 --- a/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductVideo/ProductVideo.jsx +++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductVideo/ProductVideo.jsx @@ -5,6 +5,8 @@ import { startMediaPlayer, finishMediaPreview, switchMediaToFullscreen, + minimizeModalMedia, + restoreModalMedia, } from '../../../../actions/mediaActions'; import CustomImage from '../../../../components/CustomImage/CustomImage'; import { panel_names } from '../../../../utils/Config'; @@ -13,7 +15,14 @@ import css from './ProductVideo.module.less'; const SpottableComponent = Spottable('div'); -export default function ProductVideo({ productInfo, videoUrl, thumbnailUrl, onScrollToImages }) { +export default function ProductVideo({ + productInfo, + videoUrl, + thumbnailUrl, + onScrollToImages, + autoPlay = false, // ์ž๋™ ์žฌ์ƒ ์—ฌ๋ถ€ + continuousPlay = false, // ๋ฐ˜๋ณต ์žฌ์ƒ ์—ฌ๋ถ€ +}) { const dispatch = useDispatch(); // MediaPanel ์ƒํƒœ ์ฒดํฌ๋ฅผ ์œ„ํ•œ selectors ์ถ”๊ฐ€ @@ -21,6 +30,7 @@ export default function ProductVideo({ productInfo, videoUrl, thumbnailUrl, onSc const [isLaunchedFromPlayer, setIsLaunchedFromPlayer] = useState(false); const [focused, setFocused] = useState(false); const [modalState, setModalState] = useState(true); // ๋ชจ๋‹ฌ ์ƒํƒœ ๊ด€๋ฆฌ ์ถ”๊ฐ€ + const [hasAutoPlayed, setHasAutoPlayed] = useState(false); // ์ž๋™ ์žฌ์ƒ ์™„๋ฃŒ ์—ฌ๋ถ€ const topPanel = panels[panels.length - 1]; @@ -40,6 +50,51 @@ export default function ProductVideo({ productInfo, videoUrl, thumbnailUrl, onSc } }, [topPanel]); + // autoPlay ๊ธฐ๋Šฅ: ์ปดํฌ๋„ŒํŠธ ๋งˆ์šดํŠธ ์‹œ ์ž๋™์œผ๋กœ ๋น„๋””์˜ค ์žฌ์ƒ + useEffect(() => { + if (autoPlay && canPlayVideo && !hasAutoPlayed && productInfo) { + console.log('[ProductVideo] Auto-playing video'); + setHasAutoPlayed(true); + + // ์งง์€ ๋”ธ๋ ˆ์ด ํ›„ ์žฌ์ƒ ์‹œ์ž‘ (์ปดํฌ๋„ŒํŠธ ๋งˆ์šดํŠธ ์™„๋ฃŒ ํ›„) + setTimeout(() => { + dispatch( + startMediaPlayer({ + qrCurrentItem: productInfo, + showUrl: productInfo?.prdtMediaUrl, + showNm: productInfo?.prdtNm, + patnrNm: productInfo?.patncNm, + patncLogoPath: productInfo?.patncLogoPath, + orderPhnNo: productInfo?.orderPhnNo, + disclaimer: productInfo?.disclaimer, + subtitle: productInfo?.prdtMediaSubtitlUrl, + lgCatCd: productInfo?.catCd, + patnrId: productInfo?.patnrId, + lgCatNm: productInfo?.catNm, + prdtId: productInfo?.prdtId, + patncNm: productInfo?.patncNm, + prdtNm: productInfo?.prdtNm, + thumbnailUrl: productInfo?.thumbnailUrl960, + shptmBanrTpNm: 'MEDIA', + modal: true, + modalContainerId: 'product-video-player', + modalClassName: modalClassNameChange(), + spotlightDisable: true, + continuousPlay, // ๋ฐ˜๋ณต ์žฌ์ƒ ์˜ต์…˜ ์ „๋‹ฌ + }) + ); + }, 100); + } + }, [ + autoPlay, + canPlayVideo, + hasAutoPlayed, + productInfo, + dispatch, + modalClassNameChange, + continuousPlay, + ]); + // ๋น„๋””์˜ค ์žฌ์ƒ ๊ฐ€๋Šฅ ์—ฌ๋ถ€ ์ฒดํฌ const canPlayVideo = useMemo(() => { return Boolean(productInfo?.prdtMediaUrl); @@ -57,18 +112,20 @@ export default function ProductVideo({ productInfo, videoUrl, thumbnailUrl, onSc const videoContainerOnFocus = useCallback(() => { if (canPlayVideo) { setFocused(true); + console.log('[ProductVideo] Calling restoreModalMedia'); + // ProductVideo์— ํฌ์ปค์Šค๊ฐ€ ๋Œ์•„์˜ค๋ฉด ๋น„๋””์˜ค ๋ณต์› + dispatch(restoreModalMedia()); } - }, [canPlayVideo]); + }, [canPlayVideo, dispatch]); const videoContainerOnBlur = useCallback(() => { console.log('[ProductVideo] onBlur called - canPlayVideo:', canPlayVideo); if (canPlayVideo) { setFocused(false); - console.log('[ProductVideo] Calling finishMediaPreview'); - // ProductVideo์—์„œ ํฌ์ปค์Šค๊ฐ€ ๋ฒ—์–ด๋‚˜๋ฉด ๋น„๋””์˜ค ์žฌ์ƒ ์ข…๋ฃŒ - dispatch(finishMediaPreview()); + // minimize๋Š” handleScrollToImages์—์„œ ๋ช…์‹œ์ ์œผ๋กœ ์ฒ˜๋ฆฌ + // ์—ฌ๊ธฐ์„œ๋Š” focused ์ƒํƒœ๋งŒ ๋ณ€๊ฒฝ } - }, [canPlayVideo, dispatch]); + }, [canPlayVideo]); // Spotlight Down ํ‚ค ํ•ธ๋“ค๋Ÿฌ - ๋น„๋””์˜ค ๋‹ค์Œ ์ด๋ฏธ์ง€๋กœ ์Šคํฌ๋กค const handleSpotlightDown = useCallback( @@ -136,6 +193,7 @@ export default function ProductVideo({ productInfo, videoUrl, thumbnailUrl, onSc modalContainerId: 'product-video-player', modalClassName: modalClassNameChange(), spotlightDisable: true, + continuousPlay, // ๋ฐ˜๋ณต ์žฌ์ƒ ์˜ต์…˜ ์ „๋‹ฌ }) ); } diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductVideo/ProductVideo.module.less b/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductVideo/ProductVideo.module.less index 418b6459..208c72b3 100644 --- a/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductVideo/ProductVideo.module.less +++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductVideo/ProductVideo.module.less @@ -63,7 +63,7 @@ &::after { overflow: hidden; .position(@position: absolute, @top: 0, @left: 0, @right: 0, @bottom: 0); - z-index: 19; + z-index: 23; // MediaPanel(z-index: 22)๋ณด๋‹ค ์œ„์— ํ‘œ์‹œ๋˜์–ด์•ผ ๋น„๋””์˜ค ์žฌ์ƒ ์ค‘์—๋„ ํฌ์ปค์Šค ํ…Œ๋‘๋ฆฌ๊ฐ€ ๋ณด์ž„ border: 6px solid @PRIMARY_COLOR_RED; box-shadow: 0 0 22px 0 rgba(0, 0, 0, 0.5); border-radius: 12px; diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductVideo/ProductVideo.v2.jsx b/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductVideo/ProductVideo.v2.jsx index b254fedf..9acede89 100644 --- a/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductVideo/ProductVideo.v2.jsx +++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductVideo/ProductVideo.v2.jsx @@ -38,7 +38,13 @@ const YOUTUBECONFIG = { }, }; -export default function ProductVideoV2({ productInfo, videoUrl, thumbnailUrl, autoPlay = false }) { +export default function ProductVideoV2({ + productInfo, + videoUrl, + thumbnailUrl, + autoPlay = false, + onScrollToImages, +}) { const [isPlaying, setIsPlaying] = useState(false); const [focused, setFocused] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); @@ -126,6 +132,20 @@ export default function ProductVideoV2({ productInfo, videoUrl, thumbnailUrl, au setIsFullscreen(false); // ์ „์ฒดํ™”๋ฉด๋„ ํ•ด์ œ }, []); + // Spotlight Down ํ‚ค ํ•ธ๋“ค๋Ÿฌ - ๋น„๋””์˜ค ๋‹ค์Œ ์ด๋ฏธ์ง€๋กœ ์Šคํฌ๋กค + const handleSpotlightDown = useCallback( + (e) => { + if (canPlayVideo && onScrollToImages) { + e.preventDefault(); + e.stopPropagation(); + onScrollToImages(); + return true; // ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ์™„๋ฃŒ + } + return false; // Spotlight๊ฐ€ ๊ธฐ๋ณธ ๋™์ž‘ ์ˆ˜ํ–‰ + }, + [canPlayVideo, onScrollToImages] + ); + // Back ๋ฒ„ํŠผ ํ•ธ๋“ค๋Ÿฌ - ์ „์ฒดํ™”๋ฉด ํ•ด์ œ ๋˜๋Š” ๋น„๋””์˜ค ์ข…๋ฃŒ const handleBackButton = useCallback(() => { if (isFullscreen) { @@ -226,12 +246,14 @@ export default function ProductVideoV2({ productInfo, videoUrl, thumbnailUrl, au ? { spotlightRestrict: 'self-only', // ํฌ์ปค์Šค๊ฐ€ ๋ฐ–์œผ๋กœ ๋‚˜๊ฐ€์ง€ ์•Š๋„๋ก spotlightId: 'product-video-v2-fullscreen', + onSpotlightDown: handleSpotlightDown, // ์ „์ฒดํ™”๋ฉด์—์„œ๋„ Down ํ‚ค ๋™์ž‘ // ์ „์ฒดํ™”๋ฉด ๋ชจ๋“œ: window ๋ ˆ๋ฒจ์—์„œ ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ } : isPlaying ? { spotlightId: 'product-video-v2-playing', onKeyDown: handleContainerKeyDown, // ์ผ๋ฐ˜ ๋ชจ๋“œ: ์ปจํ…Œ์ด๋„ˆ์—์„œ ์ง์ ‘ ์ฒ˜๋ฆฌ + onSpotlightDown: handleSpotlightDown, // ์ผ๋ฐ˜ ์žฌ์ƒ์—์„œ๋„ Down ํ‚ค ๋™์ž‘ // ์ผ๋ฐ˜ ์žฌ์ƒ ๋ชจ๋“œ: ์ปจํ…Œ์ด๋„ˆ๊ฐ€ ํฌ์ปค์Šค ๋ฐ›์Œ } : {}; @@ -250,6 +272,7 @@ export default function ProductVideoV2({ productInfo, videoUrl, thumbnailUrl, au onClick={handleThumbnailClick} onFocus={videoContainerOnFocus} onBlur={videoContainerOnBlur} + onSpotlightDown={handleSpotlightDown} spotlightId="product-video-v2-thumbnail" aria-label={`${productInfo?.prdtNm} ๋™์˜์ƒ ์žฌ์ƒ`} > diff --git a/com.twin.app.shoptime/src/views/MediaPanel/MediaPanel.jsx b/com.twin.app.shoptime/src/views/MediaPanel/MediaPanel.jsx index 15faa28b..649d93d0 100644 --- a/com.twin.app.shoptime/src/views/MediaPanel/MediaPanel.jsx +++ b/com.twin.app.shoptime/src/views/MediaPanel/MediaPanel.jsx @@ -113,28 +113,43 @@ const MediaPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props // modal ์Šคํƒ€์ผ ์„ค์ • useEffect(() => { if (panelInfo.modal && panelInfo.modalContainerId) { + // modal ๋ชจ๋“œ: modalContainerId ๊ธฐ๋ฐ˜์œผ๋กœ ์œ„์น˜์™€ ํฌ๊ธฐ ๊ณ„์‚ฐ const node = document.querySelector(`[data-spotlight-id="${panelInfo.modalContainerId}"]`); if (node) { const { width, height, top, left } = node.getBoundingClientRect(); + + // ProductVideo์˜ padding(6px * 2)๊ณผ ์ถ”๊ฐ€ ์—ฌ์œ ๋ฅผ ๊ณ ๋ คํ•˜์—ฌ ํฌ๊ธฐ ์กฐ์ • + // ๋น„๋””์˜ค๊ฐ€ ์˜ค๋ฅธ์ชฝ์œผ๋กœ ๋„˜์น˜์ง€ ์•Š๋„๋ก ์ถฉ๋ถ„ํ•œ ์—ฌ์œ  ํ™•๋ณด + const paddingOffset = 6 * 2; // padding ์–‘์ชฝ + const extraMargin = 6 * 2; // ์ถ”๊ฐ€ ์—ฌ์œ  (ํฌ์ปค์Šค ํ…Œ๋‘๋ฆฌ + ๋น„๋””์˜ค ๋น„์œจ ๊ณ ๋ ค) + const totalOffset = paddingOffset + extraMargin; // 24px + + const adjustedWidth = width - totalOffset; + const adjustedHeight = height - totalOffset; + const adjustedTop = top + totalOffset / 2; + const adjustedLeft = left + totalOffset / 2; + const style = { - width: width + 'px', - height: height + 'px', - top: top + 'px', - left: left + 'px', + width: adjustedWidth + 'px', + height: adjustedHeight + 'px', + maxWidth: adjustedWidth + 'px', + maxHeight: adjustedHeight + 'px', + top: adjustedTop + 'px', + left: adjustedLeft + 'px', position: 'fixed', - overflow: 'visible', + overflow: 'hidden', // visible โ†’ hidden์œผ๋กœ ๋ณ€๊ฒฝํ•˜์—ฌ ๋„˜์น˜๋Š” ๋ถ€๋ถ„ ์ˆจ๊น€ }; setModalStyle(style); let scale = 1; if (typeof window === 'object') { - scale = width / window.innerWidth; + scale = adjustedWidth / window.innerWidth; setModalScale(scale); } } else { setModalStyle(panelInfo.modalStyle || {}); setModalScale(panelInfo.modalScale || 1); } - } else if (isOnTop && !panelInfo.modal && videoPlayer.current) { + } else if (isOnTop && !panelInfo.modal && !panelInfo.isMinimized && videoPlayer.current) { if (videoPlayer.current?.getMediaState()?.paused) { videoPlayer.current.play(); } @@ -263,8 +278,9 @@ const MediaPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props const onEnded = useCallback( (e) => { - // console.log('[MediaPanel] Video ended'); - // ๋น„๋””์˜ค ์ข…๋ฃŒ ์‹œ ํŒจ๋„ ๋‹ซ๊ธฐ + console.log('[MediaPanel] Video ended'); + // continuousPlay๋Š” MediaPlayer(VideoPlayer) ์ปดํฌ๋„ŒํŠธ ๋‚ด๋ถ€์—์„œ loop ์†์„ฑ์œผ๋กœ ์ฒ˜๋ฆฌ + // onEnded๊ฐ€ ํ˜ธ์ถœ๋˜๋ฉด loop=false ์ธ ๊ฒฝ์šฐ์ด๋ฏ€๋กœ ํŒจ๋„์„ ๋‹ซ์Œ Spotlight.pause(); setTimeout(() => { Spotlight.resume(); @@ -290,23 +306,48 @@ const MediaPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props // console.log('[MediaPanel] ========== Rendering =========='); // console.log('[MediaPanel] isOnTop:', isOnTop); - // console.log('[MediaPanel] panelInfo:', JSON.stringify(panelInfo, null, 2)); + // console.log('[MediaPanel] panelInfo.modal:', panelInfo.modal); + // console.log('[MediaPanel] panelInfo.isMinimized:', panelInfo.isMinimized); + // console.log('[MediaPanel] panelInfo.isPaused:', panelInfo.isPaused); // console.log('[MediaPanel] currentPlayingUrl:', currentPlayingUrl); // console.log('[MediaPanel] hasVideoPlayer:', !!videoPlayer.current); + // classNames ์ ์šฉ ์ƒํƒœ ํ™•์ธ + // console.log('[MediaPanel] ========== ClassNames Analysis =========='); + // console.log('[MediaPanel] css.videoContainer:', css.videoContainer); + // console.log('[MediaPanel] Condition [panelInfo.modal && !panelInfo.isMinimized]:', panelInfo.modal && !panelInfo.isMinimized); + // console.log('[MediaPanel] css.modal:', css.modal); + // console.log('[MediaPanel] Condition [panelInfo.isMinimized]:', panelInfo.isMinimized); + // console.log('[MediaPanel] css["modal-minimized"]:', css['modal-minimized']); + // console.log('[MediaPanel] Condition [!isOnTop]:', !isOnTop); + // console.log('[MediaPanel] css.background:', css.background); + + const appliedClassNames = classNames( + css.videoContainer, + panelInfo.modal && !panelInfo.isMinimized && css.modal, + panelInfo.isMinimized && css['modal-minimized'], + !isOnTop && css.background + ); + // console.log('[MediaPanel] Final Applied ClassNames:', appliedClassNames); + // console.log('[MediaPanel] modalStyle:', modalStyle); + // console.log('[MediaPanel] modalScale:', modalScale); + // console.log('[MediaPanel] ==============================================='); + + // minimized ์ƒํƒœ์ผ ๋•Œ๋Š” spotlightRestrict ํ•ด์ œ (ํฌ์ปค์Šค ์ด๋™ ํ—ˆ์šฉ) + const containerSpotlightRestrict = panelInfo.isMinimized ? 'none' : 'self-only'; + return ( - + {currentPlayingUrl && ( state.common?.loadingComplete); + const logViewerRef = useRef(null); + + // Platform detection: Luna/Voice framework only works on TV + const isTV = typeof window === 'object' && window.PalmSystem; + + // Voice state from Redux + const voiceState = useSelector((state) => state.voice); + const { isRegistered, voiceTicket, logs, registrationError } = voiceState; useEffect(() => { if (isOnTop) { @@ -24,11 +31,83 @@ export default function VoicePanel({ panelInfo, isOnTop, spotlightId }) { } }, [isOnTop, dispatch]); + // Cleanup on unmount + useEffect(() => { + return () => { + dispatch(unregisterVoiceFramework()); + }; + }, [dispatch]); + const handleBackButton = useCallback(() => { console.log(`[VoicePanel] Back button clicked - returning to previous panel`); dispatch(popPanel()); }, [dispatch]); + const handleRegister = useCallback(() => { + if (!isTV) { + console.warn('[VoicePanel] Voice framework is only available on TV platform'); + dispatch({ + type: types.VOICE_ADD_LOG, + payload: { + timestamp: new Date().toISOString(), + type: 'ERROR', + title: 'Platform Not Supported', + data: { message: 'Voice framework is only available on webOS TV platform' }, + success: false, + }, + }); + return; + } + console.log('[VoicePanel] Register button clicked'); + dispatch(registerVoiceFramework()); + }, [dispatch, isTV]); + + const handleClearLogs = useCallback(() => { + console.log('[VoicePanel] Clear logs button clicked'); + dispatch({ type: types.VOICE_CLEAR_LOGS }); + }, [dispatch]); + + const handleLoadMockData = useCallback(() => { + console.log('[VoicePanel] Loading 200 mock log entries for scroll test'); + // Add all mock logs to Redux + mockLogs.forEach((log) => { + dispatch({ + type: types.VOICE_ADD_LOG, + payload: log, + }); + }); + }, [dispatch]); + + const formatTime = useCallback((timestamp) => { + const date = new Date(timestamp); + return date.toLocaleTimeString('en-US', { hour12: false }); + }, []); + + const getTypeColor = useCallback((type) => { + const colors = { + REQUEST: '#4A90E2', + RESPONSE: '#7ED321', + COMMAND: '#F5A623', + ERROR: '#D0021B', + ACTION: '#9013FE', + }; + return colors[type] || '#FFFFFF'; + }, []); + + const handleScrollUp = useCallback(() => { + if (!logViewerRef.current) return; + const scrollAmount = 200; // Scroll 200px up + logViewerRef.current.scrollTop -= scrollAmount; + console.log('[VoicePanel] Scroll Up clicked'); + }, []); + + const handleScrollDown = useCallback(() => { + if (!logViewerRef.current) return; + const scrollAmount = 200; // Scroll 200px down + logViewerRef.current.scrollTop += scrollAmount; + console.log('[VoicePanel] Scroll Down clicked'); + }, []); + return ( - - {loadingComplete && ( - -
-

Voice Panel

-

- Voice search functionality will be implemented here. -

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