From f7272dc0d17c36787c7539fba11137a312ff6923 Mon Sep 17 00:00:00 2001 From: Shahin Gharghi Date: Thu, 8 Oct 2026 16:56:40 +0100 Subject: [PATCH] Add LG webOS TV app A remote-friendly TV version of CCloud in webos/, built as a small web app (Flutter does not target webOS). It uses the same API and helper-server fallback as the desktop repositories, with Home, Movies, Series, Search, Favorites, Countries and Settings, seasons/episodes, resume and quality switching. The release workflow gets a build-webos job that packages CCloud-webos.ipk with @webos-tools/cli, using the version from pubspec.yaml. Install steps are in assets/WEBOS_INSTALL.md. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yaml | 41 ++- README.md | 6 +- assets/WEBOS_INSTALL.md | 38 +++ webos/.gitignore | 1 + webos/README.md | 46 ++++ webos/app/appinfo.json | 14 ++ webos/app/css/app.css | 142 +++++++++++ webos/app/img/icon.png | Bin 0 -> 1927 bytes webos/app/img/largeIcon.png | Bin 0 -> 3267 bytes webos/app/img/logo.png | Bin 0 -> 7179 bytes webos/app/index.html | 59 +++++ webos/app/js/api.js | 111 ++++++++ webos/app/js/app.js | 473 +++++++++++++++++++++++++++++++++++ webos/app/js/nav.js | 173 +++++++++++++ webos/app/js/player.js | 214 ++++++++++++++++ webos/app/js/store.js | 39 +++ 16 files changed, 1355 insertions(+), 2 deletions(-) create mode 100644 assets/WEBOS_INSTALL.md create mode 100644 webos/.gitignore create mode 100644 webos/README.md create mode 100644 webos/app/appinfo.json create mode 100644 webos/app/css/app.css create mode 100644 webos/app/img/icon.png create mode 100644 webos/app/img/largeIcon.png create mode 100644 webos/app/img/logo.png create mode 100644 webos/app/index.html create mode 100644 webos/app/js/api.js create mode 100644 webos/app/js/app.js create mode 100644 webos/app/js/nav.js create mode 100644 webos/app/js/player.js create mode 100644 webos/app/js/store.js diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index b5a5a02..50288d3 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -301,8 +301,38 @@ jobs: release/CCloud-*-linux.AppImage release/ccloud.deb release/ccloud.rpm + build-webos: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install webOS CLI + run: npm install -g @webos-tools/cli + + - name: Build webOS package + run: | + # webOS versions are x.y.z, so drop the build number from pubspec.yaml + VERSION=$(grep '^version:' pubspec.yaml | cut -d ':' -f 2 | tr -d ' ' | cut -d '+' -f 1) + node -e "const f='webos/app/appinfo.json',a=require('./'+f);a.version='$VERSION';require('fs').writeFileSync(f,JSON.stringify(a,null,2)+'\n')" + mkdir -p webos/dist release + ares-package webos/app -o webos/dist --no-minify + cp webos/dist/com.pira.ccloud_${VERSION}_all.ipk release/CCloud-webos.ipk + shell: bash + + - name: Upload webOS artifacts + uses: actions/upload-artifact@v4 + with: + name: webos-build + path: release/CCloud-webos.ipk + release: - needs: [build-windows, build-macos, build-linux] + needs: [build-windows, build-macos, build-linux, build-webos] runs-on: ubuntu-latest steps: - name: Checkout code @@ -323,6 +353,11 @@ jobs: with: name: linux-build + - name: Download webOS artifacts + uses: actions/download-artifact@v4 + with: + name: webos-build + - name: Get tag name id: get_tag run: | @@ -353,6 +388,7 @@ jobs: CCloud-*-linux.AppImage ccloud.deb ccloud.rpm + CCloud-webos.ipk body: | ## CCloud Release ${{ env.TAG_NAME }} @@ -374,6 +410,9 @@ jobs: | Linux | x64 (AppImage) | Linux AppImage | | Linux | x64 (.deb) | Linux .deb | | Linux | x64 (.rpm) | Linux .rpm | + | LG webOS TV | webOS 4.0+ (.ipk) | LG webOS TV .ipk | | Android | Universal APK | Android Universal APK | For detailed instructions on installing and running CCloud on Linux, please refer to our [Linux Installation Guide](https://github.com/code3-dev/CCloud-GUI/blob/main/assets/LINUX_INSTALL.md). + + To install CCloud on an LG TV, see the [webOS TV Installation Guide](https://github.com/code3-dev/CCloud-GUI/blob/main/assets/WEBOS_INSTALL.md). diff --git a/README.md b/README.md index 2c4be0f..55eb748 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ CCloud features a sleek, contemporary user interface designed for optimal user e - **Unlimited Content**: Enjoy unlimited streaming of Iranian Persian movies and series ### 🖥️ Platform Support -- **Cross-Platform**: Available for Windows, macOS, and Linux +- **Cross-Platform**: Available for Windows, macOS, Linux, and LG webOS TVs - **Modern Interface**: Clean, intuitive, and user-friendly design ### ⚡ Performance @@ -90,6 +90,10 @@ If you encounter any errors when installing or running CCloud on Windows, please For detailed instructions on installing and running CCloud on Linux, please refer to our [Linux Installation Guide](assets/LINUX_INSTALL.md). +### 📺 LG webOS TV Installation + +CCloud is also available for LG smart TVs as `CCloud-webos.ipk`. It's a remote-friendly TV app built with web technologies, in the [`webos/`](webos/) folder. See the [webOS TV Installation Guide](assets/WEBOS_INSTALL.md) to install it in Developer Mode. + ## 💻 Development ### 🛠️ Technologies diff --git a/assets/WEBOS_INSTALL.md b/assets/WEBOS_INSTALL.md new file mode 100644 index 0000000..cf5fae1 --- /dev/null +++ b/assets/WEBOS_INSTALL.md @@ -0,0 +1,38 @@ +# LG webOS TV Installation Guide for CCloud + +CCloud for LG TVs is a `.ipk` package (`CCloud-webos.ipk` on the releases page). LG TVs only install apps from outside the LG Content Store in **Developer Mode**. + +## 1. Enable Developer Mode on the TV + +1. Create a free account at [developer.lge.com](https://developer.lge.com). +2. On the TV, install **Developer Mode** from the LG Content Store and sign in with that account. +3. Turn on **Dev Mode Status**, let the TV restart, and open Developer Mode again. +4. Turn on **Key Server** and note the TV's IP address shown there. + +## 2. Install the app from a computer + +1. Install Node.js, then the webOS CLI: + ```bash + npm install -g @webos-tools/cli + ``` +2. Add the TV (use the IP address shown in Developer Mode): + ```bash + ares-setup-device --add tv --info "{'host':'192.168.1.20','port':'9922','username':'prisoner'}" + ares-novacom --device tv --getkey # enter the passphrase shown in Developer Mode + ``` +3. Install and launch CCloud: + ```bash + ares-install --device tv CCloud-webos.ipk + ares-launch --device tv com.pira.ccloud + ``` + +If you prefer a graphical tool, [webOS Dev Manager](https://github.com/webosbrew/dev-manager-desktop) can install the same `.ipk`. On a rooted TV with the Homebrew Channel, you can install the `.ipk` directly. + +## Keeping the app + +A Developer Mode session lasts about 50 days. When it expires, apps installed this way are removed. To keep them, open the Developer Mode app and press **Extend** before the timer runs out. + +## Notes + +- **Remote:** use the arrows and OK to move and select, and Back to go back. In the player, OK plays or pauses, ◀/▶ seek 10 seconds, and Yellow changes the quality. +- **Video servers:** most of them only accept connections from Iran. If a video doesn't start, the app says so and you can choose another quality. diff --git a/webos/.gitignore b/webos/.gitignore new file mode 100644 index 0000000..849ddff --- /dev/null +++ b/webos/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/webos/README.md b/webos/README.md new file mode 100644 index 0000000..a7cdb55 --- /dev/null +++ b/webos/README.md @@ -0,0 +1,46 @@ +# CCloud for LG webOS TV + +A webOS TV app for CCloud. Flutter does not target webOS, so this is a small web app (plain HTML, CSS and JavaScript, no build step) that talks to the same API as the desktop app and has the same sections: Movies, Series, Search, Favorites, Countries and Settings, plus a Home screen built from the API's `first` rows. The interface is in Persian and laid out right to left, like the desktop app. + +| Desktop (`lib/src/repositories`) | TV (`app/js/api.js`) | +| --- | --- | +| `BaseRepository` (main server, then the helper servers) | `call()` tries the same three servers in the same order | +| `MovieRepository`, `SeriesRepository` | `Api.movies`, `Api.series` (genre and sort filters) | +| `CountriesRepository` | `Api.countries`, `Api.posters({country})` | +| `GenreRepository`, `SearchRepository`, `SeasonsRepository` | `Api.genres`, `Api.search`, `Api.seasons` | +| `StorageUtils` (favorites) | `app/js/store.js`: favorites, watch positions and the preferred quality, in localStorage | + +The TV plays MP4 and MKV files directly with its own media player. The player is built for the remote: +- The app resumes where you stopped. +- **Yellow** changes the quality. +- Audio and subtitle buttons appear when a file has several tracks. +- Series have a next-episode button. + +Live TV is not included, because the TVGarden channel list that the desktop app uses currently returns 404. + +## Files + +``` +webos/app/ + appinfo.json app id com.pira.ccloud; the version follows pubspec.yaml in CI + index.html layout and Content-Security-Policy + css/app.css + js/nav.js remote-control (spatial) navigation + js/api.js CCloud API client + js/store.js favorites, positions, preferences + js/player.js video player + js/app.js screens and routing +``` + +## Build and run + +```bash +npm install -g @webos-tools/cli +ares-package webos/app -o webos/dist # creates com.pira.ccloud__all.ipk +ares-install --device tv webos/dist/*.ipk +ares-launch --device tv com.pira.ccloud +``` + +You can also open `webos/app` in the [webOS TV simulator](https://webostv.developer.lge.com/develop/tools/simulator-introduction), or with `ares-launch -s webos/app`. Debug on a TV with `ares-inspect --device tv com.pira.ccloud`. + +See [assets/WEBOS_INSTALL.md](../assets/WEBOS_INSTALL.md) for how to set up the TV. diff --git a/webos/app/appinfo.json b/webos/app/appinfo.json new file mode 100644 index 0000000..bc8e795 --- /dev/null +++ b/webos/app/appinfo.json @@ -0,0 +1,14 @@ +{ + "id": "com.pira.ccloud", + "version": "1.0.4", + "vendor": "Hossein Pira", + "type": "web", + "main": "index.html", + "title": "CCloud", + "icon": "img/icon.png", + "largeIcon": "img/largeIcon.png", + "bgColor": "#0e1116", + "resolution": "1920x1080", + "disableBackHistoryAPI": true, + "requiredPermissions": [] +} diff --git a/webos/app/css/app.css b/webos/app/css/app.css new file mode 100644 index 0000000..e6e8f17 --- /dev/null +++ b/webos/app/css/app.css @@ -0,0 +1,142 @@ +:root { + --bg: #0e1116; + --panel: #1a1f27; + --panel-2: #262c36; + --text: #f2f4f7; + --muted: #98a0ad; + --accent: #3d8bfd; + --gold: #f5c518; + --focus: #ffffff; + --menu-w: 280px; +} +* { box-sizing: border-box; } +/* Spacing uses margins, not flex "gap", which TVs before webOS 6 lack. + Pages are RTL, so the next item in a row sits to the left. */ +html, body { + margin: 0; width: 1920px; height: 1080px; overflow: hidden; + background: var(--bg); color: var(--text); + font-family: "LG Smart UI", Vazirmatn, Tahoma, "Segoe UI", sans-serif; font-size: 28px; +} +.focusable:focus { outline: none; } +button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; } +.hidden { display: none !important; } + +/* Menu on the right (RTL). */ +.menu { +.menu > * + * { margin-top: 8px; } + position: fixed; right: 0; top: 0; bottom: 0; width: var(--menu-w); + background: linear-gradient(270deg, #000 0%, rgba(0,0,0,.85) 100%); + padding: 44px 24px; display: flex; flex-direction: column; z-index: 5; +} +.brand { display: flex; align-items: center; margin: 0 10px 34px; font-size: 38px; font-weight: 800; } +.brand img { margin-left: 14px; } +.brand img { width: 64px; height: 64px; border-radius: 14px; } +.menu-item { text-align: right; padding: 15px 20px; border-radius: 12px; color: var(--muted); font-size: 30px; } +.menu-item.active { color: var(--text); } +.menu-item.focused { background: var(--text); color: #000; } + +#view { position: absolute; right: var(--menu-w); top: 0; left: 0; bottom: 0; overflow: hidden; } +.scroller { position: absolute; top: 0; right: 0; bottom: 0; left: 0; overflow-y: auto; padding: 44px 40px 120px 60px; scroll-behavior: smooth; } +.scroller::-webkit-scrollbar { display: none; } +.page-title { font-size: 46px; margin: 0 8px 20px 0; } + +.row { margin-bottom: 18px; } +.row h2 { font-size: 34px; margin: 0 8px 4px 0; font-weight: 700; } +.row-items { display: flex; overflow-x: auto; padding: 16px 8px 18px; scroll-behavior: smooth; align-items: flex-start; } +.row-items > * + * { margin-right: 24px; } +.row-items::-webkit-scrollbar { display: none; } + +/* Poster cards, 2:3. */ +.card { flex: 0 0 auto; width: 230px; border-radius: 14px; transition: transform .15s; text-align: right; } +.card .frame { position: relative; width: 230px; height: 345px; border-radius: 14px; overflow: hidden; background: var(--panel); } +.card .poster { width: 100%; height: 100%; object-fit: cover; display: block; } +.card .badge { position: absolute; top: 8px; left: 8px; background: rgba(0,0,0,.8); padding: 2px 10px; border-radius: 8px; font-size: 20px; direction: ltr; } +.card .badge b { color: var(--gold); } +.card .kind { position: absolute; bottom: 8px; right: 8px; background: var(--accent); padding: 1px 10px; border-radius: 8px; font-size: 18px; } +.card .name { font-size: 22px; margin-top: 10px; line-height: 1.3; height: 2.6em; overflow: hidden; color: var(--muted); } +.card.focused { transform: scale(1.07); } +.card.focused .frame { outline: 5px solid var(--focus); outline-offset: 2px; } +.card.focused .name { color: var(--text); } +.more { flex: 0 0 auto; width: 180px; height: 345px; border-radius: 14px; background: var(--panel); font-size: 28px; color: var(--muted); } +.more.focused { background: var(--text); color: #000; } + +.grid { display: flex; flex-wrap: wrap; padding: 16px 8px; } +.grid > * { margin: 0 0 34px 24px; } +.chips { display: flex; flex-wrap: wrap; padding: 8px 8px 0; } +.chips > * { margin: 0 0 16px 16px; } +.chip { display: flex; align-items: center; padding: 14px 24px; border-radius: 12px; background: var(--panel-2); font-size: 26px; } +.chip img { margin-left: 12px; } +.chip img { width: 44px; height: 30px; object-fit: cover; border-radius: 4px; } +.chip.on { background: var(--accent); } +.chip.focused { background: var(--text); color: #000; } + +.btn { padding: 16px 34px; border-radius: 12px; background: var(--panel-2); font-size: 28px; font-weight: 600; } +.btn.primary { background: var(--accent); color: #fff; } +.btn.focused { outline: 5px solid var(--focus); outline-offset: 3px; background: var(--text); color: #000; } +.actions { display: flex; flex-wrap: wrap; } +.actions > * { margin: 0 0 18px 18px; } +.filters { display: flex; margin: 0 8px 10px 0; } +.filters > * + * { margin-right: 18px; } + +.search-box { display: flex; align-items: center; margin: 0 8px 24px 0; } +.search-box > * + * { margin-right: 20px; } +.input { width: 900px; font: inherit; font-size: 34px; padding: 18px 26px; border-radius: 14px; border: 3px solid #333; background: var(--panel); color: var(--text); outline: none; } +.input.focused, .input:focus { border-color: var(--focus); } +.empty, .loading { color: var(--muted); font-size: 30px; padding: 40px 8px; } + +/* Details */ +.details { position: relative; min-height: 900px; } +.backdrop { position: fixed; top: 0; left: 0; right: var(--menu-w); height: 760px; background-size: cover; background-position: center; opacity: .35; z-index: -1; -webkit-mask-image: linear-gradient(180deg, #000 30%, transparent 100%); } +.d-head { display: flex; align-items: flex-start; padding: 10px 8px 0 0; } +.d-poster { margin-left: 44px; } +.d-poster { width: 300px; height: 450px; object-fit: cover; border-radius: 16px; flex: 0 0 auto; background: var(--panel); box-shadow: 0 10px 40px rgba(0,0,0,.6); } +.d-info { flex: 1; min-width: 0; } +.d-info h1 { font-size: 54px; margin: 0 0 12px; line-height: 1.2; } +.d-meta { display: flex; color: #ccd; margin-bottom: 14px; flex-wrap: wrap; } +.d-meta > * { margin-left: 24px; } +.d-meta b { color: var(--gold); } +.d-desc { color: #ccd; line-height: 1.6; max-height: 6.4em; overflow: hidden; white-space: pre-line; margin: 0 0 26px; font-size: 26px; } +.section-title { font-size: 32px; margin: 34px 8px 10px 0; } +.sources .btn { direction: rtl; } +.episodes { display: flex; flex-direction: column; padding: 8px; } +.episodes > * + * { margin-top: 12px; } +.ep { display: flex; align-items: center; text-align: right; padding: 16px 24px; border-radius: 12px; background: var(--panel); font-size: 28px; } +.ep > * + * { margin-right: 20px; } +.ep .n { color: var(--muted); min-width: 60px; } +.ep > .pos { margin-right: auto; color: var(--accent); font-size: 22px; } +.ep.focused { background: var(--text); color: #000; } +.ep.focused .n, .ep.focused .pos { color: #333; } + +.settings { padding: 10px 8px; max-width: 1300px; } +.settings h2 { font-size: 34px; margin: 34px 0 14px; } +.settings p, .settings li { line-height: 1.6; color: #ccd; } + +.player { position: fixed; top: 0; right: 0; bottom: 0; left: 0; background: #000; z-index: 20; } +.player video { width: 100%; height: 100%; background: #000; } +.overlay { position: absolute; top: 0; right: 0; bottom: 0; left: 0; background: linear-gradient(0deg, rgba(0,0,0,.9) 0%, rgba(0,0,0,0) 40%, rgba(0,0,0,0) 75%, rgba(0,0,0,.8) 100%); } +.ov-top { position: absolute; top: 50px; left: 80px; right: 80px; } +.ov-title { font-size: 44px; font-weight: 800; } +.ov-sub { font-size: 28px; color: #ccc; margin-top: 6px; } +.ov-msg { position: absolute; top: 40%; left: 50%; transform: translate(-50%, -50%); max-width: 1300px; background: rgba(20,24,30,.95); padding: 30px 44px; border-radius: 16px; font-size: 30px; line-height: 1.6; text-align: center; } +.ov-bottom { position: absolute; left: 80px; right: 80px; bottom: 50px; } +.progress { padding: 18px 0; border-radius: 8px; } +.progress .bar { position: relative; height: 10px; background: rgba(255,255,255,.25); border-radius: 5px; overflow: hidden; } +.progress .fill { position: absolute; left: 0; top: 0; bottom: 0; background: var(--accent); z-index: 2; } +.progress .buf { position: absolute; left: 0; top: 0; bottom: 0; background: rgba(255,255,255,.35); z-index: 1; } +.progress.focused .bar { height: 18px; outline: 4px solid #fff; outline-offset: 4px; } +.times { display: flex; justify-content: space-between; font-size: 26px; color: #ddd; margin: 4px 0 20px; } +.controls { display: flex; } +.controls > * + * { margin-right: 16px; } +.ctl { padding: 14px 24px; border-radius: 12px; background: rgba(255,255,255,.14); font-size: 26px; white-space: nowrap; } +.ctl.focused { background: #fff; color: #000; } +.spinner { position: absolute; left: 50%; top: 50%; width: 90px; height: 90px; margin: -45px; border: 8px solid rgba(255,255,255,.2); border-top-color: #fff; border-radius: 50%; animation: spin 1s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } + +.modal { position: fixed; top: 0; right: 0; bottom: 0; left: 0; background: rgba(0,0,0,.6); z-index: 40; display: flex; align-items: center; justify-content: flex-start; } +.modal-box { width: 560px; height: 100%; background: #151a21; padding: 60px 40px; overflow-y: auto; } +.modal-box h3 { font-size: 38px; margin: 0 0 30px; } +.opt { display: block; width: 100%; text-align: right; padding: 18px 24px; border-radius: 12px; margin-bottom: 8px; font-size: 30px; } +.opt.sel::before { content: "✓ "; color: var(--accent); } +.opt.focused { background: #fff; color: #000; } + +.toast { position: fixed; left: 50%; bottom: 70px; transform: translateX(-50%); background: rgba(30,34,42,.96); padding: 18px 36px; border-radius: 14px; z-index: 60; font-size: 28px; max-width: 1400px; text-align: center; } diff --git a/webos/app/img/icon.png b/webos/app/img/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..227e53e2521316577d6437e7e19885acb3c120bc GIT binary patch literal 1927 zcmV;22YC32P)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91P@n?<1ONa40RR91Pyhe`05RZ9PXGW0@kvBMRCodHocn7XMHI)++L7`h!y<@d?EheZ(7iTSi#>cQi=*K z_GKwm6#YTlga(D$XnpjNdw2Yv9d5aK+zxDJp@2)fK_qfF)fKJK?Pi00@QtNV zVk;z?#kt)D%-!3fG#XB%!IxKEUlVPyf;oEN_M9prP5oq_f!vI7yoTZXFsL%9p4SfJXvI-KSWW>|3jn2{PB&Oy z6*^>TK%hJ+H!B0g!SV(i`h$kq8hdIpYk@_%8eMiE3zk=sHM`8gOCDgKTnQx)!te1a zawa7?3w#8@a{hX-+yofTd=M;OfaM9gIZGkSVhoZAr2DBC@a}@bO!~cAknwln0eO4Y zg5_FZ^RMFtI4GAx0mpkeK+LS)tA!XJz>+LrS-ynAI{+`$D=f8u{I25z_z!|*No%0c ztlwix&)Ku~cmVdxZiFno#^I@#>n-@mcn5cM#-o;0-hYkq-0JJ2G+Ib2PEN^x<0CnSm8Na8tx3R7*0Jj5Y-npeOd1S57hgjwq zrTs$70Q7xe5rMKKbMV;0c^3lFlYln{-O=DNc1D(?opcW6X^I{|Uf-)=X;EA$uCX^mRGb!2}h-OywdX*pC$UUFtT4ukXqU`VZhv-q1xEtqA;4~xL0zq%; ze)mMr0=a1?F&$FVrxw?C_@17Wfvy!F#yGNXM_C19@VNC3eS#&B_w=gH?4tH0BJ6tX*aoAP*W2{fXZr=(@kxx1nfHvI|g6 zD<=>vpNfXaJ9jn#s;>Js{wc)=-p!BxGH--82IDILu z_%}a#imUn#DQ!I6?z3D?!jP9k0(_&ERb8_&_@WC--riG@KWpn zjB7WIg2(S?o=GZL_QIL&F02NPKVW?$2w5(AH8iPb_AD@-7V)dl-T41mr{gIud@1R* zK>p|}^cDtAqDSN)hc3_m!8F8V(H`mLrl7XfZR zjuO&*R&&3hE^9w?`xSdYu0kc&0cH*sIYmdrwEapoYtSUnB#>bO{{a>llna0;Et&uT N002ovPDHLkV1n`KZP5S# literal 0 HcmV?d00001 diff --git a/webos/app/img/largeIcon.png b/webos/app/img/largeIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..d857820c8364b7962041c645fb1f549ce3cc0987 GIT binary patch literal 3267 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 + + + + + + + CCloud + + + + +
+ + + + + + + + + + + + + diff --git a/webos/app/js/api.js b/webos/app/js/api.js new file mode 100644 index 0000000..d65a70d --- /dev/null +++ b/webos/app/js/api.js @@ -0,0 +1,111 @@ +/* CCloud API client, the same endpoints the CCloud desktop app uses + * (github.com/code3-dev/CCloud-GUI, lib/src/repositories). Like the desktop + * app, a failed request is retried on the helper servers. */ +(function () { + var KEY = '4F5A9C3D9A86FA54EACEDDD635185'; + var SERVERS = ['https://server-hi-speed-iran.info', 'https://hostinnegar.com', 'https://windowsdiba.info']; + var good = 0; // index of the server that answered last + + function fetchText(url, ms) { + // XMLHttpRequest rather than fetch + AbortController, which older webOS lacks. + return new Promise(function (resolve, reject) { + var x = new XMLHttpRequest(); + x.open('GET', url); + x.timeout = ms || 20000; + x.onload = function () { if (x.status === 200) resolve(x.responseText); else reject(new Error('HTTP ' + x.status)); }; + x.onerror = function () { reject(new Error('network error')); }; + x.ontimeout = function () { reject(new Error('timeout')); }; + x.send(); + }); + } + + // path is everything after /api, without the key, e.g. "genre/all". + // The server wants a trailing slash; without it, it answers with a redirect. + function call(path) { + var order = [good].concat(SERVERS.map(function (_, i) { return i; }).filter(function (i) { return i !== good; })); + var i = 0; + function attempt() { + var idx = order[i]; + return fetchText(SERVERS[idx] + '/api/' + path + '/' + KEY + '/').then(function (t) { + good = idx; + return JSON.parse(t); + }, function (e) { + if (++i < order.length) return attempt(); + throw new Error('Could not reach the CCloud servers (' + e.message + ')'); + }); + } + return attempt(); + } + + function list(x) { return Array.isArray(x) ? x : []; } + + function poster(p) { + return { + id: p.id, type: p.type === 'serie' ? 'serie' : 'movie', title: p.title || '', + description: (p.description || '').replace(/\r/g, ''), + year: p.year || 0, imdb: p.imdb || 0, duration: p.duration || '', + image: p.image || '', cover: p.cover || p.image || '', + genres: list(p.genres).map(function (g) { return g.title; }), + countries: list(p.country).map(function (c) { return c.title; }), + sources: list(p.sources).map(source) + }; + } + function source(s) { + return { id: s.id, quality: s.quality || 'تیزر', type: (s.type || '').toLowerCase(), url: s.url || '' }; + } + + // Sort orders, as in the desktop app's FilterType. + var SORTS = [ + { value: 'created', label: 'جدیدترین' }, + { value: 'year', label: 'سال انتشار' }, + { value: 'imdb', label: 'امتیاز IMDb' } + ]; + + window.Api = { + SORTS: SORTS, + // Home rows: genres with their newest posters. + home: function () { + return call('first').then(function (d) { + return list(d.genres).map(function (g) { + return { id: g.id, title: g.title, items: list(g.posters).map(poster) }; + }).filter(function (g) { return g.items.length; }); + }); + }, + movies: function (o) { + return call('movie/by/filtres/' + (o.genre || 0) + '/' + (o.sort || 'created') + '/' + (o.page || 0)).then(function (d) { return list(d).map(poster); }); + }, + series: function (o) { + return call('serie/by/filtres/' + (o.genre || 0) + '/' + (o.sort || 'created') + '/' + (o.page || 0)).then(function (d) { return list(d).map(poster); }); + }, + // Movies and series together, by genre and/or country (0 = any). + posters: function (o) { + return call('poster/by/filtres/' + Math.max(0, o.genre || 0) + '/' + (o.country || 0) + '/' + (o.sort || 'created') + '/' + (o.page || 0)).then(function (d) { return list(d).map(poster); }); + }, + genres: function () { + return call('genre/all').then(function (d) { + return list(d).map(function (g) { return { id: g.id, title: g.title }; }) + .sort(function (a, b) { return a.title.localeCompare(b.title, 'fa'); }); + }); + }, + countries: function () { + return call('country/all').then(function (d) { + return list(d).map(function (c) { return { id: c.id, title: c.title, image: c.image }; }); + }); + }, + search: function (q) { + return call('search/' + encodeURIComponent(q)).then(function (d) { return list(d.posters).map(poster); }); + }, + seasons: function (serieId) { + return call('season/by/serie/' + serieId).then(function (d) { + return list(d).map(function (s) { + return { + id: s.id, title: s.title || '', + episodes: list(s.episodes).map(function (e) { + return { id: e.id, title: e.title || '', description: e.description || '', image: e.image || '', sources: list(e.sources).map(source) }; + }) + }; + }); + }); + } + }; +})(); diff --git a/webos/app/js/app.js b/webos/app/js/app.js new file mode 100644 index 0000000..cef5801 --- /dev/null +++ b/webos/app/js/app.js @@ -0,0 +1,473 @@ +/* Screens, routing and shared UI. The screens follow the CCloud desktop + * app: movies, series, search, favorites, countries and settings, plus a + * home screen built from the API's own home rows. */ +(function () { + var $ = function (id) { return document.getElementById(id); }; + var view = $('view'); + var stack = []; + + function h(tag, attrs, children) { + var el = document.createElement(tag); + Object.keys(attrs || {}).forEach(function (k) { + if (k === 'class') el.className = attrs[k]; + else if (k === 'text') el.textContent = attrs[k]; + else el.setAttribute(k, attrs[k]); + }); + (children || []).forEach(function (c) { if (c) el.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); }); + return el; + } + function errText(e) { return (e && e.message) || String(e); } + function loading(text) { return h('div', { class: 'loading', text: text || 'در حال بارگذاری…' }); } + function fa(n) { return String(n).replace(/\d/g, function (d) { return '۰۱۲۳۴۵۶۷۸۹'[d]; }); } + + /* ---------- toast / picker ---------- */ + var toastTimer = 0; + function toast(msg, ms) { + var t = $('toast'); + t.textContent = msg; t.classList.remove('hidden'); + clearTimeout(toastTimer); + toastTimer = setTimeout(function () { t.classList.add('hidden'); }, ms || 3500); + } + var modal = { open: false, prevFocus: null, handler: null }; + function pick(title, options, selected, onPick) { + var box = $('modalBox'); + box.innerHTML = ''; + box.appendChild(h('h3', { text: title })); + var selEl = null; + options.forEach(function (o) { + var b = h('button', { class: 'opt focusable' + (o.value === selected ? ' sel' : ''), text: o.label }); + b.addEventListener('click', function () { closeModal(); onPick(o.value); }); + if (o.value === selected) selEl = b; + box.appendChild(b); + }); + modal.open = true; modal.prevFocus = Nav.current; + $('modal').classList.remove('hidden'); + box.scrollTop = 0; + Nav.pushScope(box); + modal.handler = function (key) { if (Nav.isBack(key)) { closeModal(); return true; } return false; }; + Nav.pushHandler(modal.handler); + if (selEl) Nav.focus(selEl); else Nav.first(); + } + function closeModal() { + if (!modal.open) return; + modal.open = false; + $('modal').classList.add('hidden'); + Nav.popScope($('modalBox')); Nav.popHandler(modal.handler); + if (modal.prevFocus && document.body.contains(modal.prevFocus)) Nav.focus(modal.prevFocus); + } + window.UI = { toast: toast, pick: pick, modalOpen: function () { return modal.open; } }; + + /* ---------- cards ---------- */ + var seenObs = 'IntersectionObserver' in window ? new IntersectionObserver(function (entries) { + entries.forEach(function (e) { + if (!e.isIntersecting) return; + seenObs.unobserve(e.target); + e.target.src = e.target.dataset.src; + }); + }, { rootMargin: '600px 900px' }) : null; + function lazyImg(src, cls) { + var i = new Image(); + i.className = cls; i.decoding = 'async'; + i.onerror = function () { i.style.visibility = 'hidden'; }; + if (src) { i.dataset.src = src; if (seenObs) seenObs.observe(i); else i.src = src; } + return i; + } + function card(p) { + var c = h('button', { class: 'card focusable' }, [ + h('div', { class: 'frame' }, [ + lazyImg(p.image, 'poster'), + p.imdb ? h('span', { class: 'badge' }, [h('b', { text: '★ ' }), String(p.imdb)]) : null, + p.type === 'serie' ? h('span', { class: 'kind', text: 'سریال' }) : null + ]), + h('div', { class: 'name', text: p.title + (p.year ? ' (' + p.year + ')' : '') }) + ]); + c.addEventListener('click', function () { go('details', { poster: p }); }); + return c; + } + + /* ---------- routing ---------- */ + var routes = {}; + function settle(entry, el) { + if (stack[stack.length - 1] !== entry || Nav.moves !== entry.moves || Player.active || modal.open) return; + if (el) Nav.focus(el); else Nav.first(); + } + function render(entry) { + view.innerHTML = ''; + view.appendChild(entry.el); + if (entry.scroll) entry.el.scrollTop = entry.scroll; + document.querySelectorAll('.menu-item').forEach(function (m) { + m.classList.toggle('active', m.dataset.route === stack[0].route); + }); + } + function go(route, params, reset) { + var top = stack[stack.length - 1]; + if (top && !reset) { top.focus = Nav.current; top.scroll = top.el.scrollTop; } + if (reset) stack = []; + var entry = { route: route, params: params || {}, el: h('div', { class: 'scroller' }), moves: Nav.moves }; + stack.push(entry); + render(entry); + routes[route](entry.el, entry.params, entry); + } + function back() { + if (stack.length > 1) { + stack.pop(); + var e = stack[stack.length - 1]; + render(e); + if (e.onShow) e.onShow(); + if (e.focus && document.body.contains(e.focus)) Nav.focus(e.focus); else Nav.first(); + return true; + } + var menu = $('menu'); + if (!menu.contains(Nav.current)) { Nav.focus(menu.querySelector('.menu-item.active') || menu.querySelector('.menu-item')); return true; } + window.close(); + return true; + } + function afterPlayer() { + var e = stack[stack.length - 1]; + if (e && e.onShow) e.onShow(); + if (Nav.current && document.body.contains(Nav.current) && !Nav.current.closest('#player')) Nav.focus(Nav.current); + else if (e && e.focus && document.body.contains(e.focus)) Nav.focus(e.focus); + else Nav.first(); + } + + /* A grid that loads more pages as focus nears the end. load(page) returns + * a promise of posters; pages start at 0, as in the API. */ + function grid(el, entry, load, emptyText) { + var g = h('div', { class: 'grid' }); + var more = loading(); + el.appendChild(g); el.appendChild(more); + var page = 0, busy = false, done = false, ids = {}, gen = {}; + function next() { + if (busy || done) return; + busy = true; + var mine = gen; + load(page).then(function (list) { + if (mine !== gen) return; + busy = false; page++; + var first = null, added = 0; + list.forEach(function (p) { + var k = p.type + p.id; + if (ids[k]) return; + ids[k] = true; added++; + var c = card(p); if (!first) first = c; g.appendChild(c); + }); + if (!added) { done = true; more.remove(); } + if (!g.children.length) el.appendChild(h('div', { class: 'empty', text: emptyText || 'چیزی پیدا نشد.' })); + if (page === 1 && first) settle(entry, first); + }, function (e) { if (mine !== gen) return; busy = false; more.textContent = 'خطا: ' + errText(e); }); + } + function check() { + var cards = g.children, cur = Nav.current; + for (var i = Math.max(0, cards.length - 14); i < cards.length; i++) if (cards[i] === cur) { next(); break; } + } + entry.onNav = check; + next(); + return { stop: function () { gen = null; } }; + } + + /* ---------- screens ---------- */ + routes.home = function (el, params, entry) { + var box = h('div', {}, [loading()]); + el.appendChild(box); + Api.home().then(function (rows) { + box.innerHTML = ''; + var settled = false; + rows.forEach(function (r) { + var items = h('div', { class: 'row-items', 'data-row': '1' }); + r.items.forEach(function (p) { items.appendChild(card(p)); }); + var all = h('button', { class: 'more focusable', text: 'همه' }); + all.addEventListener('click', function () { go('list', { title: r.title, genre: r.id }); }); + items.appendChild(all); + box.appendChild(h('section', { class: 'row' }, [h('h2', { text: r.title }), items])); + if (!settled) { settled = true; settle(entry, items.firstChild); } + }); + if (!rows.length) box.appendChild(h('div', { class: 'empty', text: 'چیزی پیدا نشد.' })); + }, function (e) { + box.innerHTML = ''; + var retry = h('button', { class: 'btn primary focusable', text: 'تلاش دوباره' }); + retry.addEventListener('click', function () { go('home', {}, true); }); + box.appendChild(h('div', { class: 'empty', text: 'خطا: ' + errText(e) })); + box.appendChild(retry); + settle(entry, retry); + }); + }; + + routes.list = function (el, params, entry) { + el.appendChild(h('h1', { class: 'page-title', text: params.title })); + grid(el, entry, function (page) { return Api.posters({ genre: params.genre, country: params.country, page: page }); }); + }; + + // Movies and Series: genre and sort filters, as in the desktop app. + function catalog(kind) { + return function (el, params, entry) { + var p = Store.prefs(); + var state = { genre: 0, genreTitle: 'همه ژانرها', sort: p[kind + 'Sort'] || 'created' }; + var title = kind === 'movies' ? 'فیلم‌ها' : 'سریال‌ها'; + var gBtn = h('button', { class: 'btn focusable' }); + var sBtn = h('button', { class: 'btn focusable' }); + var body = h('div'); + el.appendChild(h('h1', { class: 'page-title', text: title })); + el.appendChild(h('div', { class: 'filters' }, [gBtn, sBtn])); + el.appendChild(body); + var current = null; + function label() { + gBtn.textContent = 'ژانر: ' + state.genreTitle; + sBtn.textContent = 'مرتب‌سازی: ' + Api.SORTS.filter(function (s) { return s.value === state.sort; })[0].label; + } + function load(focusFirst) { + label(); + if (current) current.stop(); + body.innerHTML = ''; + entry.moves = focusFirst ? Nav.moves : -1; + current = grid(body, entry, function (page) { return Api[kind]({ genre: state.genre, sort: state.sort, page: page }); }); + } + gBtn.addEventListener('click', function () { + Api.genres().then(function (gs) { + var opts = [{ label: 'همه ژانرها', value: 0 }].concat(gs.map(function (g) { return { label: g.title, value: g.id }; })); + pick('ژانر', opts, state.genre, function (id) { + state.genre = id; + state.genreTitle = opts.filter(function (o) { return o.value === id; })[0].label; + load(false); + }); + }, function (e) { toast('خطا: ' + errText(e)); }); + }); + sBtn.addEventListener('click', function () { + pick('مرتب‌سازی', Api.SORTS, state.sort, function (v) { state.sort = v; Store.setPref(kind + 'Sort', v); load(false); }); + }); + load(true); + }; + } + routes.movies = catalog('movies'); + routes.series = catalog('series'); + + routes.countries = function (el, params, entry) { + el.appendChild(h('h1', { class: 'page-title', text: 'کشورها' })); + var box = h('div', { class: 'chips' }, [loading()]); + el.appendChild(box); + Api.countries().then(function (cs) { + box.innerHTML = ''; + cs.forEach(function (c) { + var b = h('button', { class: 'chip focusable' }, [c.image ? lazyImg(c.image, '') : null, c.title]); + b.addEventListener('click', function () { go('list', { title: c.title, country: c.id }); }); + box.appendChild(b); + }); + settle(entry, box.firstChild); + }, function (e) { box.textContent = 'خطا: ' + errText(e); }); + }; + + routes.search = function (el, params, entry) { + var input = h('input', { class: 'input focusable', type: 'text', placeholder: 'نام فیلم یا سریال' }); + var btn = h('button', { class: 'btn primary focusable', text: 'جستجو' }); + var results = h('div'); + el.appendChild(h('div', { class: 'search-box' }, [input, btn])); + el.appendChild(results); + function run() { + var q = input.value.trim(); + results.innerHTML = ''; + if (!q) return; + // Search returns a single page. + grid(results, { moves: -1 }, function (page) { return page ? Promise.resolve([]) : Api.search(q); }, 'نتیجه‌ای برای «' + q + '» پیدا نشد.'); + } + btn.addEventListener('click', run); + input.addEventListener('submit-input', function () { input.blur(); run(); }); + settle(entry, input); + }; + + routes.favorites = function (el, params, entry) { + el.appendChild(h('h1', { class: 'page-title', text: 'علاقه‌مندی‌ها' })); + var g = h('div', { class: 'grid' }); + el.appendChild(g); + function fill() { + var keep = Nav.current && g.contains(Nav.current) ? Array.prototype.indexOf.call(g.children, Nav.current) : -1; + g.innerHTML = ''; + var favs = Store.favorites(); + favs.forEach(function (p) { g.appendChild(card(p)); }); + if (!favs.length) g.appendChild(h('div', { class: 'empty', text: 'هنوز چیزی اضافه نکرده‌اید. در صفحه هر فیلم، «افزودن به علاقه‌مندی‌ها» را بزنید.' })); + if (keep >= 0) Nav.focus(g.children[Math.min(keep, g.children.length - 1)]); + } + entry.onShow = fill; + fill(); + settle(entry, g.querySelector('.card')); + }; + + routes.settings = function (el, params, entry) { + var qBtn = h('button', { class: 'btn focusable' }); + qBtn.addEventListener('click', function () { + var opts = ['480', '720', '1080']; + pick('کیفیت پیش‌فرض', [{ label: 'خودکار', value: '' }].concat(opts.map(function (o) { return { label: o, value: o }; })), Store.prefs().qualityPrefix || '', function (v) { + Store.setPref('qualityPrefix', v); + qBtn.textContent = 'کیفیت پیش‌فرض: ' + (v || 'خودکار'); + }); + }); + qBtn.textContent = 'کیفیت پیش‌فرض: ' + (Store.prefs().qualityPrefix || 'خودکار'); + var favBtn = h('button', { class: 'btn focusable', text: 'پاک کردن علاقه‌مندی‌ها' }); + favBtn.addEventListener('click', function () { Store.clearFavorites(); toast('علاقه‌مندی‌ها پاک شد.'); }); + var posBtn = h('button', { class: 'btn focusable', text: 'پاک کردن سابقه تماشا' }); + posBtn.addEventListener('click', function () { Store.clearPositions(); toast('سابقه تماشا پاک شد.'); }); + el.appendChild(h('div', { class: 'settings' }, [ + h('h1', { class: 'page-title', text: 'تنظیمات' }), + h('div', { class: 'actions' }, [qBtn, favBtn, posBtn]), + h('h2', { text: 'درباره' }), + h('p', { text: 'نسخه تلویزیون LG از CCloud، با همان سرورها و داده‌های برنامه دسکتاپ CCloud (github.com/code3-dev/CCloud-GUI).' }), + h('p', { text: 'بیشتر سرورهای ویدیو فقط از داخل ایران در دسترس هستند. اگر ویدیویی شروع نشد، کیفیت یا لینک دیگری را امتحان کنید.' }) + ])); + settle(entry, qBtn); + }; + + /* ---------- details ---------- */ + function sourceLabel(s) { return s.quality + (s.type ? ' · ' + s.type.toUpperCase() : ''); } + + // Preferred source: one matching the chosen quality prefix (e.g. "720"), else the first non-trailer. + function preferred(sources) { + var real = sources.filter(function (s) { return s.quality !== 'تیزر'; }); + var pref = Store.prefs().qualityPrefix; + return (pref && real.filter(function (s) { return s.quality.indexOf(pref) === 0; })[0]) || real[0] || sources[0]; + } + + routes.details = function (el, params, entry) { + var p = params.poster; + var favBtn = h('button', { class: 'btn focusable' }); + function favLabel() { favBtn.textContent = Store.isFavorite(p) ? '★ حذف از علاقه‌مندی‌ها' : '☆ افزودن به علاقه‌مندی‌ها'; } + favLabel(); + favBtn.addEventListener('click', function () { toast(Store.toggleFavorite(p) ? 'به علاقه‌مندی‌ها اضافه شد' : 'از علاقه‌مندی‌ها حذف شد'); favLabel(); }); + + var meta = h('div', { class: 'd-meta' }, [ + p.year ? h('span', { text: String(p.year) }) : null, + p.imdb ? h('span', {}, [h('b', { text: 'IMDb ' }), String(p.imdb)]) : null, + p.duration ? h('span', { text: p.duration }) : null, + p.countries && p.countries.length ? h('span', { text: p.countries.join('، ') }) : null + ]); + var actions = h('div', { class: 'actions' }); + var below = h('div'); + var backdrop = h('div', { class: 'backdrop' }); + if (p.cover) backdrop.style.backgroundImage = 'url("' + p.cover.replace(/"/g, '%22') + '")'; + el.appendChild(h('div', { class: 'details' }, [ + backdrop, + h('div', { class: 'd-head' }, [ + h('img', { class: 'd-poster', src: p.image }), + h('div', { class: 'd-info' }, [ + h('h1', { text: p.title }), meta, + p.genres && p.genres.length ? h('div', { class: 'd-meta', text: p.genres.slice(0, 6).join(' · ') }) : null, + h('p', { class: 'd-desc', text: p.description }), + actions + ]) + ]), + below + ])); + + if (p.type === 'serie') seriesDetails(p, actions, below, favBtn, entry); + else movieDetails(p, actions, below, favBtn, entry); + }; + + function movieDetails(p, actions, below, favBtn, entry) { + // Favorites only keep a summary, so fetch the sources when they are missing. + var have = p.sources && p.sources.length ? Promise.resolve(p) : Api.search(p.title).then(function (list) { + return list.filter(function (x) { return x.id === p.id && x.type === p.type; })[0] || p; + }); + actions.appendChild(loading()); + have.then(function (full) { + actions.innerHTML = ''; + var sources = full.sources || []; + if (!sources.length) { + actions.appendChild(favBtn); + below.appendChild(h('div', { class: 'empty', text: 'لینکی برای پخش این عنوان وجود ندارد.' })); + settle(entry, favBtn); + return; + } + var play = h('button', { class: 'btn primary focusable', text: '▶ پخش' }); + play.addEventListener('click', function () { Player.open({ title: p.title, sub: p.year ? String(p.year) : '', sources: sources, source: preferred(sources) }); }); + actions.appendChild(play); actions.appendChild(favBtn); + below.appendChild(h('h2', { class: 'section-title', text: 'کیفیت‌ها' })); + var row = h('div', { class: 'actions sources' }); + sources.forEach(function (s) { + var b = h('button', { class: 'btn focusable', text: sourceLabel(s) }); + b.addEventListener('click', function () { Player.open({ title: p.title, sub: sourceLabel(s), sources: sources, source: s }); }); + row.appendChild(b); + }); + below.appendChild(row); + settle(entry, play); + }, function (e) { actions.innerHTML = ''; actions.appendChild(favBtn); toast('خطا: ' + errText(e)); settle(entry, favBtn); }); + } + + function seriesDetails(p, actions, below, favBtn, entry) { + actions.appendChild(favBtn); + var seasonsBox = h('div', { class: 'chips', 'data-row': '1' }); + var eps = h('div', { class: 'episodes' }); + below.appendChild(h('h2', { class: 'section-title', text: 'فصل‌ها' })); + below.appendChild(seasonsBox); + below.appendChild(eps); + eps.appendChild(loading()); + var seasons = [], cur = 0; + + function playEpisode(si, ei, source) { + var s = seasons[si], e = s.episodes[ei]; + var nextIdx = ei + 1 < s.episodes.length ? [si, ei + 1] : (si + 1 < seasons.length && seasons[si + 1].episodes.length ? [si + 1, 0] : null); + var nextEp = nextIdx && seasons[nextIdx[0]].episodes[nextIdx[1]]; + var src = source || preferred(e.sources); + // Keep the same quality for the next episode where it exists. + var nextSrc = function () { + var list = nextEp.sources; + return list.filter(function (x) { return x.quality === src.quality; })[0]; + }; + Player.open({ + title: p.title, sub: s.title + ' · ' + e.title, sources: e.sources, source: src, + next: nextEp && nextEp.sources.length ? { open: function () { playEpisode(nextIdx[0], nextIdx[1], nextSrc()); } } : null + }); + } + + function showSeason(si) { + cur = si; + Array.prototype.forEach.call(seasonsBox.children, function (c, i) { c.classList.toggle('on', i === si); }); + eps.innerHTML = ''; + var s = seasons[si]; + s.episodes.forEach(function (e, ei) { + var src = preferred(e.sources); + var pos = src ? Store.position(src.url) : 0; + var b = h('button', { class: 'ep focusable' }, [ + h('span', { class: 'n', text: fa(ei + 1) }), + h('span', { text: e.title }), + pos ? h('span', { class: 'pos', text: 'ادامه' }) : null + ]); + if (!e.sources.length) b.disabled = true; + // Plays the preferred quality; the player's Quality button switches. + b.addEventListener('click', function () { playEpisode(si, ei); }); + eps.appendChild(b); + }); + if (!s.episodes.length) eps.appendChild(h('div', { class: 'empty', text: 'قسمتی وجود ندارد.' })); + } + entry.onShow = function () { if (seasons.length) { var f = Nav.current, idx = Array.prototype.indexOf.call(eps.children, f); showSeason(cur); if (idx >= 0) Nav.focus(eps.children[idx]); } }; + + Api.seasons(p.id).then(function (list) { + seasons = list; + eps.innerHTML = ''; + list.forEach(function (s, si) { + var b = h('button', { class: 'chip focusable', text: s.title }); + b.addEventListener('click', function () { showSeason(si); Nav.focus(eps.querySelector('.focusable') || b); }); + seasonsBox.appendChild(b); + }); + if (!list.length) { eps.appendChild(h('div', { class: 'empty', text: 'قسمتی برای این سریال ثبت نشده است.' })); settle(entry, favBtn); return; } + showSeason(0); + settle(entry, seasonsBox.firstChild); + }, function (e) { eps.innerHTML = ''; eps.appendChild(h('div', { class: 'empty', text: 'خطا: ' + errText(e) })); settle(entry, favBtn); }); + } + + /* ---------- keys ---------- */ + Nav.pushHandler(function (key) { + if (Player.active) return false; + if (Nav.isBack(key)) return back(); + return false; + }); + document.addEventListener('keydown', function () { + var top = stack[stack.length - 1]; + if (top && top.onNav) setTimeout(top.onNav, 0); + }); + + document.querySelectorAll('.menu-item').forEach(function (m) { + m.addEventListener('click', function () { go(m.dataset.route, {}, true); }); + }); + + window.App = { go: go, afterPlayer: afterPlayer }; + + Player.init(); + go('home', {}, true); +})(); diff --git a/webos/app/js/nav.js b/webos/app/js/nav.js new file mode 100644 index 0000000..03c83b6 --- /dev/null +++ b/webos/app/js/nav.js @@ -0,0 +1,173 @@ +/* Spatial navigation for the TV remote. + * + * Every actionable element has the class `focusable`; arrows move focus to + * the nearest focusable in that direction inside the current scope, OK + * clicks it. Views and the player push key handlers that get first look at + * each key press. Works with the Magic Remote pointer too (hover focuses, + * click is a normal click). */ +(function () { + var K = { + LEFT: 37, UP: 38, RIGHT: 39, DOWN: 40, ENTER: 13, BACK: 461, ESC: 27, BACKSPACE: 8, + PLAY: 415, PAUSE: 19, PLAYPAUSE: 179, STOP: 413, FF: 417, RW: 412, + RED: 403, GREEN: 404, YELLOW: 405, BLUE: 406 + }; + var current = null; + var moves = 0; // bumped on every user arrow press + var scopes = []; // stack of root elements; top one restricts navigation + var handlers = []; // stack of fn(key, event) -> true if handled + + function scope() { return scopes.length ? scopes[scopes.length - 1] : document.body; } + function visible(el) { return el.getClientRects().length > 0 && !el.closest('.hidden'); } + function isInput(el) { return el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA'); } + + function focus(el) { + if (!el) return; + if (current && current !== el) current.classList.remove('focused'); + var trap = el.closest('[data-trap]'); + if (trap && current && !trap.contains(current)) trap._outside = current; + current = el; + el.classList.add('focused'); + if (!isInput(el)) { + if (isInput(document.activeElement)) document.activeElement.blur(); + try { el.focus({ preventScroll: true }); } catch (e) { el.focus(); } + } + ensureVisible(el); + var row = el.closest('[data-row]'); + if (row) row._last = el; + } + + function ensureVisible(el) { + var p = el.parentElement; + while (p && p !== document.body) { + var cs = getComputedStyle(p); + var r = el.getBoundingClientRect(), pr = p.getBoundingClientRect(); + if (/(auto|scroll)/.test(cs.overflowX) && p.scrollWidth > p.clientWidth) { + var padX = 60; + if (r.left < pr.left + padX) p.scrollLeft -= (pr.left + padX - r.left); + else if (r.right > pr.right - padX) p.scrollLeft += (r.right - pr.right + padX); + } + if (/(auto|scroll)/.test(cs.overflowY) && p.scrollHeight > p.clientHeight) { + var padY = 140; + if (r.top < pr.top + padY) p.scrollTop -= (pr.top + padY - r.top); + else if (r.bottom > pr.bottom - padY) p.scrollTop += (r.bottom - pr.bottom + padY); + } + p = p.parentElement; + } + } + + function candidates() { + return Array.prototype.filter.call(scope().querySelectorAll('.focusable'), function (el) { + return el !== current && visible(el) && !el.disabled; + }); + } + + function usable(el) { return el && document.body.contains(el) && visible(el) && scope().contains(el); } + + function move(dir) { + var list = candidates(); + if (!usable(current)) { + focus(first()); + return true; + } + // A container with data-trap="up,down" keeps those directions inside it; + // leaving it any other way returns to where focus was before entering. + var trap = current.closest('[data-trap]'); + if (trap) { + if (trap.dataset.trap.split(',').indexOf(dir) >= 0) { + list = list.filter(function (el) { return trap.contains(el); }); + } else { + list = list.filter(function (el) { return !trap.contains(el); }); + if (usable(trap._outside)) { focus(trap._outside); return true; } + } + } + var a = current.getBoundingClientRect(); + var ax = a.left + a.width / 2, ay = a.top + a.height / 2; + var best = null, bestScore = Infinity; + list.forEach(function (el) { + var b = el.getBoundingClientRect(); + var bx = b.left + b.width / 2, by = b.top + b.height / 2; + var primary, secondary; + if (dir === 'right') { if (b.left < a.right - 5 && bx <= ax + 5) return; primary = Math.max(0, b.left - a.right); secondary = Math.abs(by - ay); } + else if (dir === 'left') { if (b.right > a.left + 5 && bx >= ax - 5) return; primary = Math.max(0, a.left - b.right); secondary = Math.abs(by - ay); } + else if (dir === 'down') { if (b.top < a.bottom - 5 && by <= ay + 5) return; primary = Math.max(0, b.top - a.bottom); secondary = Math.abs(bx - ax); } + else { if (b.bottom > a.top + 5 && by >= ay - 5) return; primary = Math.max(0, a.top - b.bottom); secondary = Math.abs(bx - ax); } + var horizontal = dir === 'left' || dir === 'right'; + var score = primary + secondary * (horizontal ? 3 : 0.6); + if (score < bestScore) { bestScore = score; best = el; } + }); + // Entering a horizontal row from above/below: return to the item that + // was focused there last time, if it is roughly in view. + if (best && (dir === 'up' || dir === 'down')) { + var row = best.closest('[data-row]'); + if (row && row !== current.closest('[data-row]') && row._last && document.body.contains(row._last)) { + var lr = row._last.getBoundingClientRect(); + if (lr.right > 0 && lr.left < window.innerWidth) best = row._last; + } + } + if (best) { focus(best); return true; } + return false; + } + + function first() { + var s = scope(); + return s.querySelector('.focusable.autofocus:not(.hidden)') || + candidates().filter(function (el) { return !el.closest('#menu') || s !== document.body; })[0] || + candidates()[0]; + } + + // Back can arrive twice: as key 461 and, if disableBackHistoryAPI is + // off, as history.back(). + // Keep a spare history entry, treat popstate as Back, and drop duplicates. + var lastBack = 0; + function backOnce() { var now = Date.now(), dup = now - lastBack < 600; lastBack = now; return !dup; } + try { history.pushState({ spare: 1 }, ''); } catch (e) {} + window.addEventListener('popstate', function () { + try { history.pushState({ spare: 1 }, ''); } catch (e) {} + if (!backOnce()) return; + var el = document.activeElement; + if (isInput(el)) { el.blur(); return; } + for (var i = handlers.length - 1; i >= 0; i--) if (handlers[i](K.BACK)) return; + }); + + function onKey(e) { + var key = e.keyCode; + if (key === K.BACK && !backOnce()) { e.preventDefault(); return; } + var typing = isInput(document.activeElement); + if (typing) { + if (key === K.ENTER) { document.activeElement.dispatchEvent(new CustomEvent('submit-input')); e.preventDefault(); return; } + if (key === K.UP || key === K.DOWN || key === K.BACK || key === K.ESC) { document.activeElement.blur(); } + else return; // let the input handle characters, backspace, caret moves + if (key === K.BACK || key === K.ESC) { e.preventDefault(); return; } + } + for (var i = handlers.length - 1; i >= 0; i--) { + if (handlers[i](key, e)) { e.preventDefault(); return; } + } + var dir = { 37: 'left', 38: 'up', 39: 'right', 40: 'down' }[key]; + if (dir) { moves++; move(dir); e.preventDefault(); return; } + if (key === K.ENTER && current) { + e.preventDefault(); + if (isInput(current)) current.focus(); + else current.click(); + } + } + + document.addEventListener('keydown', onKey, true); + document.addEventListener('mouseover', function (e) { + var el = e.target.closest && e.target.closest('.focusable'); + if (el && scope().contains(el) && el !== current) focus(el); + }); + + window.Nav = { + K: K, + focus: focus, + move: move, + first: function () { focus(first()); }, + get current() { return current; }, + get moves() { return moves; }, + pushScope: function (el) { scopes.push(el); }, + popScope: function (el) { var i = scopes.lastIndexOf(el); if (i >= 0) scopes.splice(i, 1); }, + pushHandler: function (fn) { handlers.push(fn); }, + popHandler: function (fn) { var i = handlers.lastIndexOf(fn); if (i >= 0) handlers.splice(i, 1); }, + isBack: function (key) { return key === K.BACK || key === K.ESC || key === K.BACKSPACE; } + }; +})(); diff --git a/webos/app/js/player.js b/webos/app/js/player.js new file mode 100644 index 0000000..2163a23 --- /dev/null +++ b/webos/app/js/player.js @@ -0,0 +1,214 @@ +/* Video player. Sources are plain MP4/MKV files, which the TV's own media + * pipeline plays directly (including MKV with several audio and subtitle + * tracks, when the TV exposes them). + * + * Most CCloud video servers only accept connections from Iranian IPs. If a + * file does not start within a while, the player says so instead of + * spinning forever. */ +(function () { + var $ = function (id) { return document.getElementById(id); }; + var root, video, overlay, st = null, hideTimer = 0, stallTimer = 0, saveTimer = 0, handler = null; + var START_TIMEOUT = 25000; + + function fmt(t) { + t = Math.max(0, Math.floor(t || 0)); + var h = Math.floor(t / 3600), m = Math.floor(t / 60) % 60, s = t % 60; + return (h ? h + ':' + (m < 10 ? '0' : '') : '') + m + ':' + (s < 10 ? '0' : '') + s; + } + function host(u) { var m = /^https?:\/\/([^/]+)/.exec(u); return m ? m[1] : u; } + + function savePos() { + if (st && st.src && video.duration) Store.savePosition(st.src.url, video.currentTime, video.duration); + } + + function fail(msg) { + clearTimeout(stallTimer); + if (!st) return; + $('spinner').classList.add('hidden'); + $('ovMsg').textContent = msg; + $('ovMsg').classList.remove('hidden'); + showOverlay($('btnQuality')); + } + + function attach(src, startAt) { + st.src = src; + st.started = false; + $('ovMsg').classList.add('hidden'); + $('btnQuality').textContent = 'کیفیت: ' + src.quality; + video.pause(); + video.removeAttribute('src'); video.load(); + $('spinner').classList.remove('hidden'); + video.src = src.url; + // A property handler rather than addEventListener({ once }), which older webOS lacks. + video.onloadedmetadata = function () { + video.onloadedmetadata = null; + if (!st || st.src !== src) return; + var t = startAt != null ? startAt : Store.position(src.url); + if (t > 0) { try { video.currentTime = t; } catch (e) {} if (startAt == null) UI.toast('ادامه از ' + fmt(t)); } + updateTrackButtons(); + }; + video.play().catch(function () {}); + clearTimeout(stallTimer); + stallTimer = setTimeout(function () { + if (st && st.src === src && !st.started) { + fail('سرور ' + host(src.url) + ' پاسخ نمی‌دهد. بیشتر سرورهای CCloud فقط از داخل ایران در دسترس هستند. کیفیت دیگری را امتحان کنید.'); + } + }, START_TIMEOUT); + } + + /* ctx: { title, sub, sources, source, next?: { open: fn } } */ + function open(ctx) { + if (!ctx.sources || !ctx.sources.length) { UI.toast('لینکی برای پخش وجود ندارد'); return; } + if (st) teardown(); + st = { ctx: ctx, src: null, started: false }; + $('ovTitle').textContent = ctx.title; + $('ovSub').textContent = ctx.sub || ''; + $('btnNext').classList.toggle('hidden', !ctx.next); + root.classList.remove('hidden'); + Nav.pushScope(overlay); + handler = onKey; Nav.pushHandler(handler); + showOverlay(); + attach(ctx.source || ctx.sources[0]); + saveTimer = setInterval(savePos, 10000); + } + + function teardown() { + savePos(); + clearTimeout(hideTimer); clearTimeout(stallTimer); clearInterval(saveTimer); + video.pause(); video.removeAttribute('src'); video.load(); + Nav.popScope(overlay); Nav.popHandler(handler); + st = null; + } + + function close() { + if (!st) return; + teardown(); + root.classList.add('hidden'); overlay.classList.add('hidden'); + App.afterPlayer(); + } + + function showOverlay(focusEl) { + overlay.classList.remove('hidden'); + if (focusEl) Nav.focus(focusEl); + else if (!Nav.current || !overlay.contains(Nav.current)) Nav.focus($('btnPlay')); + armHide(); + } + function hideOverlay() { overlay.classList.add('hidden'); clearTimeout(hideTimer); } + function armHide() { + clearTimeout(hideTimer); + hideTimer = setTimeout(function () { + if (!video.paused && !UI.modalOpen() && $('ovMsg').classList.contains('hidden')) hideOverlay(); + }, 5000); + } + + function toggle() { if (video.paused) video.play().catch(function () {}); else video.pause(); } + function seek(d) { + if (!video.duration) return; + video.currentTime = Math.max(0, Math.min(video.duration - 1, video.currentTime + d)); + } + + function tracks(kind) { + var l = video[kind]; + var out = []; + if (!l) return out; + for (var i = 0; i < l.length; i++) out.push(l[i]); + return out; + } + function trackName(t, i) { return t.label || t.language || ('#' + (i + 1)); } + function updateTrackButtons() { + $('btnAudio').classList.toggle('hidden', tracks('audioTracks').length < 2); + $('btnSubs').classList.toggle('hidden', !tracks('textTracks').length); + } + + function act(name) { + if (!st) return; + if (name === 'play') toggle(); + else if (name === 'back10') seek(-10); + else if (name === 'fwd30') seek(30); + else if (name === 'next' && st.ctx.next) { var n = st.ctx.next; savePos(); n.open(); } + else if (name === 'quality') { + UI.pick('کیفیت', st.ctx.sources.map(function (s, i) { return { label: s.quality + ' · ' + (s.type || '').toUpperCase(), value: i }; }), + st.ctx.sources.indexOf(st.src), function (i) { + var s = st.ctx.sources[i]; + var digits = /^\d+/.exec(s.quality); + if (digits) Store.setPref('qualityPrefix', digits[0]); + var t = st.started ? video.currentTime : null; + attach(s, t); + }); + } else if (name === 'audio') { + var a = tracks('audioTracks'); + var cur = a.filter(function (t) { return t.enabled; })[0]; + UI.pick('صدا', a.map(function (t, i) { return { label: trackName(t, i), value: i }; }), a.indexOf(cur), function (i) { + a.forEach(function (t, j) { t.enabled = j === i; }); + }); + } else if (name === 'subs') { + var s = tracks('textTracks'); + var on = s.filter(function (t) { return t.mode === 'showing'; })[0]; + var opts = [{ label: 'خاموش', value: -1 }].concat(s.map(function (t, i) { return { label: trackName(t, i), value: i }; })); + UI.pick('زیرنویس', opts, on ? s.indexOf(on) : -1, function (i) { + s.forEach(function (t, j) { t.mode = j === i ? 'showing' : 'disabled'; }); + }); + } + } + + function onKey(key) { + if (UI.modalOpen()) return false; + var K = Nav.K, hidden = overlay.classList.contains('hidden'); + if (Nav.isBack(key)) { if (!hidden && !video.paused) hideOverlay(); else close(); return true; } + if (key === K.PLAY) { video.play().catch(function () {}); showOverlay(); return true; } + if (key === K.PAUSE) { video.pause(); showOverlay(); return true; } + if (key === K.PLAYPAUSE) { toggle(); showOverlay(); return true; } + if (key === K.STOP) { close(); return true; } + if (key === K.RW) { seek(-10); showOverlay(); return true; } + if (key === K.FF) { seek(30); showOverlay(); return true; } + if (key === K.YELLOW) { act('quality'); return true; } + if (hidden) { + // RTL layout, but the remote's arrows keep their physical meaning. + if (key === K.ENTER) { toggle(); showOverlay(); return true; } + if (key === K.LEFT) { seek(-10); showOverlay($('progress')); return true; } + if (key === K.RIGHT) { seek(10); showOverlay($('progress')); return true; } + if (key === K.UP || key === K.DOWN) { showOverlay(); return true; } + return true; + } + armHide(); + if (Nav.current === $('progress') && (key === K.LEFT || key === K.RIGHT)) { seek(key === K.LEFT ? -10 : 10); return true; } + return false; + } + + function init() { + root = $('player'); video = $('video'); overlay = $('overlay'); + $('controls').addEventListener('click', function (e) { + var b = e.target.closest('[data-act]'); if (b) { act(b.dataset.act); armHide(); } + }); + $('progress').addEventListener('click', toggle); + video.addEventListener('playing', function () { + if (!st) return; + st.started = true; clearTimeout(stallTimer); + $('ovMsg').classList.add('hidden'); + $('spinner').classList.add('hidden'); $('btnPlay').textContent = 'توقف'; armHide(); + }); + video.addEventListener('pause', function () { if (st) { $('btnPlay').textContent = 'پخش'; savePos(); showOverlay(); } }); + video.addEventListener('waiting', function () { if (st) $('spinner').classList.remove('hidden'); }); + video.addEventListener('error', function () { + if (!st || !video.getAttribute('src')) return; + var code = video.error && video.error.code; + fail(code === 4 + ? 'این فایل پخش نمی‌شود یا سرور ' + host(st.src.url) + ' در دسترس نیست. کیفیت دیگری را امتحان کنید.' + : 'خطا در پخش (' + code + '). کیفیت دیگری را امتحان کنید.'); + }); + video.addEventListener('ended', function () { + if (!st) return; + Store.savePosition(st.src.url, 0, 0); + if (st.ctx.next) showOverlay($('btnNext')); else showOverlay(); + }); + video.addEventListener('timeupdate', function () { + var d = video.duration || 0, t = video.currentTime; + $('progFill').style.width = d ? (t / d * 100) + '%' : '0'; + $('tCur').textContent = fmt(t); $('tDur').textContent = fmt(d); + var b = video.buffered; if (d && b.length) $('progBuf').style.width = (b.end(b.length - 1) / d * 100) + '%'; + }); + document.addEventListener('visibilitychange', function () { if (document.hidden && st) { savePos(); video.pause(); } }); + } + + window.Player = { init: init, open: open, close: close, get active() { return !!st; } }; +})(); diff --git a/webos/app/js/store.js b/webos/app/js/store.js new file mode 100644 index 0000000..5ca0df0 --- /dev/null +++ b/webos/app/js/store.js @@ -0,0 +1,39 @@ +/* Saved state, kept in localStorage like the desktop app's + * shared_preferences: favorites, watch positions and preferences. */ +(function () { + function read(k, d) { try { var v = JSON.parse(localStorage.getItem('ccloud:' + k)); return v == null ? d : v; } catch (e) { return d; } } + function write(k, v) { try { localStorage.setItem('ccloud:' + k, JSON.stringify(v)); } catch (e) {} } + + // Only what a card and the details screen need; sources are fetched again. + function slim(p) { + return { id: p.id, type: p.type, title: p.title, year: p.year, imdb: p.imdb, image: p.image, cover: p.cover, description: p.description, genres: p.genres, countries: p.countries, duration: p.duration }; + } + + window.Store = { + favorites: function () { return read('favorites', []); }, + isFavorite: function (p) { return read('favorites', []).some(function (f) { return f.id === p.id && f.type === p.type; }); }, + toggleFavorite: function (p) { + var list = read('favorites', []), i = -1; + list.forEach(function (f, j) { if (f.id === p.id && f.type === p.type) i = j; }); + if (i >= 0) list.splice(i, 1); else list.unshift(slim(p)); + write('favorites', list); + return i < 0; + }, + clearFavorites: function () { write('favorites', []); }, + + // Positions are keyed by video URL, so each quality and episode resumes on its own. + position: function (url) { return (read('positions', {})[url] || {}).t || 0; }, + savePosition: function (url, t, d) { + var all = read('positions', {}); + if (!d || t < 30 || t > d - 60) delete all[url]; + else all[url] = { t: Math.floor(t), at: Date.now() }; + var keys = Object.keys(all); + if (keys.length > 300) keys.sort(function (a, b) { return all[a].at - all[b].at; }).slice(0, keys.length - 300).forEach(function (k) { delete all[k]; }); + write('positions', all); + }, + clearPositions: function () { write('positions', {}); }, + + prefs: function () { return read('prefs', {}); }, + setPref: function (k, v) { var p = read('prefs', {}); p[k] = v; write('prefs', p); } + }; +})();