diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 0900666ba8..e0e16ce9c5 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,176 +1,70 @@ -name: Bug report -description: Follow the troubleshooting guide before reporting a bug -title: "[Issue]: " +name: Something doesn't work +description: Tell us what went wrong in MoonCast +title: "[Bug]: " labels: bug body: - type: markdown attributes: value: | - Thank you for taking the time to fill out this bug form! - - **READ ME FIRST!** - If you're here because something basic is not working (like gamepad input, video, or similar), it's probably something specific to your setup, so make sure you've gone through the Troubleshooting Guide first: https://github.com/moonlight-stream/moonlight-docs/wiki/Troubleshooting - - If you still have trouble with basic functionality after following the guide, join our Discord server where there are many other volunteers who can help (or direct you back here if it looks like a Moonlight bug after all). https://moonlight-stream.org/discord - - type: textarea - id: describe-bug - attributes: - label: Describe the bug - description: A clear and concise description of what the bug is. - validations: - required: true - - type: textarea - id: steps-reproduce - attributes: - label: Steps to reproduce - description: Any special steps that are required for the bug to appear. - validations: - required: true - - type: textarea - id: affected-games - attributes: - label: Affected games - description: List the games you've tried that exhibit the issue. To see if the issue is game-specific, try streaming Steam Big Picture with Moonlight and see if the issue persists there. - validations: - required: true + Thanks for testing MoonCast! A few details help us find the problem quickly. - type: dropdown - id: other-clients + id: app attributes: - label: Other Moonlight clients - description: Does the issue occur when using Moonlight on PC or iOS? + label: Which app options: - - "PC" - - "iOS" + - MoonCast (phone, handheld, tablet) + - MoonCast TV (Android TV / Google TV) + - Both validations: required: true - - type: dropdown - id: settings-adjusted - attributes: - label: Moonlight adjusted settings - description: Have any settings been adjusted from defaults? - options: - - "Yes" - - "No" - validations: - required: true - - type: textarea - id: settings-adjusted-settings - attributes: - label: Moonlight adjusted settings (please complete the following information) - description: If the settings have been adjusted, which settings have been changed? - validations: - required: true - - type: dropdown - id: settings-default + - type: input + id: version attributes: - label: Moonlight default settings - description: Does the problem still occur after reverting settings back to default? - options: - - "Yes" - - "No" + label: MoonCast version + description: Settings, About (for example 1.0-beta1) + placeholder: 1.0-beta1 validations: required: true - - type: dropdown - id: gamepad-connected + - type: input + id: device attributes: - label: Gamepad-related connection issue - description: Do you have any gamepads connected to your host PC directly? - options: - - "Yes" - - "No" + label: Device and Android version + placeholder: "Samsung Galaxy Z Fold, Android 16 / AYN Odin 2 Portal, Android 13 / Chromecast with Google TV" validations: required: true - type: dropdown - id: gamepad-on-screen + id: playing attributes: - label: Gamepad-related input issue - description: If gamepad input is not working, does it work if you use Moonlight's on-screen controls? + label: What were you playing + multiple: true options: - - "Yes" - - "No" + - PC (Sunshine) + - PS5 + - Xbox + - Nothing yet (menus, pairing, sign-in) validations: required: true - type: dropdown - id: gamepad-test + id: where attributes: - label: Gamepad-related streaming issue - description: | - Does the problem still remain if you stream the desktop and use https://html5gamepad.com to test your gamepad? - Instructions for streaming the desktop can be found here: https://github.com/moonlight-stream/moonlight-docs/wiki/Setup-Guide + label: Where were you options: - - "Yes" - - "No" - validations: - required: true - - type: input - id: android - attributes: - label: Android version - description: What is the Android version? - placeholder: e.g. Android 10 - validations: - required: true - - type: input - id: device - attributes: - label: Device model - description: What is the device model? - placeholder: e.g. Samsung Galaxy S21 - validations: - required: true - - type: input - id: server-os - attributes: - label: Server PC OS version - description: What is the PC OS version? - placeholder: e.g. Windows 10 1809 - validations: - required: true - - type: input - id: server-geforce - attributes: - label: Server PC GeForce Experience version - description: What is the GeForce Experience version? - placeholder: e.g. 3.16.0.140 - validations: - required: true - - type: input - id: server-driver - attributes: - label: Server PC Nvidia GPU driver version - description: What is the Nvidia GPU driver version? - placeholder: e.g. 417.35 - validations: - required: true - - type: input - id: server-antivirus - attributes: - label: Server PC antivirus and firewall software - description: Which antivirus and firewall software are installed on the Server PC? - placeholder: e.g. Windows Defender and Windows Firewall - validations: - required: true + - At home (same network as the PC or console) + - Away from home (another network, mobile data, a hotspot) + - Not sure - type: textarea - id: screenshots + id: what attributes: - label: Screenshots - description: If applicable, add screenshots to help explain your problem. If the issue is related to video glitching or poor quality, please include screenshots. + label: What happened + description: What you did, what you expected, and what happened instead. Screenshots or a short video help a lot. + placeholder: | + 1. Opened MoonCast and pressed Play on my PS5 + 2. It said "Connecting…" for a minute + 3. Then "Couldn't reach your PlayStation" validations: - required: false - - type: textarea - id: logs - attributes: - label: Relevant log output - description: | - Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. - render: Shell - validations: - required: false + required: true - type: textarea - id: additional + id: more attributes: - label: Additional context - description: Anything else you think may be relevant to the issue or special about your specific setup. - validations: - required: false - \ No newline at end of file + label: Anything else + description: Controller used, Wi-Fi or cable, how often it happens, anything you noticed. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 79ef0911ce..afd14705b0 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,37 +1,16 @@ -name: Feature request -description: Suggest an idea for this project -title: "[Feature request]: " +name: Idea or wish +description: Something you'd like MoonCast to do +title: "[Idea]: " labels: enhancement body: - - type: markdown - attributes: - value: | - Thank you for taking the time to fill out this feature form! - type: textarea - id: feature + id: idea attributes: - label: Is your feature request related to a problem? Please describe. - description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + label: What would you like + description: What you'd like to do, and why it would help you. validations: required: true - - type: textarea - id: solution - attributes: - label: Describe the solution you'd like - description: A clear and concise description of what you want to happen. - validations: - required: true - - type: textarea - id: alternatives + - type: input + id: device attributes: - label: Describe alternatives you've considered - description: A clear and concise description of any alternative solutions or features you've considered. - validations: - required: true - - type: textarea - id: screenshots - attributes: - label: Screenshots - description: Add any other context or screenshots about the feature request here. - validations: - required: false + label: Your device (if it matters) diff --git a/.github/auto-comment.yml b/.github/auto-comment.yml deleted file mode 100644 index e6985d222f..0000000000 --- a/.github/auto-comment.yml +++ /dev/null @@ -1,4 +0,0 @@ -issuesOpened: > - If this is a question about Moonlight or you need help troubleshooting a streaming problem, please use the help channels on our [Discord server](https://moonlight-stream.org/discord) instead of GitHub issues. There are many more people available on Discord to help you and answer your questions.

- This issue tracker should only be used for specific bugs or feature requests.

- Thank you, and happy streaming! diff --git a/.github/no-response.yml b/.github/no-response.yml deleted file mode 100644 index 156bc9e083..0000000000 --- a/.github/no-response.yml +++ /dev/null @@ -1,8 +0,0 @@ -# ProBot No Response (https://probot.github.io/apps/no-response/) - -daysUntilClose: 7 -responseRequiredLabel: 'need more info' -closeComment: > - This issue has been automatically closed because there was no response to a - request for more information from the issue opener. Please leave a comment or - open a new issue if you have additional information related to this issue. diff --git a/.github/stale.yml b/.github/stale.yml deleted file mode 100644 index df9115c27b..0000000000 --- a/.github/stale.yml +++ /dev/null @@ -1,14 +0,0 @@ -# ProBot Stale (https://probot.github.io/apps/stale/) - -daysUntilStale: 90 -daysUntilClose: 7 -exemptLabels: - - accepted - - bug - - enhancement - - meta -staleLabel: stale -markComment: > - This issue has been automatically marked as stale because it has not had - recent activity. It will be closed if no further activity occurs. -closeComment: false diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000000..11ea79c88e --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,55 @@ +# Builds both MoonCast apps from a clean clone, with lint, so a missing file or submodule shows up at once. +# The APKs are kept as the run's artifacts (debug builds, signed with the runner's debug key). +name: Build + +on: + pull_request: + push: + branches: [master] + workflow_dispatch: + +concurrency: + group: build-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + # third-party/chiaki-ng is private until launch: SUBMODULES_TOKEN (read access to it) lets the runner clone it + - uses: actions/checkout@v4 + with: + submodules: recursive + token: ${{ secrets.SUBMODULES_TOKEN || github.token }} + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Android SDK parts (the app's NDK, and chiaki-ng's NDK and CMake) + run: | + yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null + "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "ndk;29.0.14206865" "ndk;28.2.13676358" "cmake;3.22.1" > /dev/null + + - name: protoc (chiaki-ng's nanopb) + run: sudo apt-get update -q && sudo apt-get install -y -q protobuf-compiler python3-protobuf + + - name: Build and lint both apps + run: | + echo "sdk.dir=$ANDROID_HOME" > local.properties + BUILD=ci${{ github.run_number }} ./gradlew --no-daemon \ + assembleNonRootHandheldDebug assembleNonRootTvDebug \ + lintNonRootHandheldDebug lintNonRootTvDebug + + - uses: actions/upload-artifact@v4 + with: + name: mooncast-apks + path: app/build/outputs/apk/**/*.apk + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: lint-reports + path: app/build/reports/lint-results-*.html diff --git a/.gitignore b/.gitignore index e0ab95a517..219b2f8677 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,8 @@ build/ app/.externalNativeBuild/ # NDK stuff -.cxx/ \ No newline at end of file +.cxx/ +# MoonCast release signing: never commit the key or its passwords +keystore.properties +*.jks +*.keystore diff --git a/.gitmodules b/.gitmodules index 577509631d..35a02cd0c5 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,7 @@ [submodule "app/src/main/jni/moonlight-core/moonlight-common-c"] path = app/src/main/jni/moonlight-core/moonlight-common-c url = https://github.com/moonlight-stream/moonlight-common-c.git +[submodule "third-party/chiaki-ng"] + path = third-party/chiaki-ng + url = https://github.com/MoonCastDev/chiaki-ng-mooncast.git + branch = mooncast diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000..23833d190f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,464 @@ +# Changelog + +## 1.0-test257 (2026-10-23) +- **Add to home screen** (handheld): a PC game (ES-DE on xserv Gaming, for one), a PS5 or an Xbox gets an icon on the device's own home screen. Long press the tile (or X on Home), Add to home screen, and confirm in the launcher's box. The icon is your own picture, else the box art the tile shows, else its cover with the name. Tapping it opens MoonCast and starts the game here the usual way (it waits for the PC, offers to wake it, and asks first when another game runs). Android 8 and newer. (A box art held as a hardware bitmap crashed the icon drawing on the Odin; it's copied first.) + +## 1.0-test256 (2026-10-22) +- Fixes from the UI review: + - **Menus show all their options:** dialog rows are a little tighter and the room is worked out more truly, so a PC game's menu (Play here, Play on a TV, Change picture, Hide…) no longer hides options below a scroll. + - **One Frame pacing** for PC and PS5, in Picture ("PC and PS5…; Xbox paces itself"). The PlayStation group's own Frame pacing is gone; the PS5 now reads the Picture one (Moonlight's Cap FPS counts as Balanced). A PS5-only choice saved before is no longer used. + - **Add apps on Home** is a quiet tile (plain, with + and its name) instead of a bright cover. + - **Top bar:** "Playing on " takes at most half the screen and a long TV name ends in "…" (it could run off the screen upright). + - **Settings groups:** the selected group has the same rounded corner as the rest of the app, and the focus ring follows it. + +## 1.0-test255 (2026-10-22) +- UI review on the Odin (test254, 20 screens): Home, All games (every tab), Add apps, the game menus, Consoles and every Settings group. Measured: cold start 327 ms; janky frames 0.6% moving along Home, 0.4% scrolling All games, 0.5% moving down the Settings groups (5-12 ms frames). + - Fixed: lists ran on under the hint bar (Consoles' "Find my Xbox" drew over the controller name; a sliver of All games' next row showed below it). A view is clipped by its parent and the content area doesn't clip, so the All games grid, the list pages and the Settings list now sit in a frame that clips them. + +## 1.0-test254 (2026-10-22) +- UI review, round 2: + 1. **Phones** (no built-in controller): tabs, chips, Back and the settings groups are at least 48 dp tall (they were 23-35 dp on a 1080p phone), and text follows Android's Font size (up to 1.3x) on the usual layout. Handhelds with controls (the Odin) look the same. + 2. **One row style** for Consoles, Settings and dialogs: the same icon square (72, corner 18), title weight, subtitle spacing and a value on the right in the quiet colour. + 3. **Quick menu**: Quit game and other dangerous items are red on the handheld too, the hints read A then B on both apps, the upright sheet's list scrolls so the hints never fall off, and TV rows grow for a long label instead of being cut. + 4. **All games**: no shadow per grid tile (smoother scrolling with many PC games), and a redraw in the background keeps your place in All games and in Home's row instead of jumping back to the start. + 5. **TV**: lays itself out again when the display changes (HDMI mode, resolution) instead of keeping the old scale until a restart, and covers without box art use the handheld's colours (`Covers`), so a game has the same colour on both. + +## 1.0-test253 (2026-10-22) +- UI review, first round (handheld): faster and lighter, same look. + - Settings: moving down the groups rebuilt the whole screen at every step; it now waits until focus rests on a group (160 ms). + - Home and All games work out the games list once per redraw instead of 3 times, and "On this device" keeps its list, app names and icons in memory (refreshed when MoonCast comes back to the front, for installs and updates meanwhile). + - Tile pictures: which tiles have one is read from disk once, not checked per tile per redraw; the picture cache counts bytes (about 24 MB), and each picture's colour is worked out once. + - Add apps loads the app list and icons off the main thread; switching Games / All apps reuses them. + - The focus ring's breathing stops while MoonCast isn't in front (it redrew the ring 60 times a second in the background). + - Focus and scroll listeners were added again on every rotation or fold; now once. + - Stats corner and Quick menu: the numbers are only set when they changed (a new text lays the player out again). + - L1/R1 on All games skipped the This tab. + - The focus ring fits the Add apps chips and app cells. + - TalkBack: tiles, the round buttons and Back say what they are. + +## 1.0-test252 (2026-10-22) +- Adding more apps was hard to find (only All games, This , Add apps). Now also: an **Add apps** tile on Home, always there before All games, and **Settings, Handheld, Apps on this ** (shows how many are added). All three open the same picker. + +## 1.0-test251 (2026-10-22) +- Added Android apps "disappeared after a while" (Saleh on the Odin): + - **Home** kept the last-played games first and cut the row at ten, so after about ten games played your added apps (and the PS5 and Xbox) fell off Home. Now the row is: what's running, the last five played, then every console and every added app (they always keep a place), then PC games up to ten. + - **Add apps** started with the first app selected and the hints said "A OK", so an A meant to close the page took that app out (Azahar, first in the list). Focus now starts on Done, the hints say "B Done, A Add or remove", and each tap says " added" or " removed. Tap it again to add it back". + +## 1.0-test250 (2026-10-22) +- **Change picture** for any game tile on the handheld: a PC game, a PS5, an Xbox or an Android app. Long press the tile (or X on Home), Change picture, and pick an image with Android's photo picker. MoonCast keeps a resized copy (longest side 900 px) in its own storage and shows it filling the tile, darkened at the bottom so the name stays readable; the background wash takes its colour. **Reset picture** goes back to MoonCast's own look. + - `home/CustomArt.kt`. PS5 and Xbox tiles now have a menu too (Play, Change picture). + +## 1.0-test249 (2026-10-22) +- On this device, a better look: + - **Tiles** take their colour from the app's own icon (its strongest hue, toned down so the name stays readable), with a big faded copy of the icon leaning in from the corner and the icon itself larger, above the name. The background wash follows the same colour. Grey or white icons keep the usual covers. + - **Add apps** is a wide page with a grid of the apps' real icons and names instead of a text list: Games / All apps chips with counts, "N added" at the top, and a ✓ and green ring on what's added. A tap adds or removes at once without the grid jumping back to the top. MoonCast's own copies (a beta next to a test build) aren't listed. + +## 1.0-test248 (2026-10-22) +- On this device, tested on the Odin (test247): the tab, the picker (emulators listed first as games), adding, opening (Back returns to MoonCast) and Remove from MoonCast (the app stays installed) all work. The Add apps tile showed only "+": it now says Add apps. + +## 1.0-test247 (2026-10-22) +- **On this device** (handheld): Android apps you pick show in MoonCast next to your PC, PS5 and Xbox games, like ES-DE's Android apps. Nothing is added by itself. + - All games has a new tab, **This ** (This Odin), starting with **Add apps**: the installed apps, games first, each ticked or not. A pick adds or removes it straight away and the list stays open. + - The apps are tiles with their own icon and an "Android" badge, on Home and in All games, and open the app. Long press (or X on Home): **Open** or **Remove from MoonCast** (the app stays installed). + - `home/DeviceApps.kt`; the manifest's `` lets MoonCast see launcher apps only (no QUERY_ALL_PACKAGES). + - Play on TV doesn't apply to these yet: they open on the device (#99). + +## 1.0-test246 (2026-10-22) +- **Anonymous usage check-in** (opt-in), so the project can count daily and monthly active users (`home/Usage.kt`, TelemetryDeck's ingest API v2, no SDK): + - Official builds ask once, a moment after the home screen opens: "Help improve MoonCast?" with **No thanks** selected. Settings, About has the switch afterwards. + - With yes: at most once a day, one signal with the app version, TV or handheld, Android version, device model, and whether PC, PS5 or Xbox was played since the last one. The install is a random ID made on the device, sent hashed. No names, addresses, games or accounts. + - Only builds made with an app ID (`-PmcTelemetryAppId` or `MOONCAST_TELEMETRY_APP_ID`, plus the TelemetryDeck organization's namespace, `-PmcTelemetryNamespace` or `MOONCAST_TELEMETRY_NAMESPACE`, sent to `/v2/namespace//` like TelemetryDeck's SDKs) have it; everything else, forks and this test build included, never asks and never sends. Debug builds send in TelemetryDeck's test mode. + - TV: `confirm()` can pick its no button and run something on no. +- README: install section for the beta's file names, **Obtainium** links for automatic updates (prereleases on, release titles `^MoonCast [0-9.]+( |$)`, one APK filter per app), and a **Privacy** section. + +## 1.0-test245 (2026-10-22) +- **"On battery, stream at" is removed** (Settings, Handheld, and `home/BatteryCap.kt`, added for #65 in test237). It saved little: the screen uses most of the battery and doesn't change, and Native resolution already avoids streaming more pixels than the screen shows. With it goes test244's Auto-bitrate fix. A saved setting is simply ignored. + +## 1.0-test244 (2026-10-22) +- **Quit prompts pick Cancel.** "Quit X and start Y?", "Quit game?", Unpair and Remove now have Cancel selected, on the handheld and the TV, so pressing A (or OK on a remote) by habit doesn't do them. On 2026-10-21 an A on the pre-selected "Quit and start" quit Ryujinx. Wake it stays selected, since it's harmless. + - Handheld: while a dialog is open the hints say "B Cancel, A OK" instead of the page's "A Start", and a tapped A hint presses the dialog's selected button, not the game behind it. + - Handheld: a PC game moves to the front of Recent only once it really starts, not when the prompt opens, so Cancel no longer reshuffles the tiles. +- **On battery with bitrate Auto:** the bitrate was still the one for the uncapped size (80 Mbps for 4K on a 720p stream). It's now worked out for the capped size (10 Mbps for 720p60, Moonlight's usual Auto). + +## 1.0-test243 (2026-10-22) +- Touch screen (Quick menu, PC), found by Saleh on test242: + - The PC's pointer stayed in the picture with touch Off. With Off, MoonCast now moves it to the middle of the right edge, where it's off the screen, when the stream starts and when you switch to Off. (Not a corner: Windows' bottom-right corner is Show desktop.) + - New: Settings, Handheld, **Touch screen** (Off, Mouse, Touch, default Off) sets how each PC stream starts. The Quick menu still changes it for the game you're in. +- Quick menu: with more items than fit (the PC menu now has up to 9), the last ones were out of reach with a controller, so Hibernate PC looked gone. The list now scrolls with the selection, on the handheld and the TV (the TV's list didn't scroll at all). + +## 1.0-test242 (2026-10-22) +- Hibernate PC worked on Ahmed's PC (test240/241), but MoonCast stayed on the last picture, frozen: a hibernating PC just stops sending video, and Moonlight only notices after a long timeout. After Hibernate PC, MoonCast now watches the video: after 3 seconds without a new frame it checks whether the PC still answers (its http port), and if not it leaves the stream with " is hibernating". The game isn't quit; it stays as it was in the hibernated PC. + +## 1.0-test241 (2026-10-22) +- In-game Quick menu, PC: **Touch screen** (Off, Mouse, Touch), for the stream it's in. Every stream starts Off, so a hand on a handheld's screen can't click on the PC. + - Mouse: the screen is a trackpad (Moonlight's relative touch: drag moves the pointer, tap clicks, two fingers right-click and scroll). + - Touch: the pointer goes where the finger is and a tap clicks there (Moonlight's absolute touch). + - A stylus and a real mouse work in every mode. The on-screen controller isn't affected. +- Moonlight's "Use the touchscreen as a trackpad" (All settings) is gone; the Quick menu's switch replaces it. + +## 1.0-test240 (2026-10-22) +- Hibernate PC still didn't work on Ahmed's PC: the Run box never opened, so Win + R didn't arrive (games often turn the Windows key off while they play). It now opens Start with Ctrl + Esc, which doesn't use the Windows key, types `shutdown /h` into Start's search and presses Enter 2 seconds later. + +## 1.0-test239 (2026-10-22) +- Hibernate PC didn't work on Ahmed's PC (Task Manager and Streaming status did). The power menu's U and H only match on an English layout and English Windows. It now opens Run (Win + R), types `shutdown /h` as text (Sunshine types text the same on any keyboard layout) and presses Enter. That also works without Hibernate ticked in Power Options; it needs hibernation itself on, which most PCs have (`powercfg /h on` turns it on). + +## 1.0-test238 (2026-10-22) +- In-game Quick menu, new items: + - **Streaming status** (PC, PS5 and Xbox): turns the stats corner on or off right away, and saves it as Settings' "Stats while playing". The PC shows Moonlight's stats; PS5 and Xbox get a new one-line corner (frame rate, network delay, bitrate, lost packets, and decode time on PS5). + - **Task Manager** (PC): presses Ctrl + Shift + Esc on the PC, which opens Task Manager on Windows. It does nothing on Linux or macOS PCs. + - **Hibernate PC** (PC): asks first, with Cancel picked, then presses Win + X, U, H: the Windows power menu's Hibernate. Windows only lists Hibernate when it's turned on in Power Options; without it, the power menu stays open on the PC. When the PC goes down within a minute, the stream ends with " is hibernating" instead of an error. Wake the PC from MoonCast's home. + +## 1.0-test237 (2026-10-21) +- Handheld, "On battery, stream at" (Settings, Handheld) now works (#65). It was saved but nothing read it. Unplugged, PC and PS5 streams go no higher than the chosen height (1080p, 720p or 540p); plugged in, the Picture settings apply as before. + - PC: the size keeps the stream's shape (Native 2448 × 1696 at 720p becomes 1040 × 720). The fold check compares the screen with the size before the cap, so a capped stream doesn't keep reconnecting at the screen's size. + - PS5: the lower of the PS5's own setting and the cap. + - Xbox: not affected; its resolution comes from the Xbox service. + +## 1.0-test236 (2026-10-21) +- Found on the JAL TV (AQUOS, Android 11, MediaTek with a Mali GPU), PS5 over PlayStation Network at 720p: + - Super resolution didn't start there ("eglChooseConfig"): the GPU has no OpenGL ES 3 setup for both a window and an off-screen surface. FSR now tries several setups, and without an off-screen surface it works surfaceless. Before, it fell back to the plain picture, as designed. + - PS5 decoder: the TV's decoder (OMX.MS.HEVC.Decoder) refused the low-latency settings, and MoonCast dropped all of them. Now it drops them one step at a time (vendor settings, then MediaTek's low-latency decode, then Android's standard low-latency mode, then real-time priority), so a decoder keeps as many as it takes. The log says which level was used. +- The same JAL TV test passed: PS5 over PSN on OpenSSL 3 (connected in 84 s including waking the console), 60 fps, 25 ms network delay, no lost packets, Decode time 20.5 ms, and Put PS5 to rest from the Quick menu. + +## 1.0-test235 (2026-10-21) +- Super resolution now works on PC and Xbox streams too, not only PS5. The FSR passes are shared (`FsrPasses`), used by: + - PC: Moonlight's decoder renders into FSR's surface. FSR stays off with HDR, since its passes are 8-bit. The screen surface keeps the screen's size instead of the stream's, so FSR has room to upscale into; with Stretch, the picture is stretched by FSR. + - Xbox: WebRTC's renderer draws each frame through FSR's drawer (`FsrGlDrawer`), with WebRTC's hardware scaler off so the surface keeps the view's size. Frames that aren't hardware-decoded, and GPUs without OpenGL ES 3, are drawn by WebRTC's own drawer, as before. + - PS5: as in test234. +- In all three, FSR only takes effect when the stream is clearly smaller than the screen; otherwise the picture shows as before. + +## 1.0-test234 (2026-10-21) +- Super resolution (Settings, Picture, both apps): AMD FidelityFX Super Resolution 1.0, for the PS5 first (mockups in docs/mockups/fsr). With it on, and the stream clearly smaller than the screen (a PS5's 1080p or 720p on the Fold's 2448 × 1696, say), each frame takes three GPU passes before it shows: + - a copy of the decoder's frame at the stream's size; + - EASU, FSR's edge-adaptive upscale to the screen's size; + - RCAS, FSR's sharpening, at the new Sharpening setting (0 to 100%, 60% by default). +- How it fits in: + - The shaders are ports of AMD's ffx_fsr1.h (MIT) to OpenGL ES 3.0. Its 4-texel gathers are 4 texel reads, as ES 3.0 has no textureGather. + - Frames keep the decoder's presentation time, so Frame pacing still works. + - On a foldable, a fold only moves FSR's output; the decoder keeps drawing into FSR. + - Off by default. When it's not worth it (the stream is as big as the screen) or the GPU can't set it up, the stream shows directly, as before. +- About, Open-source licences: AMD FidelityFX Super Resolution 1.0 (MIT). + +## 1.0-test233 (2026-10-21) +- PS5: frame pacing (Settings, PlayStation, Frame pacing: Lowest delay, Balanced, Smoothest), like the PC's. + - Lowest delay (the default, as before): each frame shows the moment it's decoded, so frames that arrive unevenly over Wi-Fi show unevenly. + - Balanced and Smoothest: each frame is handed to Android to show one stream frame interval after the last (1/60 s at 60 fps), so they land on evenly spaced screen refreshes. That costs at most 1 (Balanced) or 3 (Smoothest) frames of extra delay. When frames pile up past that, it shows the next one at once, so the delay can't build up. + +## 1.0-test232 (2026-10-21) +- PS5: a decoder setting (Settings, PlayStation, Decoder: Low latency, Ultra low latency, Standard). The PS5 player decodes in chiaki-ng, which set up the device's video decoder with nothing but the format and size. It now gets the same low-latency settings as MoonCast's PC player. + - Low latency (the default): Android's standard low-latency mode, real-time priority, MediaTek and Amlogic's low-latency decode, and Qualcomm's low-latency decode. + - Ultra low latency: Artemis' presets on top, notably software output fencing on Snapdragon 8 Gen 2 and newer (the Odin 2 Portal's chip), plus the MediaTek and NVIDIA presets. + - Standard: as before. + - A decoder that refuses any of these settings starts again without them. +- PS5 Quick menu: a fifth number, Decode time (from a frame reaching the decoder to its picture coming out), so the settings can be compared. + +## 1.0-test231 (2026-10-21) +- Handheld, Settings, Picture: three settings that were only in About, All settings (Moonlight's screen); MoonCast TV already had them. + - **Unlock all frame rates** (under Frame rate): adds 144 and 240 to Frame rate, and Custom frame rate goes up to 240. Turning it off brings a rate above 120 back down to 120. + - **Custom bitrate** (under Bitrate): any bitrate from 0.5 to 150 Mbps in 0.5 steps, as Moonlight's slider allows. Bitrate shows "Custom" then. + - **Full range colour** (under HDR): experimental, off by default. + +## 1.0-beta1 (2026-10-21) +- The first beta for testers: test230's app as a signed release build (version 1.0-beta1, code 991, so 1.0 installs over it), published as a pre-release. +- Release builds: code shrinking (R8) had removed what PS5 Remote Play's native code reads by name (`ConnectInfo` with its `psnToken` and `videoProfile`, and `Session`), so a release build couldn't start a PS5 session. Debug builds don't shrink, so the test builds never showed it. `proguard-rules.pro` keeps `com.metallic.chiaki.**` and Google's WebRTC (`org.webrtc.**`, Xbox). All 48 fields and 9 methods the PS5 library looks up are now present in the release build. +- `scripts/release.sh`: `VERSION_NAME=1.0-beta1 VERSION_CODE=991` builds a beta; `app/build.gradle` takes `-PmcVersionName` and `-PmcVersionCode`. +- Issue forms: MoonCast's "Something doesn't work" (app, version, device, what was played, home or away, what happened) and "Idea or wish" replace Moonlight's. Moonlight's issue bots' settings (stale, no-response, auto-comment, which pointed to Moonlight's Discord) are gone. + +## 1.0-test230 (2026-10-21) +- PS5: leaving the PS5 screen while it connects really stops the attempt. Before, an attempt through PlayStation Network carried on in the background for up to a minute and could still connect after you'd left. It then held the console, so the next try got "Connection refused" and the PS5 refused its stream (seen on the Odin). + - Leaving cancels the PSN attempt in flight at once (a new `psnCancel` in chiaki-ng-mooncast). + - An attempt that finishes after you've left is dropped, never started. + - A new PS5 screen waits (at most 15 s, "Finishing the last attempt…") for the last one's attempt to end. +- PS5: when a console PSN just woke, or one just switched on, answers on the home network, MoonCast switches from PSN to the home connection: seconds instead of a minute-long hole-punch. It asks the home network every 4 s while a PSN attempt runs. + +## 1.0-test229 (2026-10-21) +- Version: MoonCast's own numbering. The version is 1.0 (code 1000) instead of Moonlight's 12.2 (315); test builds are now 1.0-testN. The code only ever goes up (1.0.1 = 1001, 1.1 = 1100), so each release installs over the one before it. +- Release signing: release builds are signed with MoonCast's own key, which is never in git. `keystore.properties` sits next to `settings.gradle`, or `MOONCAST_KEYSTORE` names it; without it, release builds come out unsigned, as before. `.gitignore` keeps out `keystore.properties`, `*.jks` and `*.keystore`. +- `scripts/release.sh`: builds both signed release APKs (`out/MoonCast-.apk`, `out/MoonCastTV-.apk`) and prints their signing certificate. +- CI: `.github/workflows/build.yml` builds both apps and runs lint from a clean clone on every pull request and every push to master, keeping the APKs and lint reports. While chiaki-ng-mooncast is private, the runner needs a `SUBMODULES_TOKEN` secret to clone it. + +## 12.2-test228 (2026-10-21) +- PS5: the encryption library inside chiaki-ng is OpenSSL 3.5.9 (the 3.5 long-term line, security fixes until 2030) instead of 1.1.1w, which has had no security fixes since September 2023. It secures the PSN sign-in, playing away from home and the PS5 connection itself. + - chiaki-ng's own unit tests pass on OpenSSL 3.5 (114 of 114: PSN certificate chains, stream encryption, registration). The console's key exchange isn't among them, so a PS5 session is the real test. + - The PS5 library is about 3 MB bigger (OpenSSL 3 is larger). +- About, Open-source licences: OpenSSL 3 is under Apache-2.0 (1.1.1 had its own licence). +- Build: buildChiaki also runs when chiaki's cmake files change. +- Build: chiaki's build script starts the native build clean when chiaki's build files change. The OpenSSL upgrade otherwise failed on a machine that had built before (old configured headers left in the cache). + +## Unreleased +- CONTRIBUTING.md: how to report problems, build, and send changes (one topic per pull request, CHANGELOG in each, lint and CI, the chiaki-ng submodule, no game pictures or secrets, licences). SECURITY.md: report privately through GitHub's security advisories, and what MoonCast keeps where. +- README: rewritten for MoonCast (it was still Moonlight's). It covers what the two apps play and how, the features, install and Sunshine's gamepad setting, building with the chiaki-ng submodule, the licences and the credits. + +## 12.2-test227 (2026-10-21) +- About, Open-source licences (both apps; mockups in docs/mockups/about): the 23 open-source projects MoonCast is built on, grouped The app, PS5, Xbox and More, each with what it does and its licence. Pressing one shows its full licence text; Back returns to About. +- About, Source code: github.com/MoonCastDev, GPL-3.0 with the PS5 and Xbox parts under AGPL-3.0 (on a phone it opens the page). +- The licence texts ship in the app (assets/licences, 176 KB; the GPL, AGPL and Apache texts once each), and NOTICE in the repo lists the same projects. +- Xbox: XStreaming, whose protocol the Xbox code follows, is AGPL-3.0 (not MIT, as assumed before), so that part is listed as AGPL. + +## 12.2-test226 (2026-10-21) +- PS5 on Android 5 (5.0 and 5.1): starting a PS5 stream crashed. Picking the home bitrate asked Android for the active network, which only exists from Android 6, and the error it throws there isn't one the code caught. Below Android 6 it now skips the Ethernet check and goes by Wi-Fi speed. +- MoonCast TV on Android 5: the service that keeps the TV reachable for the phone crashed as it started (it looked up the notification service the Android 6 way). +- Only MoonCast TV tells Android it supports TVs (it has the TV launcher entry). The handheld app, which has none, no longer claims it. This was also the build check (lint) error. + +## 12.2-test225 (2026-10-21) +- Build: PS5 Remote Play's native libraries are now built from source. Before, `libchiaki-jni.so`, `liboboe.so` and `libc++_shared.so` sat only on the build machine (git ignores `jniLibs`), so a fresh clone built an app without PS5, and the chiaki-ng changes behind them were in no repository. + - The patched chiaki-ng is a submodule, `third-party/chiaki-ng` (MoonCastDev/chiaki-ng-mooncast: upstream chiaki-ng plus one MoonCast commit, AGPL-3.0). + - A `buildChiaki` task runs before every build and calls `third-party/chiaki-ng/mooncast/build-jni.sh`; it does nothing when chiaki's sources haven't changed. + - Needs `git submodule update --init --recursive`, CMake 3.22.1 in the Android SDK and protoc on the PATH. +- No change to the app itself. + +## 12.2-test224 (2026-10-20) +- On-screen controller, Xbox skin (mockup vx2 in docs/mockups/onscreen-pad). Settings, Controls, "On-screen controller" now goes Off, PlayStation, Xbox, Classic. + - The real Xbox arrangement: left stick up on the left, the d-pad lower beside it, A B X Y on the right with the right stick below. + - Same sizes, spacing and transparency as the PlayStation skin: LT over LB, RT over RB, the Xbox (guide) button between the sticks, View, Share and Menu along the bottom middle, MoonCast's menu key at the top middle. + - A B X Y in their Xbox colours; a one-piece d-pad on a round base; the triggers shaped like triggers. + - The PC is told it's an Xbox pad (when no real controller is attached), so Share works. +- The PC needs Sunshine's gamepad setting on "auto" (its default) for either skin to show up as that pad; a forced type (like "xone") wins. + +## 12.2-test223 (2026-10-20) +- On-screen controller, PlayStation skin, after the first try (mockups v9 to v14 in docs/mockups/onscreen-pad): + - The d-pad and the shape buttons sit higher (60% down the screen instead of 73%); the sticks moved in closer under them. + - The d-pad, the shape buttons and the shoulder keys are 10% bigger, with 5% more room between them, so a thumb doesn't catch the wrong one. + - The shoulder keys are stacked in the top corners: L2 over L1, R2 over R1. + - A PS button between the sticks, above the touchpad, as on the DualSense (the controller's home button). + - Half see-through by default. Settings, Controls, "Controller transparency" (shown with the PlayStation skin): 0% to 85%. + - Create, Touchpad and Options labels are drawn solid with a dark edge, so they read over bright scenes. + +## 12.2-test222 (2026-10-20) +- On-screen controller, PlayStation skin (#74, first step), for PC games. Settings, Controls, "On-screen controller": Off, PlayStation or Classic (Moonlight's outline keys, as before). + - Same engine as Moonlight's on-screen controller (touch, sliding a thumb from key to key, what's sent to the PC); only the look and the layout are new. + - The look of the v8 mockup: dark keys over faint see-through plates, the game showing through. + - Laid out on Delta's landscape positions: L1 L2 ... R2 R1 along the top, the d-pad lower left, the shape buttons lower right, both sticks low between them, Create, touchpad and Options along the bottom middle, and MoonCast's menu key at the top middle (opens the Quick menu; it replaces Moonlight's gear, which sat where L1 is). + - A very light tick under the finger on each press, each new d-pad direction and each stick touch. + - The PC is told it's a PlayStation pad (when no real controller is attached), so games show the PlayStation symbols and the touchpad click works. Double tap a stick for L3 / R3. + - PS5 and Xbox Remote Play don't use it yet. + +## 12.2-test221 (2026-10-20) +- MoonCast's own logo (R2, the synthwave moon: a sunset-striped moon on a night sky, mockups in docs/mockups/logo on design/handheld-compact) replaces Moonlight's wheel everywhere it showed: + - the app icon on every device (all sizes; Android's adaptive icon with a night-sky background, and a one-colour version for themed icons); + - the Android TV banner, which still said "Moonlight Game Streaming": now the logo with "MoonCast, Play your PC, PS5 and Xbox"; + - the TV app's channel icon on the Android TV home screen; + - the handheld's Home: the logo instead of the orange circle with an initial. +- Moonlight's unused images (its app icon and the OUYA banner) are gone. + +## 12.2-test220 (2026-10-20) +- Handheld on its side: everything sat far in from the left. Android reserves the whole edge for the small camera hole (104 px on the Fold's cover), and MoonCast's own margin came on top of it. Now the content keeps all of a system bar but only as much of the cutout as its margin doesn't already cover, so it moves about 90 px left and still clears the camera. + +## 12.2-test219 (2026-10-20) +- Handheld on its side (the Fold's cover screen): the background and its colour wash stopped short of the camera cutout and the status bar, leaving a strip down the left and across the top. They now go edge to edge; the content and dialogs stay clear of the bars and the cutout. +- The row of tiles fades out at its edges, so a tile cut at the edge reads as "more this way" instead of a hard cut. +- On a touch screen (no controller connected) the round buttons always show their labels, spaced wider so long ones don't touch, and Home shares out spare height (a third above, the rest below) on its side too, as it does upright. + +## 12.2-test218 (2026-10-20) +- Handheld: "Hidden games" in a PC's menu (Consoles), when it has any: lists the games hidden from All games (also ones the PC no longer lists), each shown again with a press, or "Show all". Hiding a game now points there. +- "Manage PCs" (Moonlight's own PC list) is gone from the handheld, in Consoles and in Settings, About: everything it offered for players is in MoonCast now (pair, wake, unpair, remove, resume, quit, games, network test, add by address, the away address, and now hidden games). +- The two remaining ways into Moonlight's PC list open MoonCast's home instead: the full settings screen after a language change (Android 12 and older), and a shortcut that names a PC but no game. + +## 12.2-test217 (2026-10-20) +- Clean-up: the test-only fold trace (the yellow box on PC streams, test211) is gone; the fold steps are still written to the log ("MoonCast fold:"). +- No "Moonlight" anywhere you can see it, in both apps: + - Handheld: "Manage PCs" now says "The full PC list" (its badge is PC, not ML); hiding a game says to show it again from the PC's full game list; "All Moonlight settings" is "All settings" (also on the TV); a pad-link error says "The PC manager didn't start". + - Text from Moonlight (network test, decoder crash, a few setting descriptions, the language setting, the local-network permission) now says MoonCast, in every language. The network test no longer tells you to install Moonlight's Internet Hosting Tool: to play away from home it says to connect a VPN to home or open Sunshine's ports. + - No more Help buttons that opened Moonlight's website: the old error dialogs, the PC list's help button and its GameStream FAQ, and the network test's Help. + +## 12.2-test216 (2026-10-20) +- Foldables: one stream shape for both screens, so folding or unfolding mid-game just re-fits the picture, instantly, with no reconnect and no game restart. MoonCast remembers the screens it runs on; once it has seen two clearly different shapes (the Fold's cover, 1.58 : 1, and its inner screen, 1.32 : 1), Native is a shape between them (1.45 : 1, 2448 × 1696 on the Fold). Picture shape decides how it fills each screen: Fill (the default on a foldable) trims about 8% (4% per edge), Stretch keeps everything and bends it about 8%, Fit leaves thin bars. Settings shows "Native suits both your screens". Until both screens have been seen, the first fold still restarts the game once (test215); after that a stream that ends on a fold only reconnects. Phones and handhelds with one screen keep the screen's own Native. + +## 12.2-test215 (2026-10-20) +- PC streams with Native, at Saleh's request: folding or unfolding mid-game now restarts the game by itself on the new screen ("Restarting the game for this screen…"): it quits the game on the PC and launches it again, so the PC uses the new screen's shape and the picture fills it. A reconnect alone kept the other screen's size inside the new frame. Unsaved progress in the game is lost on each fold or unfold. The Quick menu's "Restart at this size" and its hint (test213) are gone. + +## 12.2-test214 (2026-10-20) +- PS5 streams now keep the picture's 16:9 shape and follow Picture shape and Zoom (Settings, Video), like the PC and Xbox players: the video view used to fill the whole screen, so the PS5's 16:9 picture was stretched to the screen's shape (a little on the Fold's cover, about a third taller on the open screen). Fit keeps bars, Fill crops to cover the screen, Stretch fills it. It re-fits by itself when the screen changes size, so a fold or unfold keeps the right shape. On a 16:9 TV, Fit looks as before. +- Xbox streams: Stretch is worked out again when the screen changes size (it was worked out once at the start). Fit and Fill already followed the screen. +- PS5 and Xbox never get "Restart at this size": a console streams 16:9 at its own resolution whatever the phone asks for, and Remote Play can't restart a console game. (A fold doesn't hide the app on the Fold, it only swaps the video surface, which the PS5 and Xbox players already carry over; to confirm on the device, #68.) + +## 12.2-test213 (2026-10-20) +- PC streams: after a fold or unfold reconnects the stream, the PC can still be drawing at the other screen's size (a PC sets its resolution when a game launches), so bars stay. A hint now says so, and the Quick menu (Back, or Select + Start + LB + RB) has "Restart at this size": it quits the game on the PC and starts it again here, so it launches in this screen's shape. Unsaved progress in the game is lost, so it's only offered, never done by itself. Only PC streams, only after a fold reconnect. + +## 12.2-test212 (2026-10-20) +- Fix, found with the test211 trace on the Fold ("Native false"): picking Resolution in Settings could leave it showing Native with the screen's size saved but Native's switch off, so the stream kept that size after a fold. The page redrew before the pick's switch was saved, and the redraw put the screen size back. The pick is now saved first. A saved size that is the current screen's own now counts as Native, which repairs phones already in that state (open MoonCast or Settings on that screen once). The test trace stays for one more try. + +## 12.2-test211 (2026-10-20) +- TEST ONLY: a small yellow text box at the top left of a PC stream traces what the phone reports, with the time: FOLDED or UNFOLDED and the stream's size at start, each screen-size change, the video surface lost or replaced, the stream screen hidden or shown again, and the check 1.5 seconds after a change (screen size, stream size, connected, Native, and whether it reconnects). To find why unfolding doesn't reconnect; it comes out again once that's fixed. + +## 12.2-test210 (2026-10-20) +- Fix (test207-209): unfolding mid-game kept the stream going at the cover's size and never reconnected. The size check ran only at the moment the screen changed, when the new screen's size may not be known yet, and it measured the display's mode, which can still report the other panel for a moment. It now checks again once the screen settles, after every change, and measures the app's own full-screen window. The "MoonCast:" log lines show the sizes it saw. + +## 12.2-test209 (2026-10-20) +- Fix (test208): unfolding mid-game still left a stopped stream. On the Fold the screen switch replaces the video surface without hiding the app, and losing the surface ends the stream (Moonlight's own behaviour); the new surface only ever started the first connection. Now a new surface after one lost mid-stream reconnects, at the new screen's size with Native ("Fitting the stream to this screen…"). The other two ways in (shown again within 2 seconds, or a size change) stay, and only one reconnect happens. Log lines starting "MoonCast:" trace it. + +## 12.2-test208 (2026-10-20) +- Fix (test207): unfolding the Fold mid-game ended the PC stream instead of reconnecting. Switching screens hides the app for a moment, and the stream screen closed as soon as it was hidden (Moonlight's own behaviour), so the reconnect never ran. Now, when hidden, it ends the stream as before but waits 2 seconds before closing; if it comes back in that time (a fold or unfold), it reconnects at the new screen's size ("Fitting the stream to this screen…"). Leaving the app or turning the screen off closes it 2 seconds later, as before. PS5 and Xbox streams still end on a fold (issue filed). + +## 12.2-test207 (2026-10-20) +- PC streams with Resolution on Native: folding or unfolding a foldable mid-game now reconnects by itself at the new screen's size, about 1.5 seconds after the screen settles ("Fitting the stream to this screen…"; a short blackout, the game keeps running on the PC). The Quick menu's "Fit this screen" is gone. Not in picture-in-picture. (With a PC that sets its own resolution only when a game launches, the PC keeps its desktop size until the game is started again.) + +## 12.2-test206 (2026-10-20) +- Handheld: no 16:9 choice any more. Resolution is 720p, 1080p, 1440p, 4K or Native, and Native is always the whole screen in its own shape (an open Fold 2448 × 1848, folded 1968 × 1248; on other handhelds their full screen, rounded to multiples of 8), following the screen a stream starts on. An earlier Native (16:9) becomes this one. + +## 12.2-test205 (2026-10-20) +- Handheld, from trying test204 on the Fold: "Native" showed twice (in Resolution and in its own Native shape row). There's now one list: 720p, 1080p, 1440p, 4K, 16:9 and Native. Native is the whole screen in its own shape (an open Fold 2448 × 1848, folded 1968 × 1248); 16:9 is the widest 16:9 that fits (2448 × 1376 / 1972 × 1108). Both follow the screen a stream starts on. On a 16:9 screen they're the same, so only Native shows. The Native shape row is gone. A new install starts on Native; an earlier Native stays 16:9 until changed. + +## 12.2-test204 (2026-10-20) +- Handheld: "Native shape" under Resolution (with Native on): 16:9 (as before, the largest 16:9 that fits) or Whole screen, which streams in the screen's own shape, rounded to multiples of 8 for the PC's display modes: an open Fold 2448 × 1848 (about 4:3), folded 1968 × 1248. The picture then fills the screen with no bars and goes under the camera cutout too; the PC draws its desktop and menus (ES-DE) in that shape, and a 16:9 game shows its bars on the PC side instead. "Fit this screen" in the Quick menu uses the chosen shape. + +## 12.2-test203 (2026-10-20) +- Handheld: Resolution "Native" now means the screen you play on, each time a PC stream starts: on a foldable, the cover screen's size when folded and the inner screen's when open (2448 × 1376 instead of the cover's 1972 × 1108 on the Fold). It used to be saved once, as the size of whichever screen the app first opened on. A size you pick (720p to 4K) stays fixed. An earlier saved screen size counts as Native. +- In-game Quick menu (PC streams): after folding or unfolding mid-game, "Fit this screen" reconnects at the new screen's size; the game keeps running on the PC. It shows only with Native on and when the size really differs. (With a PC that sets its own resolution when a game launches, the PC side stays at the size it launched at until the game is started again.) + +## 12.2-test202 (2026-10-20) +- Handheld polish: a dialog with many options (a PC's menu) showed four and cut the fifth near its top edge. It now shows as many as the screen has room for, and when it has to scroll it cuts a row through the middle so it's plain there's more. The symbols in the Consoles list (+) fill their squares like the dialogs'. + +## 12.2-test201 (2026-10-20) +- Handheld: a PC keeps a home address and an address for away (a name like home.example.com, or a public IP), and this device tries both and uses whichever answers. The PC's menu in Consoles now lists them ("At home", "Away", which one is in use) and has "Address for away" to add, change or remove it. It's saved without trying it, because at home an internet address usually can't be reached. Adding a PC by its address did keep a typed internet name already, but nothing showed it. +- Adding a PC by an internet name from home, when nothing answers, now says to use Address for away on the PC instead. + +## 12.2-test200 (2026-10-20) +- Fix (test199): on the Fold's cover screen Home's bottom row of round buttons ran under the bottom bar. Home measured its height including the status bar and navigation, so it thought two rows of tiles fit. It now lays out inside them (also Samsung's taskbar on an open Fold). +- Handheld on a big screen on its side (an open Fold): the 1920 x 1080 canvas made everything 1.3 times a phone's size, so only one row of tiles fit and the grid never showed. On its side it now keeps a phone's real size too: a centred grid of tiles (five across, two rows on the Fold) with every round button labelled. + +## 12.2-test199 (2026-10-20) +- Handheld UI polish, no new features (mockups in docs/mockups/small-touches on design/handheld-compact): + - Spacing: the two rows of round buttons sit 48 apart (were 24); the spare height on a tall screen goes a third above Home and the rest below, so there's no big gap over the tiles; the name line sits closer to its tiles. + - Header: the avatar and the "This device" pill are the same height, on one line. + - Tiles: rounder corners, a soft shadow, equal gaps; the focus ring follows the rounder corners. + - Round-button labels a step bigger and brighter. + - The bottom bar lines up with the content edge (it sat further in). + - Dialogs: symbols fill their icon squares (the + and dot were tiny), rounder cards and rows, and the buttons are real rounded buttons side by side instead of text under a line. + - The Network test shows its result in the app's own dialog instead of the old grey Moonlight one (same text, with Help and OK). + - Tiles, round buttons and dialog buttons press in a little under a finger. +- Handheld on big screens (an open foldable, a tablet: 600 dp and more): upright keeps a phone's real size and fits more (three tiles across on an open Fold, as many rows as fit); on its side a screen with room for two rows gets a centred grid instead of one row in the corner, with every round button labelled. + +## 12.2-test198 (2026-10-20) +- Back to test193, without the two-player work: player 2 on a TV (test194) and player 2 on its own screen (test196/197, #58) are parked for later on their branches feat/player-two and fix/joiner-resolution. The TV link is back to version 6. +- Kept from that work, because it isn't two-player: the fix for a keyboard or TV-remote Back that closed the in-game Quick menu and then opened it again (#57). PC, PS5 and Xbox streams. + +## 12.2-test193 (2026-10-19) +- Fix: clicking a DualSense's (or DualShock 4's) touchpad did nothing in PS5 Remote Play on newer devices (Pixel, Retroid, Odin). There Android shows the touchpad as a separate small mouse, so its click came as a mouse button, not a controller button. The PS5 screen now sends it as the touchpad button. Only a Sony touchpad counts. + +## 12.2-test192 (2026-10-19) +- Handheld: the status bar and navigation bar take the app's background colour instead of black (Android 14 and older), so the menus look like one surface. + +## 12.2-test191 (2026-10-19) +- Handheld: the menus keep the phone's own status bar and navigation, like any app, instead of hiding them and drawing MoonCast's own clock and battery. Content stays clear of the status bar, the navigation and the camera cutout. Games are still full screen. + +## 12.2-test190 (2026-10-19) +- Handheld on tall phones (Pixel): Home used only the top half of the screen. Upright, the tiles now go two across the full width and wrap into as many rows as fit above the round buttons (the last tile is All games); with height to spare, Home is centred. A near-square screen upright (Retroid) keeps one row. +- Handheld: a back button at the top left of every page but Home, for touch screens (same as B). The bottom bar's button hints can be tapped (they do what the button does), and they're hidden when no controller is connected. + +## 12.2-test189 (2026-10-19) +- Handheld: portrait shows the same screens as landscape, only fitted to the upright screen (it had its own Home with a big banner and a dock, and Settings without the side menu). Upright: the bottom hint bar, the tile row with tiles about a third of the screen high, the six round buttons in two rows of three with their labels beside them, and Settings with the side menu. Upright always uses the compact sizes, scaled by the screen's width, so text is the same size both ways round. This applies to phones held upright too. +- Compact layout: a little more space between a row's title and its description, and taller rows. + +## 12.2-test188 (2026-10-18) +- Handheld compact layout, from the first look on the Retroid: the round-button labels were crowding each other, so compact uses shorter ones ("Play on TV", "Network") and spaces the buttons wider; Home tiles a little bigger (350) to use more of the screen. + +## 12.2-test187 (2026-10-18) +- Handheld: a compact layout for small screens. On a small or near-square screen (Retroid Pocket Mini V2: 1240 x 1080) the normal 1920-wide layout was drawn at 65%, with tiny text and 40% of Home empty. The app now picks Compact by itself when the normal layout would be drawn below 80%: a 1240-wide layout with about 1.5x bigger text, tiles, buttons and rows, labels under the round buttons, and a narrower side menu in Settings. Android's own large Font size makes it a step bigger. New setting Handheld > Screen size: Auto (default), Compact, Normal, Large. The Odin and phones stay on Normal. Mockups: docs/mockups/handheld-compact (branch design/handheld-compact). + +## 12.2-test186 (2026-10-18) +- Fix (#41 follow-up): quitting a PC game from the TV's Quick menu left the TV's link reporting "failed: The stream from the PC stopped (error -1)". Quit makes the PC close the game, which ends the stream with an error code. A stream the user ended (Quit game, Disconnect, Back) no longer counts as a failure, so the link goes back to "idle". + +## 12.2-test185 (2026-10-18) +- Fix (#44): the "can't open database" error was still logged on every check from a handheld, from Moonlight's two other old databases (computers2.db, computers3.db). Those are skipped too when they don't exist. + +## 12.2-test184 (2026-10-18) +- Small fixes from the test173 pass (#44): + - TV: the PS5 and Xbox detail panels show the picture the profile in use streams with (for example "1080p, 60 fps (Home)") instead of always "60 fps, set by this TV's link". + - TV: when a box or menu closes (Save, Cancel, Back), focus goes back to the row that opened it, not the first item of the page. + - Handheld: choosing a theme keeps you on the Theme page; before, the app came back on Home, so the next Back closed it. + - Handheld: before sending a PC game to an unpaired TV, it asks the TV for its key again (the TV makes a new one each time its app starts), so a remembered key can't be stale. + - Both: no more "can't open computers.db" error in the log on every check (an old Moonlight database that doesn't exist on new installs). + - Decided: no Retro tab in the handheld's All games (#25); retro games stay under PC as the ES-DE app. + +## 12.2-test183 (2026-10-18) +- PC streams get a Custom frame rate (#42), TV and handheld: a "Custom frame rate" row under Frame rate takes any rate from 15 to 120 fps, like Custom resolution and Custom bitrate. It's saved per profile like the other PC settings. "Turn off" on the TV goes back to 60. + +## 12.2-test182 (2026-10-18) +- Fix (#39): the PS5 only streams at 30 or 60 fps, and a Custom frame rate such as 45 gave a broken picture (0 fps, all packets lost). The PS5 Frame rate rows (Home and Away, TV and handheld) now offer 60 or 30 only; a Custom value saved earlier uses the nearer of the two. Custom bitrate for the PS5 stays. + +## 12.2-test181 (2026-10-18) +- Fix (#40): after a game sent by a handheld ended, the TV kept the handheld's address, and a PS5 or Xbox game started later on the TV itself kept calling that handheld for its controller every few seconds. The TV now forgets the sender when the last stream screen closes; the next game a handheld sends sets it again. + +## 12.2-test180 (2026-10-18) +- Fix (#41): the TV's link (GET /status) goes back to "idle" when the last stream screen closes, after a Stop from the handheld, a stop on the TV or the game ending. Before, it kept saying "playing" until the next game. A failed start still reports "failed" with its message until the next game, so the handheld can show why. + +## 12.2-test179 (2026-10-18) +- Fix (#43): in a TV number box (Custom bitrate, Custom frame rate, custom resolution), Down on the remote or pad now leaves the box for the Save button, and A in the box saves, as its glyph says. Before, only the keyboard's Enter saved. Also, closing a Custom box without saving no longer leaves the row on Custom: it moves on to the first option. + +## 12.2-test178 (2026-10-18) +- Fix (#37 follow-up): ending a PS5 game could still crash the TV app (seen once in test177) when the screen closed before the decoder let go of its surface: the decoder then failed and chiaki copied a frame into a missing buffer. The PS5 screen now releases the decoder first and waits for it (at most half a second) before its surface goes, also when the PS5 itself ended the game; and chiaki drops a frame that has no buffer instead of crashing (native patch `native/mc_decoder_nullbuf_patch.py`). + +## 12.2-test177 (2026-10-18) +- Fix (#37 follow-up): ending a PS5 game could still crash the TV app at random on Android 9 (1 of 2 stops on test176, 2 of 3 on test175). On Android 9 any decoder call after the decoder is stopped aborts the app, and chiaki stopped it first: the stream thread then fed it one more frame, and the output thread's wait returned into it. The decoder now stops taking frames and lets the output thread finish first, and is stopped and deleted last (native patch `native/mc_decoder_shutdown_patch.py`, which replaces test176's `mc_decoder_join_patch.py`). + +## 12.2-test176 (2026-10-18) +- Fix (#37 follow-up): ending a PS5 game after the PS5 had already closed the stream (Disconnect, then rest mode) could crash the TV app. The decoder was deleted while its output thread was still running; it now waits for that thread first (native patch `native/mc_decoder_join_patch.py`). Only reachable since test174 freed the session. + +## 12.2-test175 (2026-10-18) +- Fix (#38): Wake and play with the PS5 in rest mode no longer gives up at once with "Couldn't reach your PlayStation". A console still waking refuses the session within milliseconds, often before the session start returned, so the retry wasn't armed yet and the screen closed. It's now armed before the start, so the app keeps retrying for about a minute while the PS5 wakes. + +## 12.2-test174 (2026-10-18) +- Fix (#37): every PS5 game kept a hardware video decoder after it ended, so after a few PS5 games PC streams on the TV failed to start (error -5) until the app was restarted. Cause: in chiaki's Android decoder, removing the screen surface took a lock it already held and froze, so the session was never freed. The lock is now released first (native patch `native/mc_surface_lock_patch.py`). + +## 12.2-test173 (2026-10-18) +- TV receiver reports a PC as paired only when the PC really accepts this TV's identity (checked in the background), so a handheld sends its identity whenever the TV can't stream on its own. + +## 12.2-test172 (2026-10-18) +- TV home: a PC card added for a sent session goes away as soon as the session ends, without reopening the app. + +## 12.2-test171 (2026-10-18) +- A PC added for a sent session joins the TV's PC list at once, so the stream can start straight away. Build fix: `@Override` kept on `onCreate`. + +## 12.2-test170 (2026-10-18) +- TV receiver: a handheld's identity for an unpaired PC is used for that session only. It is saved to session files, the PC is added for the session, and both are removed when the stream stops or the app closes. No prompt on the TV. + +## 12.2-test169 (2026-10-18) +- Handheld: when the TV is not paired with the PC, the handheld sends its Sunshine identity encrypted to the TV's key (X25519 + HKDF-SHA256 + AES-256-GCM). Only ciphertext crosses the network. + +## 12.2-test168 (2026-10-18) +- TV receiver publishes an identity key and whether it is paired with the PC, so a handheld knows when to send its identity. + +## 12.2-test167 (2026-10-17) +- Decode delay now measures the decoder directly (frame in to frame out), not from host timestamps that read 0. + +## 12.2-test166 (2026-10-17) +- Quick menu shows up to five stat tiles, so Decode delay is visible. + +## 12.2-test165 (2026-10-17) +- PC stream Quick menu shows Decode delay (average decoder time per frame), so Ultra low latency can be compared on a device. The Quick menu now shows up to five stat tiles. + +## 12.2-test164 (2026-10-17) +- Handheld: a saved resolution from before the 16:9 fix moves to the new Native, so Native shows in the Resolution list again. +- Bitrate and frame rate: picking Custom opens the number box straight away (TV and handheld). Range: 2-50 Mbps, 15-60 fps. + +## 12.2-test163 (2026-10-17) +- Streaming profile is now Home or Away (Auto removed). It moved out of the General tab, which is gone: TV in Video, handheld in Picture. A saved Auto counts as Home. Note: a PlayStation over PSN now uses Home settings unless Away is chosen. + +## 12.2-test162 (2026-10-17) +- Profiles now keep all their streaming settings, not only the PC's resolution, frame rate, bitrate and codec: PC video (HDR, colour range, picture shape, zoom, frame pacing, ultra low latency, PC game tuning, unlock frame rate, refresh rate), audio (audio config, host audio, audio effects) and the Xbox resolution. Switching the General profile saves and restores them. Controller and device settings stay shared. PlayStation rows were already per profile. + +## 12.2-test161 (2026-10-17) +- Profiles keep their own PC video settings (resolution, frame rate, bitrate, codec). Switching the General profile saves the current values into the profile being left and loads the saved values for the new one. Auto uses Home for the PC, which only streams on the home network. Xbox settings are not profile-specific yet. + +## 12.2-test160 (2026-10-17) +- Handheld: Native resolution is now the largest 16:9 size that fits the screen (Retroid Pocket 1240x1080 gives 1240x696), so widescreen games fill the screen instead of leaving black bars. + +## 12.2-test159 (2026-10-17) +- TV receiver: a game sent by any handheld (phone, tablet or Odin) is looked up by name in this TV's own PC list. The sender's game ID is only the fallback. Fixes a sender whose PC numbers games differently (for example a tablet paired with another PC) leaving the TV stuck on 'starting'. + +## 12.2-test158 (2026-10-17) +- PlayStation Frame rate and Bitrate rows (Home and Away) get a Custom option. Choose Custom, then tap the Custom value row to type a number: frame rate 15-60 fps, bitrate 2-50 Mbps. The stream uses the typed value. + +## 12.2-test157 (2026-10-17) +- PlayStation settings: the same four rows (Resolution, Frame rate, Bitrate, Video codec) for each profile. The General profile decides which set they change. Frame rate can be 60 or 30 fps, per profile. +- Fix: the Away settings had no frame rate and a different resolution row, so changes in Home didn't apply to Away. + +## 12.2-test156 (2026-10-17) +- Streaming profile moved to a General tab (TV and handheld). PlayStation settings show the rows for the chosen profile: Home rows, Away rows, or both under Auto. PC and Xbox settings are not switched by the profile yet. + +## 12.2-test155 (2026-10-17) +- Home and Away streaming profiles, first slice (issue #35): `stream_profile` = Auto / Home / Away. Auto uses Away only when the PS5 is reached through PlayStation Network. Away uses "Quality away from home", plus new "Bitrate away" and "Video codec away" settings. Shared module `home/Profile.kt`, in the TV and handheld apps. PC and Xbox profiles are not in yet. Not yet tested on a device. + +## 12.2-test154 (2026-10-17) +- Quick menu panel is 80% opaque, so the game shows behind it. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..3b24894691 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,39 @@ +# Contributing to MoonCast + +Thanks for helping! A few rules keep the history easy to follow and the apps easy to release. + +## Reporting a problem + +Use **New issue → Something doesn't work**. Say: +- which app (MoonCast or MoonCast TV) and which version (Settings, About); +- your device and Android version; +- what you played (PC, PS5 or Xbox), at home or away; +- what happened. + +A screenshot or a short video helps a lot. For ideas, use **Idea or wish**. + +Security problems go privately, not in an issue: see [SECURITY.md](SECURITY.md). + +## Building + +See [Building](README.md#building) in the README. Clone with `--recurse-submodules`: the PS5 part is built from `third-party/chiaki-ng`. + +## Changes + +- **One topic per branch and pull request.** A fix and a feature are two pull requests. Name branches by kind and topic: `fix/battery-res`, `feat/fsr`, `docs/readme`. +- **CHANGELOG.md in every pull request.** Add an entry at the top that says what changed for someone using the app, in plain words. Then add what changed in the code. +- **Match the code around you.** Use the same naming, comment style and amount of comments. Keep it simple: no new libraries or abstractions without a reason. +- **Both apps.** Settings and behaviour that make sense on TVs and handhelds go into both (`src/tv` and `src/handheld`); shared code lives in `src/main`. +- **Lint and CI must pass.** CI builds both apps and runs lint on every pull request. Locally: `./gradlew lintNonRootHandheldDebug lintNonRootTvDebug`. +- **Test on a device** before asking for review, and say in the pull request what you tested, on which device, and what you couldn't test. +- **PS5 native code** lives in [MoonCastDev/chiaki-ng-mooncast](https://github.com/MoonCastDev/chiaki-ng-mooncast). Change it there first, then move the `third-party/chiaki-ng` submodule to the new commit in your pull request. +- **No copyrighted game pictures** in the repository (screenshots, box art, mockups). Use made-up scenes, or free games. +- **No secrets.** Never commit keys, passwords, tokens, sign-in data or `keystore.properties`. + +## Test builds + +Put the build name in the version: `BUILD=test123 ./gradlew assembleNonRootHandheldDebug assembleNonRootTvDebug` gives `1.0-test123`. Pre-releases on GitHub carry these test builds; releases are signed with the MoonCast key, built with `scripts/release.sh`. + +## Licence + +MoonCast is GPL-3.0, with the parts from chiaki-ng (PS5) and XStreaming (Xbox) under AGPL-3.0. By contributing, you agree that your work is published under the same licences. When you add a library, also add it to `app/src/main/java/com/limelight/home/Licences.kt`, to `NOTICE`, and add its licence text to `app/src/main/assets/licences/`. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000000..266618709c --- /dev/null +++ b/NOTICE @@ -0,0 +1,49 @@ +MoonCast +Copyright (C) MoonCast contributors + +MoonCast is free software: you can redistribute it and/or modify it under the terms of the GNU General Public +License, version 3 (LICENSE.txt). The parts that come from chiaki-ng (PS5) and follow XStreaming (Xbox) are +under the GNU Affero General Public License, version 3. MoonCast is based on Moonlight for Android. + +The source code: https://github.com/MoonCastDev (the app) and https://github.com/MoonCastDev/chiaki-ng-mooncast +(chiaki-ng with MoonCast's changes, built into the app from third-party/chiaki-ng). + +MoonCast includes the open-source projects below. Each licence's full text is in +app/src/main/assets/licences/ and in the app, under Settings, About, Open-source licences. + + +The app +------- +Moonlight for Android (GPL-3.0): The app MoonCast grew from: PC streaming, the on-screen controller. https://github.com/moonlight-stream/moonlight-android [licences/gpl-3.0.txt] +moonlight-common-c (GPL-3.0): The PC streaming protocol. https://github.com/moonlight-stream/moonlight-common-c [licences/gpl-3.0.txt] +AMD FidelityFX Super Resolution 1.0 (MIT): Super resolution: the upscaling and sharpening (FSR 1). https://github.com/GPUOpen-Effects/FidelityFX-FSR [licences/fsr1.txt] +ENet (MIT): Inside moonlight-common-c: the network link. https://github.com/lsalzman/enet [licences/enet.txt] +nanors (MIT): Inside moonlight-common-c: repairing lost video. https://github.com/sleepybishop/nanors [licences/nanors.txt] + +PS5 +--- +chiaki-ng (AGPL-3.0): PS5 Remote Play, with MoonCast's changes (MoonCastDev/chiaki-ng-mooncast). https://github.com/streetpea/chiaki-ng [licences/agpl-3.0.txt] +OpenSSL (Apache-2.0): Inside chiaki-ng: encryption. https://www.openssl.org [licences/apache-2.0.txt] +curl (curl): Inside chiaki-ng: PSN sign-in and play away from home. https://curl.se [licences/curl.txt] +json-c (MIT): Inside chiaki-ng: PSN messages. https://github.com/json-c/json-c [licences/json-c.txt] +miniupnpc (BSD-3-Clause): Inside chiaki-ng: router port setup (UPnP). https://github.com/miniupnp/miniupnp [licences/miniupnpc.txt] +Jerasure (BSD-3-Clause): Inside chiaki-ng: repairing lost video. https://github.com/streetpea/jerasure [licences/jerasure.txt] +GF-Complete (BSD-3-Clause): Inside chiaki-ng: the maths behind the repair. https://github.com/streetpea/gf-complete [licences/gf-complete.txt] +nanopb (Zlib): Inside chiaki-ng: messages with the console. https://github.com/nanopb/nanopb [licences/nanopb.txt] +Oboe (Apache-2.0): Inside chiaki-ng: sound. https://github.com/google/oboe [licences/apache-2.0.txt] +LLVM libc++ (Apache-2.0 with LLVM exception): The C++ runtime chiaki-ng runs on. https://libcxx.llvm.org [licences/libcxx.txt] + +Xbox +---- +XStreaming (AGPL-3.0): The Xbox Remote Play protocol MoonCast follows. https://github.com/Geocld/XStreaming [licences/agpl-3.0.txt] +WebRTC (BSD-3-Clause): Xbox video and sound. https://webrtc.org [licences/webrtc.txt] + +More +---- +Bouncy Castle (MIT): Pairing with a PC: its certificates. https://www.bouncycastle.org [licences/bouncycastle.txt] +OkHttp (Apache-2.0): Talking to PCs and Xbox and PlayStation services. https://square.github.io/okhttp [licences/apache-2.0.txt] +JmDNS (Apache-2.0): Finding PCs on the network. https://github.com/jmdns/jmdns [licences/apache-2.0.txt] +JCodec (BSD-2-Clause): Reading video stream settings. https://github.com/jcodec/jcodec [licences/jcodec.txt] +Shield Controller Extensions (MIT): Rumble and battery on NVIDIA Shield controllers. https://github.com/cgutman/ShieldControllerExtensions [licences/shield-controller-extensions.txt] +Figtree (OFL-1.1): The menus' font. https://github.com/erikdkennedy/figtree [licences/figtree.txt] +Unbounded (OFL-1.1): The headings' font. https://github.com/googlefonts/unbounded [licences/unbounded.txt] diff --git a/README.md b/README.md index 0b1bbe780e..f77669bfde 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,96 @@ -# Moonlight Android +# MoonCast -[![AppVeyor Build Status](https://ci.appveyor.com/api/projects/status/232a8tadrrn8jv0k/branch/master?svg=true)](https://ci.appveyor.com/project/cgutman/moonlight-android/branch/master) -[![Translation Status](https://hosted.weblate.org/widgets/moonlight/-/moonlight-android/svg-badge.svg)](https://hosted.weblate.org/projects/moonlight/moonlight-android/) +Play your PC, PS5 and Xbox on Android, on a handheld or on a TV. -[Moonlight for Android](https://moonlight-stream.org) is an open source client for NVIDIA GameStream and [Sunshine](https://github.com/LizardByte/Sunshine). +MoonCast is two apps: -Moonlight for Android will allow you to stream your full collection of games from your Windows PC to your Android device, -whether in your own home or over the internet. +- **MoonCast**, for phones, handhelds, tablets and foldables. It plays games on its own screen, or sends a game to a TV running MoonCast TV, and then the handheld becomes the controller. +- **MoonCast TV**, for Android TV and Google TV. It has its own home screen for your consoles and games, and it receives the games your handheld sends it. -Moonlight also has a [PC client](https://github.com/moonlight-stream/moonlight-qt) and [iOS/tvOS client](https://github.com/moonlight-stream/moonlight-ios). +MoonCast grew from [Moonlight for Android](https://github.com/moonlight-stream/moonlight-android) and keeps its streaming engine. The home screens, the console support and the TV link are MoonCast's own. -You can follow development on our [Discord server](https://moonlight-stream.org/discord) and help translate Moonlight into your language on [Weblate](https://hosted.weblate.org/projects/moonlight/moonlight-android/). +## What it plays -## Downloads -* [Google Play Store](https://play.google.com/store/apps/details?id=com.limelight) -* [Amazon App Store](https://www.amazon.com/gp/product/B00JK4MFN2) -* [F-Droid](https://f-droid.org/packages/com.limelight) -* [APK](https://github.com/moonlight-stream/moonlight-android/releases) +| | How | What you need | +|---|---|---| +| **PC** | The Moonlight protocol, to [Sunshine](https://github.com/LizardByte/Sunshine) on your PC | Sunshine, with its default settings | +| **PS5** | Native PS Remote Play (built on [chiaki-ng](https://github.com/streetpea/chiaki-ng)), at home or away through PlayStation Network, with no VPN or port forwarding | A PlayStation Network sign-in, once, to register the console | +| **Xbox** | Native Xbox Remote Play (Google's WebRTC, following the [XStreaming](https://github.com/Geocld/XStreaming) protocol) | A Microsoft sign-in: the app shows a code, you enter it at microsoft.com/link | + +## Features + +- **Send a game to the TV.** Pick a game on the handheld and choose a TV. The TV plays it, and the handheld becomes the controller. +- **Home and away.** Each PC can have an address for away from home (an IP address or a domain name). The PS5 reaches you through PlayStation Network when you're out. +- **On-screen controller** for PC games, with PlayStation and Xbox layouts. The keys are see-through over the game and give a light tick under your finger. The PC sees a real PlayStation or Xbox pad, so games show the right button symbols. +- **Foldables.** The picture follows the screen when you fold or unfold, and Native resolution uses the whole screen. +- **Controllers.** Bluetooth and USB pads, a test screen for every button, and rumble on the device when a pad can't rumble. +- **Quick menu** while playing: streaming status, touch screen (off, mouse or touch), keyboard, controllers, Task Manager, disconnect, quit, move to a TV. + +## Install + +Download the APKs from [Releases](https://github.com/MoonCastDev/moonlight-android/releases). The betas (*MoonCast 1.0 beta …*) have: + +- `MoonCast-….apk` for phones, handhelds and tablets; +- `MoonCastTV-….apk` for Android TV and Google TV. + +Both run on Android 5.0 and later. Releases named `1.0-test…` are development builds (`MoonCast (Debug)`), installed next to the beta. + +### Automatic updates with Obtainium + +[Obtainium](https://github.com/ImranR98/Obtainium) installs MoonCast from these releases and tells you when a new beta is out. Install Obtainium, then open one of these on the device: + +- [Add MoonCast (phones and handhelds) to Obtainium](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22me.mooncast.handheld%22%2C%22url%22%3A%22https%3A%2F%2Fgithub.com%2FMoonCastDev%2Fmoonlight-android%22%2C%22author%22%3A%22MoonCastDev%22%2C%22name%22%3A%22MoonCast%22%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3A%20true%2C%20%5C%22filterReleaseTitlesByRegEx%5C%22%3A%20%5C%22%5EMoonCast%20%5B0-9.%5D%2B%28%20%7C%24%29%5C%22%2C%20%5C%22apkFilterRegEx%5C%22%3A%20%5C%22%5EMoonCast-.%2A%5C%5C%5C%5C.apk%24%5C%22%2C%20%5C%22appName%5C%22%3A%20%5C%22MoonCast%5C%22%7D%22%7D) +- [Add MoonCast TV to Obtainium](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22me.mooncast.tv%22%2C%22url%22%3A%22https%3A%2F%2Fgithub.com%2FMoonCastDev%2Fmoonlight-android%22%2C%22author%22%3A%22MoonCastDev%22%2C%22name%22%3A%22MoonCast%20TV%22%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3A%20true%2C%20%5C%22filterReleaseTitlesByRegEx%5C%22%3A%20%5C%22%5EMoonCast%20%5B0-9.%5D%2B%28%20%7C%24%29%5C%22%2C%20%5C%22apkFilterRegEx%5C%22%3A%20%5C%22%5EMoonCastTV-.%2A%5C%5C%5C%5C.apk%24%5C%22%2C%20%5C%22appName%5C%22%3A%20%5C%22MoonCast%20TV%5C%22%7D%22%7D) + +Or add it by hand: **Add App**, the URL `https://github.com/MoonCastDev/moonlight-android`, then in its settings turn on **Include prereleases**, set **Filter release titles by regular expression** to `^MoonCast [0-9.]+( |$)` and **Filter APKs by regular expression** to `^MoonCast-.*\.apk$` (or `^MoonCastTV-.*\.apk$` on a TV). + +### On the PC + +Install Sunshine and pair MoonCast with it, as you would with Moonlight. Keep Sunshine's **gamepad** setting on `auto` (its default). Then each controller reaches the PC as what it is. A forced type such as `xone` makes every pad, including the on-screen PlayStation one, an Xbox pad. + +MoonCast needs nothing else on the PC: no helper and no extra service. ## Building -* Install Android Studio and the Android NDK -* Run ‘git submodule update --init --recursive’ from within moonlight-android/ -* In moonlight-android/, create a file called ‘local.properties’. Add an ‘ndk.dir=’ property to the local.properties file and set it equal to your NDK directory. -* Build the APK using Android Studio or gradle -## Authors +You need: + +- JDK 17; +- the Android SDK with NDK **29.0.14206865** (for the app) and NDK **28.2.13676358** and CMake **3.22.1** (for chiaki-ng); +- `protoc` (the Protocol Buffers compiler) on your `PATH`. + +```sh +git clone --recurse-submodules https://github.com/MoonCastDev/moonlight-android +cd moonlight-android +echo "sdk.dir=/path/to/android-sdk" > local.properties +./gradlew assembleNonRootHandheldDebug assembleNonRootTvDebug +``` + +The APKs land in `app/build/outputs/apk/`. Two notes: + +- **PS5 part.** The first build also compiles the PS5 part from `third-party/chiaki-ng` ([MoonCastDev/chiaki-ng-mooncast](https://github.com/MoonCastDev/chiaki-ng-mooncast): chiaki-ng plus MoonCast's changes). That takes a few minutes, and later builds skip it unless chiaki's sources change. +- **Existing clone.** If you already cloned without submodules, run `git submodule update --init --recursive`. + +Test builds put the build number in the version: `BUILD=test123 ./gradlew …` gives `12.2-test123`. + +## Privacy + +MoonCast has no ads and no accounts of its own. Your PlayStation Network and Xbox sign-ins and your PC pairings stay on the device and go only to Sony, Microsoft and your own PC. + +Official builds ask once whether you'll send an **anonymous usage check-in** (Settings, About). If you say yes, once a day it sends [TelemetryDeck](https://telemetrydeck.com) the app version, TV or handheld, the device model and Android version, and whether you played PC, PS5 or Xbox. The install is a random ID made on the device, sent hashed. It never sends names, addresses, games, accounts or anything you type. Say no, or turn it off later, and nothing is sent. Builds you make yourself never ask and never send. The code is in [`home/Usage.kt`](app/src/main/java/com/limelight/home/Usage.kt). + +## Licence + +MoonCast is free software under the [GNU General Public License v3](LICENSE.txt). The parts that come from chiaki-ng (PS5) and follow XStreaming (Xbox) are under the GNU Affero General Public License v3. [NOTICE](NOTICE) lists every open-source project MoonCast includes, with its licence. The app shows the same list under Settings, About, Open-source licences. + +## Credits + +MoonCast stands on the work of: + +- **Moonlight for Android**, by Cameron Gutman, Diego Waxemberg, Aaron Neyer, Andrew Hennessy and its contributors. It's the streaming engine and the app MoonCast grew from. +- **chiaki-ng**, by streetpea and its contributors, and chiaki before it. It powers PS5 Remote Play. +- **XStreaming**, by Geocld. Its Xbox Remote Play protocol is the one MoonCast follows. +- **Sunshine**, by LizardByte, on the PC side. -* [Cameron Gutman](https://github.com/cgutman) -* [Diego Waxemberg](https://github.com/dwaxemberg) -* [Aaron Neyer](https://github.com/Aaronneyer) -* [Andrew Hennessy](https://github.com/yetanothername) +Each project's own copyright and licence notices stay in its files. -Moonlight is the work of students at [Case Western](http://case.edu) and was -started as a project at [MHacks](http://mhacks.org). +MoonCast isn't affiliated with or endorsed by Sony Interactive Entertainment, Microsoft or NVIDIA. PlayStation, PS5 and DualSense are trademarks of Sony Interactive Entertainment Inc.; Xbox is a trademark of Microsoft Corporation. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000000..0c828a3fb7 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,16 @@ +# Security + +MoonCast handles pairing with your PC, and sign-ins to PlayStation Network and Xbox. If you find a way to get at +someone's pairing, sign-in or console, or a way into MoonCast or the devices it talks to, please report it **privately**: + +- **[Report a vulnerability](https://github.com/MoonCastDev/moonlight-android/security/advisories/new)** (GitHub's private security advisory). Only the maintainers see it. + +Please don't open a public issue for it. Include what you found, how to reproduce it, and which version (Settings, About). +We'll reply as soon as we can and agree with you on when the fix and the report are made public. + +What MoonCast keeps, and where: +- **PC pairing certificates:** stay on the device. When you send a game to a MoonCast TV, they go to that TV for the session only, encrypted, over your own network. +- **PlayStation Network and Xbox sign-ins, and a PS5's registration:** stay on your own devices (moving a game to your MoonCast TV hands what it needs to that TV, over your own network) and go only to Sony and Microsoft. +- **Release builds:** signed with the MoonCast key (certificate SHA-256 `ac7cce06b53c84cdfdaf7f05c29479e332b434bd5799dc5716cba632173dff07`). Don't install a "MoonCast" signed with anything else. + +Supported versions: the latest release and the latest beta. diff --git a/THIRD_PARTY_SOUNDS.md b/THIRD_PARTY_SOUNDS.md new file mode 100644 index 0000000000..793b733461 --- /dev/null +++ b/THIRD_PARTY_SOUNDS.md @@ -0,0 +1,7 @@ +# Third-party sounds + +Sound effects: 'glass' set from ps5-homebrew-ui, Copyright (C) 2026 BlackBearReloaded, GPL-3.0-or-later. + +- Source: https://github.com/BlackBearReloaded/ps5-homebrew-ui (`assets/audio/sfx/glass/*.wav`) +- In this app: `app/src/main/res/raw/mc_*.wav` (renamed with an `mc_` prefix, otherwise unchanged) +- Licence: GNU General Public License v3.0 or later, the same licence as this app (see `LICENSE.txt`) diff --git a/app/build.gradle b/app/build.gradle index c9cc3e47ac..9e38b3f4fb 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -11,14 +11,29 @@ android { minSdk 21 targetSdk 36 - versionName "12.2" - versionCode = 315 - - // Generate native debug symbols to allow Google Play to symbolicate our native crashes - ndk.debugSymbolLevel = 'FULL' + // MoonCast's own numbering (Moonlight's was 12.2 / 315). versionCode only ever goes up: 1.0 = 1000, 1.0.1 = 1001, 1.1 = 1100 + // A beta overrides both, below the release it leads to: -PmcVersionName=1.0-beta1 -PmcVersionCode=991 + versionName (project.findProperty("mcVersionName") ?: "1.0") + versionCode = (project.findProperty("mcVersionCode") ?: "1000").toString().toInteger() + + // MoonCast TV dev build: arm64 only (the stick + JAL TV are arm64) to keep the native compile within memory, + // and limit parallel native jobs so Gradle + ndk-build don't OOM the 4 GB builder. + ndk { abiFilters "arm64-v8a", "armeabi-v7a" } + + // Anonymous usage check-in (home/Usage.kt): only official builds get an app ID, from the release build's + // environment or -PmcTelemetryAppId; without one the app never asks and never sends + buildConfigField "String", "TELEMETRY_APP_ID", + "\"" + (project.findProperty("mcTelemetryAppId") ?: System.getenv("MOONCAST_TELEMETRY_APP_ID") ?: "") + "\"" + // the TelemetryDeck organization's namespace (its dashboard shows it); signals go to /v2/namespace// + buildConfigField "String", "TELEMETRY_NAMESPACE", + "\"" + (project.findProperty("mcTelemetryNamespace") ?: System.getenv("MOONCAST_TELEMETRY_NAMESPACE") ?: "") + "\"" } flavorDimensions.add("root") + // Which app this is. tv = MoonCast TV, always the RECEIVER (its home + the :8099 listener, src/tv). + // handheld = MoonCast for Odin-style handhelds and phones, always the SENDER (src/handheld). + // Everything else (Moonlight, PS5, Xbox, sign-ins, settings, Quick menu) is shared in src/main. + flavorDimensions.add("role") buildFeatures { buildConfig = true @@ -45,16 +60,27 @@ android { nonRoot { externalNativeBuild { ndkBuild { - arguments "PRODUCT_FLAVOR=nonRoot" + arguments "PRODUCT_FLAVOR=nonRoot", "-j2" } } - applicationId "com.limelight" + applicationId "me.mooncast.tv" dimension "root" buildConfigField "boolean", "ROOT_BUILD", "false" } } + productFlavors { + tv { + dimension "role" + buildConfigField "boolean", "TV_APP", "true" + } + handheld { + dimension "role" + buildConfigField "boolean", "TV_APP", "false" + } + } + compileOptions { encoding "UTF-8" sourceCompatibility JavaVersion.VERSION_17 @@ -81,13 +107,31 @@ android { } } + // Release signing: MoonCast's own key, never in git. keystore.properties (storeFile, storePassword, keyAlias, + // keyPassword) sits next to settings.gradle, or MOONCAST_KEYSTORE names it. Without it, release builds come out + // unsigned, as before. + def keystoreFile = System.getenv("MOONCAST_KEYSTORE") ? new File(System.getenv("MOONCAST_KEYSTORE")) : rootProject.file("keystore.properties") + if (keystoreFile.exists()) { + def ks = new Properties() + keystoreFile.withInputStream { ks.load(it) } + signingConfigs { + release { + storeFile file(ks.getProperty("storeFile")) + storePassword ks.getProperty("storePassword") + keyAlias ks.getProperty("keyAlias") + keyPassword ks.getProperty("keyPassword") + } + } + } + buildTypes { debug { applicationIdSuffix ".debug" - resValue "string", "app_label", "Moonlight (Debug)" - resValue "string", "app_label_root", "Moonlight (Root Debug)" + // test builds: BUILD=testN ./gradlew ... puts the build name in the version (shown in Settings, About) + versionNameSuffix "-" + (System.getenv("BUILD") ?: "dev") } release { + if (keystoreFile.exists()) signingConfig signingConfigs.release // To whomever is releasing/using an APK in release mode with // Moonlight's official application ID, please stop. I see every // single one of your crashes in my Play Console and it makes @@ -118,9 +162,7 @@ android { // is to please change the applicationId before you publish. // // TL;DR: Leave the following line alone! - applicationIdSuffix ".unofficial" - resValue "string", "app_label", "Moonlight" - resValue "string", "app_label_root", "Moonlight (Root)" + // MoonCast TV: our own application id (as the upstream author asks forks to do) minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' @@ -148,6 +190,47 @@ dependencies { implementation 'com.squareup.okhttp3:okhttp:5.5.0' implementation 'org.jmdns:jmdns:3.6.3' implementation 'com.github.cgutman:ShieldControllerExtensions:1.0.1' + // Google's WebRTC (libwebrtc) for Xbox Remote Play, the build XStreaming's player uses (react-native-webrtc 124) + implementation 'org.jitsi:webrtc:124.0.0' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5' } + +// Per app: its own application id (both can be installed side by side) and its own name. +androidComponents { + onVariants(selector().all()) { variant -> + def handheld = variant.productFlavors.any { it.second == "handheld" } + if (handheld) variant.applicationId.set(variant.applicationId.get().replace("me.mooncast.tv", "me.mooncast.handheld")) + def name = handheld ? "MoonCast" : "MoonCast TV" + def debug = variant.buildType == "debug" + variant.resValues.put(variant.makeResValueKey("string", "app_label"), + new com.android.build.api.variant.ResValue(name + (debug ? " (Debug)" : ""), null)) + variant.resValues.put(variant.makeResValueKey("string", "app_label_root"), + new com.android.build.api.variant.ResValue(name + (debug ? " (Root Debug)" : " (Root)"), null)) + } +} + +// PS5 Remote Play: libchiaki-jni.so (with liboboe.so and libc++_shared.so) is built from source, from the +// third-party/chiaki-ng submodule (MoonCastDev/chiaki-ng-mooncast, chiaki-ng with MoonCast's changes, AGPL-3.0). +// It runs again only when chiaki's sources change. The libraries land in src/main/jniLibs, which git ignores. +// Needs CMake 3.22.1 in the SDK and protoc on the PATH (see third-party/chiaki-ng/mooncast/README.md). +def chiakiDir = rootProject.file('third-party/chiaki-ng') +def chiakiLibs = ['arm64-v8a', 'armeabi-v7a'].collectMany { abi -> + ['libchiaki-jni.so', 'liboboe.so', 'libc++_shared.so'].collect { file("src/main/jniLibs/$abi/$it") } +} +tasks.register('buildChiaki', Exec) { + description = 'Builds the PS5 Remote Play libraries from third-party/chiaki-ng' + inputs.files(fileTree(chiakiDir) { + include 'CMakeLists.txt', 'cmake/**', 'lib/**', 'android/app/CMakeLists.txt', 'android/app/build.gradle', 'android/app/src/main/cpp/**', 'mooncast/build-jni.sh' + exclude 'android/app/src/main/cpp/oboe/**' + }) + outputs.files(chiakiLibs) + // the script finds the SDK from ANDROID_HOME or local.properties + commandLine 'sh', new File(chiakiDir, 'mooncast/build-jni.sh').path, rootProject.projectDir.path + doFirst { + if (!new File(chiakiDir, 'mooncast/build-jni.sh').exists()) { + throw new GradleException("third-party/chiaki-ng is empty: run git submodule update --init --recursive") + } + } +} +tasks.named('preBuild') { dependsOn 'buildChiaki' } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index d43398660e..f897867437 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -26,3 +26,9 @@ # jMDNS -dontwarn javax.jmdns.impl.DNSCache -dontwarn org.slf4j.** + +# MoonCast: chiaki-ng's native code (PS5) reads these classes' fields and calls their methods by name, so code +# shrinking must keep all of them. Without this a release build dropped ConnectInfo.psnToken and .videoProfile. +-keep class com.metallic.chiaki.** { *; } +# Google's WebRTC (Xbox) calls back into its Java classes from native code too +-keep class org.webrtc.** { *; } diff --git a/app/src/handheld/AndroidManifest.xml b/app/src/handheld/AndroidManifest.xml new file mode 100644 index 0000000000..b493f228d4 --- /dev/null +++ b/app/src/handheld/AndroidManifest.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + diff --git a/app/src/handheld/java/com/limelight/handheld/DirectPad.kt b/app/src/handheld/java/com/limelight/handheld/DirectPad.kt new file mode 100644 index 0000000000..ff873f2f07 --- /dev/null +++ b/app/src/handheld/java/com/limelight/handheld/DirectPad.kt @@ -0,0 +1,266 @@ +package com.limelight.handheld + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.util.Log +import com.limelight.binding.PlatformBinding +import com.limelight.computers.ComputerManagerService +import com.limelight.nvstream.NvConnection +import com.limelight.nvstream.NvConnectionListener +import com.limelight.nvstream.StreamConfiguration +import com.limelight.nvstream.av.audio.AudioRenderer +import com.limelight.nvstream.av.video.VideoDecoderRenderer +import com.limelight.nvstream.http.ComputerDetails +import com.limelight.nvstream.http.NvApp +import com.limelight.nvstream.http.NvHTTP +import com.limelight.nvstream.input.ControllerPacket +import com.limelight.nvstream.jni.MoonBridge +import com.limelight.utils.ServerHelper + +/** + * The controller straight to the PC: while a PC game plays on a TV, this handheld opens its own, controller-only + * Sunshine session to the same game and sends its pad as controller 0. That skips the TV's Wi-Fi hop (a few ms + * straight to the PC, often tens of ms through a TV on a busy network), like the old phone app's InputSession did + * (640x360, a picture it throws away). A PC that refuses a second session makes this fail, and controller mode falls + * back to the TV's relay by itself. + * + * The session: 640x360 (small, so a host script that matches the display to each client can tell it apart), 15 fps, + * 500 kbps, H.264, resume only + * (never launches or quits). The picture is never decoded (the decoder just drops every frame) and the sound is never + * played (the audio renderer drops it) - only the network packets arrive. + */ +class DirectPad(ctx: Context, private val pcUuid: String, private val appId: Int, private val appName: String) { + enum class State { WAITING, CONNECTING, CONNECTED, FAILED, STOPPED } + + companion object { private const val TAG = "MoonCast-DirectPad" } + + private val app = ctx.applicationContext + private val ui = Handler(Looper.getMainLooper()) + @Volatile var state = State.WAITING; private set + @Volatile var pcName: String = "the PC"; private set + /** What went wrong last (for the status line). */ + @Volatile var why: String? = null; private set + /** Called on the main thread whenever [state] changes. */ + var onState: ((State) -> Unit)? = null + + @Volatile private var stopped = false + @Volatile private var conn: NvConnection? = null // the session, once it's up (input goes here) + @Volatile private var live: NvConnection? = null // the session being set up or running + // NvConnection.stop() releases Moonlight's one-connection semaphore: call it exactly once per started session + private val stoppedConns = java.util.Collections.newSetFromMap(java.util.WeakHashMap()) + private fun stopOnce(c: NvConnection) { + synchronized(stoppedConns) { if (!stoppedConns.add(c)) return } + Thread({ try { c.stop() } catch (e: Throwable) { Log.w(TAG, "stop: $e") } }, "direct-pad-stop").start() + } + private var binder: ComputerManagerService.ComputerManagerBinder? = null + private var bound = false + @Volatile private var everConnected = false + + private val sc = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName, service: IBinder) { + val b = service as ComputerManagerService.ComputerManagerBinder + binder = b + Thread({ b.waitForReady(); run(b) }, "direct-pad").start() + } + override fun onServiceDisconnected(name: ComponentName) { binder = null } + } + + fun start() { + bound = app.bindService(Intent(app, ComputerManagerService::class.java), sc, Context.BIND_AUTO_CREATE) + if (!bound) set(State.FAILED, "The PC manager didn't start") + } + + fun stop() { + if (stopped) return + stopped = true + set(State.STOPPED, null) + val up = conn; conn = null + try { up?.sendControllerInput(0, 0x1, 0, 0, 0, 0, 0, 0, 0) } catch (e: Throwable) { } // let go of everything + live?.let { stopOnce(it) }; live = null + if (bound) try { app.unbindService(sc) } catch (e: Exception) { } + bound = false + } + + private fun set(s: State, reason: String?) { + if (stopped && s != State.STOPPED) return + state = s; if (reason != null) why = reason + ui.post { onState?.invoke(s) } + } + + /** Wait for the TV's game to be running on the PC, then join it. Reconnects if the session drops. */ + private fun run(b: ComputerManagerService.ComputerManagerBinder) { + var tries = 0 + while (!stopped) { + val pc = b.getComputer(pcUuid) + if (pc == null) { set(State.FAILED, "This device doesn't know that PC"); return } + pcName = pc.name ?: pcName + // 1. the game is running on the PC (the TV started it): never launch it ourselves + val running = waitForGame(b, pc, if (everConnected) 20_000 else 45_000) + if (stopped) return + if (!running) { set(State.FAILED, "${appName} isn't running on $pcName"); return } + // 2. join it, controller only + set(State.CONNECTING, null) + val ended = java.util.concurrent.CountDownLatch(1) + val ok = connect(b, pc) { ended.countDown() } + if (!ok) { + if (++tries >= 3) { set(State.FAILED, why ?: "Couldn't reach $pcName"); return } + } else tries = 0 + ended.await() // until the session ends + conn = null + if (stopped) return + Log.i(TAG, "direct session ended, reconnecting") + try { Thread.sleep(1500) } catch (e: InterruptedException) { return } + } + } + + private fun waitForGame(b: ComputerManagerService.ComputerManagerBinder, pc: ComputerDetails, maxMs: Long): Boolean { + val end = System.currentTimeMillis() + maxMs + var seenAt = 0L + while (!stopped && System.currentTimeMillis() < end) { + try { + val http = NvHTTP(ServerHelper.getCurrentAddressFromComputer(pc), pc.httpsPort, b.uniqueId, pc.serverCert, + PlatformBinding.getCryptoProvider(app)) + val cur = http.getCurrentGame(http.getServerInfo(true)) + if (cur == appId) { + // give the TV's own session a moment to set up before we join next to it + if (seenAt == 0L) seenAt = System.currentTimeMillis() + if (everConnected || System.currentTimeMillis() - seenAt >= 2000) return true + } else seenAt = 0L + } catch (e: Exception) { Log.w(TAG, "poll: $e") } + try { Thread.sleep(1000) } catch (e: InterruptedException) { return false } + } + return false + } + + /** One session. True once it started; [onEnd] runs when it ends (or failed to start). */ + private fun connect(b: ComputerManagerService.ComputerManagerBinder, pc: ComputerDetails, onEnd: () -> Unit): Boolean { + val addr = try { ServerHelper.getCurrentAddressFromComputer(pc) } catch (e: Exception) { why = "$pcName is offline"; onEnd(); return false } + val cfg = StreamConfiguration.Builder() + .setResolution(640, 360) + .setLaunchRefreshRate(15).setRefreshRate(15) + .setApp(NvApp(appName, appId, false)) + .setBitrate(500) + .setEnableSops(false) + .enableLocalAudioPlayback(false) + .setMaxPacketSize(1392) + .setRemoteConfiguration(StreamConfiguration.STREAM_CFG_AUTO) + .setSupportedVideoFormats(MoonBridge.VIDEO_FORMAT_H264) + .setAttachedGamepadMask(0x1) // /resume asks remoteControllersBitmap=1, gcmap=1: pad 0, player 1 + .setAudioConfiguration(MoonBridge.AUDIO_CONFIGURATION_STEREO) + .setPersistGamepadsAfterDisconnect(false) + .build() + val c = NvConnection(app, addr, pc.httpsPort, b.uniqueId, cfg, PlatformBinding.getCryptoProvider(app), pc.serverCert) + c.setResumeOnly(true) + live = c + val started = java.util.concurrent.CountDownLatch(1) + var ok = false + val done = java.util.concurrent.atomic.AtomicBoolean(false) + fun end() { if (done.compareAndSet(false, true)) { started.countDown(); onEnd() } } + c.start(NoAudio, NoVideo, object : NvConnectionListener { + override fun stageStarting(stage: String) {} + override fun stageComplete(stage: String) {} + override fun stageFailed(stage: String, portFlags: Int, errorCode: Int) { + // no stop() here: a failed start already let go of Moonlight's connection slot + Log.w(TAG, "stage $stage failed: $errorCode"); if (why == null) why = "Couldn't join the game on $pcName" + synchronized(stoppedConns) { stoppedConns.add(c) } + if (live === c) live = null + end() + } + override fun connectionStarted() { + ok = true; everConnected = true + conn = c + if (stopped) { stopOnce(c); end(); return } + Log.i(TAG, "controller session up on $pcName") + // claim pad 0 now (remoteControllersBitmap=1 in /resume asked for it): Sunshine creates the pad at + // once, before the first press, as an Xbox-style pad with analog triggers + val all = ControllerPacket.A_FLAG or ControllerPacket.B_FLAG or ControllerPacket.X_FLAG or ControllerPacket.Y_FLAG or + ControllerPacket.LB_FLAG or ControllerPacket.RB_FLAG or ControllerPacket.BACK_FLAG or ControllerPacket.PLAY_FLAG or + ControllerPacket.LS_CLK_FLAG or ControllerPacket.RS_CLK_FLAG or ControllerPacket.UP_FLAG or ControllerPacket.DOWN_FLAG or + ControllerPacket.LEFT_FLAG or ControllerPacket.RIGHT_FLAG or ControllerPacket.SPECIAL_BUTTON_FLAG + try { c.sendControllerArrivalEvent(0, 0x1, MoonBridge.LI_CTYPE_XBOX, all, MoonBridge.LI_CCAP_ANALOG_TRIGGERS) } + catch (e: Throwable) { Log.w(TAG, "arrival: $e") } + set(State.CONNECTED, null) + started.countDown() + push() + } + override fun connectionTerminated(errorCode: Int) { + Log.i(TAG, "controller session ended: $errorCode") + conn = null + stopOnce(c) + if (live === c) live = null + if (!stopped) set(State.CONNECTING, null) + end() + } + override fun connectionStatusUpdate(connectionStatus: Int) {} + override fun displayMessage(message: String) { why = message } + override fun displayTransientMessage(message: String) {} + override fun rumble(controllerNumber: Short, lowFreqMotor: Short, highFreqMotor: Short) {} + override fun rumbleTriggers(controllerNumber: Short, leftTrigger: Short, rightTrigger: Short) {} + override fun setHdrMode(enabled: Boolean, hdrMetadata: ByteArray?) {} + override fun setMotionEventState(controllerNumber: Short, motionType: Byte, reportRateHz: Short) {} + override fun setControllerLED(controllerNumber: Short, r: Byte, g: Byte, b: Byte) {} + }) + started.await(20, java.util.concurrent.TimeUnit.SECONDS) + if (!ok && !done.get()) { why = "$pcName didn't answer"; stopOnce(c); if (live === c) live = null; end() } + return ok + } + + // ---- the pad: W3C Standard Gamepad state -> Moonlight controller 0 ---- + private val bt = FloatArray(PadServer.BUTTONS) + private val ax = FloatArray(PadServer.AXES) + + fun update(b: FloatArray, a: FloatArray) { + System.arraycopy(b, 0, bt, 0, bt.size); System.arraycopy(a, 0, ax, 0, ax.size) + push() + } + + private fun push() { + val c = conn ?: return + var f = 0 + if (bt[0] > .5f) f = f or ControllerPacket.A_FLAG + if (bt[1] > .5f) f = f or ControllerPacket.B_FLAG + if (bt[2] > .5f) f = f or ControllerPacket.X_FLAG + if (bt[3] > .5f) f = f or ControllerPacket.Y_FLAG + if (bt[4] > .5f) f = f or ControllerPacket.LB_FLAG + if (bt[5] > .5f) f = f or ControllerPacket.RB_FLAG + if (bt[8] > .5f) f = f or ControllerPacket.BACK_FLAG + if (bt[9] > .5f) f = f or ControllerPacket.PLAY_FLAG + if (bt[10] > .5f) f = f or ControllerPacket.LS_CLK_FLAG + if (bt[11] > .5f) f = f or ControllerPacket.RS_CLK_FLAG + if (bt[12] > .5f) f = f or ControllerPacket.UP_FLAG + if (bt[13] > .5f) f = f or ControllerPacket.DOWN_FLAG + if (bt[14] > .5f) f = f or ControllerPacket.LEFT_FLAG + if (bt[15] > .5f) f = f or ControllerPacket.RIGHT_FLAG + if (bt[16] > .5f) f = f or ControllerPacket.SPECIAL_BUTTON_FLAG + fun trig(v: Float) = (v.coerceIn(0f, 1f) * 255).toInt().toByte() + fun axis(v: Float) = Math.round(v * 32767).coerceIn(-32767, 32767).toShort() + // W3C Y is down-positive, Moonlight's is up-positive (as in PhoneGamepad) + c.sendControllerInput(0, 0x1, f, trig(bt[6]), trig(bt[7]), axis(ax[0]), axis(-ax[1]), axis(ax[2]), axis(-ax[3])) + } + + /** The picture is thrown away: nothing is decoded or drawn. */ + private object NoVideo : VideoDecoderRenderer() { + override fun setup(format: Int, width: Int, height: Int, redrawRate: Int) = 0 + override fun start() {} + override fun stop() {} + override fun submitDecodeUnit(decodeUnitData: ByteArray, decodeUnitLength: Int, decodeUnitType: Int, frameNumber: Int, + frameType: Int, frameHostProcessingLatency: Char, receiveTimeUs: Long, enqueueTimeUs: Long) = MoonBridge.DR_OK + override fun cleanup() {} + override fun getCapabilities() = 0 + override fun setHdrMode(enabled: Boolean, hdrMetadata: ByteArray?) {} + } + + /** The sound is never played. */ + private object NoAudio : AudioRenderer { + override fun setup(audioConfiguration: MoonBridge.AudioConfiguration, sampleRate: Int, samplesPerFrame: Int) = 0 + override fun start() {} + override fun stop() {} + override fun playDecodedAudio(audioData: ShortArray) {} + override fun cleanup() {} + } +} diff --git a/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt b/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt new file mode 100644 index 0000000000..612866bef1 --- /dev/null +++ b/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt @@ -0,0 +1,2578 @@ +package com.limelight.handheld + +import android.app.Activity +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.ServiceConnection +import android.content.SharedPreferences +import android.content.pm.ActivityInfo +import android.content.res.Configuration +import android.graphics.Outline +import android.hardware.input.InputManager +import android.net.Uri +import android.os.BatteryManager +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.provider.Settings +import android.text.format.DateFormat +import android.view.FocusFinder +import android.view.Gravity +import android.view.InputDevice +import android.view.KeyEvent +import android.view.View +import android.view.ViewGroup +import android.view.ViewOutlineProvider +import android.widget.EditText +import android.widget.FrameLayout +import android.widget.GridLayout +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import com.limelight.AppView +import com.limelight.R +import com.limelight.binding.PlatformBinding +import com.limelight.binding.crypto.AndroidCryptoProvider +import com.limelight.home.Profile +import com.limelight.computers.ComputerManagerListener +import com.limelight.computers.ComputerManagerService +import com.limelight.grid.assets.CachedAppAssetLoader +import com.limelight.grid.assets.DiskAssetLoader +import com.limelight.grid.assets.MemoryAssetLoader +import com.limelight.grid.assets.NetworkAssetLoader +import com.limelight.home.Feel +import com.limelight.home.FeelSounds +import com.limelight.home.FocusGlide +import com.limelight.home.Launch +import com.limelight.home.MoveToTv +import com.limelight.home.PadTestActivity +import com.limelight.home.Ps5Store +import com.limelight.home.Psn +import com.limelight.home.PsnSignInActivity +import com.limelight.home.QuickMenu +import com.limelight.home.Xbox +import com.limelight.nvstream.http.ComputerDetails +import com.limelight.nvstream.http.NvApp +import com.limelight.nvstream.http.NvHTTP +import com.limelight.nvstream.http.PairingManager +import com.limelight.nvstream.wol.WakeOnLanSender +import com.limelight.preferences.PreferenceConfiguration +import com.limelight.preferences.StreamSettings +import com.limelight.utils.CacheHelper +import com.limelight.utils.ServerHelper +import com.limelight.utils.ShortcutHelper +import java.io.FileNotFoundException +import java.io.StringReader +import java.net.UnknownHostException +import java.util.Date +import java.util.concurrent.Executors + +/** + * MoonCast handheld home, in the Switch style of mooncast-handheld-v2.html: one row of big game tiles (what you played + * last first), a row of round buttons, a hint bar. Upright it becomes a phone layout with a dock. This app is always + * the sender: games play here, or go to a MoonCast TV ([TvFinder] finds them, [TvSender] sends, and + * [TvControllerActivity] makes this device the TV's controller). It never receives games from other devices. + * + * Moonlight's own pieces do the work, as on the TV: ComputerManagerService finds PCs, ServerHelper starts PC games, + * Ps5StreamActivity plays a PS5 and XboxStreamActivity an Xbox. + */ +class HandheldHomeActivity : Activity() { + private enum class Page(val title: String) { HOME("Home"), GAMES("All games"), CONSOLES("Consoles"), SETTINGS("Settings") } + + /** One thing you can play: a PC game, a PS5, an Xbox, or an Android app picked for "On this device". */ + private class Game(val key: String, val title: String, val src: String, val seed: String, val running: Boolean, + val pc: ComputerDetails? = null, val app: NvApp? = null, + val ps: Ps5Store.Console? = null, val xb: Xbox.Console? = null, + val dev: com.limelight.home.DeviceApps.App? = null) + + private class Opt(val icon: String?, val iconColor: Int, val title: String, val sub: String? = null, val right: String? = null, + val enabled: Boolean = true, val onClick: () -> Unit = {}) + + private lateinit var sw: Sw + private lateinit var root: FrameLayout + private lateinit var wash: Wash + private lateinit var main: LinearLayout + private lateinit var topBar: LinearLayout + private lateinit var content: FrameLayout + private lateinit var foot: LinearLayout + private lateinit var overlay: FrameLayout + private lateinit var glide: FocusGlide + private var treeHooked = false + private var padName: TextView? = null + private var portrait = false + private val ui = Handler(Looper.getMainLooper()) + + // Moonlight's PC manager + private var binder: ComputerManagerService.ComputerManagerBinder? = null + private var polling = false + private var resumed = false + private val pcs = LinkedHashMap() + private val apps = HashMap>() + private val rawLists = HashMap() + private val loaders = HashMap() + private val appPollers = HashMap() + + private var consoles = listOf() + private val psStatus = HashMap() + private val probes = Executors.newSingleThreadExecutor() + private var xboxes: List = emptyList() + + private var page = Page.HOME + private var lastPage: Page? = null + private var gamesTab = "all" + private var sortAz = false + private var settingsGroup = "handheld" + private var selKey: String? = null // the selected game (focused, or tapped upright) + private var lastSig = "" + private var overlayKind: String? = null + private var stagger = ArrayList() + private val rbLabels = ArrayList>() + private var gname: TextView? = null + private var gnameBox: View? = null + private var gnameTarget: View? = null + + private val prefs: SharedPreferences by lazy { getSharedPreferences("mc_tv", Context.MODE_PRIVATE) } + private val stream: SharedPreferences by lazy { android.preference.PreferenceManager.getDefaultSharedPreferences(this) } + + // ---------------------------------------------------------------- lifecycle + + override fun onCreate(b: Bundle?) { + super.onCreate(b) + Profile.syncPcSettings(this) + applyRotation() + // a handheld streams at its own screen size unless you chose one (Moonlight's default is 720p); + // a resolution saved earlier, in either of Moonlight's keys, is left alone + if (!stream.contains("list_resolution") && !stream.contains("list_resolution_fps")) + stream.edit().putString("list_resolution", nativeResolution()).apply() + // Native used to be the raw panel size, which no longer matches the list: move that saved value to the new Native. + if (stream.getString("list_resolution", null) == rawNativeSize()) + stream.edit().putString("list_resolution", nativeResolution()).apply() + // Native follows the screen a stream starts on (a foldable's cover or inner screen). It used to be saved as one + // size, of the screen the app first opened on: any saved size that isn't one of the fixed steps was that. + if (!stream.contains(PreferenceConfiguration.NATIVE_RES_PREF_STRING)) + stream.edit().putBoolean(PreferenceConfiguration.NATIVE_RES_PREF_STRING, + stream.getString("list_resolution", null).let { it == null || it !in RES_STEPS.map { s -> s.second } || it == nativeResolution() }).apply() + + syncNativeRes() + MoveToTv.handler = TvSender // the players' Quick menu offers "Move to " through this + // Rebuilt for a theme change (recreate): come back to the same page, not Home (#44) + b?.getString(STATE_PAGE)?.let { n -> Page.values().firstOrNull { it.name == n }?.let { page = it } } + b?.getString(STATE_GROUP)?.let { settingsGroup = it } + buildShell() + bindService(Intent(this, ComputerManagerService::class.java), conn, Context.BIND_AUTO_CREATE) + takeIntent(intent) + (getSystemService(Context.INPUT_SERVICE) as InputManager).registerInputDeviceListener(pads, ui) + } + + override fun onSaveInstanceState(out: Bundle) { + super.onSaveInstanceState(out) + out.putString(STATE_PAGE, page.name) + out.putString(STATE_GROUP, settingsGroup) + } + + override fun onNewIntent(i: Intent) { + super.onNewIntent(i) + takeIntent(i) + } + + /** Controller mode's "Play here instead": start that game here once it's known (PCs may still be loading). */ + private var pendingPlay: String? = null + private fun takeIntent(i: Intent?) { + val k = i?.getStringExtra(EXTRA_PLAY_HERE) ?: return + i.removeExtra(EXTRA_PLAY_HERE) + pendingPlay = k + ui.post { runPending() } + } + + private var pendingSince = 0L + private val pendingTimeout = Runnable { runPending(force = true) } + + /** + * Start the pending game once its PC's state is known: right after resuming, the PC list still holds the state + * from before the poll (often "offline"), so wait for the first poll since resume, up to 6 s. + */ + private fun runPending(force: Boolean = false) { + val k = pendingPlay ?: return + if (pendingSince == 0L) { + pendingSince = android.os.SystemClock.elapsedRealtime() + toast("Starting on this ${deviceShort()}…") + ui.postDelayed(pendingTimeout, 6000) + } + val g = gameFor(k) + val pc = g?.pc + if (!force && (g == null || (pc != null && (polledAt[pc.uuid] ?: 0L) < resumedAt))) return // onComputer tries again + ui.removeCallbacks(pendingTimeout) + pendingPlay = null; pendingSince = 0L + if (g == null) { toast("Couldn't find that game here yet. Try it from the home."); return } + playHere(g) // after 6 s without a fresh answer it just tries (only a poll that says offline asks to wake it) + } + private val polledAt = HashMap() // PC uuid -> when its state last came in + private var resumedAt = 0L + + override fun onResume() { + super.onResume() + resumed = true + resumedAt = android.os.SystemClock.elapsedRealtime() + if (::glide.isInitialized) glide.resumeBreath() + com.limelight.home.DeviceApps.refresh() // apps may have been installed, updated or removed meanwhile + // anonymous usage check-in: once a day if the user said yes; the question once, a moment after home is up + com.limelight.home.Usage.checkIn(this) + if (com.limelight.home.Usage.shouldAsk(this)) ui.postDelayed({ + if (resumed && overlayKind == null && com.limelight.home.Usage.shouldAsk(this)) + dialog(com.limelight.home.Usage.QUESTION, com.limelight.home.Usage.EXPLAIN, emptyList(), + listOf("No thanks" to { com.limelight.home.Usage.answer(this, false) }, + "Yes, send" to { com.limelight.home.Usage.answer(this, true) }), primaryAt = 0) + }, 1500) + TvFinder.onChange = { render() } + if (System.currentTimeMillis() - TvFinder.lastSweepAt > 90_000) TvFinder.sweep(this) else TvFinder.check(this) + consoles = Ps5Store.all(this) + xboxes = Xbox.consoles(this) + refreshXboxes() + startPolling() + ui.post(tick) + holdMulticast(true) + render(force = true) + runPending() + // back from a stream: put the ring back on the focused item once it's laid out + ui.postDelayed({ currentFocus?.let { glide.moveTo(it) } }, 120) + } + + override fun onPause() { + resumed = false + TvFinder.onChange = null + holdMulticast(false) + stopPolling() + ui.removeCallbacks(tick) + ui.removeCallbacks(groupRender) + if (::glide.isInitialized) glide.pauseBreath() + super.onPause() + } + + override fun onDestroy() { + probes.shutdownNow() + glide.detach() + try { (getSystemService(Context.INPUT_SERVICE) as InputManager).unregisterInputDeviceListener(pads) } catch (e: Exception) { } + loaders.values.forEach { it.cancelBackgroundLoads(); it.cancelForegroundLoads(); it.freeCacheMemory() } + try { unbindService(conn) } catch (e: Exception) { } + super.onDestroy() + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (hasFocus) systemBars() + } + + /** + * The menus keep the phone's own status bar (clock, battery, notifications) and navigation, like any app; only a + * game is full screen (its own activity). Icons dark on the light theme. + */ + @Suppress("DEPRECATION") + private fun systemBars() { + // bars the colour of the app (Android 14 and older draw them black; 15+ is edge to edge and shows the app) + window.statusBarColor = sw.bg + window.navigationBarColor = sw.bg + window.decorView.systemUiVisibility = if (sw.light) + View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR or (if (android.os.Build.VERSION.SDK_INT >= 26) View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR else 0) + else 0 + } + + override fun onConfigurationChanged(c: Configuration) { + super.onConfigurationChanged(c) + syncNativeRes() // folded or unfolded: Native is now the other screen + // a turn or any size change: rebuild, everything is laid out in code for the new shape + overlayKind = null + glide.detach() + buildShell() + render(force = true) + } + + /** The status bar, navigation (or Samsung's taskbar) and cutout the menus sit inside, in px. */ + private fun systemBarSizes(): android.graphics.Rect { + if (Build.VERSION.SDK_INT < 30) return android.graphics.Rect() + val w = windowManager.currentWindowMetrics.windowInsets + return contentInsets(w.getInsetsIgnoringVisibility(android.view.WindowInsets.Type.systemBars()), + w.getInsetsIgnoringVisibility(android.view.WindowInsets.Type.displayCutout())) + } + + /** + * How far the content keeps from each edge: all of a system bar, but only as much of the camera cutout as its own + * margin doesn't already cover. On its side Android reserves the whole edge for a small camera hole (104 px on + * the Fold's cover); adding the margin on top of that pushed everything far in from the left. + */ + @android.annotation.TargetApi(30) + private fun contentInsets(bars: android.graphics.Insets, cut: android.graphics.Insets): android.graphics.Rect { + val m = if (::sw.isInitialized) sw.px(sw.edge) - sw.px(8) else 0 + fun side(b: Int, c: Int) = maxOf(b, c - m, 0) + return android.graphics.Rect(side(bars.left, cut.left), side(bars.top, cut.top), side(bars.right, cut.right), side(bars.bottom, cut.bottom)) + } + + private fun isPortrait() = resources.displayMetrics.let { it.heightPixels > it.widthPixels } + + private fun applyRotation() { + requestedOrientation = if (prefs.getBoolean("hh_rotate", true)) ActivityInfo.SCREEN_ORIENTATION_FULL_USER + else ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + } + + private val conn = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName, service: IBinder) { + val local = service as ComputerManagerService.ComputerManagerBinder + Thread { + local.waitForReady() + AndroidCryptoProvider(this@HandheldHomeActivity).clientCertificate + runOnUiThread { binder = local; startPolling() } + }.start() + } + override fun onServiceDisconnected(name: ComponentName) { binder = null; polling = false } + } + + private fun startPolling() { + val b = binder ?: return + if (!resumed || polling) return + polling = true + b.startPolling(ComputerManagerListener { d -> runOnUiThread { onComputer(d) } }) + } + + /** + * mDNS on Wi-Fi only arrives while someone holds a multicast lock (Android 13 and older; 14+ uses NsdManager). + * Moonlight's discovery agent takes one too; holding ours while the home is open covers phones that drop it. + */ + private var multicast: android.net.wifi.WifiManager.MulticastLock? = null + private fun holdMulticast(on: Boolean) { + if (Build.VERSION.SDK_INT >= 34) return + try { + if (on) { + if (multicast == null) multicast = (applicationContext.getSystemService(Context.WIFI_SERVICE) as android.net.wifi.WifiManager) + .createMulticastLock("MoonCast home mDNS").apply { setReferenceCounted(false) } + multicast?.acquire() + } else multicast?.release() + } catch (e: Exception) { } + } + + /** Restart Moonlight's discovery and polling, so a PC that just came up is found without leaving the screen. */ + private fun lookAgain() { + stopPolling() + startPolling() + } + private var lastKick = 0L + private var tvCheckAt = 0L + + private fun stopPolling() { + appPollers.values.forEach { it.stop() } + appPollers.clear() + if (polling) binder?.stopPolling() + polling = false + } + + private val pads = object : InputManager.InputDeviceListener { + override fun onInputDeviceAdded(id: Int) { buildFoot() } + override fun onInputDeviceRemoved(id: Int) { buildFoot() } + override fun onInputDeviceChanged(id: Int) { padName?.text = controllerName() } + } + + /** PS5 and TV probes while the home is on screen. */ + private val tick = object : Runnable { + override fun run() { + probeConsoles() + // MoonCast TVs: the known ones every half minute, a full sweep every two minutes (a couple of seconds' work) + val now = System.currentTimeMillis() + if (now - TvFinder.lastSweepAt > 120_000) TvFinder.sweep(this@HandheldHomeActivity) + else if (now - tvCheckAt > 30_000) { tvCheckAt = now; TvFinder.check(this@HandheldHomeActivity) } + // no PC yet: give discovery a fresh start every half minute + if (pcs.isEmpty() && android.os.SystemClock.elapsedRealtime() - lastKick > 30_000) { + lastKick = android.os.SystemClock.elapsedRealtime(); if (polling) lookAgain() } + ui.postDelayed(this, 10_000) + } + } + + + // ---------------------------------------------------------------- data + + private fun onComputer(d: ComputerDetails) { + val uuid = d.uuid ?: return + pcs[uuid] = d + // UNKNOWN = the service timed out its old poll data and hasn't asked the PC again yet + if (d.state != ComputerDetails.State.UNKNOWN) polledAt[uuid] = android.os.SystemClock.elapsedRealtime() + val raw = d.rawAppList + if (raw != null && raw != rawLists[uuid]) { + rawLists[uuid] = raw + apps[uuid] = try { NvHTTP.getAppListByReader(StringReader(raw)) } catch (e: Exception) { apps[uuid] ?: emptyList() } + } else if (!apps.containsKey(uuid)) { + apps[uuid] = try { + NvHTTP.getAppListByReader(StringReader(CacheHelper.readInputStreamToString( + CacheHelper.openCacheFileForInput(cacheDir, "applist", uuid)))) + } catch (e: Exception) { emptyList() } + } + if (online(d) && paired(d) && !appPollers.containsKey(uuid)) { + binder?.let { b -> appPollers[uuid] = b.createAppListPoller(b.getComputer(uuid) ?: d).also { it.start() } } + } + render() + runPending() + } + + private fun probeConsoles() { + val list = consoles + if (list.isEmpty()) return + probes.execute { + for (c in list) { + val st = Ps5Store.probe(c.host, c.ps5) + runOnUiThread { + psStatus[c.host] = st + val hn = st.hostName + if (hn != null && hn != c.name) { Ps5Store.rename(this, c.host, hn); consoles = Ps5Store.all(this) } + render() + } + } + } + } + + private fun refreshXboxes(loud: Boolean = false) { + if (!Xbox.signedIn(this)) return + Thread { + val r = try { Xbox.refreshConsoles(this) } catch (e: Exception) { null } + runOnUiThread { + if (r != null) { xboxes = r; render() } + if (loud) toast(when { r == null -> "Couldn't reach Xbox network"; r.isEmpty() -> "No Xbox on this account" + else -> "Found " + r.joinToString { it.name } }) + } + }.start() + } + + private fun paired(d: ComputerDetails) = d.pairState == PairingManager.PairState.PAIRED + private fun online(d: ComputerDetails) = d.state == ComputerDetails.State.ONLINE + private fun runningApp(d: ComputerDetails): NvApp? { + if (d.runningGameId == 0) return null + return apps[d.uuid]?.firstOrNull { it.appId == d.runningGameId } ?: NvApp("Your game", d.runningGameId, false) + } + private fun power(c: Ps5Store.Console) = psStatus[c.host]?.power ?: Ps5Store.Power.CHECKING + + private fun loader(d: ComputerDetails): CachedAppAssetLoader = loaders.getOrPut(d.uuid) { + CachedAppAssetLoader(d, (300.0 / sw.px(380).coerceAtLeast(1)).coerceAtLeast(1.0), + NetworkAssetLoader(this, binder?.uniqueId ?: ""), MemoryAssetLoader(), DiskAssetLoader(this), + android.graphics.BitmapFactory.decodeResource(resources, R.drawable.no_app_image)) + } + + private fun hiddenIds(pc: ComputerDetails): Set = + getSharedPreferences(AppView.HIDDEN_APPS_PREF_FILENAME, MODE_PRIVATE).getStringSet(pc.uuid, emptySet())!! + .mapNotNull { it.toIntOrNull() }.toSet() + + /** Every game: each PS5 and Xbox (one tile per console) and every PC game that isn't hidden. */ + private fun allGames(): List { + if (memoOn) gamesMemo?.let { return it } + val out = ArrayList() + consoles.forEach { c -> + val st = psStatus[c.host] + out += Game("ps:${c.host}", st?.runningApp ?: c.name, "PS5", "ps", st?.power == Ps5Store.Power.ON && st.runningApp != null, ps = c) + } + xboxes.forEach { c -> out += Game("xb:${c.id}", c.name, "Xbox", "xb", false, xb = c) } + com.limelight.home.DeviceApps.pinned(this).forEach { a -> out += Game("dev:${a.pkg}", a.label, "Android", a.pkg, false, dev = a) } + pcs.values.filter { paired(it) }.forEach { pc -> + val hidden = hiddenIds(pc) + (apps[pc.uuid] ?: emptyList()).filter { it.appId !in hidden }.forEach { a -> + out += Game("pc:${pc.uuid}:${a.appId}", a.appName, "PC", a.appName, a.appId == pc.runningGameId, pc = pc, app = a) + } + } + if (memoOn) gamesMemo = out + return out + } + + /** + * Home's row: what's running, the last few played, then every console and every app you added (they always keep + * their place: with only the last-played first, ten PC games played pushed them off Home), then PC games to fill + * up to ten, before "All games". + */ + private fun homeGames(): List { + val all = allGames() + val byKey = all.associateBy { it.key } + val recent = Recents.list(this).mapNotNull { byKey[it] } + val ordered = LinkedHashMap() + all.filter { it.running }.forEach { ordered[it.key] = it } + recent.take(5).forEach { ordered[it.key] = it } + all.filter { it.ps != null || it.xb != null }.forEach { ordered[it.key] = it } + all.filter { it.dev != null }.forEach { ordered[it.key] = it } + val kept = ordered.size + recent.forEach { ordered[it.key] = it } + all.filter { it.pc != null }.sortedBy { it.title.lowercase() }.forEach { ordered[it.key] = it } + return ordered.values.take(maxOf(10, kept)) + } + + private fun gameFor(key: String?): Game? = key?.let { k -> allGames().firstOrNull { it.key == k } } + + // ---------------------------------------------------------------- shell + + private fun buildShell() { + portrait = isPortrait() + sw = Sw(this, prefs.getString("hh_theme", "dark") == "light", portrait, Sw.pick(this, portrait, prefs.getString("hh_screen_size", "auto"))) + sw.touchFirst = !hasController() + sw.bars = systemBarSizes() + root = FrameLayout(this).apply { setBackgroundColor(sw.bg) } + wash = Wash(this) + root.addView(wash, FrameLayout.LayoutParams(-1, -1)) + main = sw.col().apply { clipChildren = false } + + topBar = sw.row() + val side = sw.edge + main.addView(topBar, sw.lp(-1, 96).apply { leftMargin = sw.px(side); rightMargin = sw.px(side); topMargin = sw.px(if (sw.compact) 24 else 44) }) + + content = FrameLayout(this).apply { clipChildren = false; clipToPadding = false } + main.addView(content, LinearLayout.LayoutParams(-1, 0, 1f)) + + foot = sw.row() + // the same hint bar upright and on its side (portrait used to have a dock of its own) + foot.background = sw.topLine() + main.addView(foot, sw.lp(-1, if (sw.compact) 100 else 120).apply { leftMargin = sw.px(side); rightMargin = sw.px(side) }) + + root.addView(main, FrameLayout.LayoutParams(-1, -1)) + overlay = FrameLayout(this).apply { visibility = View.GONE; isClickable = true } + root.addView(overlay, FrameLayout.LayoutParams(-1, -1)) + setContentView(root) + // drawn edge to edge (target SDK 36): keep everything clear of the status bar, the navigation and any cutout + // the background and the colour wash go edge to edge, behind the bars and the cutout; the content and the + // dialogs stay inside them (on its side the cutout and the navigation are at the sides) + root.setOnApplyWindowInsetsListener { _, ins -> + val pad = if (android.os.Build.VERSION.SDK_INT >= 30) { + val r = contentInsets(ins.getInsets(android.view.WindowInsets.Type.systemBars()), ins.getInsets(android.view.WindowInsets.Type.displayCutout())) + intArrayOf(r.left, r.top, r.right, r.bottom) + } else @Suppress("DEPRECATION") intArrayOf(ins.systemWindowInsetLeft, ins.systemWindowInsetTop, ins.systemWindowInsetRight, ins.systemWindowInsetBottom) + main.setPadding(pad[0], pad[1], pad[2], pad[3]) + overlay.setPadding(pad[0], pad[1], pad[2], pad[3]) + ins + } + systemBars() + + // the Switch ring: a background-coloured gap, then cyan, standing off the item, breathing slowly + glide = FocusGlide(root).apply { + ghost = false + strokeColor = sw.ring + strokeWidth = sw.pxf(7) + gapColor = sw.bg + gapWidth = sw.pxf(6) + outset = sw.pxf(13) + cornerRadius = sw.pxf(19) + duration = 200 + radiusFor = { v -> + val t = v.tag as? String + when { + v.getTag(R.id.hh_pill) == true -> 9999f + t != null && (t.startsWith("rb:") || t.startsWith("pill:") || t.startsWith("tab:") || t.startsWith("dock:") || t.startsWith("chip:")) -> 9999f + t != null && t.startsWith("app:") -> sw.pxf(13 + 22) + t != null && t.startsWith("group:") -> sw.pxf(13 + 16) + t != null && t.startsWith("g:") -> sw.pxf(13 + sw.tileCorner) + else -> null + } + } + accept = { v -> v !is EditText } + breathe = true + }.attach() + // once per window: buildShell runs again on every rotation or fold, and each run used to add another pair + if (!treeHooked) { + treeHooked = true + window.decorView.viewTreeObserver.addOnGlobalFocusChangeListener { _, n -> onFocus(n) } + window.decorView.viewTreeObserver.addOnScrollChangedListener { placeName() } + } + snack = null + FeelSounds.warm(this) + lastPage = null + lastSig = "" + } + + private fun go(p: Page) { + if (p == page && lastPage == p) return + page = p + render(force = true) + } + + private fun signature(): String = buildString { + append(page).append(gamesTab).append(sortAz).append(settingsGroup).append(portrait) + pcs.values.forEach { append(it.uuid).append(it.state).append(it.pairState).append(it.runningGameId).append(apps[it.uuid]?.size) } + consoles.forEach { append(it.host).append(it.name).append(psStatus[it.host]?.power).append(psStatus[it.host]?.runningApp) } + xboxes.forEach { append(it.id).append(it.name).append(it.powerState) } + if (portrait && page == Page.HOME) append(selKey) + append(TvFinder.target(this@HandheldHomeActivity)?.let { it.id + it.name + TvFinder.isHere(it) }) + } + + /** Rebuild the page when something visible changed, keeping focus on the same item. */ + private fun render(force: Boolean = false) { + Profile.syncPcSettings(this) + val sig = signature() + if (!force && sig == lastSig) return + gamesMemo = null; memoOn = true + try { renderPage(sig) } finally { memoOn = false; gamesMemo = null } + } + + /** allGames() is asked for several times per render (Home's row, the wash, the selected game): work it out once. */ + private var gamesMemo: List? = null + private var memoOn = false + + private fun renderPage(sig: String) { + lastSig = sig + val entering = lastPage != page + lastPage = page + val focusTag = currentFocus?.tag as? String + stagger = ArrayList() + rbLabels.clear() + gname = null; gnameBox = null; gnameTarget = null + buildTop() + buildFoot() + content.removeAllViews() + primaryView = null + val pg = when (page) { + Page.HOME -> homeLandscape() // one Home for both ways round, fitted to the screen + Page.GAMES -> gamesPage() + Page.CONSOLES -> consolesPage() + Page.SETTINGS -> settingsPage() + } + content.addView(pg, FrameLayout.LayoutParams(-1, -1)) + // what a pad's first press lands on when the screen was last touched (Android restores the default focus) + if (Build.VERSION.SDK_INT >= 26) primaryView?.isFocusedByDefault = true + if (page == Page.HOME) wash.color = gameFor(selKey)?.let { sw.tint(it.seed) } ?: (homeGames().firstOrNull()?.let { sw.tint(it.seed) } ?: 0) + if (entering) entrance() + if (overlayKind != null) return + when { + !entering && focusTag != null && content.findViewWithTag(focusTag)?.requestFocus() == true -> {} + else -> (primaryView ?: firstFocusable(content))?.requestFocus() + } + if (entering && stagger.isNotEmpty()) { + // the ring waits for the screen to build in, then lands on the focused item + glide.hide() + ui.postDelayed({ currentFocus?.let { glide.moveTo(it) } }, stagger.size * 55L + 360) + } + ui.post { placeName(); syncLabels(currentFocus) } + } + + private fun firstFocusable(v: View): View? { + if (v.isFocusable && v.isClickable && v.visibility == View.VISIBLE) return v + if (v is ViewGroup) for (i in 0 until v.childCount) firstFocusable(v.getChildAt(i))?.let { return it } + return null + } + + /** Screens build in: each part rises and fades in, 55 ms after the one before (the feel mockup's entrance). */ + private fun entrance() { + val reduced = try { Settings.Global.getFloat(contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f } catch (e: Exception) { false } + stagger.forEachIndexed { i, v -> + if (reduced) return@forEachIndexed + v.alpha = 0f; v.translationY = sw.pxf(22) + v.animate().alpha(1f).translationY(0f).setStartDelay(i * 55L).setDuration(420) + .setInterpolator(android.view.animation.PathInterpolator(0.22f, 1f, 0.36f, 1f)).start() + } + } + + private fun part(v: T): T { stagger.add(v); return v } + + // ---- top bar and foot ---- + + private fun buildTop() { + topBar.removeAllViews() + if (page == Page.HOME) { + topBar.addView(avatar(), sw.lp(84, 84).apply { rightMargin = sw.px(20) }) + topBar.addView(sw.row().apply { + background = sw.round(sw.panel, 42) + setPadding(sw.px(28), 0, sw.px(34), 0) + addView(View(this@HandheldHomeActivity).apply { background = sw.round(0, 5, sw.ink, 3) }, sw.lp(32, 22).apply { rightMargin = sw.px(14) }) + val tv = TvFinder.target(this@HandheldHomeActivity) + addView(sw.text(when { + tv != null -> "Playing on ${tv.name}" + if (TvFinder.isHere(tv)) "" else " (not found)" + portrait -> "This ${deviceShort()}" + else -> "Playing on this ${deviceShort()}" + }, 26, sw.ink, 700).apply { maxWidth = resources.displayMetrics.widthPixels / 2 }) // a long TV name ends in … + setOnClickListener { playWhere(null) } + isFocusable = false // a tap target only; the pad starts on the games + isSoundEffectsEnabled = !FeelSounds.enabled(this@HandheldHomeActivity) + }, sw.lp(-2, 84)) + } else { + // a back button you can tap: a touch screen has no B (the same as B / the system back) + topBar.addView(roundButton("top:back", "back", sw.ink, 84, sw.panel) { onBackPressed() }.apply { isFocusable = false; contentDescription = "Back" }, + LinearLayout.LayoutParams(sw.tap(84), sw.tap(84)).apply { rightMargin = sw.px(24) }) + topBar.addView(sw.text(page.title, 44, sw.ink, 800)) + } + topBar.addView(sw.grow()) + // the clock and battery are the phone's own status bar now + part(topBar) + } + + /** MoonCast's logo at the start of Home's top bar (it was a circle with the player's initial). */ + private fun avatar(): View = ImageView(this).apply { + setImageResource(R.drawable.mc_logo) + scaleType = ImageView.ScaleType.FIT_CENTER + contentDescription = "MoonCast" + } + + private fun buildFoot() { + foot.removeAllViews() + padName = null + foot.gravity = Gravity.CENTER_VERTICAL + foot.addView(Glyph(this, "pad", sw.sub), sw.lp(74, 38).apply { rightMargin = sw.px(14) }) + padName = sw.text(controllerName(), 28, sw.sub, 700) + foot.addView(padName) + foot.addView(sw.grow()) + // button hints mean nothing without buttons: touch only (no controller) shows none + if (!hasController()) { part(foot); return } + val h = if (overlayKind == "picker") listOf("B" to "Done", "A" to "Add or remove") + else if (overlayKind != null) listOf("B" to "Cancel", "A" to "OK") else when (page) { + Page.HOME -> listOf("Y" to (if (TvFinder.target(this) != null) "Play here" else "Play on TV"), "A" to "Start") + Page.GAMES -> listOf("L R" to "Tabs", "X" to (if (sortAz) "Recent first" else "A to Z"), "B" to "Back", "A" to "Start") + Page.CONSOLES -> listOf("B" to "Back", "A" to "Open") + Page.SETTINGS -> listOf("B" to "Back", "A" to "OK") + } + h.forEach { (k, l) -> + foot.addView((if (k.length > 1) sw.row().apply { + addView(sw.text(k, 22, sw.bg, 800).apply { gravity = Gravity.CENTER; background = sw.round(sw.ink, 23); setPadding(sw.px(14), 0, sw.px(14), 0) }, sw.lp(-2, 46).apply { rightMargin = sw.px(14) }) + addView(sw.text(l, 28, sw.ink, 700)) + } else sw.hint(k, l)).apply { + // a tap on a hint does what its button does + setOnClickListener { pressButton(k) } + isFocusable = false + }, sw.lp(-2, -2).apply { leftMargin = sw.px(40) }) + } + part(foot) + } + + // ---------------------------------------------------------------- Home + + private fun homeLandscape(): View { + val games = homeGames() + val col = sw.col().apply { clipChildren = false } + val box = FrameLayout(this).apply { clipChildren = false } + gname = sw.text("", 34, sw.ring, 700) + box.addView(gname, FrameLayout.LayoutParams(-2, -2)) + gnameBox = box + val e = sw.edge + val gap = 32 + val headTop = if (sw.compact) 28 else 80 + // the round buttons: two rows of three upright, one row on its side + val rbGap = 48 + val roundsTop = if (portrait) 64 else if (sw.compact) 72 else 80 + val roundsH = if (portrait) 110 * 2 + rbGap else 180 + // the height left for tiles after the top bar, the bottom bar, the name line and the round buttons + val freeH = sw.designHeight - ((if (sw.compact) 24 else 44) + 96) - (if (sw.compact) 100 else 120) - + (headTop + 48 + 20) - (roundsTop + roundsH) - 40 + val usableW = sw.designWidth - 2 * e + // Upright the tiles fill the width, as many columns as fit about 480 wide (two on a phone, three on an open + // foldable) and as many rows as the height allows. On its side they keep their size, and a screen tall enough + // for two rows (an open foldable, a tablet) gets a grid instead of the scrolling row. + var cols: Int; var rows: Int; var ts: Int + if (portrait) { + cols = ((usableW + gap) / (480 + gap)).toInt().coerceAtLeast(2) + ts = ((usableW - gap * (cols - 1)) / cols).toInt() + rows = ((freeH + gap) / (ts + gap)).toInt() + if (rows < 2) ts = minOf(ts, (sw.designHeight * 0.34f).toInt(), freeH.toInt()).coerceAtLeast(240) + } else { + ts = if (sw.compact) 350 else 380 + rows = ((freeH + gap) / (ts + gap)).toInt() + cols = ((usableW + gap) / (ts + gap)).toInt() + if (rows >= 2) ts = minOf(((usableW - gap * (cols - 1)) / cols).toInt(), ((freeH - gap * (rows - 1)) / rows).toInt()) + } + val grid = rows >= 2 && cols >= 2 + // labels under every round button whenever there's room for them (compact, upright, a big screen) + labelsShown = sw.compact || grid || !hasController() + val gridW = cols * ts + (cols - 1) * gap + val left = if (grid) ((sw.designWidth - gridW) / 2f).toInt() else e + col.addView(part(box), sw.lp(-1, 48).apply { topMargin = sw.px(headTop); leftMargin = sw.px(left) }) + + val tiles = ArrayList() + val shown = if (grid) games.take(cols * rows - 2) else games // the last two cells: Add apps, All games + if (games.isEmpty()) tiles += tileView(null, ts, 36, "g:add", "Add a console") { go(Page.CONSOLES) } + shown.forEach { g -> tiles += tileView(g, ts, 36, "g:${g.key}", g.title) { play(g) } } + // always on Home: the way to add this device's apps (before, it was only in All games, This ) + tiles += tileView(null, ts, 36, "g:devadd", "Add apps") { pickDeviceApps() } + tiles += tileView(null, ts, 36, "g:all", "All games") { gamesTab = "all"; go(Page.GAMES) } + if (grid) { + val gl = GridLayout(this).apply { columnCount = cols; clipChildren = false } + tiles.forEachIndexed { i, t -> + gl.addView(t, GridLayout.LayoutParams().apply { + width = sw.px(ts); height = sw.px(ts) + if (i % cols > 0) leftMargin = sw.px(gap) + if (i >= cols) topMargin = sw.px(gap) + }) + } + col.addView(part(gl), sw.lp(-2, -2).apply { topMargin = sw.px(20); leftMargin = sw.px(left) }) + } else { + val row = sw.row().apply { clipChildren = false; setPadding(sw.px(e), 0, sw.px(e), 0) } + tiles.forEachIndexed { i, t -> row.addView(t, sw.lp(ts, ts).apply { if (i > 0) leftMargin = sw.px(gap) }) } + val keepX = homeScrollX + val scroll = RowScroll(this, sw.px(e)).apply { + if (keepX > 0) post { scrollTo(keepX, 0) } + if (Build.VERSION.SDK_INT >= 23) setOnScrollChangeListener { _, x, _, _, _ -> homeScrollX = x } + isHorizontalScrollBarEnabled = false; isFocusable = false; clipChildren = false; clipToPadding = false + isHorizontalFadingEdgeEnabled = true; setFadingEdgeLength(sw.px(90)) + overScrollMode = View.OVER_SCROLL_NEVER + addView(row) + } + // room under the row for the tiles' shadow + col.addView(part(scroll), sw.lp(-1, ts + 24).apply { topMargin = sw.px(20) }) + scroll.setPadding(0, 0, 0, sw.px(24)) + } + // the tile you were on (or the first) is where focus starts + primaryView = tiles.firstOrNull { it.tag == "g:$selKey" } ?: tiles.first() + + // upright: two rows of three, each label beside its button (six in a row came out cramped) + val rounds: ViewGroup = if (portrait) GridLayout(this).apply { columnCount = 3; clipChildren = false } + else sw.row(Gravity.TOP).apply { gravity = Gravity.CENTER_HORIZONTAL; clipChildren = false } + val cell = (usableW / 3f).toInt() + // where every label shows at once they're the short ones, to sit side by side + listOf(Triple("Consoles", "consoles", Sw.C_CYAN), Triple(if (labelsShown) "Play on TV" else "Play on a TV", "tv", Sw.C_AMBER), Triple("Controllers", "pad", Sw.C_BLUE), + Triple(if (labelsShown) "Network" else "Network test", "net", Sw.C_GREEN), Triple("Settings", "gear", sw.sub), Triple("Exit", "power", Sw.C_RED)).forEachIndexed { i, (label, icon, color) -> + val btn = roundButton("rb:$icon", icon, color, 112, sw.panel) { roundAction(icon) } + btn.contentDescription = label + val lab = sw.text(label, 27, if (labelsShown) sw.label else sw.ring, 700).apply { gravity = Gravity.CENTER_HORIZONTAL; visibility = if (labelsShown) View.VISIBLE else View.INVISIBLE } + rbLabels += btn to lab + if (portrait) { + lab.gravity = Gravity.CENTER_VERTICAL + rounds.addView(sw.row().apply { + clipChildren = false + addView(btn, sw.lp(110, 110)) + addView(lab, sw.lp(-2, -2).apply { leftMargin = sw.px(22) }) + }, GridLayout.LayoutParams().apply { width = sw.px(cell); height = sw.px(110); if (i >= 3) topMargin = sw.px(rbGap) }) + } else rounds.addView(FrameLayout(this).apply { + clipChildren = false + addView(btn, sw.flp(112, 112)) + addView(lab, FrameLayout.LayoutParams(sw.px(420), -2, Gravity.CENTER_HORIZONTAL).apply { topMargin = sw.px(132) }) + }, sw.lp(112, 180).apply { if (i > 0) leftMargin = sw.px(if (grid) 120 else if (labelsShown) 76 else 40) }) + } + if (portrait) col.addView(part(rounds), sw.lp(-2, -2).apply { topMargin = sw.px(roundsTop); leftMargin = sw.px(if (grid) left else e) }) + else col.addView(part(rounds), sw.lp(-1, 180).apply { topMargin = sw.px(roundsTop) }) + // with height to spare (a tall phone, few games): the spare room goes a third above and the rest below, + // instead of one big gap over the tiles + return BiasFrame(this, 0.3f).apply { clipChildren = false; addView(col, FrameLayout.LayoutParams(-1, -2)) } + } + + /** Home shows every round button's label (compact, upright or a big screen), not only the focused one's. */ + private var labelsShown = false + + /** Where focus lands when a page opens (each page sets it while it builds). */ + private var primaryView: View? = null + + private fun roundAction(icon: String) { + when (icon) { + "consoles" -> go(Page.CONSOLES) + "tv" -> playWhere(null) + "pad" -> controllers() + "net" -> networkTest() + "gear" -> go(Page.SETTINGS) + "power" -> { FeelSounds.play(this, FeelSounds.Cue.BACK); PadLink.stop(); finishAndRemoveTask() } + } + } + + private fun roundButton(tag: String, icon: String, color: Int, size: Int, fill: Int, onClick: () -> Unit): View = FrameLayout(this).apply { + this.tag = tag + background = sw.round(fill, size / 2) + addView(Icon(this@HandheldHomeActivity, icon, color), sw.flp(size * 54 / 112, size * 54 / 112, Gravity.CENTER)) + sw.focusable(this) + sw.pressable(this) + setOnClickListener { onClick() } + } + + private fun pill(tag: String, label: String, main: Boolean, onClick: () -> Unit): View = sw.text(label, 32, if (main) sw.bg else sw.ink, 800).apply { + this.tag = tag + gravity = Gravity.CENTER + background = sw.round(if (main) sw.ink else sw.panel, 56) + sw.focusable(this) + sw.pressable(this) + setOnClickListener { onClick() } + } + + /** A square game tile: art (box art for PC games), its source, Running, and the name at the bottom. */ + private fun tileView(g: Game?, size: Int, titleSize: Int, tag: String, label: String, onClick: (View) -> Unit): FrameLayout { + val f = FrameLayout(this) + f.tag = tag + f.setTag(R.id.hh_title, label) + f.contentDescription = label + val custom = g?.let { com.limelight.home.CustomArt.bitmap(this, it.key) } + val appColor = if (custom != null) com.limelight.home.CustomArt.color(g!!.key, custom) + else g?.dev?.let { com.limelight.home.DeviceApps.color(this, it.pkg) } ?: 0 + f.setTag(R.id.hh_tint, if (appColor != 0) appColor else if (g != null) sw.tint(g.seed) else 0) + f.clipToOutline = true + f.outlineProvider = rounded(sw.tileCorner) + // a soft shadow lifts the tiles off the background on Home; All games' grid goes without (one shadow per tile + // to draw for every cover while scrolling) + if (page != Page.GAMES) f.elevation = sw.pxf(8) + sw.pressable(f) + if (g == null) { + // All games and Add apps are quiet tiles next to the games; Add a console (nothing paired yet) stands out + f.background = when (tag) { "g:all" -> sw.allArt(); "g:devadd" -> sw.round(sw.tile, sw.tileCorner); else -> sw.art("add") } + if (tag != "g:all") f.addView(sw.text("+", size * 0.3f, 0xFFFFFFFF.toInt(), 600).apply { gravity = Gravity.CENTER }, FrameLayout.LayoutParams(-1, -1)) + // All games doesn't show the name above the grid, so Add apps carries its own + if (tag == "g:devadd") f.addView(sw.text(label, titleSize, 0xFFFFFFFF.toInt(), 800).apply { + gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL; setPadding(0, 0, 0, sw.px(size * 0.08f)) + }, FrameLayout.LayoutParams(-1, -1)) + } else { + f.background = if (appColor != 0) sw.artFrom(appColor) else sw.art(g.seed) + val k = size / 380f + val title = sw.text(g.title, titleSize, 0xFFFFFFFF.toInt(), 800, 3).apply { + gravity = Gravity.BOTTOM + setPadding(sw.px(26 * k.coerceAtMost(1.5f)), 0, sw.px(26 * k.coerceAtMost(1.5f)), sw.px(24 * k.coerceAtMost(1.5f))) + setShadowLayer(sw.pxf(10), 0f, sw.pxf(2), 0x59000000) + } + if (custom != null) { + // your own picture fills the tile, darkened at the bottom so the name stays readable + f.addView(ImageView(this).apply { setImageBitmap(custom); scaleType = ImageView.ScaleType.CENTER_CROP }, FrameLayout.LayoutParams(-1, -1)) + f.addView(View(this).apply { + background = android.graphics.drawable.GradientDrawable(android.graphics.drawable.GradientDrawable.Orientation.TOP_BOTTOM, + intArrayOf(0x00000000, 0x00000000, 0xB0000000.toInt())) + }, FrameLayout.LayoutParams(-1, -1)) + f.addView(title, FrameLayout.LayoutParams(-1, -1)) + } else if (g.pc != null && g.app != null) { + val img = ImageView(this).apply { scaleType = ImageView.ScaleType.CENTER_CROP } + f.addView(img, FrameLayout.LayoutParams(-1, -1)) + f.addView(title, FrameLayout.LayoutParams(-1, -1)) + loader(g.pc).populateImageView(g.app, img, title) + } else if (g.dev != null) { + // an Android app, on a cover in its own colour: a big faded copy of its icon leaning in from the + // corner, the icon itself above the middle, the name under it + val icon = com.limelight.home.DeviceApps.icon(this, g.dev.pkg) + if (icon != null) { + f.addView(ImageView(this).apply { + setImageDrawable(icon.constantState?.newDrawable()?.mutate() ?: icon) + scaleType = ImageView.ScaleType.FIT_CENTER; alpha = 0.16f; rotation = -14f + }, FrameLayout.LayoutParams(sw.px(size * 0.95f), sw.px(size * 0.95f), Gravity.END or Gravity.BOTTOM).apply { + rightMargin = -sw.px(size * 0.22f); bottomMargin = -sw.px(size * 0.18f) }) + f.addView(ImageView(this).apply { setImageDrawable(icon); scaleType = ImageView.ScaleType.FIT_CENTER }, + FrameLayout.LayoutParams(sw.px(size * 0.5f), sw.px(size * 0.5f), Gravity.CENTER_HORIZONTAL).apply { topMargin = sw.px(size * 0.15f) }) + } + f.addView(title, FrameLayout.LayoutParams(-1, -1)) + } else f.addView(title, FrameLayout.LayoutParams(-1, -1)) + val bs = if (size >= 500) 24 else if (size >= 360) 20 else 17 + val m = if (size >= 360) 18 else 12 + f.addView(sw.badge(g.src, bs), sw.flp(-2, bs * 2).apply { leftMargin = sw.px(m); topMargin = sw.px(m) }) + if (g.running) f.addView(sw.live(bs), sw.flp(-2, bs * 2, Gravity.END).apply { rightMargin = sw.px(m); topMargin = sw.px(m) }) + run { + f.setOnLongClickListener { gameMenu(g); true } + f.setOnKeyListener { _, code, e -> + if (code == KeyEvent.KEYCODE_BUTTON_X && page == Page.HOME) { if (e.action == KeyEvent.ACTION_UP) gameMenu(g); true } else false + } + } + } + sw.focusable(f) + f.setOnClickListener { onClick(it) } + return f + } + + /** Focus moved: the name above the row, the round button's label, the colour wash and the selected game follow. */ + private fun onFocus(v: View?) { + syncLabels(v) + val t = v?.tag as? String + // the name above the row belongs to a game tile: hide it while focus is anywhere else (the round buttons) + gnameBox?.let { box -> + val show = t != null && t.startsWith("g:") || v == null || v.isInTouchMode + box.animate().cancel(); box.animate().alpha(if (show) 1f else 0f).setDuration(150).start() + } + if (t == null) return + if (t.startsWith("g:") || t == "hero") { + if (t.startsWith("g:") && t != "g:all" && t != "g:add") selKey = t.removePrefix("g:") + (v.getTag(R.id.hh_tint) as? Int)?.let { if (it != 0) wash.color = it } + if (gname != null && t.startsWith("g:")) { + gnameTarget = v + fadeSwap(gname!!, v.getTag(R.id.hh_title) as? String ?: "") + placeName() + } + } + } + + private fun syncLabels(v: View?) { + rbLabels.forEach { (b, l) -> + if (labelsShown) l.setTextColor(if (b === v && !b.isInTouchMode) sw.ring else sw.label) // always shown, the focused one lit + else l.visibility = if (b === v && !b.isInTouchMode) View.VISIBLE else View.INVISIBLE + } + if (gname != null && gnameTarget == null) { + // nothing focused yet (touch): name the first tile + val first = (content.findViewWithTag("g:$selKey") ?: primaryView) + if (first != null && first.tag.toString().startsWith("g:")) { gnameTarget = first; gname?.text = first.getTag(R.id.hh_title) as? String ?: "" } + placeName() + } + } + + /** Upright, moving over the Recent grid puts that game in the big tile without rebuilding the page. */ + private fun placeName() { + val n = gname ?: return + val t = gnameTarget ?: return + val box = gnameBox ?: return + if (!t.isAttachedToWindow) return + val a = IntArray(2); val b = IntArray(2) + t.getLocationInWindow(a); box.getLocationInWindow(b) + val x = (a[0] - b[0]).toFloat() + sw.pxf(6) + // keep the name on screen when the tile is near the right edge + val maxX = (resources.displayMetrics.widthPixels - b[0] - n.width - sw.px(48)).toFloat() + n.translationX = x.coerceAtMost(maxX.coerceAtLeast(0f)).coerceAtLeast(0f) + } + + private fun fadeSwap(v: TextView, s: String) { + if (v.text.toString() == s) return + v.animate().cancel() + if (v.text.isEmpty()) { v.text = s; v.alpha = 1f; v.translationY = 0f; return } + v.animate().alpha(0f).translationY(-sw.pxf(10)).setDuration(110).withEndAction { + v.text = s; v.translationY = sw.pxf(12) + ui.post { placeName() } + v.animate().alpha(1f).translationY(0f).setStartDelay(40).setDuration(240).start() + }.start() + } + + // ---------------------------------------------------------------- All games + + private fun gamesPage(): View { + val col = sw.col().apply { setPadding(sw.px(sw.edge), 0, sw.px(sw.edge), 0); clipChildren = false } + val tabs = sw.row() + listOf("all" to "All", "pc" to "PC", "ps" to "PlayStation", "xb" to "Xbox", "dev" to "This ${deviceShort()}").forEachIndexed { i, (k, label) -> + val on = k == gamesTab + tabs.addView(sw.text(label, 26, if (on) sw.bg else sw.sub, 700).apply { + tag = "tab:$k" + gravity = Gravity.CENTER + setPadding(sw.px(30), 0, sw.px(30), 0) + background = sw.round(if (on) sw.ink else sw.panel, 32) + sw.focusable(this) + setOnClickListener { if (gamesTab != k) { gamesTab = k; FeelSounds.play(this@HandheldHomeActivity, FeelSounds.Cue.TAB); render(force = true) } } + }, LinearLayout.LayoutParams(-2, sw.tap(64)).apply { if (i > 0) leftMargin = sw.px(14) }) + } + col.addView(part(tabs), LinearLayout.LayoutParams(-2, sw.tap(64)).apply { topMargin = sw.px(20) }) + + val recent = Recents.list(this) + var list = allGames().filter { g -> when (gamesTab) { "pc" -> g.pc != null; "ps" -> g.ps != null; "xb" -> g.xb != null; "dev" -> g.dev != null; else -> true } } + list = if (sortAz) list.sortedBy { it.title.lowercase() } + else list.sortedWith(compareByDescending { it.running }.thenBy { recent.indexOf(it.key).let { i -> if (i < 0) Int.MAX_VALUE else i } } + .thenBy { if (it.pc != null) 1 else 0 }.thenBy { it.title.lowercase() }) + if (list.isEmpty() && gamesTab != "dev") { + val what = when (gamesTab) { "pc" -> "No PC games yet"; "ps" -> "No PlayStation yet"; "xb" -> "No Xbox yet"; else -> "No games yet" } + col.addView(part(sw.text(what, 40, sw.ink, 800)), sw.lp(-2, -2).apply { topMargin = sw.px(80) }) + col.addView(part(sw.text(if (pcs.isEmpty() && consoles.isEmpty() && xboxes.isEmpty()) "Pair a PC, register a PS5 or sign in to Xbox in Consoles." + else "Still loading, or nothing here. Consoles shows what's connected.", 26, sw.sub, 600, 2)), + sw.lp(-1, -2).apply { topMargin = sw.px(14) }) + col.addView(part(pill("pill:consoles", "Go to Consoles", true) { go(Page.CONSOLES) }.apply { primaryView = this }), sw.lp(480, 112).apply { topMargin = sw.px(40) }) + return col + } + val cols = if (portrait) 3 else if (sw.compact) 4 else 6 + val gap = if (portrait) 22 else 24 + val size = ((sw.designWidth - 2 * sw.edge - (cols - 1) * gap) / cols).toInt() + val grid = GridLayout(this).apply { columnCount = cols; clipChildren = false } + val cells = ArrayList() + // This device's tab starts with Add apps: you pick which apps show (and remove them from a tile's menu) + if (gamesTab == "dev") cells += tileView(null, size, if (portrait) 28 else 26, "g:devadd", "Add apps") { pickDeviceApps() } + list.forEach { g -> cells += tileView(g, size, if (portrait) 28 else 26, "g:${g.key}", g.title) { play(g) } } + cells.forEachIndexed { i, t -> + grid.addView(t, GridLayout.LayoutParams().apply { + width = sw.px(size); height = sw.px(size) + if (i % cols != 0) leftMargin = sw.px(gap) + bottomMargin = sw.px(gap) + }) + } + primaryView = grid.getChildAt(0) + // a redraw in the background (a poll, a game starting on the PC) kept you where you were, not back at the top + val keepY = if (gamesScrollTab == gamesTab) gamesScrollY else 0 + gamesScrollTab = gamesTab + col.addView(part(clipped(ColScroll(this, sw.px(40)).apply { + isVerticalScrollBarEnabled = false; isFocusable = false; clipToPadding = false; clipChildren = false + setPadding(0, sw.px(26), 0, sw.px(26)) + addView(grid) + if (keepY > 0) post { scrollTo(0, keepY) } + if (Build.VERSION.SDK_INT >= 23) setOnScrollChangeListener { _, _, y, _, _ -> gamesScrollY = y } + })), LinearLayout.LayoutParams(-1, 0, 1f).apply { topMargin = sw.px(20) }) + return col + } + + // ---------------------------------------------------------------- Consoles + + private fun consolesPage(): View { + val list = sw.col() + consoles.forEach { c -> + val pw = power(c) + listRow(list, "con:ps:${c.host}", "PS", Sw.PS, c.name, when (pw) { + Ps5Store.Power.ON -> "On" + (psStatus[c.host]?.runningApp?.let { ", playing $it" } ?: "") + Ps5Store.Power.REST -> "Rest mode. Play wakes it in about a minute" + Ps5Store.Power.NOT_FOUND -> if (Psn.signedIn(this)) "Away. Plays through PlayStation Network" else "Not on this network" + Ps5Store.Power.CHECKING -> "Checking…" + }, if (pw == Ps5Store.Power.ON) "On" else null) { psMenu(c) } + } + xboxes.forEach { c -> + val on = c.powerState.equals("On", true) + listRow(list, "con:xb:${c.id}", "XB", Sw.XB, c.name, if (on) "On" else "Wakes when you play", if (on) "On" else null) { xbMenu(c) } + } + pcs.values.forEach { d -> + val n = apps[d.uuid]?.size ?: 0 + listRow(list, "con:pc:${d.uuid}", "PC", Sw.PC, d.name, when { + !online(d) -> "Offline. Wake it to play" + !paired(d) -> "Found on your network. Pair it to play" + d.runningGameId != 0 -> "Playing ${runningApp(d)?.appName}" + else -> if (n > 0) "$n games" else "Paired" + }, when { !online(d) -> null; !paired(d) -> "Pair"; else -> "Online" }) { pcMenu(d) } + } + if (pcs.isEmpty()) listRow(list, "add:look", "PC", Sw.PC, "Looking for PCs on this network…", + "PCs running Sunshine show up here by themselves. Select to look again", null) { lookAgain(); toast("Looking for PCs…") } + groupHeader(list, "Add") + listRow(list, "add:pc", "+", sw.tile, "Add a PC by its address", "For a PC this device doesn't find on its own", null) { addPcByAddress() } + listRow(list, "add:ps", "PS", Sw.PS, "Register a PS5", if (Psn.signedIn(this)) "Link it with the code from its Remote Play settings" else "Sign in to PlayStation Network, then link it", null) { registerPs() } + if (Xbox.signedIn(this)) listRow(list, "add:xb", "XB", Sw.XB, "Find my Xbox", "Signed in" + (Xbox.gamertag(this)?.let { " as $it" } ?: ""), null) { refreshXboxes(true) } + else listRow(list, "add:xb", "XB", Sw.XB, "Sign in to Xbox", "Play your Xbox here, at home or away", null) { xboxSignIn() } + primaryView = null + return scrollPane(list) + } + + private fun scrollPane(list: LinearLayout): View = clipped(ColScroll(this, sw.px(40)).apply { + isVerticalScrollBarEnabled = false; isFocusable = false; clipToPadding = false; clipChildren = false + val side = sw.edge + setPadding(sw.px(side), sw.px(30), sw.px(side), sw.px(30)) + addView(part(list)) + }) + + /** + * A scrolling list inside a frame that clips it at its edges. A view is clipped by its parent, and the content area + * doesn't clip (tiles and the focus ring may spill over), so rows scrolled below the bottom drew over the hint bar + * (Consoles' Find my Xbox, a sliver of All games' next row). The focus ring draws on its own layer. + */ + private fun clipped(v: View): FrameLayout = FrameLayout(this).apply { clipChildren = true; addView(v, FrameLayout.LayoutParams(-1, -1)) } + + private fun groupHeader(list: LinearLayout, title: String) = + list.addView(sw.text(title, 26, sw.sub, 800), sw.lp(-2, -2).apply { topMargin = sw.px(40); bottomMargin = sw.px(14); leftMargin = sw.px(26) }) + + private fun listRow(list: LinearLayout, tag: String, icon: String, iconColor: Int, title: String, sub: String?, right: String?, onClick: () -> Unit) { + list.addView(sw.row().apply { + this.tag = tag + background = sw.bottomLine() + setPadding(sw.px(26), sw.px(if (sw.compact) 24 else 18), sw.px(26), sw.px(if (sw.compact) 24 else 18)) + minimumHeight = sw.px(if (sw.compact) 128 else 116) + // a symbol fills its square; a short word (PC, PS) stays the size of a badge + addView(sw.text(icon, if (icon.length == 1) 36 else 23, 0xFFFFFFFF.toInt(), 800).apply { gravity = Gravity.CENTER; background = sw.round(iconColor, ROW_ICON_R) }, + sw.lp(ROW_ICON, ROW_ICON).apply { rightMargin = sw.px(26) }) + addView(sw.col().apply { + addView(sw.text(title, 30, sw.ink, 700)) + if (sub != null) addView(sw.text(sub, 23, sw.sub, 600, 2), sw.lp(-2, -2).apply { topMargin = sw.px(sw.lineGap) }) + }, LinearLayout.LayoutParams(0, -2, 1f)) + if (right != null) addView(sw.text(right, ROW_VALUE, sw.sub, 700), sw.lp(-2, -2).apply { leftMargin = sw.px(24) }) + sw.focusable(this) + setOnClickListener { onClick() } + }, sw.lp(-1, -2)) + } + + private fun pcMenu(d: ComputerDetails) { + val run = runningApp(d) + val opts = ArrayList() + when { + !online(d) -> opts += Opt("PC", Sw.PC, "Wake it", "Needs Wake-on-LAN on the PC") { wake(d) } + !paired(d) -> opts += Opt("PC", Sw.PC, "Pair", "Once, with a PIN you type in Sunshine") { pair(d) } + else -> { + if (run != null) { + opts += Opt("▶", Sw.LIVE, "Resume ${run.appName}") { start(d, run) } + opts += Opt("✕", Sw.DANGER, "Quit ${run.appName}", "Closes it on the PC") { quit(d, run) } + } + opts += Opt("PC", Sw.PC, "Show its games") { gamesTab = "pc"; go(Page.GAMES) } + val hidden = hiddenIds(d) + if (hidden.isNotEmpty()) opts += Opt("–", Sw.PC, "Hidden games", "${hidden.size} hidden. Show them again") { hiddenGames(d) } + opts += Opt("↯", Sw.C_GREEN, "Test the network") { networkTest() } + opts += Opt("PC", Sw.PC, "Unpair", "You'll pair it again with a PIN to play") { confirmUnpair(d) } + } + } + val away = d.manualAddress?.let { hostPort(it) } + opts += Opt("⌂", Sw.PC, if (away == null) "Add an address for away" else "Address for away", away ?: "A name or address that reaches this PC from the internet") { awayAddress(d) } + opts += Opt("✕", Sw.DANGER, "Remove", "It disappears from this device") { confirmRemovePc(d) } + dialog(d.name, pcAddresses(d), opts, listOf("Close" to {})) + } + + private fun hostPort(a: ComputerDetails.AddressTuple) = if (a.port == NvHTTP.DEFAULT_HTTP_PORT) a.address else "${a.address}:${a.port}" + + /** Where this device looks for the PC: its home address and the one for away, and which it reached. */ + private fun pcAddresses(d: ComputerDetails): String? { + val lines = ArrayList() + val used = d.activeAddress + fun line(label: String, a: ComputerDetails.AddressTuple?) { + if (a == null || lines.any { it.contains(hostPort(a)) }) return + lines += "$label: ${hostPort(a)}" + if (used != null && used.address == a.address && used.port == a.port) " (in use)" else "" + } + line("At home", d.localAddress) + line("Away", d.manualAddress) + line("Internet", d.remoteAddress) + return lines.takeIf { it.isNotEmpty() }?.joinToString("\n") + } + + /** + * The address that reaches this PC from outside its network (a name like home.example.com, or a public IP), + * kept next to its home address: this device tries both and uses whichever answers. Saved without trying it, + * since at home an internet address usually can't be reached. + */ + private fun awayAddress(d: ComputerDetails) { + entry("Address for away", "A name or IP that reaches ${d.name} from the internet. Its home address stays too, and this device uses whichever answers. Sunshine's ports must be open to the internet, or connect a VPN to home. Leave it empty to remove it.", + "For example home.example.com", d.manualAddress?.let { hostPort(it) } ?: "", "address") { raw -> + val b = binder ?: return@entry "Still starting, try again in a moment." + val tuple = if (raw.isEmpty()) null else try { + val uri = try { java.net.URI("moonlight://$raw") } catch (e: Exception) { java.net.URI("moonlight://[$raw]") } + ComputerDetails.AddressTuple(uri.host ?: return@entry "That isn't an address.", if (uri.port == -1) NvHTTP.DEFAULT_HTTP_PORT else uri.port) + } catch (e: Exception) { return@entry "That isn't an address." } + if (!b.setManualAddress(d.uuid, tuple)) return@entry "This PC isn't in the list any more." + toast(if (tuple == null) "Removed the address for away." else "Saved. Away from home, ${d.name} is tried at $raw.") + render(force = true) + null + } + } + + private fun psMenu(c: Ps5Store.Console) { + val pw = power(c) + dialog(c.name, "PlayStation at ${c.host}", listOf( + Opt("PS", Sw.PS, if (pw == Ps5Store.Power.ON) "Play" else "Wake and play", "On this ${deviceShort()}") { playPs(c) }, + Opt("PS", Sw.PS, "Wake only") { Thread { val ok = Ps5Store.wake(c) + runOnUiThread { toast(if (ok) "Waking ${c.name}. It takes about a minute." else "Couldn't wake ${c.name}. Register it again.") } }.start() }, + Opt("✕", Sw.DANGER, "Remove", "Its registration here is deleted") { + confirm("Remove ${c.name}?", "To add it back, register it again with a code from the PS5.", "Remove") { + Ps5Store.remove(this, c.host); consoles = Ps5Store.all(this); psStatus.remove(c.host); Recents.forget(this, "ps:${c.host}"); render(force = true) + } }), listOf("Close" to {})) + } + + private fun xbMenu(c: Xbox.Console) { + val on = c.powerState.equals("On", true) + dialog(c.name, c.type.ifBlank { "Xbox" }, listOf( + Opt("XB", Sw.XB, if (on) "Play" else "Wake and play", "On this ${deviceShort()}, through Xbox network") { playXbox(c) }, + Opt("XB", Sw.XB, "Wake only") { Thread { val ok = try { Xbox.wake(this, c.id); true } catch (e: Exception) { false } + runOnUiThread { toast(if (ok) "Waking ${c.name}" else "Couldn't wake ${c.name}") } }.start() }), listOf("Close" to {})) + } + + /** The games hidden from All games on this PC, each one shown again with a press. */ + private fun hiddenGames(d: ComputerDetails) { + val hidden = hiddenIds(d) + val names = (apps[d.uuid] ?: emptyList()).associate { it.appId to it.appName } + val p = getSharedPreferences(AppView.HIDDEN_APPS_PREF_FILENAME, MODE_PRIVATE) + fun show(ids: Collection) { + val set = HashSet(p.getStringSet(d.uuid, emptySet())!!); ids.forEach { set.remove(it.toString()) } + p.edit().putStringSet(d.uuid, set).apply(); render(force = true) + } + // a hidden game the PC no longer lists (renamed or removed in Sunshine) still shows, by its number + val opts = hidden.sortedBy { (names[it] ?: "~$it").lowercase() }.map { id -> + val name = names[id] ?: "Game $id (not on the PC now)" + Opt("+", Sw.LIVE, name, "Show it in All games again") { show(listOf(id)); toast("$name is back in All games") } + } + dialog("Hidden games", "On ${d.name}. Hidden games don't show in All games or on Home.", opts, + listOf("Show all" to { show(hidden); toast("All hidden games are back") }, "Close" to {})) + } + + /** "Add to home screen" for a PC game, a PS5 or an Xbox (an Android app has its own icon already). */ + private fun shortcutOpt(g: Game): Opt? = if (g.dev != null) null else + Opt("⌂", Sw.C_AMBER, "Add to home screen", "An icon on this ${deviceShort()}'s home screen that starts it") { addToHomeScreen(g) } + + /** + * Pins an icon for [g] to the launcher. It opens MoonCast with [EXTRA_PLAY_HERE], which starts the game here the + * usual way: it waits for the PC's state, offers to wake it, and asks first when another game runs there. + */ + private fun addToHomeScreen(g: Game) { + if (Build.VERSION.SDK_INT < 26) { toast("Home screen icons need Android 8 or newer"); return } + val sm = getSystemService(android.content.pm.ShortcutManager::class.java) + if (sm == null || !sm.isRequestPinShortcutSupported) { toast("This ${deviceShort()}'s launcher doesn't take icons from apps"); return } + val intent = Intent(this, HandheldHomeActivity::class.java) + .setAction(Intent.ACTION_VIEW) // a shortcut's intent needs an action + .putExtra(EXTRA_PLAY_HERE, g.key) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + val where = g.pc?.name ?: g.src + val info = android.content.pm.ShortcutInfo.Builder(this, "game:${g.key}") + .setShortLabel(g.title.take(25)) + .setLongLabel("${g.title} ($where)".take(50)) + .setIcon(android.graphics.drawable.Icon.createWithAdaptiveBitmap(shortcutIcon(g))) + .setIntent(intent) + .build() + if (!sm.requestPinShortcut(info, null)) toast("Couldn't add the icon") + } + + /** + * The icon: your own picture, else the box art the tile shows, else its cover with the name in the middle. An + * adaptive icon is 108 units with the launcher's mask over the middle 72, so the name stays inside that. + */ + private fun shortcutIcon(g: Game): android.graphics.Bitmap { + val n = 432 + val out = android.graphics.Bitmap.createBitmap(n, n, android.graphics.Bitmap.Config.ARGB_8888) + val c = android.graphics.Canvas(out) + val picture = com.limelight.home.CustomArt.bitmap(this, g.key) + ?: ((content.findViewWithTag("g:${g.key}") as? ViewGroup)?.let { tile -> + (0 until tile.childCount).map { tile.getChildAt(it) }.filterIsInstance() + .firstNotNullOfOrNull { (it.drawable as? android.graphics.drawable.BitmapDrawable)?.bitmap } + }) + // box art is often decoded as a hardware bitmap (graphics memory), which a software canvas can't draw: copy it + val pic = picture?.let { if (Build.VERSION.SDK_INT >= 26 && it.config == android.graphics.Bitmap.Config.HARDWARE) it.copy(android.graphics.Bitmap.Config.ARGB_8888, false) else it } + if (pic != null) { + val picture = pic + val side = minOf(picture.width, picture.height) + val src = android.graphics.Rect((picture.width - side) / 2, (picture.height - side) / 2, (picture.width + side) / 2, (picture.height + side) / 2) + c.drawBitmap(picture, src, android.graphics.Rect(0, 0, n, n), android.graphics.Paint(android.graphics.Paint.FILTER_BITMAP_FLAG)) + return out + } + val art = sw.art(g.seed, 0); art.setBounds(0, 0, n, n); art.draw(c) + val p = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply { + color = 0xFFFFFFFF.toInt(); typeface = android.graphics.Typeface.DEFAULT_BOLD; textAlign = android.graphics.Paint.Align.CENTER + textSize = n * 0.13f + } + val safe = n * 0.58f // inside the launcher's mask + while (p.measureText(g.title) > safe && p.textSize > n * 0.06f) p.textSize -= 2f + val text = if (p.measureText(g.title) > safe) g.title.take(10) + "…" else g.title + c.drawText(text, n / 2f, n / 2f - (p.descent() + p.ascent()) / 2, p) + return out + } + + /** Change picture / Reset picture, for any tile's menu. */ + private fun pictureOpts(g: Game): List = listOfNotNull( + Opt("▣", Sw.C_BLUE, "Change picture", "Pick any image from this ${deviceShort()} for this tile") { pickPicture(g.key) }, + if (com.limelight.home.CustomArt.has(this, g.key)) Opt("↺", sw.panel, "Reset picture", "Back to MoonCast's own look") { + com.limelight.home.CustomArt.clear(this, g.key); render(force = true); toast("Picture reset") + } else null) + + /** The key of the tile whose picture is being picked (the photo picker answers in onActivityResult). */ + private var pictureFor: String? = null + + private fun pickPicture(key: String) { + pictureFor = key + val i = if (Build.VERSION.SDK_INT >= 33) Intent(android.provider.MediaStore.ACTION_PICK_IMAGES) + else Intent(Intent.ACTION_OPEN_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE).setType("image/*") + try { startActivityForResult(i, REQ_PICTURE) } + catch (e: Exception) { toast("No photo picker on this ${deviceShort()}") } + } + + private fun gameMenu(g: Game) { + if (g.ps != null || g.xb != null) { + dialog(g.title, if (g.ps != null) "A PlayStation." else "An Xbox.", listOfNotNull( + Opt("▶", Sw.LIVE, "Play") { play(g) }, shortcutOpt(g)) + pictureOpts(g), listOf("Close" to {})) + return + } + g.dev?.let { a -> + dialog(a.label, "An app on this ${deviceShort()}.", listOf( + Opt("▶", Sw.LIVE, "Open") { playHere(g) }) + pictureOpts(g) + listOf( + Opt("–", Sw.DANGER, "Remove from MoonCast", "It stays installed; add it again from All games, This ${deviceShort()}") { + com.limelight.home.DeviceApps.remove(this, a.pkg); Recents.forget(this, g.key) + render(force = true); toast("${a.label} removed from MoonCast") + }), listOf("Close" to {})) + return + } + val pc = g.pc ?: return; val app = g.app ?: return + val running = app.appId == pc.runningGameId + dialog(app.appName, "On ${pc.name}" + (if (running) ". Running now." else "."), listOfNotNull( + Opt("▶", Sw.LIVE, if (running) "Resume here" else "Play here") { playHere(g) }, + Opt("TV", sw.ring, "Play on a TV") { playWhere(g) }, + if (running) Opt("✕", Sw.DANGER, "Quit game", "Closes it on the PC") { quit(pc, app) } else null, + shortcutOpt(g), + *pictureOpts(g).toTypedArray(), + Opt("–", Sw.PC, "Hide from All games", "Show it again from the PC's menu in Consoles, Hidden games") { + val p = getSharedPreferences(AppView.HIDDEN_APPS_PREF_FILENAME, MODE_PRIVATE) + val set = HashSet(p.getStringSet(pc.uuid, emptySet())!!); set.add(app.appId.toString()) + p.edit().putStringSet(pc.uuid, set).apply(); render(force = true); toast("${app.appName} hidden") + }), listOf("Close" to {})) + } + + private var gamesScrollY = 0 + private var gamesScrollTab: String? = null + private var homeScrollX = 0 + + /** The picker's filter: "games" (what Android marks as games, the usual pick) or "all". */ + private var pickerFilter = "games" + + /** + * "On this device": a grid of the installed apps with their icons, games first. A tap adds or removes an app at + * once (a ✓ and a green ring mark what's added) and the grid stays as it is, so you can pick several. + */ + /** Opens Add apps; the first time, the app list and icons load off the main thread (hundreds of apps took a moment). */ + private fun pickDeviceApps() { + Thread { + com.limelight.home.DeviceApps.installed(this).forEach { com.limelight.home.DeviceApps.icon(this, it.pkg) } + runOnUiThread { if (!isFinishing && resumed) showPicker() } + }.start() + } + + private fun showPicker() { + val all = com.limelight.home.DeviceApps.installed(this) + if (all.isEmpty()) { toast("No apps found on this ${deviceShort()}"); return } + val games = all.filter { it.isGame } + if (games.isEmpty()) pickerFilter = "all" + val shown = if (pickerFilter == "games") games else all + val card = card() + val head = sw.row(Gravity.CENTER_VERTICAL) + head.addView(sw.text("Add apps", 40, sw.ink, 800), LinearLayout.LayoutParams(0, -2, 1f)) + val count = sw.text("", 26, Sw.LIVE, 800) + fun recount() { val n = com.limelight.home.DeviceApps.pinned(this).size; count.text = if (n == 0) "None added yet" else "$n added" } + recount() + head.addView(count) + card.addView(head, sw.lp(-1, -2).apply { leftMargin = sw.px(56); rightMargin = sw.px(56); topMargin = sw.px(44) }) + card.addView(sw.text("Tap an app to show it in MoonCast. Tap it again to take it out. Your apps stay installed.", 24, sw.sub, 600, 2), + sw.lp(-1, -2).apply { leftMargin = sw.px(56); rightMargin = sw.px(56); topMargin = sw.px(8) }) + val chips = sw.row() + listOf("games" to "Games (${games.size})", "all" to "All apps (${all.size})").forEachIndexed { i, (k, label) -> + val on = k == pickerFilter + chips.addView(sw.text(label, 24, if (on) sw.bg else sw.sub, 700).apply { + tag = "chip:$k"; gravity = Gravity.CENTER; setPadding(sw.px(28), 0, sw.px(28), 0) + background = sw.round(if (on) sw.ink else sw.bg, 30) + sw.focusable(this) + setOnClickListener { + if (pickerFilter != k) { pickerFilter = k; FeelSounds.play(this@HandheldHomeActivity, FeelSounds.Cue.TAB); showPicker() + this@HandheldHomeActivity.overlay.findViewWithTag("chip:$k")?.requestFocus() } + } + }, LinearLayout.LayoutParams(-2, sw.tap(60)).apply { if (i > 0) leftMargin = sw.px(14) }) + } + card.addView(chips, LinearLayout.LayoutParams(-2, sw.tap(60)).apply { leftMargin = sw.px(56); topMargin = sw.px(22) }) + + // the grid: as many columns as fit cells about 210 wide in the card + val cardW = minOf(1560f, sw.designWidth - 80) + val gap = 18 + val cols = ((cardW - 2 * 48 + gap) / (210 + gap)).toInt().coerceIn(3, 7) + val cell = ((cardW - 2 * 48 - (cols - 1) * gap) / cols).toInt() + val grid = GridLayout(this).apply { columnCount = cols; clipChildren = false } + shown.forEachIndexed { i, a -> + val v = FrameLayout(this).apply { + tag = "app:$i" + clipChildren = false + sw.focusable(this); sw.pressable(this) + } + val check = sw.text("✓", 26, 0xFFFFFFFF.toInt(), 800).apply { gravity = Gravity.CENTER; background = sw.round(Sw.LIVE, 22) } + fun paint() { + val on = com.limelight.home.DeviceApps.isPinned(this, a.pkg) + v.background = sw.round(sw.bg, 22, if (on) Sw.LIVE else 0, if (on) 4 else 0) + check.visibility = if (on) View.VISIBLE else View.GONE + } + val iconSize = (cell * 0.5f).toInt() + com.limelight.home.DeviceApps.icon(this, a.pkg)?.let { d -> + v.addView(ImageView(this).apply { setImageDrawable(d); scaleType = ImageView.ScaleType.FIT_CENTER }, + FrameLayout.LayoutParams(sw.px(iconSize), sw.px(iconSize), Gravity.CENTER_HORIZONTAL).apply { topMargin = sw.px(cell * 0.12f) }) + } + v.addView(sw.text(a.label, 21, sw.ink, 700, 2).apply { + gravity = Gravity.CENTER_HORIZONTAL; setPadding(sw.px(10), 0, sw.px(10), 0) + }, FrameLayout.LayoutParams(-1, -2, Gravity.BOTTOM).apply { bottomMargin = sw.px(cell * 0.08f) }) + v.addView(check, sw.flp(44, 44, Gravity.END).apply { topMargin = sw.px(10); rightMargin = sw.px(10) }) + paint() + v.setOnClickListener { + val was = com.limelight.home.DeviceApps.isPinned(this, a.pkg) + if (was) com.limelight.home.DeviceApps.remove(this, a.pkg) else com.limelight.home.DeviceApps.add(this, a.pkg) + FeelSounds.play(this@HandheldHomeActivity, FeelSounds.Cue.TOGGLE) + toast(if (was) "${a.label} removed. Tap it again to add it back" else "${a.label} added") + paint(); recount() + } + grid.addView(v, GridLayout.LayoutParams().apply { + width = sw.px(cell); height = sw.px(cell * 1.08f) + if (i % cols != 0) leftMargin = sw.px(gap) + bottomMargin = sw.px(gap) + }) + } + val gridH = (sw.designHeight - 470).coerceAtLeast(300f) + card.addView(ColScroll(this, sw.px(30)).apply { + isVerticalScrollBarEnabled = false; clipToPadding = false + setPadding(0, sw.px(8), 0, sw.px(8)) + addView(grid) + }, sw.lp(-1, gridH).apply { leftMargin = sw.px(48); rightMargin = sw.px(48); topMargin = sw.px(22) }) + // focus starts on Done: a habitual A closes the page instead of taking out the first app + buttons(card, listOf("Done" to {})) + showOverlay("picker", card, wide = true) + } + + private val groupRender = Runnable { if (resumed && page == Page.SETTINGS) render(force = true) } + + // ---------------------------------------------------------------- Settings + + private val groups = listOf("handheld" to "Handheld", "picture" to "Picture", "sound" to "Sound", "pads" to "Controllers", + "ps" to "PlayStation", "xbox" to "Xbox", "theme" to "Theme", "about" to "About") + + private fun settingsPage(): View { + val row = sw.row(Gravity.TOP) + val side = sw.col().apply { setPadding(sw.px(if (sw.compact) 20 else 96), sw.px(20), 0, 0) } + groups.forEach { (k, label) -> + val on = k == settingsGroup + side.addView(FrameLayout(this).apply { + tag = "group:$k" + if (on) background = sw.round(sw.panel, 16) + if (on) addView(View(this@HandheldHomeActivity).apply { background = sw.round(sw.ring, 3) }, sw.flp(6, -1).apply { topMargin = sw.px(18); bottomMargin = sw.px(18) }) + addView(sw.text(label, 30, if (on) sw.ring else sw.sub, 700).apply { gravity = Gravity.CENTER_VERTICAL; setPadding(sw.px(28), 0, 0, 0) }, FrameLayout.LayoutParams(-1, -1)) + sw.focusable(this) + // moving down the sidebar rebuilt the whole screen at every step: wait until focus rests on a group + setOnFocusChangeListener { _, has -> if (has && settingsGroup != k) { settingsGroup = k; licencesOpen = false + ui.removeCallbacks(groupRender); ui.postDelayed(groupRender, 160) } } + setOnClickListener { + settingsGroup = k; licencesOpen = false + render(force = true) + content.findViewWithTag("settings:list")?.let { firstFocusable(it)?.requestFocus() } + } + }, LinearLayout.LayoutParams(-1, sw.tap(92))) + } + row.addView(part(side), sw.lp(if (sw.compact) 330 else 600, -2)) + val list = sw.col().apply { tag = "settings:list" } + settingsRows(list, settingsGroup) + row.addView(part(clipped(ColScroll(this, sw.px(40)).apply { + isVerticalScrollBarEnabled = false; isFocusable = false; clipToPadding = false; clipChildren = false + setPadding(sw.px(if (sw.compact) 20 else 40), sw.px(20), sw.px(sw.edge), sw.px(30)) + addView(list) + })), LinearLayout.LayoutParams(0, -1, 1f)) + primaryView = side.findViewWithTag("group:$settingsGroup") + return row + } + + private fun settingsRows(list: LinearLayout, group: String) { + when (group) { + "handheld" -> { + // Auto picks Compact on a small or square screen; the others force a size (recreate redraws the app) + choice(list, prefs, "hh_screen_size", "Screen size", "Auto fits this screen. Compact and Large make everything bigger", + listOf("Auto" to "auto", "Compact" to "compact", "Normal" to "normal", "Large" to "large"), "auto") { recreate() } + appToggle(list, "hh_rotate", "Screen turns with the device", "Portrait when you hold it upright", true) { applyRotation() } + action(list, "set:devapps", "Apps on this ${deviceShort()}", "Pick Android apps to show next to your games (emulators, Steam Link…)", + com.limelight.home.DeviceApps.pinned(this).size.let { if (it == 0) "None" else "$it" }) { pickDeviceApps() } + // PC games: what a finger on the picture does when a game starts (the Quick menu changes it for that game) + choice(list, stream, com.limelight.Game.TOUCH_DEFAULT_PREF, "Touch screen", "In PC games. Mouse is a trackpad, Touch clicks where you tap. The Quick menu changes it while you play", + listOf("Off" to "off", "Mouse" to "mouse", "Touch" to "touch"), "off") + appToggle(list, "hh_dim_on_tv", "Dim the screen on a TV", "While this ${deviceShort()} is the controller for a TV", true) + toggle(list, "checkbox_ultra_low_latency", "Ultra low latency", "Experimental. This device: ${chipName()}", false) + } + "picture" -> { + // Home and Away: one switch for PC, PlayStation and Xbox. Each profile keeps its own settings. + choice(list, stream, Profile.PREF, "Streaming profile", "Home: best quality on your network. Away: lighter settings for PlayStation Network and slower links", + listOf("Home" to "home", "Away" to "away"), "home") + syncNativeRes() + // Native is this screen's own size and shape (no bars), whichever screen a stream starts on + // (a foldable's cover or inner screen) + val native = nativeResolution() + val fold = PreferenceConfiguration.foldableResolution(this) != null + choice(list, stream, "list_resolution", "Resolution", + if (fold) "Native suits both your screens: ${native.replace("x", " × ")}. Picture shape sets how it fills each one" + else "Native fills the screen you play on, now ${native.replace("x", " × ")}", + RES_STEPS.filter { it.second != native } + ("Native" to native), native) { v -> + stream.edit().putBoolean(PreferenceConfiguration.NATIVE_RES_PREF_STRING, v == native).apply() + } + // Unlock all frame rates (Moonlight's checkbox_unlock_fps): 144 and 240 too, and Custom up to 240 + val unlock = stream.getBoolean("checkbox_unlock_fps", false) + val fpsSteps = listOf("30", "60", "90", "120") + (if (unlock) listOf("144", "240") else emptyList()) + val fpsMax = if (unlock) 240 else 120 + choice(list, stream, "list_fps", "Frame rate", "60 is the smoothest. 30 uses less data and battery", fpsSteps.map { it to it }, "60") + // #42: any PC frame rate, saved straight into list_fps as a number + val fps = stream.getString("list_fps", "60") ?: "60" + val customFps = fps !in fpsSteps + action(list, "list_fps-custom", "Custom frame rate", "Any rate between the steps above, 15 to $fpsMax fps", if (customFps) "$fps fps" else "Off") { + entry("Custom frame rate", "In fps, from 15 to $fpsMax. The PC streams at the rate you pick.", "For example 45", if (customFps) fps else "", true) { raw -> + val v = raw.trim().toIntOrNull() + if (v == null || v !in 15..fpsMax) "A number from 15 to $fpsMax." + else { stream.edit().putString("list_fps", v.toString()).apply(); render(force = true); null } + } + } + flip(list, stream, "checkbox_unlock_fps", "Unlock all frame rates", "Adds 144 and 240, and Custom up to 240, for PCs and screens that can do it", false) { + // locked again: a rate above 120 comes back down to 120 + if (!stream.getBoolean("checkbox_unlock_fps", false) && ((stream.getString("list_fps", "60") ?: "60").toIntOrNull() ?: 60) > 120) + stream.edit().putString("list_fps", "120").apply() + } + bitrate(list) + // any bitrate, like Moonlight's slider: 0.5 to 150 Mbps in 0.5 steps + val kbps = stream.getInt("seekbar_bitrate_kbps", 0) + val customRate = kbps != 0 && (kbps / 1000 !in BITRATE_STEPS || kbps % 1000 != 0) + action(list, "seekbar_bitrate_kbps-custom", "Custom bitrate", "Any bitrate from 0.5 to 150 Mbps", if (customRate) mbps(kbps) else "Off") { + entry("Custom bitrate", "In Mbps, from 0.5 to 150. Higher looks better and needs a faster network.", "For example 25", + if (customRate) mbps(kbps).removeSuffix(" Mbps") else "", "decimal") { raw -> + val v = raw.trim().replace(',', '.').toDoubleOrNull() + if (v == null || v < 0.5 || v > 150) "A number from 0.5 to 150." + else { stream.edit().putInt("seekbar_bitrate_kbps", (Math.round(v * 2) * 500).toInt()).apply(); render(force = true); null } + } + } + // Super resolution: AMD FSR 1.0 upscales a stream smaller than the screen, then sharpens it (PS5 for now) + choice(list, stream, com.limelight.binding.video.FsrRenderer.PREF, "Super resolution", + "Sharper picture when the stream is smaller than this screen (AMD FSR 1.0), on PC, PS5 and Xbox. Not with HDR. Uses a little of the GPU", + listOf("Off" to "off", "FSR 1.0" to "fsr1"), "off") + if (stream.getString(com.limelight.binding.video.FsrRenderer.PREF, "off") == "fsr1") + intChoice(list, com.limelight.binding.video.FsrRenderer.SHARPNESS_PREF, "Sharpening", "How crisp FSR makes edges. Lower it if you see halos", + listOf(0, 20, 40, 60, 80, 100), com.limelight.binding.video.FsrRenderer.DEFAULT_SHARPNESS) { "$it%" } + choice(list, stream, "video_format", "Video codec", "H.265 and AV1 look better at the same bitrate", + listOf("Auto" to "auto", "H.264" to "neverh265", "H.265" to "forceh265", "AV1" to "forceav1"), "auto") + toggle(list, "checkbox_enable_hdr", "HDR", "Only when the PC, the game and this screen support it", false) + toggle(list, "checkbox_full_range", "Full range colour", "Experimental: deeper blacks and brighter whites. On screens that don't handle it, dark and light detail is lost", false) + choice(list, stream, "list_video_scale_mode", "Picture shape", "Fit with bars, fill and crop, or stretch", + listOf("Fit" to "fit", "Fill" to "fill", "Stretch" to "stretch"), if (stream.getBoolean("checkbox_stretch_video", false)) "stretch" else "fit") + choice(list, stream, "frame_pacing", "Frame pacing", "PC and PS5: trade a little delay for smoother motion. Xbox paces itself", + listOf("Lowest delay" to "latency", "Balanced" to "balanced", "Smoothest" to "smoothness"), "latency") + toggle(list, "checkbox_enable_perf_overlay", "Stats while playing", "Frame rate, delay and lost frames in a corner", false) + } + "sound" -> { + choice(list, stream, "list_audio_config", "Speakers", "Stereo for the built-in speakers and headphones", + listOf("Stereo" to "2", "5.1" to "51", "7.1" to "71"), "2") + toggle(list, "checkbox_host_audio", "Also play sound on the PC", "Useful when someone's in the room with it", false) + appToggle(list, FeelSounds.PREF, "Menu sounds", "Soft sounds as you move around the menus. Games aren't affected", true) + } + "pads" -> { + action(list, "padtest", "Test a controller", "See every button, stick and trigger live, and try the rumble", null) { + startActivity(Intent(this, PadTestActivity::class.java)) } + action(list, "padpair", "Pair a controller", "Opens Bluetooth settings. Put the pad in pairing mode first", null) { pairController() } + choice(list, prefs, TvControllerActivity.PAD_PREF, "Controller on a TV", "For PC games sent to a TV. Direct is fastest: it skips the TV's Wi-Fi", + listOf("Direct to the PC" to "direct", "Through the TV" to "tv"), "direct") + // the on-screen controller for PC games (Moonlight's engine, drawn by PadSkin) + val osc = if (!stream.getBoolean("checkbox_show_onscreen_controls", false)) "off" + else stream.getString(com.limelight.binding.input.virtual_controller.PadSkin.PREF, "classic") ?: "classic" + srow(list, "osc", "On-screen controller", "Touch keys over PC games, with a light tick on each press", value(when (osc) { + "ps" -> "PlayStation"; "xbox" -> "Xbox"; "classic" -> "Classic"; else -> "Off" })) { + val next = when (osc) { "off" -> "ps"; "ps" -> "xbox"; "xbox" -> "classic"; else -> "off" } + stream.edit().putBoolean("checkbox_show_onscreen_controls", next != "off") + .putString(com.limelight.binding.input.virtual_controller.PadSkin.PREF, if (next == "off") osc else next).apply() + render(force = true) + } + if (osc == "ps" || osc == "xbox") intChoice(list, com.limelight.binding.input.virtual_controller.PadSkin.OPACITY_PREF, "Controller transparency", + "How much of the game shows through the keys", listOf(50, 30, 15, 70, 85, 100), + com.limelight.binding.input.virtual_controller.PadSkin.DEFAULT_OPACITY) { "${100 - it}%" } + toggle(list, "checkbox_multi_controller", "More than one player", "On a PC each pad is its own player", true) + intChoice(list, "seekbar_deadzone", "Stick deadzone", "Raise it if a character drifts on its own", listOf(3, 5, 7, 10, 15), 7) { "$it%" } + toggle(list, "checkbox_flip_face_buttons", "Swap A and B", "For Nintendo-style layouts", false) + toggle(list, "checkbox_gamepad_motion_sensors", "Motion", "Send the pad's gyro to games that use it", true) + toggle(list, "checkbox_mouse_emulation", "Use the pad as a mouse", "Hold Start to switch", true) + toggle(list, "checkbox_vibrate_fallback", "Rumble on this device", "When a pad can't rumble", false) + info(list, "Quick menu", QuickMenu.COMBO_LABEL) + } + "ps" -> { + if (Psn.signedIn(this)) action(list, "psn", "PlayStation Network", + "Signed in" + (Psn.onlineId(this)?.takeIf { it.isNotBlank() }?.let { " as $it" } ?: ""), "Sign out") { + confirm("Sign out of PlayStation Network?", "You'll sign in again to register a PS5 or reach it away from home.", "Sign out") { + Psn.signOut(this); render(force = true) } } + else action(list, "psn", "PlayStation Network", "Sign in once to register a PS5 and play it away from home", "Sign in") { + startActivityForResult(Intent(this, PsnSignInActivity::class.java), REQ_PSN) } + // Same four rows for each profile; the General profile decides which set they edit. Auto shows both sets. + val mode = Profile.mode(this) + if (mode != "away") { + choice(list, stream, "ps5_resolution", "Resolution", "The PS5 streams up to 1080p", listOf("720p" to "720", "1080p" to "1080"), "1080") + choice(list, stream, "ps5_fps_home", "Frame rate", "60 fps is smoother; 30 fps lets a weak link keep up", + listOf("60 fps" to "60", "30 fps" to "30"), "60") // PS5: 30 or 60 only (#39) + choice(list, stream, "ps5_bitrate_home", "Bitrate", "Auto: 30 Mbps on fast Wi-Fi, 20 otherwise", + listOf("Auto" to "auto", "10" to "10", "15" to "15", "20" to "20", "30" to "30", "Custom" to "custom"), "auto", + onPick = { if (it == "custom") customDialog(stream, "ps5_bitrate_home", "Custom bitrate", "Mbps", 2, 50, 20) }) + if (stream.getString("ps5_bitrate_home", "auto") == "custom") + customNumber(list, stream, "ps5_bitrate_home", "Custom bitrate", "2 to 50 Mbps", "Mbps", 2, 50, 20) + choice(list, stream, "ps5_codec", "Video codec", "H.265 looks better at the same bitrate", listOf("H.264" to "h264", "H.265" to "h265"), "h265") + } + if (mode != "home") { + choice(list, stream, "ps5_away_quality", "Resolution", "Auto follows the Wi-Fi: 1080p strong, 720p weak", + listOf("Auto" to "auto", "540p" to "540", "720p" to "720", "1080p" to "1080"), "auto") + choice(list, stream, "away_fps", "Frame rate", "60 fps is smoother; 30 fps lets a weak link keep up", + listOf("60 fps" to "60", "30 fps" to "30"), "60") // PS5: 30 or 60 only (#39) + choice(list, stream, "away_bitrate", "Bitrate", "Auto: capped by the Wi-Fi link", + listOf("Auto" to "auto", "8" to "8", "10" to "10", "15" to "15", "Custom" to "custom"), "auto", + onPick = { if (it == "custom") customDialog(stream, "away_bitrate", "Custom bitrate", "Mbps", 2, 50, 10) }) + if (stream.getString("away_bitrate", "auto") == "custom") + customNumber(list, stream, "away_bitrate", "Custom bitrate", "2 to 50 Mbps", "Mbps", 2, 50, 10) + choice(list, stream, "away_codec", "Video codec", "H.265 looks better at the same bitrate", listOf("H.264" to "h264", "H.265" to "h265"), "h265") + } + choice(list, stream, "ps5_on_disconnect", "When I disconnect", "What happens to the PS5 after you leave a game", + listOf("Ask" to "ask", "Rest mode" to "rest", "Keep it on" to "keep"), "ask") + toggle(list, "ps5_swap_cross_circle", "Swap Cross and Circle", "For the Japanese layout", false) + choice(list, stream, com.limelight.Ps5StreamActivity.DECODER_PREF, "Decoder", "Low latency shows each frame sooner. Ultra pushes this device's chip harder. Standard if the picture misbehaves", + listOf("Low latency" to "low", "Ultra low latency" to "ultra", "Standard" to "standard"), "low") + val pin = prefs.getString("ps5_login_pin", "") ?: "" + action(list, "pspin", "Console PIN", "Signs in for you when the PS5 asks for its 4-digit login PIN", if (pin.isEmpty()) "Not set" else "••••") { + entry("Console PIN", "The 4-digit PIN your PS5 asks for when it starts. Leave empty to turn it off.", "4 digits", pin, true) { v -> + if (v.isNotEmpty() && v.length != 4) "The PIN has 4 digits" else { prefs.edit().putString("ps5_login_pin", v).apply(); render(force = true); null } + } } + info(list, "PS button", "L2 + Start") + info(list, "Touchpad", "Select + Start") + } + "xbox" -> { + if (Xbox.signedIn(this)) action(list, "xbox", "Xbox network", "Signed in" + (Xbox.gamertag(this)?.let { " as $it" } ?: ""), "Sign out") { + confirm("Sign out of Xbox?", "Your Xbox consoles leave this device until you sign in again.", "Sign out") { + Xbox.signOut(this); xboxes = emptyList(); render(force = true) } } + else action(list, "xbox", "Xbox network", "Sign in once to play your Xbox here, at home or away", "Sign in") { xboxSignIn() } + choice(list, stream, "xbox_resolution", "Picture", "The Xbox sets quality from this", + listOf("Auto" to "auto", "720p" to "720", "1080p" to "1080", "1080p high" to "1081"), "auto") + val steps = listOf(0, 5, 10, 15, 20, 30) + val cur = stream.getInt("xbox_bitrate", 0) + srow(list, "xbox_bitrate", "Bitrate", "Auto lets the Xbox decide. A cap keeps a weak Wi-Fi from stuttering", value(if (cur == 0) "Auto" else "$cur Mbps")) { + stream.edit().putInt("xbox_bitrate", steps[(steps.indexOf(steps.firstOrNull { it >= cur } ?: 0) + 1) % steps.size]).apply() + render(force = true) } + choice(list, stream, "xbox_rumble", "Rumble", "How strongly the controller shakes", + listOf("Off" to "off", "Weak" to "weak", "Medium" to "medium", "Strong" to "strong"), "strong") + intChoice(list, "xbox_deadzone", "Stick deadzone", "Raise it if a character drifts on its own", listOf(3, 5, 8, 10, 15), 8) { "$it%" } + toggle(list, "xbox_swap_ab", "Swap A and B", "For Nintendo-style layouts", false) + toggle(list, "xbox_coop", "Two players", "A second controller plays as player 2", false) + } + "theme" -> { + val light = sw.light + srow(list, "theme:dark", "Dark", "Charcoal, white type", if (!light) value("On") else null) { + if (light) { prefs.edit().putString("hh_theme", "dark").apply(); recreate() } } + srow(list, "theme:light", "Light", "Light grey, dark type", if (light) value("On") else null) { + if (!light) { prefs.edit().putString("hh_theme", "light").apply(); recreate() } } + } + "about" -> if (licencesOpen) licences(list) else { + val ver = try { packageManager.getPackageInfo(packageName, 0).versionName } catch (e: Exception) { "" } + info(list, "MoonCast", ver ?: "") + action(list, "about:licences", "Open-source licences", + "MoonCast is free software, built on Moonlight, chiaki-ng and about twenty more open-source projects", "›") { + licencesOpen = true; render(force = true) + content.findViewWithTag("settings:list")?.let { firstFocusable(it)?.requestFocus() } + } + action(list, "about:source", "Source code", "github.com/MoonCastDev · ${com.limelight.home.Licences.SOURCE_LICENCE}", "Open") { + try { startActivity(Intent(Intent.ACTION_VIEW, android.net.Uri.parse(com.limelight.home.Licences.SOURCE))) } + catch (e: Exception) { toast("No browser on this device: ${com.limelight.home.Licences.SOURCE}") } + } + if (com.limelight.home.Usage.available) + toggle(list, com.limelight.home.Usage.PREF, "Anonymous usage check-in", + "Once a day: app version, device model, and whether you played PC, PS5 or Xbox. Nothing personal", false) + action(list, "about:add", "Add a PC by its address", "For a PC this device doesn't find on its own", null) { addPcByAddress() } + action(list, "about:all", "All settings", "Every option, including the rare ones", null) { + startActivity(Intent(this, StreamSettings::class.java)) } + } + } + } + + private fun value(s: String) = sw.text(s, 28, sw.ring, 700) + + /** About, Open-source licences: each project with its licence; pressing one shows the full text. Back returns to About. */ + private var licencesOpen = false + private fun licences(list: LinearLayout) { + action(list, "lic:back", "‹ About", "Back to About", null) { licencesOpen = false; render(force = true) } + var group = "" + com.limelight.home.Licences.parts.forEach { p -> + if (p.group != group) { + group = p.group + list.addView(sw.text(group.uppercase(), 22, sw.sub, 800).apply { setPadding(sw.px(26), sw.px(26), 0, sw.px(8)) }) + } + action(list, "lic:${p.name}", p.name, p.what, p.licence) { com.limelight.home.Licences.open(this, p) } + } + } + + private fun srow(list: LinearLayout, key: String, title: String, sub: String?, value: View?, onClick: (() -> Unit)?) { + list.addView(sw.row().apply { + tag = "set:$key" + background = sw.bottomLine() + setPadding(sw.px(26), sw.px(if (sw.compact) 24 else 18), sw.px(26), sw.px(if (sw.compact) 24 else 18)) + minimumHeight = sw.px(if (sw.compact) 128 else 116) + addView(sw.col().apply { + addView(sw.text(title, 30, sw.ink, 700)) + if (sub != null) addView(sw.text(sub, 23, sw.sub, 600, 2), sw.lp(-2, -2).apply { topMargin = sw.px(sw.lineGap) }) + }, LinearLayout.LayoutParams(0, -2, 1f)) + if (value != null) addView(value, (value.layoutParams as? LinearLayout.LayoutParams ?: sw.lp(-2, -2)).apply { leftMargin = sw.px(24) }) + setOnKeyListener { _, code, e -> + if (code == KeyEvent.KEYCODE_DPAD_LEFT && e.action == KeyEvent.ACTION_DOWN) + content.findViewWithTag("group:$settingsGroup")?.requestFocus() ?: false + else false + } + if (onClick != null) { sw.focusable(this); setOnClickListener { onClick() } } + }, sw.lp(-1, -2)) + } + + /** A Custom value for a choice row: tapping it asks for a number, and saving a number switches the row to Custom. */ + private fun customNumber(list: LinearLayout, store: SharedPreferences, key: String, title: String, sub: String, unit: String, min: Int, max: Int, def: Int) { + val cur = store.getString(key + "_custom", def.toString()) ?: def.toString() + action(list, "$key-custom", title, sub, "$cur $unit") { customDialog(store, key, title, unit, min, max, def) } + } + + /** Asks for a Custom number and saves it; the row switches to Custom. */ + private fun customDialog(store: SharedPreferences, key: String, title: String, unit: String, min: Int, max: Int, def: Int) { + val cur = store.getString(key + "_custom", def.toString()) ?: def.toString() + entry(title, "$min to $max $unit", unit, cur, true) { v -> + val n = v.trim().toIntOrNull() + if (n == null || n !in min..max) "Enter a number from $min to $max" + else { + store.edit().putString(key + "_custom", n.toString()).putString(key, "custom").apply() + render(force = true) + null + } + } + } + + private fun choice(list: LinearLayout, store: SharedPreferences, key: String, title: String, sub: String, options: List>, def: String, + onPick: ((String) -> Unit)? = null) { + val cur = store.getString(key, def) + val label = options.firstOrNull { it.second == cur }?.first ?: cur ?: "" + srow(list, key, title, sub, value(label)) { + val i = options.indexOfFirst { it.second == cur } + val next = options[(i + 1) % options.size].second + store.edit().putString(key, next).apply() + // what goes with the pick is saved before the page redraws (the redraw reads it: Resolution's Native) + onPick?.invoke(next) + render(force = true) + } + } + + private fun intChoice(list: LinearLayout, key: String, title: String, sub: String, options: List, def: Int, label: (Int) -> String) { + val cur = stream.getInt(key, def) + srow(list, key, title, sub, value(label(cur))) { + stream.edit().putInt(key, options[(options.indexOf(cur) + 1) % options.size]).apply() + render(force = true) + } + } + + /** 25000 -> "25 Mbps", 7500 -> "7.5 Mbps" */ + private fun mbps(kbps: Int) = (if (kbps % 1000 == 0) "${kbps / 1000}" else "%.1f".format(java.util.Locale.ROOT, kbps / 1000.0)) + " Mbps" + + private fun bitrate(list: LinearLayout) { + val steps = listOf(0) + BITRATE_STEPS + val kbps = stream.getInt("seekbar_bitrate_kbps", 0) + val cur = kbps / 1000 + val custom = kbps != 0 && (cur !in BITRATE_STEPS || kbps % 1000 != 0) + srow(list, "seekbar_bitrate_kbps", "Bitrate", "Auto picks one for the resolution", value(if (kbps == 0) "Auto" else if (custom) "Custom" else "$cur Mbps")) { + val next = steps[(steps.indexOf(steps.firstOrNull { it >= cur } ?: 0) + 1) % steps.size] + if (next == 0) stream.edit().remove("seekbar_bitrate_kbps").apply() else stream.edit().putInt("seekbar_bitrate_kbps", next * 1000).apply() + render(force = true) + } + } + + private fun toggle(list: LinearLayout, key: String, title: String, sub: String, def: Boolean) = flip(list, stream, key, title, sub, def, null) + private fun appToggle(list: LinearLayout, key: String, title: String, sub: String, def: Boolean, after: (() -> Unit)? = null) = flip(list, prefs, key, title, sub, def, after) + + private fun flip(list: LinearLayout, store: SharedPreferences, key: String, title: String, sub: String, def: Boolean, after: (() -> Unit)?) { + val on = store.getBoolean(key, def) + val t = sw.toggle(on).apply { layoutParams = sw.lp(96, 52) } + srow(list, key, title, sub, t) { + store.edit().putBoolean(key, !on).apply() + FeelSounds.play(this, FeelSounds.Cue.TOGGLE, FeelSounds.panFor(t)) + after?.invoke() + render(force = true) + } + } + + private fun action(list: LinearLayout, key: String, title: String, sub: String, v: String?, onClick: () -> Unit) = + srow(list, key, title, sub, v?.let { value(it) }, onClick) + + private fun info(list: LinearLayout, title: String, v: String) = + srow(list, "info:$title", title, null, sw.text(v, 26, sw.sub, 700), null) + + // ---------------------------------------------------------------- playing + + /** A message in the Switch style along the bottom (system toasts showed as an empty white pill on the Odin). */ + private var snack: TextView? = null + private val hideSnack = Runnable { snack?.animate()?.alpha(0f)?.translationY(sw.pxf(20))?.setDuration(200)?.start() } + private fun toast(s: String) { + val v = snack?.takeIf { it.parent === root } ?: sw.text("", 26, sw.ink, 700, 3).apply { + gravity = Gravity.CENTER + setPadding(sw.px(36), sw.px(22), sw.px(36), sw.px(22)) + background = sw.round(sw.panel, 18, sw.line, 2) + elevation = sw.pxf(12) + alpha = 0f + root.addView(this, FrameLayout.LayoutParams(-2, -2, Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL).apply { + bottomMargin = sw.px(if (portrait) 260 else 150); leftMargin = sw.px(60); rightMargin = sw.px(60) }) + snack = this + } + v.text = s + v.bringToFront() + v.translationY = sw.pxf(20) + v.animate().alpha(1f).translationY(0f).setDuration(200).start() + ui.removeCallbacks(hideSnack) + ui.postDelayed(hideSnack, if (s.length > 60) 5000L else 3500L) + } + + /** A: plays where games go now, this device or the TV picked in "Play on" (the top chip says which). */ + private fun play(g: Game) { + if (g.dev != null) { playHere(g); return } // an app on this device opens here (Play on TV for apps: #99) + val tv = TvFinder.target(this) + if (tv != null) sendToTv(g, tv) else playHere(g) + } + + private fun playHere(g: Game) { + when { + g.pc != null && g.app != null -> { + if (g.pc.state == ComputerDetails.State.OFFLINE) { // only when a poll says so (UNKNOWN = not asked yet) + confirm("${g.pc.name} is offline", "Wake it, then start ${g.app.appName} once it's online.", "Wake it", yesPicked = true) { wake(g.pc) } + return + } + start(g.pc, g.app) { Recents.touch(this, g.key) } + } + g.ps != null -> { Recents.touch(this, g.key); playPs(g.ps) } + g.xb != null -> { Recents.touch(this, g.key); playXbox(g.xb) } + g.dev != null -> { + if (com.limelight.home.DeviceApps.launch(this, g.dev.pkg)) { Recents.touch(this, g.key); FeelSounds.play(this, FeelSounds.Cue.LAUNCH) } + else toast("${g.dev.label} can't be opened. Is it still installed?") + } + } + } + + private fun playPs(c: Ps5Store.Console) = Launch.playPs(this, c, power(c)) { toast(it) } + + private fun playXbox(c: Xbox.Console) { + FeelSounds.play(this, FeelSounds.Cue.LAUNCH) + startActivity(Launch.xboxIntent(this, c)) + } + + /** + * "Play on": this device or a MoonCast TV. With a game it plays there now; without one (the top chip, the round + * button) it picks where games go when you press A. TVs are looked for again each time it opens. + */ + private fun playWhere(g: Game?, sweep: Boolean = true) { + // what the stream will actually use: Moonlight's own reading of the saved resolution and frame rate + val pc = com.limelight.preferences.PreferenceConfiguration.readPreferences(this) + val res = "${minOf(pc.width, pc.height)}p" + val fps = pc.fps.toString() + val cur = TvFinder.target(this) + val tvs = TvFinder.known(this) + val opts = ArrayList() + opts += Opt("▭", Sw.PC, "This ${deviceShort()}", "$res, $fps fps", if (cur == null) "Now" else null) { + TvFinder.setTarget(this, null) + if (g != null) playHere(g) else { toast("Games play on this ${deviceShort()}"); render(force = true) } + } + tvs.forEach { tv -> + val found = TvFinder.isHere(tv) + val xboxNo = g?.xb != null && (!tv.canXbox || !tv.xbox) + val sub = when { + !found -> "Not on this network" + xboxNo && !tv.canXbox -> "Update MoonCast on this TV to send it an Xbox" + xboxNo -> "Sign in to Xbox on this TV first" + g?.pc != null && tv.pc.isEmpty() -> "Ready. No PC paired on it yet" + tv.pc.isNotEmpty() -> "Ready. Plays PC games from ${tv.pc}" + else -> "Ready" + } + opts += Opt("TV", sw.ring, tv.name, sub, if (cur?.id == tv.id) "Now" else if (found) "Ready" else null, found && !xboxNo) { + TvFinder.setTarget(this, tv) + if (g != null) sendToTv(g, tv) else { toast("Games play on ${tv.name}. This ${deviceShort()} is the controller"); render(force = true) } + } + } + val body = (if (g != null) "${g.title}. " else "") + when { + tvs.isEmpty() && sweep -> "Looking for MoonCast TVs on this network…" + tvs.isEmpty() -> "No MoonCast TV found. Open MoonCast on the TV, on the same Wi-Fi as this ${deviceShort()}." + else -> "On a TV, this ${deviceShort()} is the controller." + } + dialog(if (g != null) "Where should it play?" else "Play on", body, opts, listOf("Close" to {})) + whereOpen = g ?: NO_GAME + if (sweep) { + val before = tvs.filter { TvFinder.isHere(it) }.map { it.id }.toSet() + TvFinder.sweep(this) { + val again = whereOpen ?: return@sweep + if (overlayKind != "dialog") return@sweep + val now = TvFinder.known(this).filter { TvFinder.isHere(it) }.map { it.id }.toSet() + // reopen with what was found (the overlay stays up, so focus still returns where it was) + if (now != before || tvs.isEmpty()) playWhere(again.takeIf { it !== NO_GAME }, sweep = false) + } + } + } + private var whereOpen: Game? = null + private val NO_GAME = Game("", "", "", "", false) + + /** Y: switch between this device and the last TV. With no TV yet, the "Play on" list opens. */ + private fun toggleTarget() { + val last = TvFinder.lastTv(this) + when { + TvFinder.target(this) != null -> { TvFinder.setTarget(this, null); toast("Games play on this ${deviceShort()}") } + last != null -> { TvFinder.setTarget(this, last) + toast("Games play on ${last.name}" + if (TvFinder.isHere(last)) "" else ". It isn't on this network right now") } + else -> { playWhere(focusedGame() ?: gameFor(selKey)); return } + } + FeelSounds.play(this, FeelSounds.Cue.TOGGLE) + render(force = true) + } + + /** Send [g] to [tv]; controller mode shows the progress (and the TV's answer if it can't). */ + private fun sendToTv(g: Game, tv: TvFinder.Tv) { + val t = when { + g.pc != null && g.app != null -> { + if (!online(g.pc)) { + confirm("${g.pc.name} is offline", "Wake it, then send ${g.app.appName} to ${tv.name} once it's online.", "Wake it", yesPicked = true) { wake(g.pc) } + return + } + MoveToTv.Target("pc", g.app.appName, g.pc.uuid, g.pc.name, g.app.appId) + } + g.ps != null -> MoveToTv.Target("ps", g.ps.name, psHost = g.ps.host, registKey = g.ps.registKey, morning = g.ps.morning, isPs5 = g.ps.ps5) + g.xb != null -> { + if (!tv.canXbox) return toast("Update MoonCast on ${tv.name} to send it an Xbox game.") + if (TvFinder.isHere(tv) && !tv.xbox) return toast("Sign in to Xbox on ${tv.name} first. It plays your Xbox with its own sign-in.") + MoveToTv.Target("xb", g.xb.name, xboxId = g.xb.id) + } + else -> return + } + Recents.touch(this, g.key) + FeelSounds.play(this, FeelSounds.Cue.LAUNCH) + TvSender.open(this, tv, t) + } + + private fun controllers() { + dialog("Controllers", "Using: ${controllerName()}", listOf( + Opt("◎", Sw.C_BLUE, "Test a controller", "Every button, stick and trigger, live") { startActivity(Intent(this, PadTestActivity::class.java)) }, + Opt("+", Sw.C_BLUE, "Pair a controller", "Opens Bluetooth settings") { pairController() }, + Opt("⚙", Sw.PC, "Controller settings") { settingsGroup = "pads"; go(Page.SETTINGS) }), listOf("Close" to {})) + } + + /** Starts [app], asking first when another game runs there. [started] runs once it really starts (not on Cancel). */ + private fun start(pc: ComputerDetails, app: NvApp, started: () -> Unit = {}) { + val b = binder ?: return toast(getString(R.string.error_manager_not_running)) + val other = runningApp(pc) + if (other != null && other.appId != app.appId) { + confirm("Quit ${other.appName}?", "${pc.name} is running ${other.appName}. Quit it and start ${app.appName}?", "Quit and start") { + started() + FeelSounds.play(this, FeelSounds.Cue.LAUNCH) + ServerHelper.doQuit(this, pc, other, b) { runOnUiThread { ServerHelper.doStart(this, app, pc, b) } } + } + return + } + started() + FeelSounds.play(this, FeelSounds.Cue.LAUNCH) + ServerHelper.doStart(this, app, pc, b) + } + + private fun quit(pc: ComputerDetails, app: NvApp) { + val b = binder ?: return + confirm("Quit ${app.appName}?", "The game closes on ${pc.name}. Anything you haven't saved is lost.", "Quit game") { + ServerHelper.doQuit(this, pc, app, b) { runOnUiThread { b.invalidateStateForComputer(pc.uuid) } } + } + } + + private fun wake(d: ComputerDetails) { + if (d.macAddress == null || d.macAddress == "00:00:00:00:00:00") return toast(getString(R.string.wol_no_mac)) + Thread { + val msg = try { WakeOnLanSender.sendWolPacket(d); "Waking ${d.name}. It shows as online when it's ready." } + catch (e: Exception) { getString(R.string.wol_fail) } + runOnUiThread { toast(msg) } + }.start() + } + + private fun pair(d: ComputerDetails) { + val b = binder ?: return toast(getString(R.string.error_manager_not_running)) + if (!online(d) || d.activeAddress == null) return toast(getString(R.string.pair_pc_offline)) + val pin = PairingManager.generatePinString() + showPin(d, pin) + stopPolling() + Thread { + var msg: String? = null + var ok = false + try { + b.waitForPollingStopped() + val http = NvHTTP(ServerHelper.getCurrentAddressFromComputer(d), d.httpsPort, b.uniqueId, d.serverCert, + PlatformBinding.getCryptoProvider(this)) + if (http.pairState == PairingManager.PairState.PAIRED) ok = true + else { + val pm = http.pairingManager + when (pm.pair(http.getServerInfo(true), pin)) { + PairingManager.PairState.PIN_WRONG -> msg = getString(R.string.pair_incorrect_pin) + PairingManager.PairState.FAILED -> msg = getString(if (d.runningGameId != 0) R.string.pair_pc_ingame else R.string.pair_fail) + PairingManager.PairState.ALREADY_IN_PROGRESS -> msg = getString(R.string.pair_already_in_progress) + PairingManager.PairState.PAIRED -> { + ok = true + b.getComputer(d.uuid)?.serverCert = pm.pairedCert + b.invalidateStateForComputer(d.uuid) + } + else -> {} + } + } + } catch (e: UnknownHostException) { msg = getString(R.string.error_unknown_host) + } catch (e: FileNotFoundException) { msg = getString(R.string.error_404) + } catch (e: Exception) { msg = e.message ?: getString(R.string.pair_fail) } + runOnUiThread { + if (overlayKind == "pin") hideOverlay(false) + msg?.let { toast(it) } + if (ok) { toast("Paired with ${d.name}"); gamesTab = "pc"; page = Page.GAMES } + startPolling() + render(force = true) + } + }.start() + } + + private fun showPin(d: ComputerDetails, pin: String) { + val card = card() + card.addView(sw.text("Enter this PIN in Sunshine", 40, sw.ink, 800), sw.lp(-1, -2).apply { leftMargin = sw.px(56); topMargin = sw.px(48); rightMargin = sw.px(56) }) + card.addView(sw.row().apply { + gravity = Gravity.CENTER + pin.forEach { ch -> addView(sw.text(ch.toString(), 96, sw.ink, 800).apply { gravity = Gravity.CENTER; background = sw.round(sw.bg, 14) }, + sw.lp(140, 170).apply { leftMargin = sw.px(10); rightMargin = sw.px(10) }) } + }, sw.lp(-1, -2).apply { topMargin = sw.px(30) }) + val host = d.activeAddress?.address ?: "the PC" + card.addView(sw.text("On the PC open https://$host:47990, go to PIN, type $pin, name it ${deviceName()} and press Send.", 26, sw.sub, 600, 4), + sw.lp(-1, -2).apply { leftMargin = sw.px(56); rightMargin = sw.px(56); topMargin = sw.px(30) }) + buttons(card, listOf("Cancel" to {})) + showOverlay("pin", card) + } + + private fun confirmUnpair(d: ComputerDetails) = + confirm("Unpair ${d.name}?", "It stays here, but you'll pair it again with a PIN before you can play its games.", "Unpair") { + val b = binder ?: return@confirm + Thread { + val ok = try { + NvHTTP(ServerHelper.getCurrentAddressFromComputer(d), d.httpsPort, b.uniqueId, d.serverCert, + PlatformBinding.getCryptoProvider(this)).unpair(); true + } catch (e: Exception) { false } + runOnUiThread { toast(if (ok) "Unpaired ${d.name}" else "Couldn't reach ${d.name} to unpair it"); b.invalidateStateForComputer(d.uuid) } + }.start() + } + + private fun confirmRemovePc(d: ComputerDetails) = + confirm("Remove ${d.name}?", "It disappears from this device and is unpaired. To add it back, pair it again with a PIN.", "Remove") { + val b = binder ?: return@confirm + if (online(d) && paired(d)) Thread { + try { NvHTTP(ServerHelper.getCurrentAddressFromComputer(d), d.httpsPort, b.uniqueId, d.serverCert, + PlatformBinding.getCryptoProvider(this)).unpair() } catch (e: Exception) { } + }.start() + appPollers.remove(d.uuid)?.stop() + b.removeComputer(d) + DiskAssetLoader(this).deleteAssetsForComputer(d.uuid) + getSharedPreferences(AppView.HIDDEN_APPS_PREF_FILENAME, MODE_PRIVATE).edit().remove(d.uuid).apply() + try { ShortcutHelper(this).disableComputerShortcut(d, getString(R.string.scut_deleted_pc)) } catch (e: Exception) { } + pcs.remove(d.uuid); apps.remove(d.uuid); rawLists.remove(d.uuid); loaders.remove(d.uuid) + Recents.forget(this, "pc:${d.uuid}:") + render(force = true) + } + + private fun addPcByAddress() { + entry("Add a PC by its address", "The IP address or name of the PC Sunshine runs on. Add :port only if it isn't the usual one.", + "For example 192.168.1.20", "", "address") { raw -> + if (raw.isEmpty()) return@entry "Type the PC's address first." + val b = binder ?: return@entry "Still starting, try again in a moment." + toast("Looking for a PC at $raw…") + Thread { + val ok = try { + val uri = try { java.net.URI("moonlight://$raw") } catch (e: Exception) { java.net.URI("moonlight://[$raw]") } + val host = uri.host ?: throw IllegalArgumentException() + val d = ComputerDetails() + d.manualAddress = ComputerDetails.AddressTuple(host, if (uri.port == -1) NvHTTP.DEFAULT_HTTP_PORT else uri.port) + b.addComputerBlocking(d) + } catch (e: Exception) { false } + runOnUiThread { + if (ok) { toast("Added. Pair it from Consoles."); go(Page.CONSOLES) } + // an internet name usually doesn't answer from inside the home it points at + else if (pcs.isNotEmpty() && raw.any { it.isLetter() } && !raw.endsWith(".local")) + toast("No PC answered at $raw. To use it away from home for a PC you already have, open the PC in Consoles and choose Address for away.") + else toast("No PC answered at $raw. Check it's on, Sunshine is running, and the address is right.") + } + }.start() + null + } + } + + // ---- PS5 registration, here on the handheld: PSN sign-in (once) -> pick the PS5 -> 8-digit code ---- + + private fun registerPs() { + if (!Psn.signedIn(this)) { startActivityForResult(Intent(this, PsnSignInActivity::class.java), REQ_PSN_REGISTER); return } + val card = card() + card.addView(sw.text("Which PlayStation?", 40, sw.ink, 800), sw.lp(-1, -2).apply { leftMargin = sw.px(56); topMargin = sw.px(48) }) + val sub = sw.text("Looking on this network…", 26, sw.sub, 600, 3) + card.addView(sub, sw.lp(-1, -2).apply { leftMargin = sw.px(56); rightMargin = sw.px(56); topMargin = sw.px(10); bottomMargin = sw.px(26) }) + val list = sw.col() + card.addView(list, sw.lp(-1, -2)) + buttons(card, listOf("Cancel" to {})) + showOverlay("pickps", card) + Thread { + val found = Ps5Store.discover() + runOnUiThread { + if (overlayKind != "pickps") return@runOnUiThread + if (found.isEmpty()) sub.text = "No PlayStation answered. Make sure it's on or in rest mode (not fully off) and on this network." + else { + sub.text = "Pick yours. Then on it open Settings, System, Remote Play, Link device to see the code." + found.forEachIndexed { i, f -> + list.addView(optRow(Opt("PS", Sw.PS, f.name, f.host, if (f.on) "On" else "Rest mode") { enterCode(f) }, if (i == 0) "primary" else "ps:${f.host}")) + } + list.findViewWithTag("primary")?.requestFocus() + } + } + }.start() + } + + private fun enterCode(f: Ps5Store.Found) { + entry("Type the code from ${f.name}", "On the PS5: Settings, System, Remote Play, Link device. The code has 8 digits.", "8 digits", "", true) { raw -> + if (raw.length != 8 || raw.any { !it.isDigit() }) return@entry "The code has 8 digits." + val acc = Psn.accountId(this) ?: return@entry "Sign in to PlayStation Network first." + toast("Registering with ${f.name}…") + Thread { + val r = try { com.limelight.Ps5Register.register(f.host, android.util.Base64.decode(acc, android.util.Base64.DEFAULT), raw.toInt(), true, 30000L) } + catch (e: Exception) { null } + if (r != null) Ps5Store.save(this, Ps5Store.Console(f.host, r.nickname.ifBlank { f.name }, r.registKeyHex, r.morningHex, true)) + runOnUiThread { + if (r == null) toast("That didn't work. Check the code (it changes every few minutes) and that the PS5 still shows Link device.") + else { toast("${r.nickname.ifBlank { f.name }} added"); consoles = Ps5Store.all(this); render(force = true) } + } + }.start() + null + } + } + + @Deprecated("Deprecated in Java") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (resultCode != RESULT_OK) return + when (requestCode) { + REQ_PSN_REGISTER -> { toast("Signed in to PlayStation Network"); ui.post { registerPs() } } + REQ_PSN -> { toast("Signed in to PlayStation Network"); render(force = true) } + REQ_PICTURE -> { + val key = pictureFor ?: return; val uri = data?.data ?: return + Thread { + val ok = com.limelight.home.CustomArt.set(this, key, uri) + runOnUiThread { if (ok) { render(force = true); toast("Picture changed") } else toast("That image couldn't be read") } + }.start() + } + } + } + + /** Microsoft's device-code sign-in. On a handheld the page can open right here. */ + private fun xboxSignIn() { + val card = card() + card.addView(sw.text("Sign in to Xbox", 40, sw.ink, 800), sw.lp(-1, -2).apply { leftMargin = sw.px(56); topMargin = sw.px(48) }) + val sub = sw.text("Getting a code…", 26, sw.sub, 600, 3) + card.addView(sub, sw.lp(-1, -2).apply { leftMargin = sw.px(56); rightMargin = sw.px(56); topMargin = sw.px(10) }) + val code = sw.text("", 80, sw.ink, 800).apply { letterSpacing = 0.12f; gravity = Gravity.CENTER } + card.addView(code, sw.lp(-1, -2).apply { topMargin = sw.px(26); bottomMargin = sw.px(10) }) + var url: String? = null + buttons(card, listOf("Cancel" to {}, "Open the page" to { + url?.let { u -> try { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(u))) } catch (e: Exception) { toast("Open $u in a browser") } } + Unit + }), keepOpenLast = true) + showOverlay("xbox", card) + Thread { + try { + val dc = Xbox.startSignIn() + runOnUiThread { + url = dc.url + sub.text = "Open ${dc.url.removePrefix("https://").removePrefix("www.")} (here or on another device), enter this code, and sign in with your Xbox account." + code.text = dc.userCode + } + val end = System.currentTimeMillis() + dc.expiresIn * 1000L + while (System.currentTimeMillis() < end && overlayKind == "xbox") { + Thread.sleep(dc.interval * 1000L) + if (overlayKind != "xbox") return@Thread + if (Xbox.pollSignIn(this, dc)) { + val list = try { Xbox.refreshConsoles(this) } catch (e: Exception) { emptyList() } + runOnUiThread { + xboxes = list + if (overlayKind == "xbox") hideOverlay() + toast("Signed in to Xbox" + (Xbox.gamertag(this)?.let { " as $it" } ?: "") + + if (list.isEmpty()) ". No console found yet" else ". Found " + list.joinToString { it.name }) + render(force = true) + } + return@Thread + } + } + runOnUiThread { if (overlayKind == "xbox") { sub.text = "The code expired. Cancel and try again."; code.text = "" } } + } catch (e: Exception) { + runOnUiThread { if (overlayKind == "xbox") sub.text = "Sign-in didn't work: ${e.message?.take(120) ?: e.javaClass.simpleName}" } + } + }.start() + } + + /** Moonlight's network test, in this app's own dialog (the old grey system one looked like another app). */ + private fun networkTest() { + dialog("Testing the network", "Checking that nothing blocks streaming. This takes a few seconds.", emptyList(), listOf("Close" to {})) + Thread { + val r = ServerHelper.networkTestSummary(this) + runOnUiThread { + if (overlayKind != "dialog") return@runOnUiThread // closed while it ran + hideOverlay(false) + dialog("Network test done", r, emptyList(), listOf("OK" to {})) + } + }.start() + } + + private fun pairController() { + for (a in listOf(Settings.ACTION_BLUETOOTH_SETTINGS, Settings.ACTION_SETTINGS)) { + try { startActivity(Intent(a)); return } catch (e: Exception) { } + } + toast("Open Settings, then Bluetooth, to pair a controller.") + } + + // ---------------------------------------------------------------- dialogs (Switch style) + + private fun card(): LinearLayout = sw.col().apply { + background = sw.round(sw.panel, 28) + clipToOutline = true; outlineProvider = rounded(28) + } + + private fun optRow(o: Opt, tag: String): View = sw.row().apply { + this.tag = tag + background = sw.round(sw.bg, 18) + setPadding(sw.px(26), sw.px(14), sw.px(28), sw.px(14)) + minimumHeight = sw.px(100) + if (!o.enabled) alpha = 0.45f + // a symbol fills its square; a short word (PC, PS, TV) stays the size of a badge + if (o.icon != null) addView(sw.text(o.icon, if (o.icon.length == 1) 36 else 23, 0xFFFFFFFF.toInt(), 800).apply { gravity = Gravity.CENTER; background = sw.round(o.iconColor, ROW_ICON_R) }, + sw.lp(ROW_ICON, ROW_ICON).apply { rightMargin = sw.px(26) }) + addView(sw.col().apply { + addView(sw.text(o.title, 30, sw.ink, 700)) + if (o.sub != null) addView(sw.text(o.sub, 23, sw.sub, 600, 2), sw.lp(-2, -2).apply { topMargin = sw.px(sw.lineGap) }) + }, LinearLayout.LayoutParams(0, -2, 1f)) + if (o.right != null) addView(sw.text(o.right, ROW_VALUE, sw.sub, 700), sw.lp(-2, -2).apply { leftMargin = sw.px(24) }) + if (o.enabled) { + sw.focusable(this) + setOnClickListener { val n = FeelSounds.serial; hideOverlay(false); o.onClick(); if (n == FeelSounds.serial) FeelSounds.play(this@HandheldHomeActivity, FeelSounds.Cue.SELECT) } + } + layoutParams = sw.lp(-1, -2).apply { leftMargin = sw.px(40); rightMargin = sw.px(40); bottomMargin = sw.px(12) } + } + + /** A dialog's buttons along its bottom: rounded buttons side by side; the last is where focus starts. */ + /** A row of buttons; with [primary], the one at [primaryAt] (default the last) is the one A and the first focus pick. */ + private fun buttons(card: LinearLayout, list: List Unit>>, primary: Boolean = true, keepOpenLast: Boolean = false, + primaryAt: Int = list.size - 1) { + val row = sw.row() + list.forEachIndexed { i, (label, fn) -> + val keep = keepOpenLast && i == list.size - 1 + row.addView(sw.text(label, 30, sw.ring, 800).apply { + gravity = Gravity.CENTER + tag = if (primary && i == primaryAt) "primary" else "btn:$i" + setTag(R.id.hh_pill, true) + background = sw.round(sw.bg, 48) + sw.focusable(this) + sw.pressable(this) + setOnClickListener { + if (keep) { fn(); return@setOnClickListener } + val n = FeelSounds.serial + hideOverlay(false); fn() + if (n == FeelSounds.serial) FeelSounds.play(this@HandheldHomeActivity, FeelSounds.Cue.CLOSE) + } + }, LinearLayout.LayoutParams(0, sw.px(96), 1f).apply { if (i > 0) leftMargin = sw.px(20) }) + } + card.addView(row, sw.lp(-1, 96).apply { leftMargin = sw.px(40); rightMargin = sw.px(40); topMargin = sw.px(28); bottomMargin = sw.px(40) }) + } + + private fun dialog(title: String, body: String?, opts: List, btns: List Unit>>, primaryAt: Int = btns.size - 1) { + val card = card() + card.addView(sw.text(title, 40, sw.ink, 800, 2), sw.lp(-1, -2).apply { leftMargin = sw.px(56); rightMargin = sw.px(56); topMargin = sw.px(48); bottomMargin = sw.px(10) }) + if (body != null) card.addView(sw.text(body, 26, sw.sub, 600, 8), sw.lp(-1, -2).apply { leftMargin = sw.px(56); rightMargin = sw.px(56); bottomMargin = sw.px(26) }) + val firstOn = opts.indexOfFirst { it.enabled } + val list = sw.col() + opts.forEachIndexed { i, o -> list.addView(optRow(o, if (i == firstOn) "primary" else "opt:$i")) } + // as many options as the screen has room for; past that the list scrolls, cut through the middle of a row + // so it's plain there's more. A row is about 112 with its gap; title, text and buttons take about 440 (it was + // 620 and 132, so a PC game's menu showed three options and hid the rest below a scroll) + val fit = ((sw.designHeight - 440) / 112f).toInt().coerceIn(3, 8) + if (opts.size > fit) card.addView(ColScroll(this, sw.px(30)).apply { isVerticalScrollBarEnabled = false; addView(list) }, sw.lp(-1, fit * 112 - 56)) + else card.addView(list, sw.lp(-1, -2)) + buttons(card, btns, primary = firstOn < 0, primaryAt = primaryAt) + showOverlay("dialog", card) + } + + /** + * A yes/Cancel question. Cancel is picked (A presses it) unless [yesPicked]: a habitual A must not quit a game, + * unpair or remove anything (it quit Ryujinx on 2026-10-21). Only a harmless yes, like Wake it, is picked. + */ + private fun confirm(title: String, body: String, yes: String, yesPicked: Boolean = false, onYes: () -> Unit) = + dialog(title, body, emptyList(), listOf("Cancel" to {}, yes to onYes), primaryAt = if (yesPicked) 1 else 0) + + /** A one-line entry; [onSave] returns an error to show, or null when the value is good. */ + private fun entry(title: String, body: String, hint: String, v: String, numbers: Boolean, onSave: (String) -> String?) = + entry(title, body, hint, v, if (numbers) "number" else "text", onSave) + + private fun entry(title: String, body: String, hint: String, v: String, kind: String, onSave: (String) -> String?) { + val card = card() + card.addView(sw.text(title, 40, sw.ink, 800, 2), sw.lp(-1, -2).apply { leftMargin = sw.px(56); rightMargin = sw.px(56); topMargin = sw.px(48); bottomMargin = sw.px(10) }) + card.addView(sw.text(body, 26, sw.sub, 600, 4), sw.lp(-1, -2).apply { leftMargin = sw.px(56); rightMargin = sw.px(56) }) + val field = EditText(this).apply { + setText(v); setSelection(v.length) + this.hint = hint + setTextColor(sw.ink); setHintTextColor(sw.sub) + setTextSize(android.util.TypedValue.COMPLEX_UNIT_PX, sw.pxf(32)); typeface = sw.ui(700) + setSingleLine(true) + inputType = when (kind) { + "number" -> android.text.InputType.TYPE_CLASS_NUMBER + "decimal" -> android.text.InputType.TYPE_CLASS_NUMBER or android.text.InputType.TYPE_NUMBER_FLAG_DECIMAL + "address" -> android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_VARIATION_URI or android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS + else -> android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS + } + // keep the box on our dark card: no full-screen white extract editor on a landscape handheld + imeOptions = android.view.inputmethod.EditorInfo.IME_ACTION_DONE or android.view.inputmethod.EditorInfo.IME_FLAG_NO_EXTRACT_UI or + android.view.inputmethod.EditorInfo.IME_FLAG_NO_FULLSCREEN + highlightColor = sw.ring and 0x66FFFFFF + if (Build.VERSION.SDK_INT >= 29) { textCursorDrawable = sw.round(sw.ring, 2).apply { setSize(sw.px(3), sw.px(36)) } } + background = sw.round(sw.bg, 10, sw.ring, 3) + setPadding(sw.px(24), 0, sw.px(24), 0) + tag = "field" + } + card.addView(field, sw.lp(-1, 96).apply { leftMargin = sw.px(56); rightMargin = sw.px(56); topMargin = sw.px(26) }) + val err = sw.text("", 23, Sw.DANGER, 700, 2) + card.addView(err, sw.lp(-1, -2).apply { leftMargin = sw.px(56); rightMargin = sw.px(56); topMargin = sw.px(12) }) + fun submit() { val e = onSave(field.text.toString().trim()); if (e == null) hideOverlay() else err.text = e } + field.setOnEditorActionListener { _, _, _ -> submit(); true } + buttons(card, listOf("Cancel" to {}, "OK" to { submit() }), keepOpenLast = true) + showOverlay("entry", card) + field.requestFocus() + try { (getSystemService(Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager).showSoftInput(field, 0) } catch (e: Exception) { } + } + + private var returnTag: String? = null + + private fun showOverlay(kind: String, card: View, wide: Boolean = false) { + FeelSounds.play(this, FeelSounds.Cue.OPEN) + if (overlayKind == null) returnTag = currentFocus?.tag as? String // where focus goes back to when it closes + overlayKind = kind + overlay.removeAllViews() + overlay.setBackgroundColor(0x9E000000.toInt()) + overlay.addView(card, FrameLayout.LayoutParams(sw.px(if (wide) minOf(1560f, sw.designWidth - 80) else minOf(960f, sw.designWidth - 60)), -2, Gravity.CENTER)) + overlay.visibility = View.VISIBLE + overlay.setOnClickListener { hideOverlay() } // a tap outside the card closes it + card.isClickable = true + // clickable views are focusable by default on Android 8+; the scrim and the card must never take focus + overlay.isFocusable = false; card.isFocusable = false + main.descendantFocusability = ViewGroup.FOCUS_BLOCK_DESCENDANTS + (overlay.findViewWithTag("primary") ?: firstFocusable(overlay))?.requestFocus() + buildFoot() // the hints say what A and B do in the dialog, not on the page under it + } + + private fun hideOverlay(cue: Boolean = true) { + if (overlayKind == null) return + if (cue) FeelSounds.play(this, FeelSounds.Cue.CLOSE) + try { + (getSystemService(Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager) + .hideSoftInputFromWindow(window.decorView.windowToken, 0) + } catch (e: Exception) { } + overlayKind = null + whereOpen = null + overlay.removeAllViews() + overlay.visibility = View.GONE + main.descendantFocusability = ViewGroup.FOCUS_AFTER_DESCENDANTS + render(force = true) + returnTag?.let { t -> content.findViewWithTag(t)?.requestFocus() } + returnTag = null + } + + // ---------------------------------------------------------------- device bits + + private fun deviceName(): String = + (if (Build.VERSION.SDK_INT >= 25) Settings.Global.getString(contentResolver, Settings.Global.DEVICE_NAME) else null) ?: Build.MODEL + + /** "Odin" on AYN's handhelds, else "device". */ + private fun deviceShort(): String = + if (Build.MANUFACTURER.equals("AYN", true) || Build.MODEL.contains("Odin", true)) "Odin" else "device" + + /** The first real game controller's name, for the hint bar. */ + private fun hasController(): Boolean = controllerName() != "Touch screen" + + /** A tapped hint: the same as pressing that button on the pad. */ + private fun pressButton(k: String) { + val code = when (k) { + "A" -> KeyEvent.KEYCODE_BUTTON_A; "B" -> KeyEvent.KEYCODE_BUTTON_B; "X" -> KeyEvent.KEYCODE_BUTTON_X + "Y" -> KeyEvent.KEYCODE_BUTTON_Y; "L R" -> KeyEvent.KEYCODE_BUTTON_R1; else -> return + } + // A starts what's selected; after a tap nothing has focus, so use what the page starts on + if (code == KeyEvent.KEYCODE_BUTTON_A && (currentFocus == null || currentFocus?.isInTouchMode == true)) { + (if (overlayKind != null) overlay.findViewWithTag("primary") else primaryView)?.performClick(); return + } + dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, code)); dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, code)) + } + + private fun controllerName(): String { + for (id in InputDevice.getDeviceIds()) { + val d = InputDevice.getDevice(id) ?: continue + if (d.isVirtual) continue + val s = d.sources + if ((s and InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD || (s and InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK) + return d.name.ifBlank { "Controller" } + } + return "Touch screen" + } + + /** The panel's own size, landscape, even numbers: what Native used to be. */ + private fun rawNativeSize(): String { + @Suppress("DEPRECATION") val d = windowManager.defaultDisplay + var w: Int; var h: Int + if (Build.VERSION.SDK_INT >= 23) { val m = d.mode; w = m.physicalWidth; h = m.physicalHeight } + else { val p = android.graphics.Point(); @Suppress("DEPRECATION") d.getRealSize(p); w = p.x; h = p.y } + if (h > w) { val t = w; w = h; h = t } + return "${w and 1.inv()}x${h and 1.inv()}" + } + + /** With Native on, the saved size is this screen's, so the settings and Moonlight's own pages show it. */ + private fun syncNativeRes(commit: Boolean = false) { + // a foldable (both its screens seen): fill each screen from the one shared shape, unless Picture shape was set + if (PreferenceConfiguration.foldableResolution(this) == null) PreferenceConfiguration.rememberScreen(this) + if (PreferenceConfiguration.foldableResolution(this) != null && !stream.contains("list_video_scale_mode")) + stream.edit().putString("list_video_scale_mode", "fill").apply() + // A saved size that is this screen's own is Native (Settings shows it as Native): test203-211 could save it + // with Native's switch off, when the page redrew before the pick was saved + if (!stream.getBoolean(PreferenceConfiguration.NATIVE_RES_PREF_STRING, false) && stream.getString("list_resolution", null) == nativeResolution()) + stream.edit().putBoolean(PreferenceConfiguration.NATIVE_RES_PREF_STRING, true).apply() + if (stream.getBoolean(PreferenceConfiguration.NATIVE_RES_PREF_STRING, false) && stream.getString("list_resolution", null) != nativeResolution()) + stream.edit().putString("list_resolution", nativeResolution()).let { if (commit) it.commit() else it.apply() } + } + + private fun nativeResolution(): String = PreferenceConfiguration.nativeResolution(this) + + private fun chipName(): String = com.limelight.home.Chip.name() + + private fun rounded(r: Number) = object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: Outline) = outline.setRoundRect(0, 0, view.width, view.height, sw.pxf(r)) + } + + // ---------------------------------------------------------------- input + + override fun dispatchKeyEvent(e: KeyEvent): Boolean { + if (e.action == KeyEvent.ACTION_DOWN && overlayKind == null && e.repeatCount == 0) { + when (e.keyCode) { + KeyEvent.KEYCODE_BUTTON_Y -> if (page == Page.HOME || page == Page.GAMES) { toggleTarget(); return true } + KeyEvent.KEYCODE_BUTTON_X -> if (page == Page.GAMES) { + sortAz = !sortAz; FeelSounds.play(this, FeelSounds.Cue.TOGGLE); render(force = true); return true } + KeyEvent.KEYCODE_BUTTON_L1, KeyEvent.KEYCODE_BUTTON_R1 -> { + val step = if (e.keyCode == KeyEvent.KEYCODE_BUTTON_L1) -1 else 1 + when (page) { + Page.GAMES -> { + val tabs = listOf("all", "pc", "ps", "xb", "dev") + gamesTab = tabs[(tabs.indexOf(gamesTab) + step + tabs.size) % tabs.size] + FeelSounds.play(this, FeelSounds.Cue.TAB, 0.3f * step); render(force = true); return true + } + Page.SETTINGS -> run { + val i = groups.indexOfFirst { it.first == settingsGroup } + settingsGroup = groups[(i + step + groups.size) % groups.size].first + FeelSounds.play(this, FeelSounds.Cue.TAB, 0.3f * step); render(force = true) + content.findViewWithTag("group:$settingsGroup")?.requestFocus(); return true + } + else -> { + val order = Page.values() + go(order[(order.indexOf(page) + step + order.size) % order.size]) + FeelSounds.play(this, FeelSounds.Cue.TAB, 0.3f * step); return true + } + } + } + } + } + return feel(e) + } + + private fun focusedGame(): Game? { + val t = currentFocus?.tag as? String ?: return null + return if (t.startsWith("g:")) gameFor(t.removePrefix("g:")) else null + } + + /** As on the TV: a focus tick, the end-of-row nudge, and a select sound (with the ring's pop) for a confirm press. */ + private fun feel(e: KeyEvent): Boolean { + val dir = Feel.direction(e.keyCode) + if (dir != 0 && e.action == KeyEvent.ACTION_DOWN) { + val before = currentFocus + // nothing focused yet (just opened, or back from touch): start on the page's first item, not the top bar + if (before == null || !(isIn(before, content) || isIn(before, overlay) || isIn(before, foot))) { + val start = if (overlayKind != null) overlay.findViewWithTag("primary") ?: firstFocusable(overlay) + else primaryView?.takeIf { it.isAttachedToWindow } ?: firstFocusable(content) + if (start != null && start.requestFocus()) { FeelSounds.focus(start); return true } + } + if (super.dispatchKeyEvent(e)) { // a view moved the focus itself (a scrolling row, a list) + val after = currentFocus + if (after != null && after !== before) FeelSounds.focus(after) + return true + } + if (before == null || before is EditText) return false + // Nothing took the key, so Android would move the focus next and play its own click. Move it here the same + // way instead, so MoonCast's sound replaces the system one rather than playing on top of it. + val root = window.decorView as ViewGroup + val next = before.focusSearch(dir) + if (next != null && next !== before) { + val r = android.graphics.Rect() + before.getFocusedRect(r) + root.offsetDescendantRectToMyCoords(before, r) + root.offsetRectIntoDescendantCoords(next, r) + if (next.requestFocus(dir, r)) { + if (FeelSounds.enabled(this)) FeelSounds.focus(next) + else next.playSoundEffect(android.view.SoundEffectConstants.getContantForFocusDirection(dir)) + return true + } + return false + } + Feel.edgeNudge(before, dir, e, sw.pxf(14)) + return true + } + val confirm = e.keyCode == KeyEvent.KEYCODE_DPAD_CENTER || e.keyCode == KeyEvent.KEYCODE_ENTER || + e.keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER || e.keyCode == KeyEvent.KEYCODE_BUTTON_A + val focused = currentFocus + if (confirm && e.action == KeyEvent.ACTION_UP && focused != null && focused !is EditText) { + val pan = FeelSounds.panFor(focused) + val n = FeelSounds.serial + glide.pop() + val handled = super.dispatchKeyEvent(e) + if (handled && n == FeelSounds.serial) FeelSounds.play(this, FeelSounds.Cue.SELECT, pan) + return handled + } + return super.dispatchKeyEvent(e) + } + + private fun isIn(v: View, parent: View): Boolean { + var p: Any? = v + while (p is View) { if (p === parent) return true; p = p.parent } + return false + } + + @Deprecated("Deprecated in Java") + override fun onBackPressed() { + when { + overlayKind != null -> hideOverlay() + licencesOpen && page == Page.SETTINGS -> { FeelSounds.play(this, FeelSounds.Cue.BACK); licencesOpen = false; render(force = true) } + page != Page.HOME -> { FeelSounds.play(this, FeelSounds.Cue.BACK); go(Page.HOME) } + else -> { FeelSounds.play(this, FeelSounds.Cue.BACK); moveTaskToBack(true) } + } + } + + companion object { + /** The fixed resolution steps; anything else saved was a screen's own size (Native). */ + /** Bitrate's steps in Mbps (Auto is 0); anything else saved is a Custom bitrate. */ + val BITRATE_STEPS = listOf(10, 20, 30, 50, 80) + val RES_STEPS = listOf("720p" to "1280x720", "1080p" to "1920x1080", "1440p" to "2560x1440", "4K" to "3840x2160") + + private const val STATE_PAGE = "hh_page" + private const val STATE_GROUP = "hh_settings_group" + const val EXTRA_PLAY_HERE = "play_here" + private const val REQ_PSN_REGISTER = 41 + private const val REQ_PSN = 42 + private const val REQ_PICTURE = 43 + // one look for every list row (Consoles, Settings, dialogs): the icon square, its corner, a value on the right + private const val ROW_ICON = 72 + private const val ROW_ICON_R = 18 + private const val ROW_VALUE = 24 + } +} diff --git a/app/src/handheld/java/com/limelight/handheld/PadLink.kt b/app/src/handheld/java/com/limelight/handheld/PadLink.kt new file mode 100644 index 0000000000..b83542dacc --- /dev/null +++ b/app/src/handheld/java/com/limelight/handheld/PadLink.kt @@ -0,0 +1,43 @@ +package com.limelight.handheld + +/** + * The one controller server for this app process. It starts with the first game sent to a TV and stays up while you + * re-send, move a game, or controller mode reopens, so the TV's link to it (PhoneGamepad / Ps5Pad / XboxPhonePad) + * keeps working instead of dropping and hitting a closed port. Each TV gets one pad key for the session, sent again + * on every /play to it. Only Stop, Play here instead, or leaving the app ends it. + */ +object PadLink { + private var server: PadServer? = null + private val keys = HashMap() // TV id -> its pad key for this session + + /** Listeners for the number of connected TVs (called on a background thread). */ + @Volatile var onClients: ((Int) -> Unit)? = null + + @Synchronized + fun server(): PadServer = server?.takeIf { it.isRunning } ?: PadServer().also { s -> + s.keys.addAll(keys.values) + s.onClients = { n -> onClients?.invoke(n) } + s.start() + server = s + } + + /** The pad key for [tvId]: the same one for as long as the server runs, so the TV's link stays valid. */ + @Synchronized + fun keyFor(tvId: String): String { + val k = keys.getOrPut(tvId) { TvSender.newPadKey() } + server().keys.add(k) + return k + } + + val clientCount: Int get() = server?.clientCount ?: 0 + val running: Boolean get() = server?.isRunning == true + + fun update(b: FloatArray, a: FloatArray) { server?.update(b, a) } + + @Synchronized + fun stop() { + server?.stop() + server = null + keys.clear() + } +} diff --git a/app/src/handheld/java/com/limelight/handheld/PadServer.kt b/app/src/handheld/java/com/limelight/handheld/PadServer.kt new file mode 100644 index 0000000000..665f67902f --- /dev/null +++ b/app/src/handheld/java/com/limelight/handheld/PadServer.kt @@ -0,0 +1,235 @@ +package com.limelight.handheld + +import android.util.Base64 +import android.util.Log +import java.io.ByteArrayOutputStream +import java.io.DataInputStream +import java.io.InputStream +import java.io.OutputStream +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.net.Socket +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +/** + * The handheld's controller, served to the MoonCast TV that plays the game: a tiny WebSocket server on :8099 + * (ws://:8099/pad?k=). The TV's relays (PhoneGamepad for a PC game, Ps5Pad for a PS5, XboxPhonePad + * for an Xbox) connect here after /play and read + * + * {"pads":[{"b":[17 buttons, 0..1],"a":[4 axes, -1..1]}]} + * + * in W3C Standard Gamepad order (b: A B X Y LB RB LT RT Select Start L3 R3 Up Down Left Right Guide; + * a: LX LY RX RY, Y down-positive). A frame goes out at most 60 times a second when something changed, and the full + * state is repeated every 500 ms so the link never looks dead (the relays time out after 25 s of silence). + * + * A PS5 relay also takes UDP on the TV's :8100 ({"seq","k","pads"}, resent every 50 ms), which is not held back by a + * lost TCP segment on a jittery Wi-Fi; it is sent to relays that said hello as "mctv-ps5". Only a client presenting + * a pad key from [keys] (one per TV, see [PadLink]) is accepted. This is part of the SENDER: it serves the controller; + * it never receives games. + */ +class PadServer { + /** The pad keys handed to TVs in /play; a TV presents its own in /pad?k=. */ + val keys = java.util.concurrent.CopyOnWriteArraySet() + companion object { + const val PORT = 8099 + const val UDP_PORT = 8100 + private const val TAG = "MoonCast-PadServer" + const val BUTTONS = 17 + const val AXES = 4 + } + + private class Client(val sock: Socket, val out: OutputStream, val ip: String, val key: String) { @Volatile var kind: String? = null } + + private val clients = CopyOnWriteArrayList() + private var server: ServerSocket? = null + @Volatile private var running = false + private val ticker = Executors.newSingleThreadScheduledExecutor() + private var udp: DatagramSocket? = null + private var seq = 0L + + // the pad state, written on the main thread, read by the ticker + private val lock = Any() + private val b = FloatArray(BUTTONS) + private val a = FloatArray(AXES) + @Volatile private var dirty = true + private var lastWs = 0L + private var lastUdp = 0L + + /** Called (on a background thread) when the number of connected TVs changes. */ + @Volatile var onClients: ((Int) -> Unit)? = null + val clientCount get() = clients.size + + val isRunning get() = running + + fun start() { + if (running) return + running = true + Thread({ + try { + val s = ServerSocket() + s.reuseAddress = true + s.bind(InetSocketAddress(PORT)) + server = s + while (running) { val c = s.accept(); Thread({ handle(c) }, "pad-client").start() } + } catch (e: Exception) { if (running) Log.w(TAG, "pad server stopped: $e") } + }, "pad-server").start() + ticker.scheduleWithFixedDelay({ tick() }, 16, 16, TimeUnit.MILLISECONDS) + } + + fun stop() { + running = false + try { server?.close() } catch (e: Exception) { } + ticker.shutdownNow() + // let go of everything on the TV before the link closes, so nothing stays held (off the main thread) + synchronized(lock) { b.fill(0f); a.fill(0f) } + val f = frame(stateJson()) + val list = clients.toList() + clients.clear() + Thread({ + list.forEach { c -> + try { synchronized(c.out) { c.out.write(f); c.out.write(byteArrayOf(0x88.toByte(), 0)); c.out.flush() } } catch (e: Exception) { } + try { c.sock.close() } catch (e: Exception) { } + } + try { udp?.close() } catch (e: Exception) { } + }, "pad-server-stop").start() + } + + /** The newest pad state (copied). */ + fun update(buttons: FloatArray, axes: FloatArray) { + synchronized(lock) { + var changed = false + for (i in 0 until BUTTONS) if (b[i] != buttons[i]) { b[i] = buttons[i]; changed = true } + for (i in 0 until AXES) if (a[i] != axes[i]) { a[i] = axes[i]; changed = true } + if (changed) dirty = true + } + } + + private fun stateJson(): String = synchronized(lock) { + val sb = StringBuilder(160).append("{\"pads\":[{\"b\":[") + for (i in 0 until BUTTONS) { if (i > 0) sb.append(','); num(sb, b[i]) } + sb.append("],\"a\":[") + for (i in 0 until AXES) { if (i > 0) sb.append(','); num(sb, a[i]) } + sb.append("]}]}").toString() + } + + private fun num(sb: StringBuilder, v: Float) { + if (v == 0f) sb.append('0') else if (v == 1f) sb.append('1') else sb.append(String.format(java.util.Locale.US, "%.3f", v)) + } + + private fun tick() { + if (!running || clients.isEmpty()) return + val now = System.currentTimeMillis() + val changed = dirty + if (!changed && now - lastWs < 500 && now - lastUdp < 50) return + dirty = false + val json = stateJson() + if (changed || now - lastWs >= 500) { + lastWs = now + val f = frame(json) + for (c in clients) { + try { synchronized(c.out) { c.out.write(f); c.out.flush() } } catch (e: Exception) { drop(c) } + } + } + if (changed || now - lastUdp >= 50) { + lastUdp = now + val targets = clients.filter { it.kind == "mctv-ps5" } + if (targets.isNotEmpty()) try { + val sock = udp ?: DatagramSocket().also { udp = it } + val n = ++seq + for (c in targets) { + val bytes = ("{\"seq\":" + n + ",\"k\":\"" + c.key + "\"," + json.substring(1)).toByteArray(StandardCharsets.UTF_8) + sock.send(DatagramPacket(bytes, bytes.size, InetAddress.getByName(c.ip), UDP_PORT)) + } + } catch (e: Exception) { Log.w(TAG, "udp pad: $e") } + } + } + + private fun drop(c: Client) { + if (clients.remove(c)) onClients?.invoke(clients.size) + try { c.sock.close() } catch (e: Exception) { } + } + + private fun handle(s: Socket) { + var me: Client? = null + try { + s.tcpNoDelay = true // controller input: never batch small packets + s.soTimeout = 0 + val inp = s.getInputStream(); val out = s.getOutputStream() + val head = readHeader(inp) + val line = head.substringBefore("\r\n") + val path = line.split(' ').getOrNull(1) ?: "" + val given = Regex("[?&]k=([^&\\s]+)").find(path)?.groupValues?.get(1) ?: "" + val wsKey = Regex("(?im)^Sec-WebSocket-Key:\\s*(\\S+)").find(head)?.groupValues?.get(1) + if (!path.startsWith("/pad") || wsKey == null || + keys.none { MessageDigest.isEqual(given.toByteArray(StandardCharsets.US_ASCII), it.toByteArray(StandardCharsets.US_ASCII)) }) { + out.write("HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".toByteArray(StandardCharsets.US_ASCII)) + out.flush(); s.close(); return + } + val accept = Base64.encodeToString(MessageDigest.getInstance("SHA-1").digest( + (wsKey + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").toByteArray(StandardCharsets.US_ASCII)), Base64.NO_WRAP) + val c = Client(s, out, s.inetAddress.hostAddress ?: "", given) + synchronized(out) { + out.write(("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" + + "Sec-WebSocket-Accept: $accept\r\n\r\n").toByteArray(StandardCharsets.US_ASCII)) + out.write(frame(stateJson())) // the current state right away + out.flush() + } + me = c + clients.add(c) + Log.i(TAG, "TV controller link from ${c.ip}") + onClients?.invoke(clients.size) + // read what the TV sends: its hello ({"hello":{"kind":...}}), pings, and the close + val din = DataInputStream(inp) + while (running) { + val b0 = din.readUnsignedByte(); val b1 = din.readUnsignedByte() + var len = (b1 and 0x7f).toLong() + if (len == 126L) len = din.readUnsignedShort().toLong() else if (len == 127L) len = din.readLong() + if (len > 65536) break + val mask = ByteArray(4) + if (b1 and 0x80 != 0) din.readFully(mask) + val data = ByteArray(len.toInt()); din.readFully(data) + for (i in data.indices) data[i] = (data[i].toInt() xor mask[i and 3].toInt()).toByte() + when (b0 and 0x0f) { + 0x8 -> break + 0x9 -> synchronized(out) { out.write(byteArrayOf(0x8A.toByte(), data.size.toByte()) + data); out.flush() } // pong + 0x1 -> { + val t = String(data, StandardCharsets.UTF_8) + Regex("\"kind\"\\s*:\\s*\"([^\"]+)\"").find(t)?.groupValues?.get(1)?.let { c.kind = it; dirty = true } + } + } + } + } catch (e: Exception) { + } finally { + if (me != null) drop(me) else try { s.close() } catch (e: Exception) { } + } + } + + private fun frame(text: String): ByteArray { + val d = text.toByteArray(StandardCharsets.UTF_8) + val f = ByteArrayOutputStream(d.size + 4) + f.write(0x81) + if (d.size < 126) f.write(d.size) else { f.write(126); f.write(d.size shr 8); f.write(d.size and 0xff) } + f.write(d, 0, d.size) + return f.toByteArray() + } + + private fun readHeader(inp: InputStream): String { + val b = ByteArrayOutputStream() + var state = 0 + while (b.size() < 8192) { + val c = inp.read() + if (c == -1) break + b.write(c) + state = if (c == '\r'.code || c == '\n'.code) state + 1 else 0 + if (state == 4) break + } + return b.toString("US-ASCII") + } +} diff --git a/app/src/handheld/java/com/limelight/handheld/Recents.kt b/app/src/handheld/java/com/limelight/handheld/Recents.kt new file mode 100644 index 0000000000..5df63c3920 --- /dev/null +++ b/app/src/handheld/java/com/limelight/handheld/Recents.kt @@ -0,0 +1,26 @@ +package com.limelight.handheld + +import android.content.Context + +/** + * What you played last on this handheld, newest first, so Home can lead with it. Keys: "pc::", + * "ps:", "xb:". Stored with the app's own settings (mc_tv). + */ +object Recents { + private const val KEY = "hh_recent" + private const val MAX = 24 + + fun list(ctx: Context): List = + (prefs(ctx).getString(KEY, "") ?: "").split('\n').filter { it.isNotBlank() } + + fun touch(ctx: Context, key: String) { + val l = listOf(key) + list(ctx).filter { it != key } + prefs(ctx).edit().putString(KEY, l.take(MAX).joinToString("\n")).apply() + } + + fun forget(ctx: Context, prefix: String) { + prefs(ctx).edit().putString(KEY, list(ctx).filter { !it.startsWith(prefix) }.joinToString("\n")).apply() + } + + private fun prefs(ctx: Context) = ctx.getSharedPreferences("mc_tv", Context.MODE_PRIVATE) +} diff --git a/app/src/handheld/java/com/limelight/handheld/Sw.kt b/app/src/handheld/java/com/limelight/handheld/Sw.kt new file mode 100644 index 0000000000..626d191dc0 --- /dev/null +++ b/app/src/handheld/java/com/limelight/handheld/Sw.kt @@ -0,0 +1,383 @@ +package com.limelight.handheld + +import android.content.Context +import android.graphics.Canvas +import android.graphics.ColorFilter +import android.graphics.Paint +import android.graphics.Path +import android.graphics.PixelFormat +import android.graphics.RadialGradient +import android.graphics.Rect +import android.graphics.RectF +import android.graphics.Shader +import android.graphics.drawable.Drawable +import android.view.Gravity +import android.view.View +import android.widget.FrameLayout +import android.widget.HorizontalScrollView +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView +import com.limelight.home.Tv + +/** + * The handheld's Switch-style kit, from mooncast-handheld-v2.html: flat charcoal (or light grey), white type, one + * cyan double ring. Sizes are design px on a 1920 x 1080 canvas (1080 x 1920 upright), fitted to the screen by [tv]. + */ +class Sw(ctx: Context, val light: Boolean, val portrait: Boolean, val size: Size = Size.NORMAL) { + /** + * Screen size. NORMAL lays out on a 1920 x 1080 canvas (Odin, phones); COMPACT on 1240 x 1080 for small or square + * screens (Retroid Pocket Mini: the 1920 canvas shrank everything to 65%); LARGE is COMPACT a step bigger. + */ + enum class Size { NORMAL, COMPACT, LARGE } + + /** + * Compact sizes (edges, tiles, side menu). Upright is always compact: portrait shows the same screens as landscape, + * only fitted to a narrower, taller screen. + */ + val compact get() = size != Size.NORMAL || portrait + /** Space between the screen edge and the content, in design px. */ + val edge get() = if (portrait) 40 else if (compact) 44 else 96 + + // Upright: the width sets the scale (1080 wide, or 940 for Large), so text stays the size it is in landscape + // compact and the height is whatever the screen has (see [designHeight]). A wide upright screen (a foldable open, + // a tablet: 600 dp and up) gets a wider canvas, so things keep a phone's real size and more of them fit. + private val upright = portraitCanvas(ctx) + private val wide = ctx.resources.configuration.smallestScreenWidthDp >= 600 + val tv = when (size) { + // on its side a big screen likewise keeps a phone's real size (the 1920 x 1080 canvas blew it up 1.3 times) + Size.NORMAL -> if (portrait) Tv(ctx, false, upright, 0) + else if (wide) ctx.resources.configuration.let { Tv(ctx, false, Math.round(it.screenWidthDp / 0.38f), Math.round(it.screenHeightDp / 0.38f)) } + else Tv(ctx, false, 1920, 1080) + Size.COMPACT -> if (portrait) Tv(ctx, false, upright, 0) else Tv(ctx, false, 1240, 1080) + Size.LARGE -> if (portrait) Tv(ctx, false, upright * 940 / 1080, 0) else Tv(ctx, false, 1080, 940) + } + /** + * A phone with no game controller: tabs, chips, Back and the settings groups get at least Android's 48 dp touch + * size (on a 1080p phone the canvas made them 23-35 dp). Handhelds with built-in controls (the Odin) keep their look. + */ + var touchFirst = false + private val dp48 = (48 * ctx.resources.displayMetrics.density).toInt() + /** A height in design px, at least 48 dp on a touch-first phone. */ + fun tap(h: Number): Int = if (touchFirst) maxOf(px(h), dp48) else px(h) + /** Android's Font size on the usual layout (compact layouts already step up with it), up to 1.3x. */ + private val fontScale = if (size == Size.NORMAL) ctx.resources.configuration.fontScale.coerceIn(1f, 1.3f) else 1f + + /** The status bar, navigation and cutout, in px: the menus are laid out inside them (set by the activity). */ + var bars = Rect() + /** The design canvas height this screen really has for the app, inside the system bars. */ + val designHeight: Float get() = (c.resources.displayMetrics.heightPixels - bars.top - bars.bottom) / tv.pxf(1) + /** Extra space between a row's title and its description on the compact layout. */ + val lineGap get() = if (compact) 12 else 8 + val c = ctx + + val bg = if (light) 0xFFEBEBEB.toInt() else 0xFF2B2D31.toInt() + val panel = if (light) 0xFFFFFFFF.toInt() else 0xFF36393F.toInt() + val tile = if (light) 0xFFDADCE0.toInt() else 0xFF3E4249.toInt() + val line = if (light) 0xFFCDCFD4.toInt() else 0xFF474B53.toInt() + val ink = if (light) 0xFF2D2D2D.toInt() else 0xFFFFFFFF.toInt() + val sub = if (light) 0xFF6E727A.toInt() else 0xFFB5BAC3.toInt() + val ring = if (light) 0xFF00A9C2.toInt() else 0xFF2FD3E6.toInt() + /** Button labels: between [ink] and [sub], so they read at arm's length without shouting. */ + val label = if (light) 0xFF3A3D42.toInt() else 0xFFD9DCE1.toInt() + + /** Corner radius of game tiles, in design px. */ + val tileCorner = 16 + + companion object { + /** The upright design width: 1080, or wider on a screen 600 dp and more across (same real size as a phone). */ + fun portraitCanvas(ctx: Context): Int { + val wdp = ctx.resources.configuration.screenWidthDp + return if (wdp >= 600) maxOf(1080, Math.round(wdp / 0.38f)) else 1080 + } + + /** + * The size for this screen. [choice] is the "Screen size" setting: auto, compact, normal or large. Auto goes + * compact when the normal 1920-wide layout would be drawn below 80% of its size (a small or near-square + * screen), and a step larger when Android's own Font size is set large. Pixels and Android's own scaling are + * used, not the panel's reported dpi, which some handhelds get wrong. + */ + fun pick(ctx: Context, portrait: Boolean, choice: String?): Size { + when (choice) { + "normal" -> return Size.NORMAL + "compact" -> return Size.COMPACT + "large" -> return Size.LARGE + } + val m = ctx.resources.displayMetrics + val w = m.widthPixels.coerceAtLeast(1).toFloat(); val h = m.heightPixels.coerceAtLeast(1).toFloat() + val fit = if (portrait) minOf(w / 1080f, h / 1920f) else minOf(w / 1920f, h / 1080f) + if (fit >= 0.8f) return Size.NORMAL + return if (ctx.resources.configuration.fontScale >= 1.15f) Size.LARGE else Size.COMPACT + } + + const val PS = 0xFF2F5DF0.toInt() + const val XB = 0xFF107C10.toInt() + const val PC = 0xFF5B6170.toInt() + const val LIVE = 0xFF2DBE5A.toInt() + const val DANGER = 0xFFE9605A.toInt() + // the round buttons' icon colours (c1..c6 in the mockup) + const val C_CYAN = 0xFF2FD3E6.toInt() + const val C_AMBER = 0xFFF2B55C.toInt() + const val C_BLUE = 0xFF5C8DF6.toInt() + const val C_GREEN = 0xFF2DBE5A.toInt() + const val C_RED = 0xFFE9605A.toInt() + + /** Cover colours when there's no box art: shared with the TV (Covers), so a game looks the same on both. */ + val ART = com.limelight.home.Covers.ART + + /** PlayStation is always blue, Xbox always green, anything else picks by name. */ + fun artIndex(seed: String): Int = com.limelight.home.Covers.index(seed) + } + + fun px(v: Number) = tv.px(v) + fun pxf(v: Number) = tv.pxf(v) + /** The design canvas width this screen really has (wider than 1920 on a 20:9 phone). */ + val designWidth: Float get() = (c.resources.displayMetrics.widthPixels - bars.left - bars.right) / tv.pxf(1) + + fun ui(w: Int = 700) = tv.ui(w) + fun text(s: CharSequence, size: Number, color: Int = ink, weight: Int = 700, lines: Int = 1): TextView = + tv.text(s, size.toFloat() * fontScale, color, tv.ui(weight), lines) + + fun round(fill: Int, radius: Number, stroke: Int = 0, strokeW: Number = 2) = tv.round(fill, radius, stroke, strokeW) + fun row(g: Int = Gravity.CENTER_VERTICAL) = tv.row(g) + fun col() = tv.col() + fun grow() = tv.grow() + fun lp(w: Number, h: Number) = tv.lp(w, h) + fun flp(w: Number, h: Number, g: Int = Gravity.NO_GRAVITY) = tv.flp(w, h, g) + + fun focusable(v: View) { + tv.focusableView(v) // with menu sounds on, MoonCast's sound replaces the system click + if (android.os.Build.VERSION.SDK_INT >= 26) v.defaultFocusHighlightEnabled = false // the cyan ring is the highlight + } + + /** A touch presses the view in a little (tiles, round buttons, pills), and lets go when the finger lifts. */ + @android.annotation.SuppressLint("ClickableViewAccessibility") + fun pressable(v: View) { + v.setOnTouchListener { view, e -> + when (e.actionMasked) { + android.view.MotionEvent.ACTION_DOWN -> view.animate().scaleX(0.96f).scaleY(0.96f).setDuration(90).start() + android.view.MotionEvent.ACTION_UP, android.view.MotionEvent.ACTION_CANCEL -> view.animate().scaleX(1f).scaleY(1f).setDuration(160).start() + } + false // the click itself still happens as usual + } + } + + /** A 2 px divider along the bottom edge of a settings row. */ + fun bottomLine(): Drawable = object : Drawable() { + private val p = Paint().apply { color = line } + override fun draw(cv: Canvas) { val h = px(2).coerceAtLeast(1); cv.drawRect(bounds.left.toFloat(), (bounds.bottom - h).toFloat(), bounds.right.toFloat(), bounds.bottom.toFloat(), p) } + override fun setAlpha(a: Int) {} + override fun setColorFilter(f: ColorFilter?) {} + @Deprecated("Deprecated in Java") override fun getOpacity() = PixelFormat.TRANSLUCENT + } + + fun topLine(): Drawable = object : Drawable() { + private val p = Paint().apply { color = line } + override fun draw(cv: Canvas) { cv.drawRect(bounds.left.toFloat(), bounds.top.toFloat(), bounds.right.toFloat(), bounds.top + px(2).coerceAtLeast(1).toFloat(), p) } + override fun setAlpha(a: Int) {} + override fun setColorFilter(f: ColorFilter?) {} + @Deprecated("Deprecated in Java") override fun getOpacity() = PixelFormat.TRANSLUCENT + } + + /** The round face-button key in the hint bar: ink disc, background-coloured letter. */ + fun key(k: String, size: Number = 46): TextView = text(k, size.toFloat() * 0.52f, bg, 800).apply { + gravity = Gravity.CENTER + background = round(ink, size.toFloat() / 2) + } + + fun hint(k: String, label: String): LinearLayout = row().apply { + addView(key(k), lp(46, 46).apply { rightMargin = px(14) }) + addView(text(label, 28, ink, 700)) + } + + /** On/off switch: 96 x 52, cyan when on. */ + fun toggle(on: Boolean): View = FrameLayout(c).apply { + background = round(if (on) ring else line, 26) + addView(View(c).apply { background = round(0xFFFFFFFF.toInt(), 20) }, + flp(40, 40, Gravity.CENTER_VERTICAL or (if (on) Gravity.END else Gravity.START)).apply { leftMargin = px(6); rightMargin = px(6) }) + } + + /** A small source badge on a tile ("PC", "PS5", "Xbox"). */ + fun badge(label: String, size: Number = 20): TextView = text(label, size, 0xFFFFFFFF.toInt(), 800).apply { + gravity = Gravity.CENTER + setPadding(px(14), 0, px(14), 0) + background = round(0x73000000, size.toFloat()) + } + + fun live(size: Number = 20): LinearLayout = row().apply { + setPadding(px(14), 0, px(14), 0) + background = round(0xFFFFFFFF.toInt(), size.toFloat()) + addView(View(c).apply { background = round(LIVE, 6) }, lp(12, 12).apply { rightMargin = px(8) }) + addView(text("Running", size, 0xFF111111.toInt(), 800)) + } + + /** Cover art for things without box art: the mockup's radial gradients. */ + fun art(seed: String, radius: Number = tileCorner): Drawable = Art(ART[artIndex(seed)], pxf(radius)) + + /** A cover in one colour (an app's own, from its icon): lighter top, the colour, darker bottom. */ + fun artFrom(color: Int, radius: Number = tileCorner): Drawable { + fun shade(f: Float): Int { + val hsv = FloatArray(3); android.graphics.Color.colorToHSV(color, hsv) + hsv[2] = (hsv[2] * f).coerceIn(0f, 1f); if (f > 1f) hsv[1] = (hsv[1] * 0.85f) + return android.graphics.Color.HSVToColor(hsv) + } + return Art(intArrayOf(shade(1.25f), color, shade(0.45f)), pxf(radius)) + } + + /** The tint colour a cover sets for the background wash. */ + fun tint(seed: String): Int = ART[artIndex(seed)][1] + + private class Art(val cs: IntArray, val r: Float) : Drawable() { + private val p = Paint(Paint.ANTI_ALIAS_FLAG) + override fun draw(cv: Canvas) { + val w = bounds.width().toFloat(); val h = bounds.height().toFloat() + if (w <= 0 || h <= 0) return + p.shader = RadialGradient(w * 0.55f, h * 0.28f, maxOf(w, h) * 1.05f, cs, floatArrayOf(0f, 0.55f, 1f), Shader.TileMode.CLAMP) + cv.drawRoundRect(RectF(0f, 0f, w, h), r, r, p) + } + override fun setAlpha(a: Int) {} + override fun setColorFilter(f: ColorFilter?) {} + @Deprecated("Deprecated in Java") override fun getOpacity() = PixelFormat.TRANSLUCENT + } + + /** The "All games" tile: a 3 x 3 grid of small squares. */ + fun allArt(): Drawable = object : Drawable() { + private val p = Paint(Paint.ANTI_ALIAS_FLAG) + override fun draw(cv: Canvas) { + val w = bounds.width().toFloat(); val h = bounds.height().toFloat() + p.color = tile; cv.drawRoundRect(RectF(0f, 0f, w, h), pxf(tileCorner), pxf(tileCorner), p) + p.color = sub + val s = w * 36f / 380f; val g = w * 12f / 380f + val x0 = (w - 3 * s - 2 * g) / 2; val y0 = (h - 3 * s - 2 * g) / 2 + for (i in 0 until 3) for (j in 0 until 3) + cv.drawRoundRect(RectF(x0 + j * (s + g), y0 + i * (s + g), x0 + j * (s + g) + s, y0 + i * (s + g) + s), s / 6, s / 6, p) + } + override fun setAlpha(a: Int) {} + override fun setColorFilter(f: ColorFilter?) {} + @Deprecated("Deprecated in Java") override fun getOpacity() = PixelFormat.TRANSLUCENT + } +} + +/** The round buttons' line icons, drawn from the mockup's 48 x 48 SVGs. */ +class Icon(ctx: Context, private val kind: String, private val color: Int) : View(ctx) { + private val p = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE; strokeCap = Paint.Cap.ROUND; strokeJoin = Paint.Join.ROUND + } + + override fun onDraw(cv: Canvas) { + val k = width / 48f + cv.save(); cv.scale(k, k) + p.color = color; p.strokeWidth = 4f + when (kind) { + "consoles" -> { rr(cv, 6f, 10f, 36f, 22f, 3f); line(cv, 18f, 38f, 30f, 38f); line(cv, 24f, 32f, 24f, 38f) } + "tv" -> { + rr(cv, 4f, 9f, 40f, 26f, 3f); line(cv, 16f, 41f, 32f, 41f) + cv.drawPath(Path().apply { moveTo(20f, 18f); lineTo(28f, 22f); lineTo(20f, 26f); close() }, p) + } + "pad" -> { + cv.drawPath(Path().apply { + moveTo(10f, 16f); lineTo(38f, 16f); quadTo(44f, 16f, 44f, 22f); lineTo(42f, 34f) + quadTo(41f, 39f, 35f, 36f); lineTo(31f, 31f); lineTo(17f, 31f); lineTo(13f, 36f) + quadTo(7f, 39f, 6f, 34f); lineTo(4f, 22f); quadTo(4f, 16f, 10f, 16f); close() + }, p) + line(cv, 14f, 21f, 14f, 27f); line(cv, 11f, 24f, 17f, 24f) + } + "net" -> { arc(cv, 8f, 34f, 40f, 34f, 22f); arc(cv, 14f, 28f, 34f, 28f, 14f); arc(cv, 20f, 22f, 28f, 22f, 6f) } + "gear" -> { + cv.drawCircle(24f, 24f, 6f, p) + line(cv, 24f, 6f, 24f, 12f); line(cv, 24f, 36f, 24f, 42f); line(cv, 6f, 24f, 12f, 24f); line(cv, 36f, 24f, 42f, 24f) + line(cv, 11f, 11f, 15f, 15f); line(cv, 33f, 33f, 37f, 37f); line(cv, 11f, 37f, 15f, 33f); line(cv, 33f, 15f, 37f, 11f) + } + "power" -> { + line(cv, 24f, 6f, 24f, 22f) + // the open circle: radius 15 through (14,12) and (34,12), round the bottom + val cy = 12f + Math.sqrt(15.0 * 15 - 10.0 * 10).toFloat() + cv.drawArc(RectF(24f - 15, cy - 15, 24f + 15, cy + 15), -48.2f, 276.4f, false, p) + } + "home" -> cv.drawPath(Path().apply { moveTo(8f, 22f); lineTo(24f, 9f); lineTo(40f, 22f); lineTo(40f, 40f); lineTo(8f, 40f); close() }, p) + "grid" -> { rr(cv, 8f, 8f, 13f, 13f, 2f); rr(cv, 27f, 8f, 13f, 13f, 2f); rr(cv, 8f, 27f, 13f, 13f, 2f); rr(cv, 27f, 27f, 13f, 13f, 2f) } + "back" -> { line(cv, 29f, 10f, 15f, 24f); line(cv, 15f, 24f, 29f, 38f) } + } + cv.restore() + } + + private fun rr(cv: Canvas, x: Float, y: Float, w: Float, h: Float, r: Float) = cv.drawRoundRect(RectF(x, y, x + w, y + h), r, r, p) + private fun line(cv: Canvas, x1: Float, y1: Float, x2: Float, y2: Float) = cv.drawLine(x1, y1, x2, y2, p) + + /** An arc over the top from (x1,y1) to (x2,y2) with radius r (the wifi bars). */ + private fun arc(cv: Canvas, x1: Float, y1: Float, x2: Float, y2: Float, r: Float) { + val half = (x2 - x1) / 2f + val cx = x1 + half + val cy = y1 + Math.sqrt((r * r - half * half).toDouble()).toFloat() + val a1 = Math.toDegrees(Math.atan2((y1 - cy).toDouble(), (x1 - cx).toDouble())).toFloat() + val a2 = Math.toDegrees(Math.atan2((y2 - cy).toDouble(), (x2 - cx).toDouble())).toFloat() + cv.drawArc(RectF(cx - r, cy - r, cx + r, cy + r), a1, a2 - a1, false, p) + } +} + +/** The controller glyph in the hint bar and the battery in the top bar, drawn in the text colour. */ +class Glyph(ctx: Context, private val kind: String, private val color: Int, var level: Int = 100) : View(ctx) { + private val p = Paint(Paint.ANTI_ALIAS_FLAG) + override fun onDraw(cv: Canvas) { + val w = width.toFloat(); val h = height.toFloat() + val s = h / 38f + p.color = color + when (kind) { + "pad" -> { + p.style = Paint.Style.STROKE; p.strokeWidth = 4 * s + cv.drawRoundRect(RectF(2 * s, 2 * s, w - 2 * s, h - 2 * s), h / 2, h / 2, p) + p.style = Paint.Style.FILL + cv.drawCircle(w * 0.28f, h / 2, 6 * s, p); cv.drawCircle(w * 0.72f, h / 2, 6 * s, p) + } + "battery" -> { + p.style = Paint.Style.STROKE; p.strokeWidth = 3 * s + val body = RectF(1.5f * s, 1.5f * s, w - 6 * s, h - 1.5f * s) + cv.drawRoundRect(body, 5 * s, 5 * s, p) + p.style = Paint.Style.FILL + cv.drawRoundRect(RectF(w - 5 * s, h * 0.32f, w - 1 * s, h * 0.68f), 1.5f * s, 1.5f * s, p) + val inner = RectF(body.left + 4 * s, body.top + 4 * s, body.right - 4 * s, body.bottom - 4 * s) + inner.right = inner.left + inner.width() * (level.coerceIn(0, 100) / 100f) + cv.drawRoundRect(inner, 2 * s, 2 * s, p) + } + } + } +} + +/** A horizontal row that keeps a margin round the focused item when it scrolls, so its ring is never cut. */ +class RowScroll(ctx: Context, private val margin: Int) : HorizontalScrollView(ctx) { + override fun computeScrollDeltaToGetChildRectOnScreen(rect: Rect): Int = + super.computeScrollDeltaToGetChildRectOnScreen(Rect(rect.left - margin, rect.top, rect.right + margin, rect.bottom)) +} + +class ColScroll(ctx: Context, private val margin: Int) : ScrollView(ctx) { + override fun computeScrollDeltaToGetChildRectOnScreen(rect: Rect): Int = + super.computeScrollDeltaToGetChildRectOnScreen(Rect(rect.left, rect.top - margin, rect.right, rect.bottom + margin)) +} + +/** + * Holds one child a little above the middle: the space it doesn't need goes [bias] above it and the rest below, so + * a tall screen doesn't open one big gap over the content (centred looked like it had slid down). + */ +class BiasFrame(ctx: Context, private val bias: Float) : FrameLayout(ctx) { + override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { + val c = getChildAt(0) ?: return + val free = (b - t - paddingTop - paddingBottom - c.measuredHeight).coerceAtLeast(0) + val top = paddingTop + (free * bias).toInt() + c.layout(paddingLeft, top, paddingLeft + c.measuredWidth, top + c.measuredHeight) + } +} + +/** A radial wash of the selected game's colour behind everything, as in the feel mockup. */ +class Wash(ctx: Context) : View(ctx) { + var color = 0 + set(v) { field = v; invalidate() } + private val p = Paint(Paint.ANTI_ALIAS_FLAG) + override fun onDraw(cv: Canvas) { + if (color == 0 || width == 0) return + val a = (0.26f * 255).toInt() + val c0 = (a shl 24) or (color and 0xFFFFFF) + p.shader = RadialGradient(width * 0.3f, height * 0.4f, maxOf(width, height) * 0.6f, intArrayOf(c0, color and 0xFFFFFF), null, Shader.TileMode.CLAMP) + cv.drawRect(0f, 0f, width.toFloat(), height.toFloat(), p) + } +} + diff --git a/app/src/handheld/java/com/limelight/handheld/TvControllerActivity.kt b/app/src/handheld/java/com/limelight/handheld/TvControllerActivity.kt new file mode 100644 index 0000000000..5f83b6bf79 --- /dev/null +++ b/app/src/handheld/java/com/limelight/handheld/TvControllerActivity.kt @@ -0,0 +1,564 @@ +package com.limelight.handheld + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.Gravity +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.View +import android.view.WindowManager +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.TextView +import com.limelight.home.FeelSounds +import com.limelight.home.FocusGlide +import com.limelight.home.MoveToTv +import com.limelight.home.QuickMenu + +/** + * Controller mode: a game plays on a MoonCast TV and this handheld is its controller. It sends the game (POST /play), + * then serves this device's buttons and sticks to the TV over [PadServer] until you leave. + * + * Every pad press goes to the TV game, including B. The buttons on screen (Play here instead, Stop) take a touch, or + * the pad after Select + Start + LB + RB (or the device's Back key); B or Back again returns the pad to the game. + */ +class TvControllerActivity : Activity() { + companion object { + const val EXTRA_TV = "tv_id" + const val EXTRA_DELAY = "send_delay_ms" + private val COMBO = intArrayOf(4, 5, 8, 9) // LB RB Select Start + private const val LINK_WAIT_MS = 8000L + /** Settings, Controllers: "direct" (to the PC, fastest) or "tv" (through the TV). */ + const val PAD_PREF = "hh_tv_pad" + } + + private enum class State { SENDING, PLAYING, FAILED } + + private lateinit var sw: Sw + private lateinit var root: FrameLayout + private lateinit var glide: FocusGlide + private lateinit var where: TextView + private lateinit var title: TextView + private lateinit var status: TextView + private lateinit var hint: TextView + private lateinit var buttons: LinearLayout + private val ui = Handler(Looper.getMainLooper()) + + private var target: MoveToTv.Target? = null + private var tv: TvFinder.Tv? = null + private var padKey = "" + private var everConnected = false // a TV connected to the controller since this send + private var noLink = false // sent, but no TV connected within LINK_WAIT_MS + private var state = State.SENDING + private var error: String? = null + private var menu = false // the pad drives the on-screen buttons instead of the game + private var comboPending = false + private var attempt = 0 + + // PC games: the controller goes straight to the PC as its own controller-only session (DirectPad); if that can't + // connect, the TV relays it after all (/pad). PS5 and Xbox always go through the TV. + // GET /status on the TV while it plays: its player's own error when it can't start the game + @Volatile private var tvState: String? = null + private val statusPoll = object : Runnable { + override fun run() { + val tv0 = tv ?: return + if (state != State.PLAYING || tv0.version < 6) return + val mine = attempt + Thread({ + val st = TvSender.status(tv0) + runOnUiThread { + if (mine != attempt || state != State.PLAYING || isFinishing) return@runOnUiThread + if (st != null) { + tvState = st.first + if (st.first == "failed") { + stopDirect(); ui.removeCallbacks(linkTimeout) + val e = st.second.ifBlank { "it stopped" } + fail("${tv0.name} couldn't start ${target?.title ?: "the game"}: ${e.replaceFirstChar { it.lowercase() }}") + return@runOnUiThread + } + refresh() + } + ui.postDelayed(this, 2000) + } + }, "tv-status").start() + } + } + + private var direct: DirectPad? = null + private var directMode = false + private var directEverUp = false + private var fellBack: String? = null + private val directTimeout = Runnable { if (directMode && direct?.state != DirectPad.State.CONNECTED) fallBack("didn't connect in time") } + + // the pad, W3C Standard Gamepad order + private val b = FloatArray(PadServer.BUTTONS) + private val a = FloatArray(PadServer.AXES) + private var keyL2 = 0f; private var keyR2 = 0f; private var axL2 = 0f; private var axR2 = 0f + private var lastHatX = 0f; private var lastHatY = 0f + + private val prefs by lazy { getSharedPreferences("mc_tv", Context.MODE_PRIVATE) } + private val deadzone by lazy { + android.preference.PreferenceManager.getDefaultSharedPreferences(this).getInt("seekbar_deadzone", 7).coerceIn(0, 30) / 100f + } + + override fun onCreate(s: Bundle?) { + super.onCreate(s) + MoveToTv.handler = TvSender + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + build() + begin(intent) + } + + override fun onNewIntent(i: Intent) { + super.onNewIntent(i) + setIntent(i) + begin(i) + } + + override fun onDestroy() { + ui.removeCallbacksAndMessages(null) + stopDirect() + // the controller server stays up (PadLink): a restarted controller screen picks the TV's link straight back up + if (PadLink.onClients === clientsListener) PadLink.onClients = null + glide.detach() + super.onDestroy() + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + @Suppress("DEPRECATION") + if (hasFocus) window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or View.SYSTEM_UI_FLAG_FULLSCREEN or + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_STABLE or + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + } + + // ---------------------------------------------------------------- sending + + private fun begin(i: Intent) { + val t = MoveToTv.Target.from(i) + val tv0 = TvFinder.byId(this, i.getStringExtra(EXTRA_TV)) + if (t == null || tv0 == null) { finish(); return } + target = t; tv = tv0 + // one server for the app, one key per TV: a re-send reuses both, so the TV's open link keeps working + PadLink.onClients = clientsListener + padKey = PadLink.keyFor(tv0.id) // starts the server if needed: up before /play, the TV connects right after + b.fill(0f); a.fill(0f); push() + title.text = t.title + attempt++ + send(i.getLongExtra(EXTRA_DELAY, 0L), retried = false) + } + + private val clientsListener: (Int) -> Unit = { n -> runOnUiThread { onLink(n) } } + + private fun onLink(n: Int) { + if (n > 0) { + ui.removeCallbacks(linkTimeout) + everConnected = true + if (noLink) { noLink = false; if (state == State.PLAYING) setMenu(false) } // it made it after all: back to the game + } + else if (state == State.PLAYING && !directMode) { ui.removeCallbacks(linkTimeout); ui.postDelayed(linkTimeout, LINK_WAIT_MS) } + refresh() + } + + /** Sent, but the TV never connected to the controller (or didn't come back): say so, and offer Try again. */ + private val linkTimeout: Runnable = Runnable { + // the TV is still starting the game (an Xbox waking up, a PS5 busy with another device): wait for it + if (state == State.PLAYING && tvState == "starting") { ui.postDelayed(linkTimeout, LINK_WAIT_MS); return@Runnable } + if (state == State.PLAYING && !directMode && PadLink.clientCount == 0) { noLink = true; FeelSounds.play(this, FeelSounds.Cue.ERROR); setMenu(true); refresh() } + } + + private fun send(delayMs: Long, retried: Boolean) { + val t = target ?: return; val tv0 = tv ?: return + state = State.SENDING; error = null; noLink = false + stopDirect(); fellBack = null + everConnected = PadLink.clientCount > 0 + ui.removeCallbacks(linkTimeout) + if (!PadLink.running) padKey = PadLink.keyFor(tv0.id) + setMenu(true) + refresh() + val mine = attempt + ui.postDelayed({ + Thread({ + // the TV's address may have changed: look for it again before giving up + val live = TvFinder.ask(tv0.ip) + if (live != null && live.id == tv0.id) tv = live // fresh name, Xbox sign-in and version (for /stop) + if (!retried && live == null) { + runOnUiThread { + if (mine != attempt) return@runOnUiThread + status.text = "Looking for ${tv0.name} on this network…" + TvFinder.sweep(this) { + if (mine != attempt) return@sweep + val again = TvFinder.byId(this, tv0.id) + if (again != null && TvFinder.isHere(again)) { tv = again; send(0, retried = true) } + else fail("${tv0.name} isn't on this network. Open MoonCast on the TV, on the same Wi-Fi as this device.") + } + } + return@Thread + } + val tvNow = tv ?: tv0 + // Settings, Controllers, "Controller on a TV": direct to the PC. Needs MoonCast TV version 5, which skips its relay + // AND registers no pad of its own; an older TV would hold pad 0 and leave this device on pad 1. + val useDirect = t.kind == "pc" && !t.pcUuid.isNullOrEmpty() && tvNow.version >= 5 && + prefs.getString(PAD_PREF, "direct") == "direct" + val err = TvSender.send(this, tvNow, t, padKey, useDirect) + runOnUiThread { + if (mine != attempt || isFinishing) return@runOnUiThread + if (err != null) fail(err) + else { + TvFinder.setLast(this, tv0) + state = State.PLAYING + tvState = null + ui.removeCallbacks(statusPoll); ui.postDelayed(statusPoll, 2000) + FeelSounds.play(this, FeelSounds.Cue.LAUNCH) + ui.removeCallbacks(linkTimeout) + if (useDirect) startDirect(t) + else if (PadLink.clientCount == 0) ui.postDelayed(linkTimeout, LINK_WAIT_MS) + setMenu(false) + refresh() + } + } + }, "tv-send").start() + }, delayMs) + } + + private fun startDirect(t: MoveToTv.Target) { + stopDirect() + directMode = true; directEverUp = false + val d = DirectPad(this, t.pcUuid ?: "", t.appId, t.title) + direct = d + var connectingSeen = false + d.onState = { st -> + if (direct === d) { + when (st) { + DirectPad.State.CONNECTED -> { directEverUp = true; ui.removeCallbacks(directTimeout); push() } + // the 8 s count starts when the game runs and we try to join it (a cold game start takes longer) + DirectPad.State.CONNECTING -> if (!connectingSeen && !directEverUp) { connectingSeen = true; ui.postDelayed(directTimeout, LINK_WAIT_MS) } + DirectPad.State.FAILED -> fallBack(d.why ?: "failed") + else -> {} + } + refresh() + } + } + d.start() + } + + private fun stopDirect() { + ui.removeCallbacks(directTimeout) + direct?.let { it.onState = null; it.stop() } + direct = null + directMode = false + } + + /** The direct session can't connect: ask the TV to relay the controller after all, and say so. */ + private fun fallBack(reason: String) { + if (!directMode) return + val pc = direct?.pcName ?: "the PC" + android.util.Log.i("MoonCast-Controller", "direct controller to $pc: $reason; using the TV's relay") + stopDirect() + val tv0 = tv ?: return + fellBack = "The direct link to $pc didn't connect, so the controller goes through ${tv0.name}." + val key = padKey + Thread({ + val ok = TvSender.useRelay(tv0, key) + runOnUiThread { + if (isFinishing) return@runOnUiThread + if (!ok) fellBack = "The direct link to $pc didn't connect, and ${tv0.name} couldn't take the controller over." + ui.removeCallbacks(linkTimeout) + if (PadLink.clientCount == 0) ui.postDelayed(linkTimeout, LINK_WAIT_MS) + refresh() + } + }, "tv-relay").start() + refresh() + } + + private fun fail(msg: String) { + state = State.FAILED; error = msg + FeelSounds.play(this, FeelSounds.Cue.ERROR) + setMenu(true) + refresh() + } + + // ---------------------------------------------------------------- screen + + private fun build() { + val dm = resources.displayMetrics + val portrait = dm.heightPixels > dm.widthPixels + sw = Sw(this, false, portrait) + root = FrameLayout(this).apply { setBackgroundColor(0xFF050506.toInt()) } + val col = sw.col().apply { gravity = Gravity.CENTER_HORIZONTAL; clipChildren = false } + where = sw.text("", 28, sw.sub, 700).apply { gravity = Gravity.CENTER } + title = sw.text("", if (portrait) 56 else 64, sw.ink, 800, 2).apply { gravity = Gravity.CENTER } + status = sw.text("", 28, sw.sub, 600, 4).apply { gravity = Gravity.CENTER } + col.addView(where, sw.lp(-1, -2)) + col.addView(title, sw.lp(-1, -2).apply { topMargin = sw.px(16) }) + col.addView(status, sw.lp(-1, -2).apply { topMargin = sw.px(26) }) + buttons = sw.row().apply { gravity = Gravity.CENTER; clipChildren = false } + col.addView(buttons, sw.lp(-1, 112).apply { topMargin = sw.px(64) }) + root.addView(col, FrameLayout.LayoutParams(-1, -2, Gravity.CENTER).apply { leftMargin = sw.px(80); rightMargin = sw.px(80) }) + hint = sw.text("", 24, 0xFF8A8F98.toInt(), 600, 2).apply { gravity = Gravity.CENTER } + root.addView(hint, FrameLayout.LayoutParams(-1, -2, Gravity.BOTTOM).apply { + bottomMargin = sw.px(if (portrait) 90 else 48); leftMargin = sw.px(60); rightMargin = sw.px(60) }) + setContentView(root) + glide = FocusGlide(root).apply { + ghost = false; strokeColor = sw.ring; strokeWidth = sw.pxf(7); gapColor = 0xFF050506.toInt(); gapWidth = sw.pxf(6) + outset = sw.pxf(13); cornerRadius = 9999f; duration = 200; breathe = true + radiusFor = { 9999f } + }.attach() + FeelSounds.warm(this) + } + + private fun pill(label: String, main: Boolean, onClick: () -> Unit): View = sw.text(label, 30, if (main) 0xFF111111.toInt() else sw.ink, 800).apply { + gravity = Gravity.CENTER + setPadding(sw.px(48), 0, sw.px(48), 0) + background = sw.round(if (main) sw.ink else 0xFF26282C.toInt(), 56) + sw.focusable(this) + setOnClickListener { FeelSounds.play(this@TvControllerActivity, FeelSounds.Cue.SELECT); onClick() } + } + + private fun refresh() { + val t = target ?: return; val tvName = tv?.name ?: "the TV" + where.text = when (state) { State.SENDING -> "Sending to $tvName"; State.PLAYING -> "Playing on $tvName"; State.FAILED -> "Couldn't play on $tvName" } + status.setTextColor(if (state == State.FAILED) Sw.DANGER else sw.sub) + status.text = when (state) { + State.SENDING -> "Starting ${t.title} on $tvName…" + State.FAILED -> error ?: "Something went wrong." + State.PLAYING -> if (directMode) { + val d = direct; val pc = d?.pcName ?: "the PC" + when (d?.state) { + DirectPad.State.CONNECTED -> "Controller connected (direct to $pc)" + DirectPad.State.CONNECTING -> if (directEverUp) "Reconnecting to $pc…" else "Connecting the controller to $pc…" + else -> "Waiting for ${t.title} to start on $pc…" + } + } else when { + PadLink.clientCount > 0 -> "Controller connected (through $tvName)" + tvState == "starting" && !everConnected -> "$tvName is starting ${t.title}…" + noLink -> "$tvName didn't connect to this ${deviceShort()}'s controller. Check both are on the same Wi-Fi." + everConnected -> "Reconnecting to $tvName…" + else -> "Waiting for $tvName to connect…" + } + (fellBack?.let { "\n$it" } ?: "") + } + if (state == State.PLAYING && noLink) status.setTextColor(Sw.DANGER) + val focusedLabel = (currentFocus as? TextView)?.text?.toString() + buttons.removeAllViews() + val list = ArrayList Unit>>() + if (state == State.FAILED || (state == State.PLAYING && noLink)) list += "Try again" to { attempt++; send(0, retried = false) } + list += "Play here instead" to { playHere() } + list += (if (state == State.FAILED) "Back" else "Stop") to { stopAndLeave() } + list.forEachIndexed { i, (label, fn) -> + buttons.addView(pill(label, i == 0, fn), sw.lp(-2, 112).apply { if (i > 0) leftMargin = sw.px(28) }) + } + hint.text = when { + state != State.PLAYING -> "" + menu -> "B or Back: back to the game" + else -> "Every button plays the game on the TV. ${QuickMenu.COMBO_LABEL}, or Back, for these buttons." + } + applyMenuLook(focusedLabel) + } + + private fun applyMenuLook(focusLabel: String? = null) { + buttons.alpha = if (menu || state != State.PLAYING) 1f else 0.45f + if (menu) { + val again = (0 until buttons.childCount).map { buttons.getChildAt(it) }.firstOrNull { (it as? TextView)?.text?.toString() == focusLabel } + // "Play here instead" (or Try again) unless a button already had it. The Back key doesn't leave touch + // mode, and in touch mode nothing takes focus and the ring stays hidden, so leave it here explicitly. + (again ?: buttons.getChildAt(0))?.let { v -> focusOn(v) } + } else { + currentFocus?.clearFocus() + glide.hide() + } + dim(state == State.PLAYING && !menu && prefs.getBoolean("hh_dim_on_tv", true)) + } + + /** Focus [v] with the ring on it, leaving touch mode first (requestFocusFromTouch). */ + private fun focusOn(v: View) { + if (!v.requestFocusFromTouch()) v.requestFocus() + glide.moveTo(v) + ui.post { if (v.isFocused) glide.moveTo(v) } // again after layout, for buttons that were just rebuilt + } + + private fun setMenu(on: Boolean) { + if (menu == on) return + menu = on + if (on) { b.fill(0f); a.fill(0f); keyL2 = 0f; keyR2 = 0f; axL2 = 0f; axR2 = 0f; push() } // let go on the TV while the pad drives this screen + buttons.descendantFocusability = if (on) LinearLayout.FOCUS_AFTER_DESCENDANTS else LinearLayout.FOCUS_BLOCK_DESCENDANTS + if (state == State.PLAYING) refresh() else applyMenuLook() + } + + /** Settings, Handheld, "Dim the screen on a TV": the screen goes almost dark while you play on the TV. */ + private fun dim(on: Boolean) { + val lp = window.attributes + val v = if (on) 0.02f else WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE + if (lp.screenBrightness != v) { lp.screenBrightness = v; window.attributes = lp } + root.alpha = if (on) 0.6f else 1f + } + + private fun deviceShort(): String = + if (android.os.Build.MANUFACTURER.equals("AYN", true) || android.os.Build.MODEL.contains("Odin", true)) "Odin" else "device" + + /** Take the game back: the TV ends its stream first (Sunshine may let both play at once), then it starts here. */ + private fun playHere() { + val t = target ?: return + leave("Moving ${t.title} to this ${deviceShort()}…") { + startActivity(Intent(this, HandheldHomeActivity::class.java).putExtra(HandheldHomeActivity.EXTRA_PLAY_HERE, t.key) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)) + } + } + + /** Stop: the TV ends its stream (the game keeps running on the PC), and this device stops being its controller. */ + private fun stopAndLeave() { + FeelSounds.play(this, FeelSounds.Cue.BACK) + if (state != State.PLAYING) { PadLink.stop(); attempt++; finish(); return } // nothing is playing on the TV yet + leave("Stopping on ${tv?.name ?: "the TV"}…") { } + } + + private var leaving = false + private fun leave(msg: String, then: () -> Unit) { + if (leaving) return + leaving = true + attempt++ + ui.removeCallbacks(linkTimeout) + stopDirect() // our own session on the PC ends first (a local game needs Moonlight's one connection) + status.setTextColor(sw.sub); status.text = msg + val tv0 = tv; val key = padKey + Thread({ + if (tv0 != null && state == State.PLAYING) TvSender.stop(tv0, key) + runOnUiThread { + PadLink.stop() + then() + finish() + } + }, "tv-stop").start() + } + + // ---------------------------------------------------------------- input -> the TV + + private fun push() { + PadLink.update(b, a) + if (directMode) direct?.update(b, a) + } + + private fun isPad(source: Int) = (source and InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD || + (source and InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK + + private fun index(code: Int): Int = when (code) { + KeyEvent.KEYCODE_BUTTON_A -> 0; KeyEvent.KEYCODE_BUTTON_B -> 1; KeyEvent.KEYCODE_BUTTON_X -> 2; KeyEvent.KEYCODE_BUTTON_Y -> 3 + KeyEvent.KEYCODE_BUTTON_L1 -> 4; KeyEvent.KEYCODE_BUTTON_R1 -> 5; KeyEvent.KEYCODE_BUTTON_L2 -> 6; KeyEvent.KEYCODE_BUTTON_R2 -> 7 + KeyEvent.KEYCODE_BUTTON_SELECT -> 8; KeyEvent.KEYCODE_BUTTON_START -> 9 + KeyEvent.KEYCODE_BUTTON_THUMBL -> 10; KeyEvent.KEYCODE_BUTTON_THUMBR -> 11 + KeyEvent.KEYCODE_DPAD_UP -> 12; KeyEvent.KEYCODE_DPAD_DOWN -> 13; KeyEvent.KEYCODE_DPAD_LEFT -> 14; KeyEvent.KEYCODE_DPAD_RIGHT -> 15 + KeyEvent.KEYCODE_BUTTON_MODE -> 16 + else -> -1 + } + + override fun dispatchKeyEvent(e: KeyEvent): Boolean { + val code = e.keyCode + val fromPad = isPad(e.source) + if (code == KeyEvent.KEYCODE_BACK && !fromPad) { // the device's own Back key: these buttons, or back to the game + if (e.action == KeyEvent.ACTION_UP) onBack() + return true + } + if (menu) { + when (code) { + KeyEvent.KEYCODE_BUTTON_B, KeyEvent.KEYCODE_BACK -> { if (e.action == KeyEvent.ACTION_UP) onBack(); return true } + KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER -> { + if (e.action == KeyEvent.ACTION_UP) { + val f = currentFocus?.takeIf { it.parent === buttons } ?: buttons.getChildAt(0) + if (f != null) { if (!f.isFocused) focusOn(f); glide.pop(); f.performClick() } + } + return true + } + } + val dir = com.limelight.home.Feel.direction(code) + if (dir != 0 && e.action == KeyEvent.ACTION_DOWN) { moveFocus(dir); return true } + if (fromPad || KeyEvent.isGamepadButton(code)) return true + return super.dispatchKeyEvent(e) + } + // a pad's Back (some pads send it for Select) is Select + val idx = if (code == KeyEvent.KEYCODE_BACK) 8 else index(code) + if (idx < 0) return if (fromPad || KeyEvent.isGamepadButton(code)) true else super.dispatchKeyEvent(e) + if (e.repeatCount > 0) return true + val v = when (e.action) { KeyEvent.ACTION_DOWN -> 1f; KeyEvent.ACTION_UP -> 0f; else -> return true } + when (idx) { + 6 -> { keyL2 = v; b[6] = maxOf(keyL2, axL2) } + 7 -> { keyR2 = v; b[7] = maxOf(keyR2, axR2) } + else -> b[idx] = v + } + checkCombo() + if (!comboPending) push() + return true + } + + /** Select + Start + LB + RB, as for the Quick menu: once they're all let go, the pad drives these buttons. */ + private fun checkCombo() { + if (state != State.PLAYING) return + if (COMBO.all { b[it] > .5f }) { + if (!comboPending) { + comboPending = true + val zb = FloatArray(PadServer.BUTTONS); val za = FloatArray(PadServer.AXES) + PadLink.update(zb, za); if (directMode) direct?.update(zb, za) + } + } + if (comboPending && b.all { it == 0f }) { comboPending = false; FeelSounds.play(this, FeelSounds.Cue.OPEN); setMenu(true) } + } + + private fun onBack() { + if (state != State.PLAYING) { if (!menu) setMenu(true); return } // sending or failed: the buttons are already up + FeelSounds.play(this, if (menu) FeelSounds.Cue.CLOSE else FeelSounds.Cue.OPEN) + setMenu(!menu) + } + + @Deprecated("Deprecated in Java") + override fun onBackPressed() = onBack() // never leaves by itself: Stop does + + private fun moveFocus(dir: Int) { + val cur = currentFocus + if (cur == null || cur.parent !== buttons) { buttons.getChildAt(0)?.let { focusOn(it) }; return } + val i = buttons.indexOfChild(cur) + val n = when (dir) { View.FOCUS_LEFT -> i - 1; View.FOCUS_RIGHT -> i + 1; else -> -1 } + val next = if (n in 0 until buttons.childCount) buttons.getChildAt(n) else null + if (next != null) { focusOn(next); FeelSounds.focus(next) } + else com.limelight.home.Feel.edgeNudge(cur, dir, null, sw.pxf(14)) + } + + private fun stick(v: Float): Float { + val m = Math.abs(v) + if (m < deadzone) return 0f + return v.coerceIn(-1f, 1f) + } + + override fun dispatchGenericMotionEvent(e: MotionEvent): Boolean { + val joy = (e.source and InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK + if (!joy || e.action != MotionEvent.ACTION_MOVE) return super.dispatchGenericMotionEvent(e) + val hx = e.getAxisValue(MotionEvent.AXIS_HAT_X); val hy = e.getAxisValue(MotionEvent.AXIS_HAT_Y) + if (menu) { // the d-pad as a hat moves between the buttons + if (hx != lastHatX || hy != lastHatY) { + if (hx < -.5f) moveFocus(View.FOCUS_LEFT) else if (hx > .5f) moveFocus(View.FOCUS_RIGHT) + lastHatX = hx; lastHatY = hy + } + return true + } + val d = e.device + // right stick: Z/RZ on most pads, RX/RY on some + val useRx = d != null && d.getMotionRange(MotionEvent.AXIS_Z, e.source) == null && d.getMotionRange(MotionEvent.AXIS_RX, e.source) != null + a[0] = stick(e.getAxisValue(MotionEvent.AXIS_X)) + a[1] = stick(e.getAxisValue(MotionEvent.AXIS_Y)) + a[2] = stick(e.getAxisValue(if (useRx) MotionEvent.AXIS_RX else MotionEvent.AXIS_Z)) + a[3] = stick(e.getAxisValue(if (useRx) MotionEvent.AXIS_RY else MotionEvent.AXIS_RZ)) + axL2 = maxOf(e.getAxisValue(MotionEvent.AXIS_LTRIGGER), e.getAxisValue(MotionEvent.AXIS_BRAKE)).coerceIn(0f, 1f) + axR2 = maxOf(e.getAxisValue(MotionEvent.AXIS_RTRIGGER), e.getAxisValue(MotionEvent.AXIS_GAS)).coerceIn(0f, 1f) + b[6] = maxOf(keyL2, axL2); b[7] = maxOf(keyR2, axR2) + if (hx != 0f || hy != 0f || lastHatX != 0f || lastHatY != 0f) { + b[12] = if (hy < -.5f) 1f else 0f; b[13] = if (hy > .5f) 1f else 0f + b[14] = if (hx < -.5f) 1f else 0f; b[15] = if (hx > .5f) 1f else 0f + } + lastHatX = hx; lastHatY = hy + checkCombo() + if (!comboPending) push() + return true + } +} diff --git a/app/src/handheld/java/com/limelight/handheld/TvFinder.kt b/app/src/handheld/java/com/limelight/handheld/TvFinder.kt new file mode 100644 index 0000000000..beee0449e4 --- /dev/null +++ b/app/src/handheld/java/com/limelight/handheld/TvFinder.kt @@ -0,0 +1,165 @@ +package com.limelight.handheld + +import android.content.Context +import android.os.Handler +import android.os.Looper +import org.json.JSONArray +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.Inet4Address +import java.net.NetworkInterface +import java.net.URL +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +/** + * Finds MoonCast TVs on this Wi-Fi, the way the old phone app's TvAppFinder did: every address on this /24 is asked + * GET :8099/mc-native, and a MoonCast TV answers {"mooncast_native":1,"name","pc","xbox","version","id"}. + * + * TVs are remembered (with when they were last seen) so the picker can show a known TV as "Not on this network" + * while it's away. A full sweep takes about two seconds; between sweeps only the remembered addresses are asked. + */ +object TvFinder { + const val PORT = 8099 + private const val PREF_TVS = "hh_tvs" + private const val PREF_LAST = "hh_last_tv" + private const val PREF_TARGET = "hh_target_tv" + /** A TV that answered within this long counts as on this network. */ + private const val HERE_MS = 150_000L + + class Tv(val id: String, val ip: String, val name: String, val pc: String, val xbox: Boolean, val version: Int, val seen: Long, + val identityKey: String = "", val paired: Boolean = true) { + /** /play takes an Xbox (MoonCast TV from this release on). */ + val canXbox get() = version >= 2 + fun json(): JSONObject = JSONObject().put("id", id).put("ip", ip).put("name", name).put("pc", pc) + .put("xbox", xbox).put("version", version).put("seen", seen).put("identityKey", identityKey).put("paired", paired) + companion object { + fun of(j: JSONObject) = Tv(j.optString("id"), j.optString("ip"), j.optString("name", "MoonCast TV"), + j.optString("pc"), j.optBoolean("xbox"), j.optInt("version", 1), j.optLong("seen"), + j.optString("identityKey"), j.optBoolean("paired", true)) + } + } + + private val ui = Handler(Looper.getMainLooper()) + private val here = ConcurrentHashMap() // id -> when it last answered, in this run of the app + @Volatile private var sweeping = false + @Volatile var lastSweepAt = 0L; private set + /** Called on the main thread after a sweep or a check changed what's found. */ + var onChange: (() -> Unit)? = null + + private fun prefs(ctx: Context) = ctx.getSharedPreferences("mc_tv", Context.MODE_PRIVATE) + + /** Every TV this device has seen, the ones here now first, then the most recently seen. */ + fun known(ctx: Context): List = try { + val a = JSONArray(prefs(ctx).getString(PREF_TVS, "[]")) + (0 until a.length()).map { Tv.of(a.getJSONObject(it)) } + .sortedWith(compareByDescending { isHere(it) }.thenByDescending { it.seen }) + } catch (e: Exception) { emptyList() } + + fun isHere(tv: Tv): Boolean = here[tv.id]?.let { System.currentTimeMillis() - it < HERE_MS } ?: false + fun byId(ctx: Context, id: String?): Tv? = if (id.isNullOrEmpty()) null else known(ctx).firstOrNull { it.id == id } + + /** The TV used last (to move a game to), or null. */ + fun lastTv(ctx: Context): Tv? = byId(ctx, prefs(ctx).getString(PREF_LAST, null)) + + /** Where games go when you press A: a TV, or null for this device. */ + fun target(ctx: Context): Tv? = byId(ctx, prefs(ctx).getString(PREF_TARGET, null)) + + fun setTarget(ctx: Context, tv: Tv?) { + val e = prefs(ctx).edit().putString(PREF_TARGET, tv?.id ?: "") + if (tv != null) e.putString(PREF_LAST, tv.id) + e.apply() + } + + /** Remember [tv] as the one to move games to, without making it where games go by default. */ + fun setLast(ctx: Context, tv: Tv) = prefs(ctx).edit().putString(PREF_LAST, tv.id).apply() + + fun forget(ctx: Context, id: String) { + save(ctx, known(ctx).filter { it.id != id }) + here.remove(id) + if (prefs(ctx).getString(PREF_TARGET, "") == id) prefs(ctx).edit().putString(PREF_TARGET, "").apply() + if (prefs(ctx).getString(PREF_LAST, "") == id) prefs(ctx).edit().remove(PREF_LAST).apply() + } + + private fun save(ctx: Context, list: List) { + val a = JSONArray(); list.take(12).forEach { a.put(it.json()) } + prefs(ctx).edit().putString(PREF_TVS, a.toString()).apply() + } + + /** This device's address on the Wi-Fi (or Ethernet), or null when it isn't on a local network. */ + fun myIp(): String? = try { + val all = NetworkInterface.getNetworkInterfaces()?.toList().orEmpty().filter { it.isUp && !it.isLoopback } + val ordered = all.sortedBy { n -> when { n.name.startsWith("wlan") -> 0; n.name.startsWith("eth") -> 1; else -> 2 } } + ordered.flatMap { it.inetAddresses.toList() }.filterIsInstance() + .firstOrNull { it.isSiteLocalAddress }?.hostAddress + } catch (e: Exception) { null } + + /** Ask only the TVs we know (cheap: a handful of requests). */ + fun check(ctx: Context) { + val app = ctx.applicationContext + val list = known(app) + if (list.isEmpty() || sweeping) return + Thread({ + val found = list.mapNotNull { ask(it.ip) } + merge(app, found, list.map { it.ip }.toSet()) + }, "tv-check").start() + } + + /** Sweep the whole /24 for MoonCast TVs (about two seconds). [done] runs on the main thread. */ + fun sweep(ctx: Context, done: (() -> Unit)? = null) { + val app = ctx.applicationContext + if (sweeping) { done?.let { d -> ui.postDelayed({ d() }, 2500) }; return } + val me = myIp() + if (me == null) { done?.let { ui.post(it) }; return } + sweeping = true + Thread({ + val net = me.substring(0, me.lastIndexOf('.') + 1) + val hits = java.util.concurrent.CopyOnWriteArrayList() + val pool = Executors.newFixedThreadPool(48) + // the TVs we know first, so they're back quickly even if their address moved within the sweep + val order = (known(app).map { it.ip }.filter { it.startsWith(net) } + (1..254).map { net + it }).distinct() + for (ip in order) { if (ip != me) pool.execute { ask(ip)?.let { hits.add(it) } } } + pool.shutdown() + try { pool.awaitTermination(5, TimeUnit.SECONDS) } catch (e: InterruptedException) { } + pool.shutdownNow() + lastSweepAt = System.currentTimeMillis() + merge(app, hits, null) + sweeping = false + done?.let { ui.post(it) } + }, "tv-sweep").start() + } + + /** Record what answered; [asked] = the addresses tried (null = the whole network, so anything silent is away). */ + @Synchronized private fun merge(ctx: Context, found: List, asked: Set?) { + val before = known(ctx).filter { isHere(it) }.map { it.id }.toSet() + val now = System.currentTimeMillis() + val byId = LinkedHashMap() + known(ctx).forEach { byId[it.id] = it } + // a TV whose address now belongs to another TV moved; drop the stale address + found.forEach { f -> byId.values.filter { it.ip == f.ip && it.id != f.id }.forEach { byId[it.id] = Tv(it.id, "", it.name, it.pc, it.xbox, it.version, it.seen) } } + found.forEach { f -> byId[f.id] = f; here[f.id] = now } + val silent = byId.values.filter { t -> found.none { it.id == t.id } && (asked == null || t.ip in asked) } + silent.forEach { here.remove(it.id) } + save(ctx, byId.values.toList()) + val after = known(ctx).filter { isHere(it) }.map { it.id }.toSet() + if (before != after || found.isNotEmpty()) ui.post { onChange?.invoke() } + } + + /** One address: a MoonCast TV, or null. */ + fun ask(ip: String): Tv? { + if (ip.isEmpty()) return null + var c: HttpURLConnection? = null + return try { + c = URL("http://$ip:$PORT/mc-native").openConnection() as HttpURLConnection + c.connectTimeout = 400; c.readTimeout = 1200 + if (c.responseCode != 200) return null + val j = JSONObject(c.inputStream.bufferedReader().use { it.readText().take(8192) }) + if (j.optInt("mooncast_native") != 1) return null + val id = j.optString("id").ifEmpty { "ip:$ip" } // TVs from before the "id" field: known by address + Tv(id, ip, j.optString("name", "MoonCast TV").ifBlank { "MoonCast TV" }, j.optString("pc"), j.optBoolean("xbox"), + j.optInt("version", 1), System.currentTimeMillis(), + j.optString("identityKey"), j.optBoolean("paired", true)) + } catch (e: Exception) { null } finally { c?.disconnect() } + } +} diff --git a/app/src/handheld/java/com/limelight/handheld/TvSender.kt b/app/src/handheld/java/com/limelight/handheld/TvSender.kt new file mode 100644 index 0000000000..4dbb2575b6 --- /dev/null +++ b/app/src/handheld/java/com/limelight/handheld/TvSender.kt @@ -0,0 +1,153 @@ +package com.limelight.handheld + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Build +import android.provider.Settings +import com.limelight.home.MoveToTv +import com.limelight.home.Ps5Store +import com.limelight.home.Psn +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL +import java.nio.charset.StandardCharsets +import java.security.SecureRandom + +/** + * Sends a game to a MoonCast TV: POST http://:8099/play. The TV starts it with its own player and pulls this + * device's controller from our [PadServer] (padKey authorizes it). + * PC: appId, appName (the TV plays it on the PC it is paired with) + * PS5: ps5host, registKey, morning, ps5 (+ psnToken, duid, accountId when signed in to PSN, for a PS5 away from home) + * Xbox: xboxId, xboxName (the TV plays it with its own Xbox sign-in) + * Always: deviceName (the TV shows "Sent from"), phoneIp (where our pad server is), padKey. + */ +object TvSender : MoveToTv.Handler { + fun newPadKey(): String = ByteArray(16).also { SecureRandom().nextBytes(it) }.joinToString("") { "%02x".format(it) } + + fun deviceName(ctx: Context): String = + (if (Build.VERSION.SDK_INT >= 25) Settings.Global.getString(ctx.contentResolver, Settings.Global.DEVICE_NAME) else null) + ?.takeIf { it.isNotBlank() } ?: Build.MODEL + + /** Blocking. Returns null when the TV accepted the game, else what went wrong (the TV's own words when it said). */ + fun send(ctx: Context, tv: TvFinder.Tv, t: MoveToTv.Target, padKey: String, direct: Boolean = false): String? { + if (tv.ip.isEmpty()) return "${tv.name} isn't on this network. Open MoonCast on it, on the same Wi-Fi as this device." + if (t.kind == "xb" && !tv.canXbox) return "Update MoonCast on ${tv.name} to send it an Xbox game." + val j = JSONObject().put("deviceName", deviceName(ctx)).put("padKey", padKey) + TvFinder.myIp()?.let { j.put("phoneIp", it) } + when (t.kind) { + // "pad":"direct": this device opens its own controller-only session on the PC (DirectPad), so the TV must not + // relay the pad too. padKey still goes along: it authorizes /stop and /pad (the relay, if direct fails). + "pc" -> { + j.put("appId", t.appId.toString()).put("appName", t.title); if (direct) j.put("pad", "direct") + // A TV with no pairing gets this device's identity, sealed to the TV's key (identity transfer). Ask the + // TV again first: its key changes each time its app starts, so a remembered one can be stale (#44). + val live = TvFinder.ask(tv.ip) ?: tv + if (!live.paired && live.identityKey.isNotEmpty()) { + val pc = com.limelight.computers.ComputerDatabaseManager(ctx).let { db -> + try { db.getComputerByUUID(t.pcUuid ?: "") } finally { db.close() } } + com.limelight.identity.IdentityTransfer.sealIdentity(ctx, live.identityKey, pc)?.let { j.put("identity", it) } + } + } + "ps" -> { + val c = Ps5Store.all(ctx).firstOrNull { it.host == t.psHost } + val regist = t.registKey ?: c?.registKey; val morning = t.morning ?: c?.morning + if (t.psHost.isNullOrEmpty() || regist.isNullOrEmpty() || morning.isNullOrEmpty()) return "This PlayStation isn't registered here." + j.put("ps5host", t.psHost).put("registKey", regist).put("morning", morning).put("ps5", if (t.isPs5) "true" else "false") + // signed in to PSN: let the TV reach the console over the internet too (it still tries the LAN first) + if (Psn.signedIn(ctx)) try { + Psn.remoteFor(ctx, c?.name ?: t.title)?.let { (tok, duid, acc) -> j.put("psnToken", tok).put("duid", duid).put("accountId", acc) } + } catch (e: Exception) { } + } + "xb" -> j.put("xboxId", t.xboxId ?: "").put("xboxName", t.title) + } + var c: HttpURLConnection? = null + return try { + c = URL("http://${tv.ip}:${TvFinder.PORT}/play").openConnection() as HttpURLConnection + c.connectTimeout = 2000; c.readTimeout = 20000 // an Xbox may make the TV ask Xbox network first + c.requestMethod = "POST"; c.doOutput = true + c.setRequestProperty("Content-Type", "application/json") + c.outputStream.use { it.write(j.toString().toByteArray(StandardCharsets.UTF_8)) } + val code = c.responseCode + if (code == 200) null + else { + val body = try { (c.errorStream ?: c.inputStream).bufferedReader().use { it.readText() } } catch (e: Exception) { "" } + val err = try { JSONObject(body).optString("error") } catch (e: Exception) { "" } + when { + err.isBlank() -> "${tv.name} didn't take the game (error $code)." + err.startsWith("no app id") -> "${tv.name} has no paired PC. Pair it with your PC on the TV first." + err == "ps5 launch failed" -> "${tv.name} couldn't start the PlayStation." + else -> err.replaceFirstChar { it.uppercase() } + (if (err.endsWith(".")) "" else ".") + } + } + } catch (e: Exception) { + "${tv.name} didn't answer. Check MoonCast is installed on it and it's on the same Wi-Fi as this device." + } finally { c?.disconnect() } + } + + /** + * Blocking. Ask [tv] to end the stream we started there and go back to its home (POST /stop, MoonCast TV version + * 3 and later). [quit] also quits the game on the PC; otherwise it keeps running. False when the TV can't + * (older MoonCast, or it didn't answer). + */ + fun stop(tv: TvFinder.Tv, padKey: String, quit: Boolean = false): Boolean { + if (tv.ip.isEmpty() || tv.version < 3) return false + var c: HttpURLConnection? = null + return try { + c = URL("http://${tv.ip}:${TvFinder.PORT}/stop").openConnection() as HttpURLConnection + c.connectTimeout = 1500; c.readTimeout = 3000 + c.requestMethod = "POST"; c.doOutput = true + c.setRequestProperty("Content-Type", "application/json") + val body = JSONObject().put("padKey", padKey).put("quit", quit).toString() + c.outputStream.use { it.write(body.toByteArray(StandardCharsets.UTF_8)) } + c.responseCode == 200 + } catch (e: Exception) { false } finally { c?.disconnect() } + } + + /** Blocking. How the TV's stream from our last /play is going: (state, error), or null (older TV / no answer). */ + fun status(tv: TvFinder.Tv): Pair? { + if (tv.ip.isEmpty() || tv.version < 6) return null + var c: HttpURLConnection? = null + return try { + c = URL("http://${tv.ip}:${TvFinder.PORT}/status").openConnection() as HttpURLConnection + c.connectTimeout = 1500; c.readTimeout = 2000 + if (c.responseCode != 200) return null + val j = JSONObject(c.inputStream.bufferedReader().use { it.readText() }) + j.optString("state") to j.optString("error") + } catch (e: Exception) { null } finally { c?.disconnect() } + } + + /** Blocking. The direct controller session failed: ask [tv] to relay the pad after all (POST /pad, version 4+). */ + fun useRelay(tv: TvFinder.Tv, padKey: String): Boolean { + if (tv.ip.isEmpty() || tv.version < 4) return false + var c: HttpURLConnection? = null + return try { + c = URL("http://${tv.ip}:${TvFinder.PORT}/pad").openConnection() as HttpURLConnection + c.connectTimeout = 1500; c.readTimeout = 3000 + c.requestMethod = "POST"; c.doOutput = true + c.setRequestProperty("Content-Type", "application/json") + val body = JSONObject().put("padKey", padKey) + TvFinder.myIp()?.let { body.put("phoneIp", it) } + c.outputStream.use { it.write(body.toString().toByteArray(StandardCharsets.UTF_8)) } + c.responseCode == 200 + } catch (e: Exception) { false } finally { c?.disconnect() } + } + + /** Open controller mode for [t] on [tv]; it sends the game after [delayMs] (time for a local stream to end). */ + fun open(act: Activity, tv: TvFinder.Tv, t: MoveToTv.Target, delayMs: Long = 0) { + act.startActivity(t.put(Intent(act, TvControllerActivity::class.java)) + .putExtra(TvControllerActivity.EXTRA_TV, tv.id).putExtra(TvControllerActivity.EXTRA_DELAY, delayMs)) + } + + // ---- MoveToTv.Handler: the Quick menu's "Move to " ---- + + override fun tvName(ctx: Context): String? = TvFinder.lastTv(ctx)?.name + + override fun move(act: Activity, t: MoveToTv.Target) { + val tv = TvFinder.lastTv(act) ?: return + Recents.touch(act, t.key) + // the player already ended its session (MoveToTv.LocalSession); a short pause lets the PC / PS5 / Xbox notice + // the old client is gone (the TV still retries a PS5 that isn't free yet) + open(act, tv, t, when (t.kind) { "pc" -> 500L; "ps" -> 3000L; else -> 1000L }) + } +} diff --git a/app/src/handheld/res/values/ids.xml b/app/src/handheld/res/values/ids.xml new file mode 100644 index 0000000000..b769d745e1 --- /dev/null +++ b/app/src/handheld/res/values/ids.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d916899e6b..e3f8538db5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + @@ -27,9 +28,6 @@ - @@ -42,6 +40,19 @@ android:name="android.hardware.type.pc" android:required="false"/> + + + + + + + + + + + + + + - - - - - - - - + + + + + \ No newline at end of file diff --git a/app/src/main/assets/fonts/Figtree.ttf b/app/src/main/assets/fonts/Figtree.ttf new file mode 100644 index 0000000000..579e2ab5f3 Binary files /dev/null and b/app/src/main/assets/fonts/Figtree.ttf differ diff --git a/app/src/main/assets/fonts/OFL-Figtree.txt b/app/src/main/assets/fonts/OFL-Figtree.txt new file mode 100644 index 0000000000..80b12bb3d0 --- /dev/null +++ b/app/src/main/assets/fonts/OFL-Figtree.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Figtree Project Authors (https://github.com/erikdkennedy/figtree) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/app/src/main/assets/fonts/OFL-Unbounded.txt b/app/src/main/assets/fonts/OFL-Unbounded.txt new file mode 100644 index 0000000000..1c7db28325 --- /dev/null +++ b/app/src/main/assets/fonts/OFL-Unbounded.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Unbounded Project Authors (https://github.com/googlefonts/unbounded) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/app/src/main/assets/fonts/Unbounded.ttf b/app/src/main/assets/fonts/Unbounded.ttf new file mode 100644 index 0000000000..e3c064ddfa Binary files /dev/null and b/app/src/main/assets/fonts/Unbounded.ttf differ diff --git a/app/src/main/assets/licences/agpl-3.0.txt b/app/src/main/assets/licences/agpl-3.0.txt new file mode 100644 index 0000000000..c3ef0cb237 --- /dev/null +++ b/app/src/main/assets/licences/agpl-3.0.txt @@ -0,0 +1,672 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. + +Additional permission under GNU AGPL version 3 section 7 + +If you modify this program, or any covered work, by linking or +combining it with the OpenSSL project's OpenSSL library (or a +modified version of that library), containing parts covered by the +terms of the OpenSSL or SSLeay licenses, the Free Software Foundation +grants you additional permission to convey the resulting work. +Corresponding Source for a non-source form of such a combination +shall include the source code for the parts of OpenSSL used as well +as that of the covered work. diff --git a/app/src/main/assets/licences/apache-2.0.txt b/app/src/main/assets/licences/apache-2.0.txt new file mode 100644 index 0000000000..d645695673 --- /dev/null +++ b/app/src/main/assets/licences/apache-2.0.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/app/src/main/assets/licences/bouncycastle.txt b/app/src/main/assets/licences/bouncycastle.txt new file mode 100644 index 0000000000..58a50ccd6b --- /dev/null +++ b/app/src/main/assets/licences/bouncycastle.txt @@ -0,0 +1,19 @@ +MIT License + +Copyright (c) 2000-2026 The Legion of the Bouncy Castle Inc. (https://www.bouncycastle.org) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/app/src/main/assets/licences/curl.txt b/app/src/main/assets/licences/curl.txt new file mode 100644 index 0000000000..d9e7e0bef3 --- /dev/null +++ b/app/src/main/assets/licences/curl.txt @@ -0,0 +1,22 @@ +COPYRIGHT AND PERMISSION NOTICE + +Copyright (c) 1996 - 2024, Daniel Stenberg, , and many +contributors, see the THANKS file. + +All rights reserved. + +Permission to use, copy, modify, and distribute this software for any purpose +with or without fee is hereby granted, provided that the above copyright +notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF THIRD PARTY RIGHTS. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE +OR OTHER DEALINGS IN THE SOFTWARE. + +Except as contained in this notice, the name of a copyright holder shall not +be used in advertising or otherwise to promote the sale, use or other dealings +in this Software without prior written authorization of the copyright holder. diff --git a/app/src/main/assets/licences/enet.txt b/app/src/main/assets/licences/enet.txt new file mode 100644 index 0000000000..6906f8eb0b --- /dev/null +++ b/app/src/main/assets/licences/enet.txt @@ -0,0 +1,7 @@ +Copyright (c) 2002-2020 Lee Salzman + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/app/src/main/assets/licences/figtree.txt b/app/src/main/assets/licences/figtree.txt new file mode 100644 index 0000000000..80b12bb3d0 --- /dev/null +++ b/app/src/main/assets/licences/figtree.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Figtree Project Authors (https://github.com/erikdkennedy/figtree) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/app/src/main/assets/licences/fsr1.txt b/app/src/main/assets/licences/fsr1.txt new file mode 100644 index 0000000000..324cba594d --- /dev/null +++ b/app/src/main/assets/licences/fsr1.txt @@ -0,0 +1,19 @@ +Copyright (c) 2021 Advanced Micro Devices, Inc. All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/app/src/main/assets/licences/gf-complete.txt b/app/src/main/assets/licences/gf-complete.txt new file mode 100644 index 0000000000..df8d9ed33f --- /dev/null +++ b/app/src/main/assets/licences/gf-complete.txt @@ -0,0 +1,32 @@ +Copyright (c) 2013, James S. Plank, Ethan L. Miller, Kevin M. Greenan, +Benjamin A. Arnold, John A. Burnum, Adam W. Disney, Allen C. McBride +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + - Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + - Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + + - Neither the name of the University of Tennessee nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS +OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED +AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY +WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/app/src/main/assets/licences/gpl-3.0.txt b/app/src/main/assets/licences/gpl-3.0.txt new file mode 100644 index 0000000000..94a9ed024d --- /dev/null +++ b/app/src/main/assets/licences/gpl-3.0.txt @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/app/src/main/assets/licences/jcodec.txt b/app/src/main/assets/licences/jcodec.txt new file mode 100644 index 0000000000..2dc4e4b91b --- /dev/null +++ b/app/src/main/assets/licences/jcodec.txt @@ -0,0 +1,21 @@ +Copyright 2008-2019 JCodecProject + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. Redistributions in binary form +must reproduce the above copyright notice, this list of conditions and the +following disclaimer in the documentation and/or other materials provided with +the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/app/src/main/assets/licences/jerasure.txt b/app/src/main/assets/licences/jerasure.txt new file mode 100644 index 0000000000..20ffbd2d73 --- /dev/null +++ b/app/src/main/assets/licences/jerasure.txt @@ -0,0 +1,37 @@ + +Copyright (c) 2013, James S. Plank and Kevin Greenan +All rights reserved. + +Jerasure - A C/C++ Library for a Variety of Reed-Solomon and RAID-6 Erasure Coding Techniques + +Revision 2.0: Galois Field backend now links to GF-Complete + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + - Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + - Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + + - Neither the name of the University of Tennessee nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS +OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED +AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY +WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + diff --git a/app/src/main/assets/licences/json-c.txt b/app/src/main/assets/licences/json-c.txt new file mode 100644 index 0000000000..740d1258d4 --- /dev/null +++ b/app/src/main/assets/licences/json-c.txt @@ -0,0 +1,42 @@ + +Copyright (c) 2009-2012 Eric Haszlakiewicz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +---------------------------------------------------------------- + +Copyright (c) 2004, 2005 Metaparadigm Pte Ltd + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/app/src/main/assets/licences/libcxx.txt b/app/src/main/assets/licences/libcxx.txt new file mode 100644 index 0000000000..e159d28344 --- /dev/null +++ b/app/src/main/assets/licences/libcxx.txt @@ -0,0 +1,311 @@ +============================================================================== +The LLVM Project is under the Apache License v2.0 with LLVM Exceptions: +============================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + +---- LLVM Exceptions to the Apache 2.0 License ---- + +As an exception, if, as a result of your compiling your source code, portions +of this Software are embedded into an Object form of such source code, you +may redistribute such embedded portions in such Object form without complying +with the conditions of Sections 4(a), 4(b) and 4(d) of the License. + +In addition, if you combine or link compiled forms of this Software with +software that is licensed under the GPLv2 ("Combined Software") and if a +court of competent jurisdiction determines that the patent provision (Section +3), the indemnity provision (Section 9) or other Section of the License +conflicts with the conditions of the GPLv2, you may retroactively and +prospectively choose to deem waived or otherwise exclude such Section(s) of +the License, but only in their entirety and only with respect to the Combined +Software. + +============================================================================== +Software from third parties included in the LLVM Project: +============================================================================== +The LLVM Project contains third party software which is under different license +terms. All such code will be identified clearly using at least one of two +mechanisms: +1) It will be in a separate directory tree with its own `LICENSE.txt` or + `LICENSE` file at the top containing the specific license and restrictions + which apply to that software, or +2) It will contain specific license and restriction terms at the top of every + file. + +============================================================================== +Legacy LLVM License (https://llvm.org/docs/DeveloperPolicy.html#legacy): +============================================================================== + +The libc++ library is dual licensed under both the University of Illinois +"BSD-Like" license and the MIT license. As a user of this code you may choose +to use it under either license. As a contributor, you agree to allow your code +to be used under both. + +Full text of the relevant licenses is included below. + +============================================================================== + +University of Illinois/NCSA +Open Source License + +Copyright (c) 2009-2019 by the contributors listed in CREDITS.TXT + +All rights reserved. + +Developed by: + + LLVM Team + + University of Illinois at Urbana-Champaign + + http://llvm.org + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal with +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimers. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimers in the + documentation and/or other materials provided with the distribution. + + * Neither the names of the LLVM Team, University of Illinois at + Urbana-Champaign, nor the names of its contributors may be used to + endorse or promote products derived from this Software without specific + prior written permission. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS WITH THE +SOFTWARE. + +============================================================================== + +Copyright (c) 2009-2014 by the contributors listed in CREDITS.TXT + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/app/src/main/assets/licences/miniupnpc.txt b/app/src/main/assets/licences/miniupnpc.txt new file mode 100644 index 0000000000..09d3f7a013 --- /dev/null +++ b/app/src/main/assets/licences/miniupnpc.txt @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2005-2026, Thomas BERNARD +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/app/src/main/assets/licences/nanopb.txt b/app/src/main/assets/licences/nanopb.txt new file mode 100644 index 0000000000..d11c9af1d7 --- /dev/null +++ b/app/src/main/assets/licences/nanopb.txt @@ -0,0 +1,20 @@ +Copyright (c) 2011 Petteri Aimonen + +This software is provided 'as-is', without any express or +implied warranty. In no event will the authors be held liable +for any damages arising from the use of this software. + +Permission is granted to anyone to use this software for any +purpose, including commercial applications, and to alter it and +redistribute it freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you + must not claim that you wrote the original software. If you use + this software in a product, an acknowledgment in the product + documentation would be appreciated but is not required. + +2. Altered source versions must be plainly marked as such, and + must not be misrepresented as being the original software. + +3. This notice may not be removed or altered from any source + distribution. diff --git a/app/src/main/assets/licences/nanors.txt b/app/src/main/assets/licences/nanors.txt new file mode 100644 index 0000000000..89a77e6258 --- /dev/null +++ b/app/src/main/assets/licences/nanors.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Joseph Calderon + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/app/src/main/assets/licences/shield-controller-extensions.txt b/app/src/main/assets/licences/shield-controller-extensions.txt new file mode 100644 index 0000000000..54bd03f04c --- /dev/null +++ b/app/src/main/assets/licences/shield-controller-extensions.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Cameron Gutman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/app/src/main/assets/licences/unbounded.txt b/app/src/main/assets/licences/unbounded.txt new file mode 100644 index 0000000000..1c7db28325 --- /dev/null +++ b/app/src/main/assets/licences/unbounded.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Unbounded Project Authors (https://github.com/googlefonts/unbounded) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/app/src/main/assets/licences/webrtc.txt b/app/src/main/assets/licences/webrtc.txt new file mode 100644 index 0000000000..4c41b7b251 --- /dev/null +++ b/app/src/main/assets/licences/webrtc.txt @@ -0,0 +1,29 @@ +Copyright (c) 2011, The WebRTC project authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + + * Neither the name of Google nor the names of its contributors may + be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/app/src/main/java/com/limelight/Game.java b/app/src/main/java/com/limelight/Game.java index 8d369b3eab..f5a3b7aa3a 100644 --- a/app/src/main/java/com/limelight/Game.java +++ b/app/src/main/java/com/limelight/Game.java @@ -91,8 +91,10 @@ public class Game extends Activity implements SurfaceHolder.Callback, OnGenericMotionListener, OnTouchListener, NvConnectionListener, EvdevListener, OnSystemUiVisibilityChangeListener, GameGestures, StreamView.InputCallbacks, - PerfOverlayListener, UsbDriverService.UsbDriverStateListener, View.OnKeyListener { + PerfOverlayListener, UsbDriverService.UsbDriverStateListener, View.OnKeyListener, + com.limelight.home.MoveToTv.LocalSession { private int lastButtonState = 0; + private int playGen = -1; // the /play this stream came from (PhoneGamepad), for clearing "pad direct" when it ends // Only 2 touches are supported private final TouchContext[] touchContextMap = new TouchContext[2]; @@ -123,6 +125,10 @@ public class Game extends Activity implements SurfaceHolder.Callback, private boolean connected = false; private boolean autoEnterPip = false; private boolean surfaceCreated = false; + // MoonCast: Super resolution (FSR 1.0) + private boolean fsrPlanned = false; + private float fsrSharpness = 0.6f; + private volatile com.limelight.binding.video.FsrRenderer fsr; private boolean attemptedConnection = false; private int suppressPipRefCount = 0; private String pcName; @@ -184,6 +190,7 @@ public void onServiceDisconnected(ComponentName componentName) { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + com.limelight.home.Usage.used(this, "pc"); // for the next anonymous check-in, if the user said yes UiHelper.setLocale(this); @@ -217,12 +224,16 @@ protected void onCreate(Bundle savedInstanceState) { // Read the stream preferences prefConfig = PreferenceConfiguration.readPreferences(this); + trace(foldLabel(getResources().getConfiguration()) + ": stream " + prefConfig.width + "x" + prefConfig.height + + ", Native " + PreferenceConfiguration.nativeWholeScreen(this)); tombstonePrefs = Game.this.getSharedPreferences("DecoderTombstone", 0); // Enter landscape unless we're on a square screen setPreferredOrientationForCurrentDisplay(); - if (prefConfig.stretchVideo || shouldIgnoreInsetsForResolution(prefConfig.width, prefConfig.height)) { + // MoonCast "Whole screen": the stream is the screen's own shape, so it goes under the camera cutout too + if (prefConfig.stretchVideo || shouldIgnoreInsetsForResolution(prefConfig.width, prefConfig.height) + || PreferenceConfiguration.nativeWholeScreen(this)) { // Allow the activity to layout under notches if the fill-screen option // was turned on by the user or it's a full-screen native resolution if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { @@ -292,12 +303,12 @@ public boolean onCapturedPointer(View view, MotionEvent motionEvent) { // Make sure Wi-Fi is fully powered up WifiManager wifiMgr = (WifiManager) getApplicationContext().getSystemService(Context.WIFI_SERVICE); try { - highPerfWifiLock = wifiMgr.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "Moonlight High Perf Lock"); + highPerfWifiLock = wifiMgr.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "MoonCast High Perf Lock"); highPerfWifiLock.setReferenceCounted(false); highPerfWifiLock.acquire(); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - lowLatencyWifiLock = wifiMgr.createWifiLock(WifiManager.WIFI_MODE_FULL_LOW_LATENCY, "Moonlight Low Latency Lock"); + lowLatencyWifiLock = wifiMgr.createWifiLock(WifiManager.WIFI_MODE_FULL_LOW_LATENCY, "MoonCast Low Latency Lock"); lowLatencyWifiLock.setReferenceCounted(false); lowLatencyWifiLock.acquire(); } @@ -399,6 +410,21 @@ public void notifyCrash(Exception e) { Toast.makeText(this, "Decoder does not support HDR10 profile", Toast.LENGTH_LONG).show(); } + // MoonCast: Super resolution (FSR 1.0) when it's on, the stream isn't HDR (FSR's passes are 8-bit) and the + // stream is clearly smaller than this screen. The surface then keeps the screen's size (no setFixedSize) + // so FSR has room to upscale into. + { + android.content.SharedPreferences sp = android.preference.PreferenceManager.getDefaultSharedPreferences(this); + android.graphics.Point real = new android.graphics.Point(); + getWindowManager().getDefaultDisplay().getRealSize(real); + fsrPlanned = com.limelight.binding.video.FsrRenderer.Companion.enabled(sp) && !willStreamHdr + && com.limelight.binding.video.FsrRenderer.Companion.worthIt(prefConfig.height, Math.min(real.x, real.y)); + fsrSharpness = com.limelight.binding.video.FsrRenderer.Companion.sharpness(sp); + if (com.limelight.binding.video.FsrRenderer.Companion.enabled(sp)) + LimeLog.info("FSR " + (fsrPlanned ? "on" : "not needed") + ": stream " + prefConfig.width + "x" + prefConfig.height + + ", screen " + real.x + "x" + real.y + (willStreamHdr ? ", HDR" : "")); + } + // Display a message to the user if HEVC was forced on but we still didn't find a decoder if (prefConfig.videoFormat == PreferenceConfiguration.FormatOption.FORCE_HEVC && !decoderRenderer.isHevcSupported()) { Toast.makeText(this, "No HEVC decoder found", Toast.LENGTH_LONG).show(); @@ -435,6 +461,11 @@ public void notifyCrash(Exception e) { // If we're using OSC, always set at least gamepad 1. gamepadMask |= 1; } + // A handheld sent this game with "pad":"direct": its own controller-only session on the PC is player 1, so this + // stream registers no gamepad at all (not even the TV remote), or it would take pad 0 from the handheld. + final boolean padsElsewhere = PhoneGamepad.isDirect(); + playGen = PhoneGamepad.playGen(); + if (padsElsewhere) gamepadMask = 0; // Set to the optimal mode for streaming float displayRefreshRate = prepareDisplayForRendering(); @@ -486,23 +517,16 @@ public void notifyCrash(Exception e) { new ComputerDetails.AddressTuple(host, port), httpsPort, uniqueId, config, PlatformBinding.getCryptoProvider(this), serverCert); + conn.setControllersBlocked(padsElsewhere); controllerHandler = new ControllerHandler(this, conn, this, prefConfig); keyboardTranslator = new KeyboardTranslator(); InputManager inputManager = (InputManager) getSystemService(Context.INPUT_SERVICE); inputManager.registerInputDeviceListener(keyboardTranslator, null); - // Initialize touch contexts - for (int i = 0; i < touchContextMap.length; i++) { - if (!prefConfig.touchscreenTrackpad) { - touchContextMap[i] = new AbsoluteTouchContext(conn, i, streamView); - } - else { - touchContextMap[i] = new RelativeTouchContext(conn, i, - REFERENCE_HORIZ_RES, REFERENCE_VERT_RES, - streamView, prefConfig); - } - } + // Touch screen starts as Settings, Handheld, Touch screen say (Off unless changed there); the Quick menu + // switches it for this stream + setTouchMode(defaultTouchMode()); if (prefConfig.onscreenController) { // create virtual onscreen controller @@ -581,6 +605,16 @@ public void onConfigurationChanged(Configuration newConfig) { // Set requested orientation for possible new screen size setPreferredOrientationForCurrentDisplay(); + // Folded or unfolded mid-game: once the screen settles (a fold sends a few changes), reconnect at its size + // so Native fills it; the game keeps running on the PC + // (always check after it settles: right at the change the screen's new size may not be known yet) + if (streamView != null) { + trace(foldLabel(newConfig) + ": size changed " + newConfig.screenWidthDp + "x" + newConfig.screenHeightDp + " dp, screen " + + PreferenceConfiguration.nativeResolution(this)); + streamView.removeCallbacks(fitScreen); + streamView.postDelayed(fitScreen, 1500); + } + if (virtualController != null) { // Refresh layout of OSC for possible new screen size virtualController.refreshLayout(); @@ -950,13 +984,22 @@ else if (!isRefreshRateGoodMatch(candidate.getRefreshRate())) { } } - if (prefConfig.stretchVideo || aspectRatioMatch) { + if (!fsrPlanned && (prefConfig.stretchVideo || aspectRatioMatch)) { // Set the surface to the size of the video streamView.getHolder().setFixedSize(prefConfig.width, prefConfig.height); } - else { + else if (!(fsrPlanned && prefConfig.stretchVideo)) { // Set the surface to scale based on the aspect ratio of the stream streamView.setDesiredAspectRatio((double)prefConfig.width / (double)prefConfig.height); + streamView.setFill(prefConfig.fillVideo); + } + // (FSR with Stretch: the view fills the screen and FSR stretches the picture into it) + + // MoonCast: Settings, Video, Zoom. Enlarges the picture for TVs that crop the edges (overscan). + if (prefConfig.videoZoomPercent > 100) { + float z = prefConfig.videoZoomPercent / 100f; + streamView.setScaleX(z); + streamView.setScaleY(z); } // Set the desired refresh rate that will get passed into setFrameRate() later @@ -1036,6 +1079,8 @@ public void onMultiWindowModeChanged(boolean isInMultiWindowMode) { protected void onDestroy() { super.onDestroy(); + PhoneGamepad.stopFor(conn); + PhoneGamepad.streamEnded(playGen); // a later stream from the TV's own home has its controllers again if (controllerHandler != null) { controllerHandler.destroy(); } @@ -1144,7 +1189,31 @@ else if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_AV1) != 0) { } } - finish(); + // Being rebuilt (Fit to the screen, recreate): the new one reconnects; finishing here would end it. + if (isChangingConfigurations()) return; + if (isFinishing() || streamView == null) { finish(); return; } + // A foldable switching screens hides this for a moment: stopped, then started again on the other screen. + // Close only if it doesn't come back; if it does, reconnect, at the new screen's size with Native. + stoppedAwhile = true; + trace("hidden: closing in 2 s unless shown again"); + streamView.removeCallbacks(closeIfGone); + streamView.postDelayed(closeIfGone, 2000); + } + + /** Stopped, and waiting to see whether it comes back (a fold or unfold) before closing. */ + private boolean stoppedAwhile; + private final Runnable closeIfGone = () -> { if (stoppedAwhile && !isFinishing()) finish(); }; + + @Override + protected void onStart() { + super.onStart(); + if (stoppedAwhile) trace("shown again"); + if (stoppedAwhile) { + // back within a moment (folded or unfolded): the stream ended when it was hidden, so start it again + stoppedAwhile = false; + streamView.removeCallbacks(closeIfGone); + if (!isFinishing()) reconnectHere("shown again"); + } } private void setInputGrabState(boolean grab) { @@ -1326,6 +1395,10 @@ public boolean onKeyDown(int keyCode, KeyEvent event) { @Override public boolean handleKeyDown(KeyEvent event) { + // MoonCast Quick menu first: the stream view hands us keys before the IME and before the activity + Boolean menuKey = quickMenuKey(event); + if (menuKey != null) return menuKey; + // Pass-through virtual navigation keys if ((event.getFlags() & KeyEvent.FLAG_VIRTUAL_HARD_KEY) != 0) { return false; @@ -1408,6 +1481,10 @@ public boolean onKeyUp(int keyCode, KeyEvent event) { @Override public boolean handleKeyUp(KeyEvent event) { + // MoonCast Quick menu first: the stream view hands us keys before the IME and before the activity + Boolean menuKey = quickMenuKey(event); + if (menuKey != null) return menuKey; + // Pass-through virtual navigation keys if ((event.getFlags() & KeyEvent.FLAG_VIRTUAL_HARD_KEY) != 0) { return false; @@ -1504,6 +1581,261 @@ public void toggleKeyboard() { inputManager.toggleSoftInput(0, 0); } + // ---- MoonCast Quick menu: Select + Start + LB + RB on a pad, or Back on the TV remote ---- + private com.limelight.home.QuickMenu quickMenu; + + @Override + public void openQuickMenu() { + runOnUiThread(() -> { + if (isFinishing()) return; + if (quickMenu == null) quickMenu = new com.limelight.home.QuickMenu(Game.this); + java.util.List items = new java.util.ArrayList<>(); + items.add(new com.limelight.home.QuickMenu.Item("⌨", "Keyboard", "On-screen", false, this::toggleKeyboard).asToggle()); + items.add(new com.limelight.home.QuickMenu.Item("ⓘ", "Streaming status", prefConfig.enablePerfOverlay ? "On" : "Off", false, this::toggleStatus).asToggle()); + items.add(new com.limelight.home.QuickMenu.Item("☝", "Touch screen", TOUCH_LABELS[touchMode], false, + () -> setTouchMode((touchMode + 1) % TOUCH_LABELS.length)).asToggle()); + items.add(new com.limelight.home.QuickMenu.Item("✚", "Controllers", com.limelight.home.QuickMenu.pads().size() + " connected", false, this::showControllers)); + items.add(new com.limelight.home.QuickMenu.Item("▤", "Task Manager", "Windows: Ctrl + Shift + Esc", false, this::openTaskManager)); + // handheld only: send the game to the last MoonCast TV (it ends this stream, the game keeps running) + com.limelight.home.QuickMenu.Item move = com.limelight.home.MoveToTv.item(Game.this, new com.limelight.home.MoveToTv.Target( + "pc", appName != null ? appName : "Your game", getIntent().getStringExtra(EXTRA_PC_UUID), pcName, + getIntent().getIntExtra(EXTRA_APP_ID, 0), null, null, null, true, null)); + if (move != null) items.add(move); + items.add(new com.limelight.home.QuickMenu.Item("←", "Disconnect", "The game keeps running", false, this::finish)); + items.add(new com.limelight.home.QuickMenu.Item("✕", "Quit game", null, true, this::quitOnHostAndFinish)); + items.add(new com.limelight.home.QuickMenu.Item("Zz", "Hibernate PC", "Windows: runs shutdown /h", true, this::confirmHibernate)); + quickMenu.show(appName != null ? appName : "Your game", + (pcName != null ? "On " + pcName + ". " : "") + com.limelight.home.QuickMenu.COMBO_LABEL + " opens this menu", + () -> java.util.Arrays.asList( + com.limelight.home.QuickMenu.stat(String.format(java.util.Locale.US, "%.0f", com.limelight.home.StreamStats.INSTANCE.getFps()), "Frames per second"), + com.limelight.home.QuickMenu.stat(com.limelight.home.StreamStats.INSTANCE.getRttMs() + " ms", "Network delay"), + com.limelight.home.QuickMenu.stat((prefConfig.bitrate / 1000) + " Mbps", "Picture quality"), + com.limelight.home.QuickMenu.stat(String.format(java.util.Locale.US, "%.1f%%", com.limelight.home.StreamStats.INSTANCE.getLossPct()), "Lost frames"), + com.limelight.home.QuickMenu.stat(String.format(java.util.Locale.US, "%.1f ms", com.limelight.home.StreamStats.INSTANCE.getDecodeMs()), "Decode delay")), + items); + }); + } + + // Quick menu, Touch screen: what a finger on the picture does, for this stream only (every stream starts Off, + // so a hand on a handheld's screen can't click on the PC). Mouse is Moonlight's trackpad, Touch puts the pointer + // where the finger is. A stylus and a real mouse work in every mode. + private static final int TOUCH_OFF = 0, TOUCH_MOUSE = 1, TOUCH_DIRECT = 2; + private static final String[] TOUCH_LABELS = {"Off", "Mouse", "Touch"}; + /** Settings, Handheld, Touch screen: how each stream starts ("off", "mouse" or "touch"). */ + public static final String TOUCH_DEFAULT_PREF = "mc_touch_default"; + private int touchMode = TOUCH_OFF; + + private int defaultTouchMode() { + String v = android.preference.PreferenceManager.getDefaultSharedPreferences(this).getString(TOUCH_DEFAULT_PREF, "off"); + return "mouse".equals(v) ? TOUCH_MOUSE : "touch".equals(v) ? TOUCH_DIRECT : TOUCH_OFF; + } + + /** + * Touch screen Off: the PC's pointer is drawn into the picture, so move it to the middle of the right edge, where + * the arrow (drawn right and down from its tip) is off the screen. Not a corner: Windows' bottom-right corner is + * Show desktop. + */ + private void parkPointer() { + if (conn == null || !connected) return; + conn.sendMousePosition((short) (REFERENCE_HORIZ_RES - 1), (short) (REFERENCE_VERT_RES / 2), + (short) REFERENCE_HORIZ_RES, (short) REFERENCE_VERT_RES); + } + + private void setTouchMode(int mode) { + for (TouchContext c : touchContextMap) { + if (c != null) c.cancelTouch(); + } + touchMode = mode; + for (int i = 0; i < touchContextMap.length; i++) { + touchContextMap[i] = mode == TOUCH_DIRECT ? new AbsoluteTouchContext(conn, i, streamView) + : new RelativeTouchContext(conn, i, REFERENCE_HORIZ_RES, REFERENCE_VERT_RES, streamView, prefConfig); + } + LimeLog.info("Touch screen: " + TOUCH_LABELS[mode]); + if (mode == TOUCH_OFF) parkPointer(); + } + + // Quick menu, Streaming status: Moonlight's stats corner on or off right away, saved like Settings' "Stats while playing" + private void toggleStatus() { + prefConfig.enablePerfOverlay = !prefConfig.enablePerfOverlay; + android.preference.PreferenceManager.getDefaultSharedPreferences(this).edit() + .putBoolean("checkbox_enable_perf_overlay", prefConfig.enablePerfOverlay).apply(); + performanceOverlayView.setVisibility(prefConfig.enablePerfOverlay && !isHidingOverlays ? View.VISIBLE : View.GONE); + } + + // Quick menu, Task Manager: the keys Windows opens it with (stock Sunshine has no other way in) + private void openTaskManager() { + sendKeys(new short[] {VK_LCONTROL, VK_LSHIFT, VK_ESCAPE}); + } + + // Quick menu, Hibernate PC: asks first, with Cancel picked, since the game and the stream both stop + private void confirmHibernate() { + String pc = pcName != null ? pcName : "the PC"; + java.util.List items = new java.util.ArrayList<>(); + items.add(new com.limelight.home.QuickMenu.Item("↩", "Cancel", "Keep playing", false, () -> { })); + items.add(new com.limelight.home.QuickMenu.Item("Zz", "Hibernate", "Unsaved progress may be lost", true, this::hibernate)); + quickMenu.show("Hibernate " + pc + "?", + "MoonCast opens Start on the PC (Ctrl + Esc), types shutdown /h and presses Enter. It works on Windows " + + "with hibernation on (most PCs). Wake the PC again from MoonCast's home.", + java.util.Collections::emptyList, items); + } + + /** When Hibernate PC was sent: the PC going away soon after is the end the user asked for, not an error. */ + private volatile long hibernateAt; + + private void hibernate() { + hibernateAt = android.os.SystemClock.uptimeMillis(); + LimeLog.info("Quick menu: Hibernate PC"); + // Start's search runs the command. Not Win + R: games turn the Windows key off while they play (test239), and + // Ctrl + Esc opens Start without it. Not the Win + X power menu either: its U and H don't match on other + // keyboard layouts or Windows languages (test238). The command goes as text, which Sunshine types the same + // on any layout; search needs a moment to find it before Enter. + sendKeys(new short[] {VK_LCONTROL, VK_ESCAPE}); + Handler h = new Handler(getMainLooper()); + h.postDelayed(() -> { if (conn != null) conn.sendUtf8Text("shutdown /h"); }, 1500); + h.postDelayed(() -> sendKeys(new short[] {VK_RETURN}), 3500); + // A PC going into hibernation just stops sending video: Moonlight would only notice after a long timeout, + // with the last picture frozen (test241). Watch for it and end the stream ourselves. + h.postDelayed(() -> watchHibernate(60, -1, 0), 4500); + } + + /** Once a second for up to [checksLeft] s: after 3 s without a new frame, end the stream if the PC stopped answering. */ + private void watchHibernate(final int checksLeft, final int lastFrame, final int still) { + if (isFinishing() || decoderRenderer == null || displayedFailureDialog) return; + final int frame = decoderRenderer.getLastFrameNumber(); + final int quiet = frame == lastFrame ? still + 1 : 0; + if (quiet < 3) { + if (checksLeft > 0) new Handler(getMainLooper()).postDelayed(() -> watchHibernate(checksLeft - 1, frame, quiet), 1000); + return; + } + // no video for 3 s: a still screen does that too, so ask whether the PC is still there (its http port, 47989) + new Thread(() -> { + boolean up; + try (java.net.Socket sock = new java.net.Socket()) { + sock.connect(new java.net.InetSocketAddress(getIntent().getStringExtra(EXTRA_HOST), + getIntent().getIntExtra(EXTRA_PORT, NvHTTP.DEFAULT_HTTP_PORT)), 1500); + up = true; + } catch (Exception e) { + up = false; + } + final boolean answers = up; + runOnUiThread(() -> { + if (!answers) finishHibernated(); + else if (checksLeft > 0) new Handler(getMainLooper()).postDelayed(() -> watchHibernate(checksLeft - 1, frame, 0), 1000); + }); + }, "mc-hibernate-watch").start(); + } + + /** The PC went down after Hibernate PC: leave the stream without an error (and without quitting the game). */ + private void finishHibernated() { + if (displayedFailureDialog) return; + displayedFailureDialog = true; + endedByUser = true; + LimeLog.info("Stream ended after Hibernate PC"); + stopConnection(); + android.widget.Toast.makeText(this, (pcName != null ? pcName : "The PC") + " is hibernating", android.widget.Toast.LENGTH_LONG).show(); + finish(); + } + + // Windows virtual-key codes, with Moonlight's 0x80 prefix (KeyboardTranslator) + private static final short VK_LCONTROL = (short) 0x80A2, VK_LSHIFT = (short) 0x80A0, + VK_ESCAPE = (short) 0x801B, VK_RETURN = (short) 0x800D; + + /** Press [keys] in order, holding each, then let them go in reverse, like a person pressing a shortcut. */ + private void sendKeys(short[] keys) { + if (conn == null) return; + byte mod = 0; + for (short k : keys) { + conn.sendKeyboardInput(k, KeyboardPacket.KEY_DOWN, mod, (byte) 0); + mod |= keyModifier(k); + } + final byte held = mod; + new Handler(getMainLooper()).postDelayed(() -> { + if (conn == null) return; + byte m = held; + for (int i = keys.length - 1; i >= 0; i--) { + m &= (byte) ~keyModifier(keys[i]); + conn.sendKeyboardInput(keys[i], KeyboardPacket.KEY_UP, m, (byte) 0); + } + }, 100); + } + + private static byte keyModifier(short k) { + switch (k) { + case VK_LCONTROL: return KeyboardPacket.MODIFIER_CTRL; + case VK_LSHIFT: return KeyboardPacket.MODIFIER_SHIFT; + default: return 0; + } + } + + // Quick menu, Controllers: on a PC every pad is its own player (in the order they were first used). + private void showControllers() { + java.util.List items = new java.util.ArrayList<>(); + int n = 1; + for (android.view.InputDevice d : com.limelight.home.QuickMenu.pads()) { + String b = com.limelight.home.QuickMenu.battery(d); + items.add(new com.limelight.home.QuickMenu.Item(String.valueOf(n++), d.getName(), b, false, this::showControllers)); + } + items.add(new com.limelight.home.QuickMenu.Item("+", "Pair a controller", "Bluetooth settings", false, + () -> com.limelight.home.QuickMenu.pairController(Game.this))); + quickMenu.show("Controllers", prefConfig.multiController ? "Each pad is its own player on the PC." : "All pads control player 1 (Settings, Controllers).", + java.util.Collections::emptyList, items); + } + + /** The user ended this stream (Quit game, Disconnect, Back): its end is not a failure to report (#41). */ + private volatile boolean endedByUser; + + // Quit the game on the PC (not just this stream), then leave. Public for the TV's /stop {"quit":true}. + public void quitOnHostAndFinish() { + endedByUser = true; // the PC closing the game ends the stream with an error code; that's this quit + new Thread(this::quitAppOnHost, "mc-quit").start(); + finish(); + } + + /** Close the running game on the PC (blocks: call it off the main thread). */ + private void quitAppOnHost() { + final Intent i = getIntent(); + try { + X509Certificate cert = null; + byte[] der = i.getByteArrayExtra(EXTRA_SERVER_CERT); + if (der != null) cert = (X509Certificate) CertificateFactory.getInstance("X.509").generateCertificate(new ByteArrayInputStream(der)); + new NvHTTP(new ComputerDetails.AddressTuple(i.getStringExtra(EXTRA_HOST), i.getIntExtra(EXTRA_PORT, NvHTTP.DEFAULT_HTTP_PORT)), + i.getIntExtra(EXTRA_HTTPS_PORT, 0), i.getStringExtra(EXTRA_UNIQUEID), cert, + PlatformBinding.getCryptoProvider(Game.this)).quitApp(); + } catch (Exception e) { + LimeLog.warning("Quick menu quit: " + e); + } + } + + + + // The open menu takes every key; the TV remote's Back opens it (Moonlight would send it to the PC as Select). + // #57: the menu closes on Back's press; its release then came here with the menu gone and opened it again. + // Open only on a release whose press came while the menu was closed. + private boolean backPressedClosed; + + private Boolean quickMenuKey(KeyEvent event) { + if (quickMenu != null && quickMenu.isShowing()) { backPressedClosed = false; return quickMenu.onKey(event); } + if (com.limelight.home.QuickMenu.isRemoteBack(event)) { + if (event.getAction() == KeyEvent.ACTION_DOWN && event.getRepeatCount() == 0) backPressedClosed = true; + else if (event.getAction() == KeyEvent.ACTION_UP) { if (backPressedClosed) openQuickMenu(); backPressedClosed = false; } + return true; + } + return null; + } + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + Boolean menuKey = quickMenuKey(event); + if (menuKey != null) return menuKey; + return super.dispatchKeyEvent(event); + } + + @Override + public boolean dispatchGenericMotionEvent(MotionEvent event) { + if (quickMenu != null && quickMenu.isShowing()) return quickMenu.onMotion(event); + return super.dispatchGenericMotionEvent(event); + } + private byte getLiTouchTypeFromEvent(MotionEvent event) { switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: @@ -2000,10 +2332,15 @@ else if (event.getActionMasked() == MotionEvent.ACTION_UP || event.getActionMask return true; } + // Touch screen Off (the Quick menu's default): a finger on the picture does nothing on the PC + if (touchMode == TOUCH_OFF) { + return true; + } + // If this is the parent view, we'll offset our coordinates to appear as if they // are relative to the StreamView like our StreamView touch events are. float xOffset, yOffset; - if (view != streamView && !prefConfig.touchscreenTrackpad) { + if (view != streamView && touchMode == TOUCH_DIRECT) { xOffset = -streamView.getX(); yOffset = -streamView.getY(); } @@ -2035,7 +2372,7 @@ else if (event.getActionMasked() == MotionEvent.ACTION_UP || event.getActionMask // TODO: Re-enable native touch when have a better solution for handling // cancelled touches from Android gestures and 3 finger taps to activate // the software keyboard. - /*if (!prefConfig.touchscreenTrackpad && trySendTouchEvent(view, event)) { + /*if (touchMode == TOUCH_DIRECT && trySendTouchEvent(view, event)) { // If this host supports touch events and absolute touch is enabled, // send it directly as a touch event. return true; @@ -2225,7 +2562,12 @@ public void run() { public void stageComplete(String stage) { } - private void stopConnection() { + private void stopConnection() { stopConnection(null); } + + /** Stop the stream; [done] runs on the main thread once the PC was told (null: don't wait). */ + private void stopConnection(final Runnable done) { + endedByUser = true; // we stop it ourselves; a termination after this isn't a failure + PhoneGamepad.stopFor(conn); if (connecting || connected) { connecting = connected = false; updatePipAutoEnter(); @@ -2243,13 +2585,42 @@ private void stopConnection() { new Thread() { public void run() { conn.stop(); + // the decoder is gone: FSR's surface can go too + com.limelight.binding.video.FsrRenderer f = fsr; + fsr = null; + if (f != null) f.release(); + if (done != null) runOnUiThread(done); } }.start(); } + else if (done != null) runOnUiThread(done); + } + + private final Runnable fitScreen = () -> { + trace("check: screen " + PreferenceConfiguration.nativeResolution(Game.this) + ", stream " + + (prefConfig != null ? prefConfig.width + "x" + prefConfig.height : "?") + ", connected " + connected + + ", Native " + PreferenceConfiguration.nativeWholeScreen(Game.this) + " -> " + (needsRefit() ? "RECONNECT" : "no")); + if (needsRefit()) { + reconnectHere("size changed"); + } + }; + + /** A foldable folded or unfolded mid-game with Native on: the stream is still the size of the other screen. */ + private boolean needsRefit() { + if (!connected || isFinishing()) return false; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isInPictureInPictureMode()) return false; + return sizeChanged(); + } + + // "Move to TV" (handheld): end this stream (disconnect, the game keeps running) before the TV joins it. + @Override + public void endLocalSession(Runnable done) { + stopConnection(done); } @Override public void stageFailed(final String stage, final int portFlags, final int errorCode) { + com.limelight.home.StreamReport.failed("Couldn't start the game on the PC (" + stage + ", error " + errorCode + ")."); // Perform a connection test if the failure could be due to a blocked port // This does network I/O, so don't do it on the main thread. final int portTestResult = MoonBridge.testClientConnectivity(ServerHelper.CONNECTION_TEST_SERVER, 443, portFlags); @@ -2290,6 +2661,16 @@ public void run() { @Override public void connectionTerminated(final int errorCode) { + PhoneGamepad.stopFor(conn); // the stream is over: stop pulling the sender's controller + if (hibernateAt != 0 && android.os.SystemClock.uptimeMillis() - hibernateAt < 60_000) { + // the PC went down after Hibernate PC: that's what the user asked for, so no error + endedByUser = true; + runOnUiThread(this::finishHibernated); + return; + } + // A stream the user ended (Quit game makes the PC close it, error -1) isn't a failure (#41) + if (errorCode != MoonBridge.ML_ERROR_GRACEFUL_TERMINATION && !endedByUser && !isFinishing()) + com.limelight.home.StreamReport.failed("The stream from the PC stopped (error " + errorCode + ")."); // Perform a connection test if the failure could be due to a blocked port // This does network I/O, so don't do it on the main thread. final int portFlags = MoonBridge.getPortFlagsFromTerminationErrorCode(errorCode); @@ -2403,6 +2784,7 @@ else if (connectionStatus == MoonBridge.CONN_STATUS_OKAY) { @Override public void connectionStarted() { + com.limelight.home.StreamReport.playing(); runOnUiThread(new Runnable() { @Override public void run() { @@ -2414,6 +2796,11 @@ public void run() { connected = true; connecting = false; updatePipAutoEnter(); + // Touch screen Off: the PC's pointer out of the way once the picture is up + if (touchMode == TOUCH_OFF) new Handler(getMainLooper()).postDelayed(Game.this::parkPointer, 1500); + + // The phone drives this stream as the controller (native receiver flow): feed its pad into our conn. + PhoneGamepad.start(conn, null); // Hide the mouse cursor now after a short delay. // Doing it before dismissing the spinner seems to be undone @@ -2508,6 +2895,13 @@ public void surfaceChanged(SurfaceHolder holder, int format, int width, int heig throw new IllegalStateException("Surface changed before creation!"); } + trace("surface " + width + "x" + height); + if (surfaceLostMidStream && !isFinishing()) { + surfaceLostMidStream = false; + streamView.post(() -> { if (!isFinishing()) reconnectHere("new surface " + width + "x" + height); }); + return; + } + if (!attemptedConnection) { attemptedConnection = true; @@ -2515,6 +2909,17 @@ public void surfaceChanged(SurfaceHolder holder, int format, int width, int heig UiHelper.notifyStreamConnecting(Game.this); decoderRenderer.setRenderTarget(holder); + if (fsrPlanned && fsr == null) { + try { + fsr = new com.limelight.binding.video.FsrRenderer(prefConfig.width, prefConfig.height, fsrSharpness); + fsr.attachOutput(holder.getSurface()); + decoderRenderer.setSurfaceOverride(fsr.getInputSurface()); + LimeLog.info("FSR on: " + prefConfig.width + "x" + prefConfig.height + " to " + width + "x" + height); + } catch (RuntimeException e) { + LimeLog.warning("FSR unavailable, showing the stream directly: " + e); + fsr = null; + } + } conn.start(new AndroidAudioRenderer(Game.this, prefConfig.enableAudioFx), decoderRenderer, Game.this); } @@ -2567,16 +2972,64 @@ public void surfaceDestroyed(SurfaceHolder holder) { throw new IllegalStateException("Surface destroyed before creation!"); } + // FSR stops drawing to the screen surface before it goes + if (fsr != null) fsr.detachOutput(); + if (attemptedConnection) { // Let the decoder know immediately that the surface is gone decoderRenderer.prepareForStop(); if (connected) { + // a foldable switching screens takes the surface away and gives a new one: reconnect then + surfaceLostMidStream = true; + trace("surface lost, stream stopped"); stopConnection(); } } } + /** A line in the log about folding and refitting (what the stream saw, and what it did). */ + private void trace(String line) { + LimeLog.info("MoonCast fold: " + line); + } + + /** Folded or open, from the screen this is on: an open foldable is a big screen (600 dp and more across). */ + private String foldLabel(Configuration c) { + return c.smallestScreenWidthDp >= 600 ? "UNFOLDED" : "FOLDED"; + } + + /** The stream ended because its surface went away (a fold or unfold); a new surface reconnects. */ + private boolean surfaceLostMidStream; + private boolean reconnecting; + + /** + * Folded or unfolded mid-game (Native): quit the game on the PC and start it again on this screen. A PC sets its + * resolution when a game launches, so only a fresh launch fills the new screen; a reconnect alone kept the other + * screen's size inside the new frame. Unsaved progress in the game is lost. + */ + private void reconnectHere(String why) { + if (reconnecting) return; // a fold can bring a new surface, onStart and a size change: act once + reconnecting = true; + surfaceLostMidStream = false; + stoppedAwhile = false; + endedByUser = true; + // Only a new size needs the game restarted. A foldable whose two screens are known streams one shape for both + // (the picture just re-fits), so a stream that ended there only reconnects; the game keeps running. + final boolean restart = sizeChanged(); + trace((restart ? "restarting the game for this screen (" : "reconnecting (") + why + ")"); + Toast.makeText(this, restart ? "Restarting the game for this screen…" : "Reconnecting…", Toast.LENGTH_SHORT).show(); + stopConnection(() -> new Thread(() -> { + if (restart) quitAppOnHost(); + runOnUiThread(() -> { if (!isFinishing()) recreate(); }); + }, "mc-refit").start()); + } + + /** With Native on, the size this screen streams at now differs from this stream's. */ + private boolean sizeChanged() { + return prefConfig != null && PreferenceConfiguration.nativeWholeScreen(this) + && !PreferenceConfiguration.nativeResolution(this).equals(prefConfig.width + "x" + prefConfig.height); + } + @Override public void mouseMove(int deltaX, int deltaY) { conn.sendMouseMove((short) deltaX, (short) deltaY); diff --git a/app/src/main/java/com/limelight/PcView.java b/app/src/main/java/com/limelight/PcView.java index 4ec6094f6e..d94ce6f6d4 100644 --- a/app/src/main/java/com/limelight/PcView.java +++ b/app/src/main/java/com/limelight/PcView.java @@ -23,7 +23,6 @@ import com.limelight.ui.AdapterFragment; import com.limelight.ui.AdapterFragmentCallbacks; import com.limelight.utils.Dialog; -import com.limelight.utils.HelpLauncher; import com.limelight.utils.ServerHelper; import com.limelight.utils.ShortcutHelper; import com.limelight.utils.UiHelper; @@ -118,7 +117,6 @@ public void onConfigurationChanged(Configuration newConfig) { private final static int VIEW_DETAILS_ID = 8; private final static int FULL_APP_LIST_ID = 9; private final static int TEST_NETWORK_ID = 10; - private final static int GAMESTREAM_EOL_ID = 11; private void initializeViews() { setContentView(R.layout.activity_pc_view); @@ -154,19 +152,11 @@ public void onClick(View v) { startActivity(i); } }); - helpButton.setOnClickListener(new OnClickListener() { - @Override - public void onClick(View v) { - HelpLauncher.launchSetupGuide(PcView.this); - } - }); - // Amazon review didn't like the help button because the wiki was not entirely // navigable via the Fire TV remote (though the relevant parts were). Let's hide // it on Fire TV. - if (getPackageManager().hasSystemFeature("amazon.hardware.fire_tv")) { - helpButton.setVisibility(View.GONE); - } + // MoonCast: no help button (it opened Moonlight's setup guide) + helpButton.setVisibility(View.GONE); getFragmentManager().beginTransaction() .replace(R.id.pcFragmentContainer, new AdapterFragment()) @@ -358,13 +348,9 @@ public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuIn if (computer.details.state == ComputerDetails.State.OFFLINE || computer.details.state == ComputerDetails.State.UNKNOWN) { menu.add(Menu.NONE, WOL_ID, 1, getResources().getString(R.string.pcview_menu_send_wol)); - menu.add(Menu.NONE, GAMESTREAM_EOL_ID, 2, getResources().getString(R.string.pcview_menu_eol)); } else if (computer.details.pairState != PairState.PAIRED) { menu.add(Menu.NONE, PAIR_ID, 1, getResources().getString(R.string.pcview_menu_pair_pc)); - if (computer.details.nvidiaServer) { - menu.add(Menu.NONE, GAMESTREAM_EOL_ID, 2, getResources().getString(R.string.pcview_menu_eol)); - } } else { if (computer.details.runningGameId != 0) { @@ -372,9 +358,6 @@ else if (computer.details.pairState != PairState.PAIRED) { menu.add(Menu.NONE, QUIT_ID, 2, getResources().getString(R.string.applist_menu_quit)); } - if (computer.details.nvidiaServer) { - menu.add(Menu.NONE, GAMESTREAM_EOL_ID, 3, getResources().getString(R.string.pcview_menu_eol)); - } menu.add(Menu.NONE, FULL_APP_LIST_ID, 4, getResources().getString(R.string.pcview_menu_app_list)); } @@ -671,10 +654,6 @@ public void run() { ServerHelper.doNetworkTest(PcView.this); return true; - case GAMESTREAM_EOL_ID: - HelpLauncher.launchGameStreamEolFaq(PcView.this); - return true; - default: return super.onContextItemSelected(item); } diff --git a/app/src/main/java/com/limelight/PhoneGamepad.java b/app/src/main/java/com/limelight/PhoneGamepad.java new file mode 100644 index 0000000000..130ad05942 --- /dev/null +++ b/app/src/main/java/com/limelight/PhoneGamepad.java @@ -0,0 +1,299 @@ +package com.limelight; + +import android.util.Log; + +import com.limelight.nvstream.NvConnection; +import com.limelight.nvstream.input.ControllerPacket; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; + +/** + * The phone IS the controller for the native receiver. The phone (MoonCast app) broadcasts its pad on a tiny + * WebSocket server (ws://phone:8099/pad) as {"pads":[{"b":[17],"a":[4]}, ...]} (W3C gamepad order). This connects + * there and feeds pad 0 straight into THIS stream's own Moonlight connection (conn.sendControllerInput) - the same + * virtual gamepad the game already reads. That is why the native path needs no second Sunshine session for the + * controller (which gave the game a gamepad it ignored, and a second audio stream that broke the sound). + */ +class PhoneGamepad { + private static final String TAG = "MoonCast-PhonePad"; + private static final int PAD_PORT = 8099; // the phone's PadServer + private static final long GIVE_UP_MS = 30000; // stop retrying after this long without reaching the sender + + private static volatile PhoneGamepad current; + private static volatile NvConnection lastConn; // the stream it feeds, until that stream stops (stop()) + // The sender drives the PC directly with its own controller-only session (/play "pad":"direct"): the next stream + // registers no gamepad and gets no relay, or the game would get the pad twice (or the TV would take pad 0). + // /pad turns the relay on later if the direct session can't connect. + private static volatile boolean direct; + private static volatile String phoneHost; // set from the /play socket's remote address + private static volatile String padKey; // the phone's pad-server session key (for /pad?k=...) + + private final NvConnection conn; + private volatile String host; + private volatile boolean running = true; + private volatile boolean quick; // reconnect right away (the sender's key or address changed), no 1 s wait + private volatile Socket sock; + + private PhoneGamepad(NvConnection conn, String host) { this.conn = conn; this.host = host; } + + /** Remember where the phone is, from the address that POSTed /play. */ + static void setPhoneHost(String ip) { + if (ip == null || ip.isEmpty()) return; + boolean changed = !ip.equals(phoneHost); + phoneHost = ip; + PhoneGamepad p = current; + if (changed && p != null) { p.host = ip; p.kick(); } + } + + /** The last phone address that drove a /play (used by the PS5 pad relay too). */ + static String phoneHost() { return phoneHost; } + + /** + * The game the sender started has ended: forget the sender (#40). Otherwise a game started later on the TV itself + * kept calling that device for its controller every few seconds. The next /play sets it again. + */ + static void forgetPhone() { + phoneHost = null; + padKey = null; + } + + /** The phone's pad-server session key, passed in /play; the relay presents it so the pad server authorizes us. */ + static void setPadKey(String k) { + boolean changed = k == null ? padKey != null : !k.equals(padKey); + padKey = k; + // a new key from a new /play: the running link presents the old one and would only notice at the read timeout + PhoneGamepad p = current; + if (changed && p != null) p.kick(); + else if (p == null) revive(); + } + + // A new /play while the stream is still up but the link gave up (the sender was away for a while): start again. + private static synchronized void revive() { + NvConnection c = lastConn; + if (current == null && c != null && !c.controllersBlocked()) { start(c, null); } + } + + /** Drop the current link and connect again at once (with the latest key and address). */ + private void kick() { + quick = true; + try { Socket s = sock; if (s != null) s.close(); } catch (Exception ignored) { } + } + static String padKey() { return padKey; } + + /** Start feeding this connection from the phone's pad (uses the last known phone address, or an override). */ + /** /play said the sender's controller goes straight to the PC (true), or through us (false). */ + private static volatile int playGen; // counts /play requests + + /** Which /play the stream being created belongs to (see [streamEnded]). */ + static int playGen() { return playGen; } + + static synchronized void setDirect(boolean d) { + playGen++; + direct = d; + if (!d) return; + // a relay from an earlier send would feed the same pad a second time: end it, keep the stream it fed + PhoneGamepad p = current; current = null; + if (p != null) { p.running = false; try { if (p.sock != null) p.sock.close(); } catch (Exception ignored) { } } + } + + /** The next stream (the one /play starts) leaves the gamepads to the sender's direct session. */ + static boolean isDirect() { return direct; } + + /** The stream that /play started is over: streams started on the TV itself use its own controllers. */ + static void streamEnded(int gen) { if (gen == playGen) direct = false; } // a newer /play keeps its own setting + + /** The sender asks for the relay after all (its direct session failed): start it on the running stream. */ + static synchronized void useRelay() { + direct = false; + NvConnection c = lastConn; + if (c != null) c.setControllersBlocked(false); // the relay's input goes through this stream now + revive(); + } + + static synchronized void start(NvConnection conn, String overrideHost) { + stop(); + // the stream itself knows (Game blocked its controllers when /play said direct): a stream of the TV's own isn't + // affected by a flag left from an earlier send + if (conn != null && conn.controllersBlocked()) { lastConn = conn; Log.i(TAG, "sender's controller goes direct to the PC: no relay"); return; } + String h = overrideHost != null && !overrideHost.isEmpty() ? overrideHost : phoneHost; + if (conn == null || h == null || h.isEmpty()) { Log.w(TAG, "no phone address yet - controller not started"); return; } + PhoneGamepad p = new PhoneGamepad(conn, h); + current = p; + lastConn = conn; + new Thread(p::run, "phone-gamepad").start(); + } + + /** The stream [c] ended: stop the relay only if it feeds that stream (a newer stream may already have its own). */ + static synchronized void stopFor(NvConnection c) { + PhoneGamepad p = current; + if (p != null && p.conn == c) stop(); + else if (lastConn == c) lastConn = null; + } + + static synchronized void stop() { + lastConn = null; + PhoneGamepad p = current; current = null; + if (p != null) { p.running = false; try { if (p.sock != null) p.sock.close(); } catch (Exception ignored) { } } + } + + private void run() { + long failingSince = 0; // when the sender stopped answering (0 = the last link worked) + while (running) { + boolean linked = false; + try { linked = session(); } catch (Exception e) { Log.w(TAG, "phone pad link: " + e); } + if (!running) break; + long now = System.currentTimeMillis(); + if (linked) failingSince = 0; + else if (failingSince == 0) failingSince = now; + else if (now - failingSince > GIVE_UP_MS) { // the sender's pad server is gone (it stopped, or left the Wi-Fi) + Log.i(TAG, "phone pad: no answer for " + (GIVE_UP_MS / 1000) + " s, giving up"); + synchronized (PhoneGamepad.class) { if (current == this) current = null; } + running = false; + break; + } + if (quick) { quick = false; continue; } + try { Thread.sleep(1000); } catch (InterruptedException e) { break; } // reconnect if the phone drops + } + } + + /** One link. True when it got through the handshake (so a later drop is a reconnect, not a dead sender). */ + private boolean session() throws Exception { + Socket s = new Socket(); + s.connect(new InetSocketAddress(host, PAD_PORT), 2000); + s.setTcpNoDelay(true); + // The phone pings every ~8s; if neither a frame nor a ping arrives in the window the link died silently + // (WiFi power-save / NAT) - time out the blocked read so run() reconnects instead of freezing the controller. + s.setSoTimeout(25000); + sock = s; + OutputStream out = s.getOutputStream(); + InputStream in = s.getInputStream(); + + // WebSocket handshake (present the phone's pad-server key so it authorizes us: /pad?k=) + byte[] keyBytes = new byte[16]; new SecureRandom().nextBytes(keyBytes); + String key = android.util.Base64.encodeToString(keyBytes, android.util.Base64.NO_WRAP); + String path = padKey != null && !padKey.isEmpty() ? "/pad?k=" + padKey : "/pad"; + out.write(("GET " + path + " HTTP/1.1\r\nHost: " + host + ":" + PAD_PORT + "\r\nUpgrade: websocket\r\n" + + "Connection: Upgrade\r\nSec-WebSocket-Key: " + key + "\r\nSec-WebSocket-Version: 13\r\n\r\n") + .getBytes(StandardCharsets.UTF_8)); + out.flush(); + // read response headers until blank line + if (!readHeaders(in)) throw new Exception("handshake refused"); + boolean linked = true; + + // introduce ourselves so the phone sends pad states here (not expecting a web gamepad page) + sendText(out, "{\"hello\":{\"id\":\"mctv-native\",\"kind\":\"mctv-native\"}}"); + + try { + while (running) { + String msg = readTextFrame(in); + if (msg == null) break; + if (msg.isEmpty()) continue; // ping/pong keep-alive: nothing to apply + apply(msg); + } + } finally { + try { s.close(); } catch (Exception ignored) { } + // release every button on ANY exit (incl. a read timeout), so nothing sticks while we reconnect + conn.sendControllerInput((short) 0, (short) 0x1, 0, (byte) 0, (byte) 0, (short) 0, (short) 0, (short) 0, (short) 0); + } + return linked; + } + + // {"pads":[{"b":[..17..],"a":[..4..]}, ...]} -> pad 0 into the stream + private void apply(String json) { + try { + JSONObject o = new JSONObject(json); + if (!o.has("pads")) return; // control message (phoneInput/takeover/session): not a pad state + JSONArray pads = o.optJSONArray("pads"); + if (pads == null || pads.length() == 0 || pads.isNull(0)) { + conn.sendControllerInput((short) 0, (short) 0x1, 0, (byte) 0, (byte) 0, (short) 0, (short) 0, (short) 0, (short) 0); + return; + } + JSONObject pad = pads.getJSONObject(0); + JSONArray b = pad.optJSONArray("b"), a = pad.optJSONArray("a"); + double[] bt = new double[17], ax = new double[4]; + for (int i = 0; b != null && i < 17 && i < b.length(); i++) bt[i] = b.optDouble(i, 0); + for (int i = 0; a != null && i < 4 && i < a.length(); i++) ax[i] = a.optDouble(i, 0); + + int flags = 0; + if (bt[0] > .5) flags |= ControllerPacket.A_FLAG; + if (bt[1] > .5) flags |= ControllerPacket.B_FLAG; + if (bt[2] > .5) flags |= ControllerPacket.X_FLAG; + if (bt[3] > .5) flags |= ControllerPacket.Y_FLAG; + if (bt[4] > .5) flags |= ControllerPacket.LB_FLAG; + if (bt[5] > .5) flags |= ControllerPacket.RB_FLAG; + if (bt[8] > .5) flags |= ControllerPacket.BACK_FLAG; + if (bt[9] > .5) flags |= ControllerPacket.PLAY_FLAG; + if (bt[10] > .5) flags |= ControllerPacket.LS_CLK_FLAG; + if (bt[11] > .5) flags |= ControllerPacket.RS_CLK_FLAG; + if (bt[12] > .5) flags |= ControllerPacket.UP_FLAG; + if (bt[13] > .5) flags |= ControllerPacket.DOWN_FLAG; + if (bt[14] > .5) flags |= ControllerPacket.LEFT_FLAG; + if (bt[15] > .5) flags |= ControllerPacket.RIGHT_FLAG; + if (bt[16] > .5) flags |= ControllerPacket.SPECIAL_BUTTON_FLAG; + + byte lt = (byte) Math.max(0, Math.min(255, (int) (bt[6] * 255))); + byte rt = (byte) Math.max(0, Math.min(255, (int) (bt[7] * 255))); + short lx = axis(ax[0]), ly = axis(-ax[1]), rx = axis(ax[2]), ry = axis(-ax[3]); // W3C Y is down-positive + conn.sendControllerInput((short) 0, (short) 0x1, flags, lt, rt, lx, ly, rx, ry); + } catch (Exception e) { Log.w(TAG, "bad pad state: " + e); } + } + + private static short axis(double v) { + int i = (int) Math.round(v * 32767); + return (short) Math.max(-32767, Math.min(32767, i)); + } + + // ---- minimal WebSocket client ---- + private boolean readHeaders(InputStream in) throws Exception { + StringBuilder sb = new StringBuilder(); + int c, nl = 0; + while ((c = in.read()) != -1) { + sb.append((char) c); + if (c == '\n') { if (++nl == 2) break; } else if (c != '\r') nl = 0; + } + String h = sb.toString(); + return h.startsWith("HTTP/1.1 101") || h.contains(" 101 "); + } + + private void sendText(OutputStream out, String text) throws Exception { + byte[] payload = text.getBytes(StandardCharsets.UTF_8); + byte[] mask = new byte[4]; new SecureRandom().nextBytes(mask); + java.io.ByteArrayOutputStream f = new java.io.ByteArrayOutputStream(); + f.write(0x81); // FIN + text + int len = payload.length; + if (len < 126) f.write(0x80 | len); + else if (len < 65536) { f.write(0x80 | 126); f.write(len >> 8); f.write(len); } + else { f.write(0x80 | 127); for (int i = 7; i >= 0; i--) f.write((int) ((long) len >> (8 * i))); } + f.write(mask); + for (int i = 0; i < payload.length; i++) f.write(payload[i] ^ mask[i & 3]); + synchronized (out) { out.write(f.toByteArray()); out.flush(); } + } + + private String readTextFrame(InputStream in) throws Exception { + int b0 = in.read(); if (b0 == -1) return null; + int opcode = b0 & 0x0F; + int b1 = in.read(); if (b1 == -1) return null; + boolean masked = (b1 & 0x80) != 0; + long len = b1 & 0x7F; + if (len == 126) len = (read(in) << 8) | read(in); + else if (len == 127) { len = 0; for (int i = 0; i < 8; i++) len = (len << 8) | read(in); } + byte[] mask = new byte[4]; + if (masked) for (int i = 0; i < 4; i++) mask[i] = (byte) read(in); + byte[] data = new byte[(int) len]; + int off = 0; while (off < data.length) { int r = in.read(data, off, data.length - off); if (r < 0) return null; off += r; } + if (masked) for (int i = 0; i < data.length; i++) data[i] ^= mask[i & 3]; + if (opcode == 0x8) return null; // close + if (opcode == 0x9 || opcode == 0xA || opcode == 0x0) return ""; // ping/pong/continuation: ignore + return new String(data, StandardCharsets.UTF_8); + } + + private static int read(InputStream in) throws Exception { int c = in.read(); if (c == -1) throw new Exception("eof"); return c; } +} diff --git a/app/src/main/java/com/limelight/Ps5Pad.kt b/app/src/main/java/com/limelight/Ps5Pad.kt new file mode 100644 index 0000000000..e5133513b1 --- /dev/null +++ b/app/src/main/java/com/limelight/Ps5Pad.kt @@ -0,0 +1,236 @@ +package com.limelight + +import android.util.Base64 +import android.util.Log +import com.metallic.chiaki.lib.ControllerState +import com.metallic.chiaki.lib.Session +import org.json.JSONObject +import java.io.InputStream +import java.io.OutputStream +import java.net.InetSocketAddress +import java.net.Socket +import java.nio.charset.StandardCharsets +import java.security.SecureRandom + +/** + * The phone is the controller for a native PS5 session too. Connects to the phone's pad server + * (ws://phone:8099/pad), reads {"pads":[{"b":[17 W3C],"a":[4]}]} and feeds pad 0 into the chiaki + * Session via setControllerState (DualSense mapping). Same relay idea as [PhoneGamepad], but the + * target is a chiaki session instead of the Moonlight connection. + */ +object Ps5Pad { + private const val TAG = "MoonCast-PS5Pad" + private const val PAD_PORT = 8099 + @Volatile private var running = false + @Volatile private var sock: Socket? = null + @Volatile private var thread: Thread? = null + // The session the relay may drive. Every controller update goes through push() under this lock, and stop() clears + // it under the same lock - so after stop() returns nothing can call into a session that is being disposed + // (crashed natively in sessionSetControllerState when the PS5 dropped a remote session, 2026-10-16). + private val lock = Any() + @Volatile private var active: Session? = null + + private fun push(st: ControllerState) = synchronized(lock) { active?.setControllerState(st) } + + /** A controller attached to THIS device (Odin handheld, or a pad plugged into the TV): same lock as the relay. */ + fun pushLocal(st: ControllerState) = push(st) + + fun start(session: Session) { + stop() // never leave a previous relay thread running + synchronized(lock) { active = session } // local input works even without a phone relay + val h = PhoneGamepad.phoneHost() ?: return + running = true + thread = Thread({ run(session, h) }, "ps5-pad").also { it.start() } + startUdp(h) + } + + fun stop() { + synchronized(lock) { active = null } // from here on push() is a no-op + running = false + try { udpSock?.close() } catch (e: Exception) {} + udpSock = null + try { sock?.close() } catch (e: Exception) {} + try { thread?.interrupt() } catch (e: Exception) {} + thread = null + } + + // ---- controller state over UDP (preferred) ---- + // The phone also sends every pad state as a UDP packet {"seq","k","pads"} to :8100 and repeats it every 50 ms. + // Unlike the TCP WebSocket, a lost/late packet doesn't hold back later inputs (TCP resend stalls of 200+ ms on + // the Meeting Room TV's 2.4 GHz Wi-Fi made the controller feel slow). Accepted only from the phone's IP, with + // our pad key and a newer seq. While UDP is flowing the WebSocket's pad frames are ignored; if it stops for 1 s + // the WebSocket takes over again. + private const val UDP_PORT = 8100 + @Volatile private var udpSock: java.net.DatagramSocket? = null + @Volatile private var lastUdpAt = 0L + @Volatile private var lastSeq = 0L + + private fun udpFlowing() = System.currentTimeMillis() - lastUdpAt < 1000 + + private fun startUdp(phone: String) { + val ds = try { + java.net.DatagramSocket(null).apply { reuseAddress = true; bind(InetSocketAddress(UDP_PORT)) } + } catch (e: Exception) { Log.w(TAG, "udp pad: can't listen on $UDP_PORT: $e"); return } + udpSock = ds + lastSeq = 0L; lastUdpAt = 0L + Thread({ + val buf = ByteArray(8192) + var first = true + while (running && udpSock === ds) { + try { + val p = java.net.DatagramPacket(buf, buf.size) + ds.receive(p) + if (p.address?.hostAddress != phone) continue + val js = String(buf, 0, p.length, StandardCharsets.UTF_8) + val o = org.json.JSONObject(js) + val key = PhoneGamepad.padKey() + if (!key.isNullOrEmpty() && o.optString("k") != key) continue + val seq = o.optLong("seq", 0L) + // drop older packets; accept a reset (phone app restarted) or a long gap + if (seq <= lastSeq && lastSeq - seq < 10000 && udpFlowing()) continue + lastSeq = seq + lastUdpAt = System.currentTimeMillis() + if (first) { first = false; Log.i(TAG, "controller over UDP from $phone") } + apply(null, js) + } catch (e: Exception) { + if (running && udpSock === ds) Log.w(TAG, "udp pad: $e") + } + } + }, "ps5-pad-udp").start() + } + + private fun run(session: Session, h: String) { + var backoff = 1000L + while (running) { + try { session(session, h); backoff = 1000L } catch (e: Exception) { Log.w(TAG, "pad link: $e") } + if (!running) break + try { Thread.sleep(backoff) } catch (e: InterruptedException) { break } + if (backoff < 8000L) backoff += 1000L // the phone may not be reachable; don't busy-loop + } + } + + private fun session(session: Session, h: String) { + val s = Socket() + s.connect(InetSocketAddress(h, PAD_PORT), 2000) + s.tcpNoDelay = true + // The phone pings every ~8s; if nothing (frame or ping) arrives within the window the link is dead + // (WiFi power-save / NAT dropped it silently) - time out the blocked read so run() reconnects instead + // of hanging forever with a frozen controller. + s.soTimeout = 25000 + sock = s + val out = s.getOutputStream() + val inp = s.getInputStream() + val key = Base64.encodeToString(ByteArray(16).also { SecureRandom().nextBytes(it) }, Base64.NO_WRAP) + val pk = PhoneGamepad.phoneHost()?.let { PhoneGamepad.padKey() } // present the phone's pad key so it authorizes us + val path = if (!pk.isNullOrEmpty()) "/pad?k=$pk" else "/pad" + out.write(("GET $path HTTP/1.1\r\nHost: $h:$PAD_PORT\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" + + "Sec-WebSocket-Key: $key\r\nSec-WebSocket-Version: 13\r\n\r\n").toByteArray(StandardCharsets.UTF_8)) + out.flush() + if (!readHeaders(inp)) throw Exception("handshake refused") + sendText(out, "{\"hello\":{\"id\":\"mctv-ps5\",\"kind\":\"mctv-ps5\"}}") + try { + while (running) { + val msg = readTextFrame(inp) ?: break + if (msg.isEmpty()) continue // ping/pong/continuation keep-alive: nothing to apply + if (udpFlowing()) continue // UDP is delivering the pad; the TCP copy would only arrive late + apply(session, msg) + } + } finally { + // Release every button on ANY exit (incl. a read timeout) so nothing sticks while we reconnect. + try { s.close() } catch (e: Exception) {} + try { push(ControllerState()) } catch (e: Exception) {} + } + } + + private fun apply(@Suppress("UNUSED_PARAMETER") session: Session?, json: String) { + try { + val o = JSONObject(json) + if (!o.has("pads")) return + val pads = o.optJSONArray("pads") ?: return + if (pads.length() == 0 || pads.isNull(0)) { push(ControllerState()); return } + val pad = pads.getJSONObject(0) + val b = pad.optJSONArray("b"); val a = pad.optJSONArray("a") + val bt = DoubleArray(17); val ax = DoubleArray(4) + for (i in 0 until 17) if (b != null && i < b.length()) bt[i] = b.optDouble(i, 0.0) + for (i in 0 until 4) if (a != null && i < a.length()) ax[i] = a.optDouble(i, 0.0) + // Combos for the buttons an Xbox-style pad lacks (its Guide button is grabbed by the launcher): + // L2 + Start -> PS (home), Select + Start -> Touchpad click. + val select = bt[8] > .5; val start = bt[9] > .5; val l2held = bt[6] > .5 + val psCombo = l2held && start + val tpCombo = select && start && !l2held + var flags = 0u + if (bt[0] > .5) flags = flags or ControllerState.BUTTON_CROSS + if (bt[1] > .5) flags = flags or ControllerState.BUTTON_MOON + if (bt[2] > .5) flags = flags or ControllerState.BUTTON_BOX + if (bt[3] > .5) flags = flags or ControllerState.BUTTON_PYRAMID + if (bt[4] > .5) flags = flags or ControllerState.BUTTON_L1 + if (bt[5] > .5) flags = flags or ControllerState.BUTTON_R1 + if (select && !tpCombo) flags = flags or ControllerState.BUTTON_SHARE + if (start && !psCombo && !tpCombo) flags = flags or ControllerState.BUTTON_OPTIONS + if (bt[10] > .5) flags = flags or ControllerState.BUTTON_L3 + if (bt[11] > .5) flags = flags or ControllerState.BUTTON_R3 + if (bt[12] > .5) flags = flags or ControllerState.BUTTON_DPAD_UP + if (bt[13] > .5) flags = flags or ControllerState.BUTTON_DPAD_DOWN + if (bt[14] > .5) flags = flags or ControllerState.BUTTON_DPAD_LEFT + if (bt[15] > .5) flags = flags or ControllerState.BUTTON_DPAD_RIGHT + if (bt[16] > .5 || psCombo) flags = flags or ControllerState.BUTTON_PS + if (tpCombo) flags = flags or ControllerState.BUTTON_TOUCHPAD + val st = ControllerState( + buttons = flags, + l2State = (if (psCombo) 0.0 else bt[6].coerceIn(0.0, 1.0)) .let { (it * 255).toInt().toUByte() }, + r2State = (bt[7].coerceIn(0.0, 1.0) * 255).toInt().toUByte(), + leftX = axis(ax[0]), leftY = axis(ax[1]), rightX = axis(ax[2]), rightY = axis(ax[3]) + ) + push(st) + } catch (e: Exception) { Log.w(TAG, "bad pad: $e") } + } + + private fun axis(v: Double): Short = + (Math.round(v * 32767).coerceIn(-32767L, 32767L)).toShort() + + // ---- minimal WebSocket client ---- + private fun readHeaders(inp: InputStream): Boolean { + val sb = StringBuilder(); var nl = 0; var c: Int + while (inp.read().also { c = it } != -1) { + sb.append(c.toChar()) + if (c == '\n'.code) { if (++nl == 2) break } else if (c != '\r'.code) nl = 0 + } + val h = sb.toString() + return h.startsWith("HTTP/1.1 101") || h.contains(" 101 ") + } + + private fun sendText(out: OutputStream, text: String) { + val payload = text.toByteArray(StandardCharsets.UTF_8) + val mask = ByteArray(4).also { SecureRandom().nextBytes(it) } + val f = java.io.ByteArrayOutputStream() + f.write(0x81) + val len = payload.size + when { len < 126 -> f.write(0x80 or len) + len < 65536 -> { f.write(0x80 or 126); f.write(len shr 8); f.write(len) } + else -> { f.write(0x80 or 127); for (i in 7 downTo 0) f.write((len.toLong() shr (8 * i)).toInt()) } } + f.write(mask) + for (i in payload.indices) f.write(payload[i].toInt() xor mask[i and 3].toInt()) + synchronized(out) { out.write(f.toByteArray()); out.flush() } + } + + private fun readTextFrame(inp: InputStream): String? { + val b0 = inp.read(); if (b0 == -1) return null + val opcode = b0 and 0x0F + val b1 = inp.read(); if (b1 == -1) return null + val masked = (b1 and 0x80) != 0 + var len = (b1 and 0x7F).toLong() + if (len == 126L) len = ((rd(inp) shl 8) or rd(inp)).toLong() + else if (len == 127L) { len = 0; for (i in 0 until 8) len = (len shl 8) or rd(inp).toLong() } + val mask = ByteArray(4) + if (masked) for (i in 0 until 4) mask[i] = rd(inp).toByte() + val data = ByteArray(len.toInt()) + var off = 0 + while (off < data.size) { val r = inp.read(data, off, data.size - off); if (r < 0) return null; off += r } + if (masked) for (i in data.indices) data[i] = (data[i].toInt() xor mask[i and 3].toInt()).toByte() + if (opcode == 0x8) return null + if (opcode == 0x9 || opcode == 0xA || opcode == 0x0) return "" + return String(data, StandardCharsets.UTF_8) + } + + private fun rd(inp: InputStream): Int { val c = inp.read(); if (c == -1) throw Exception("eof"); return c } +} diff --git a/app/src/main/java/com/limelight/Ps5Register.kt b/app/src/main/java/com/limelight/Ps5Register.kt new file mode 100644 index 0000000000..bebb70ddad --- /dev/null +++ b/app/src/main/java/com/limelight/Ps5Register.kt @@ -0,0 +1,50 @@ +package com.limelight + +import android.util.Log +import com.metallic.chiaki.lib.ChiakiLog +import com.metallic.chiaki.lib.Regist +import com.metallic.chiaki.lib.RegistEvent +import com.metallic.chiaki.lib.RegistEventSuccess +import com.metallic.chiaki.lib.RegistHost +import com.metallic.chiaki.lib.RegistInfo +import com.metallic.chiaki.lib.Target +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +/** + * Registers a PlayStation with chiaki (the "Link Device" PIN flow): given the console address, the user's PSN + * account id (8 bytes) and the 8-digit PIN, it runs chiaki's registStart and returns the resulting remote-play + * keys (rpRegistKey + rpKey) that the phone then stores as a PlayStation host. Runs on the TV app because that's + * where libchiaki lives. Blocking: the HTTP handler waits here for the registration to finish. + */ +object Ps5Register { + private const val TAG = "MoonCast-PS5Reg" + + class Result(val registKeyHex: String, val morningHex: String, val nickname: String, val macHex: String) + + fun register(host: String, accountId: ByteArray, pin: Int, ps5: Boolean, timeoutMs: Long): Result? { + val latch = CountDownLatch(1) + var host5: RegistHost? = null + val info = RegistInfo( + if (ps5) Target.PS5_1 else Target.PS4_10, + host, false, null, accountId, pin + ) + val log = ChiakiLog(0x7f) { _, text -> Log.i(TAG, text) } + val regist = try { + Regist(info, log) { ev: RegistEvent -> + if (ev is RegistEventSuccess) host5 = ev.host + latch.countDown() + } + } catch (e: Exception) { Log.e(TAG, "registStart failed", e); return null } + try { latch.await(timeoutMs, TimeUnit.MILLISECONDS) } catch (e: InterruptedException) {} + try { regist.dispose() } catch (e: Exception) {} + val h = host5 ?: return null + return Result(hex(h.rpRegistKey), hex(h.rpKey), h.serverNickname, hex(h.serverMac)) + } + + private fun hex(b: ByteArray): String { + val sb = StringBuilder(b.size * 2) + for (x in b) sb.append("%02x".format(x.toInt() and 0xff)) + return sb.toString() + } +} diff --git a/app/src/main/java/com/limelight/Ps5StreamActivity.kt b/app/src/main/java/com/limelight/Ps5StreamActivity.kt new file mode 100644 index 0000000000..7ff5424fb4 --- /dev/null +++ b/app/src/main/java/com/limelight/Ps5StreamActivity.kt @@ -0,0 +1,1002 @@ +package com.limelight + +import android.app.Activity +import android.os.Bundle +import android.util.Log +import android.view.InputDevice +import com.limelight.home.Profile +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.SurfaceHolder +import android.view.SurfaceView +import android.view.WindowManager +import com.metallic.chiaki.lib.Codec +import com.metallic.chiaki.lib.ConnectInfo +import com.metallic.chiaki.lib.ConnectVideoProfile +import com.metallic.chiaki.lib.ConnectedEvent +import com.metallic.chiaki.lib.ControllerState +import com.metallic.chiaki.lib.DiscoveryService +import com.metallic.chiaki.lib.Event +import com.metallic.chiaki.lib.QuitEvent +import com.metallic.chiaki.lib.RumbleEvent +import com.metallic.chiaki.lib.LoginPinRequestEvent +import com.metallic.chiaki.lib.ControllerTouch +import com.metallic.chiaki.lib.Session +import com.metallic.chiaki.lib.VideoFPSPreset +import com.metallic.chiaki.lib.VideoResolutionPreset + +/** + * Streams a PS5 (or PS4) natively with chiaki (libchiaki), rendered straight to a SurfaceView via MediaCodec - + * the native MoonCast path for a "PlayStation" host. Connect info comes from the phone (Add PC -> PlayStation): + * EXTRA_HOST, EXTRA_REGIST_KEY (hex), EXTRA_MORNING (hex), EXTRA_PS5. + * The phone stays the controller: [Ps5Pad] feeds its pad into session.setControllerState. + */ +class Ps5StreamActivity : Activity(), SurfaceHolder.Callback, com.limelight.home.MoveToTv.LocalSession { + companion object { + private const val TAG = "MoonCast-PS5" + const val EXTRA_HOST = "ps5_host" + const val EXTRA_REGIST_KEY = "ps5_regist_key" // hex, 16 bytes + const val EXTRA_MORNING = "ps5_morning" // hex, 16 bytes + const val EXTRA_PS5 = "ps5_is_ps5" + private const val QUIT_RP_IN_USE = 4 + private const val SURFACE_RELEASE_WAIT_MS = 500L // longest the screen waits for the decoder to let go + private const val IN_USE_WAIT_MS = 30_000L // how long to wait for another device to let go of Remote Play + private const val KEY_TOUCHPAD = KeyEvent.KEYCODE_BUTTON_16 // internal: an odd Sony pad's touchpad click + private val STEP_NAMES = listOf("Found your PS5", "Waking it up", "Signing in", "Starting video") + private val MENU_COMBO = setOf(KeyEvent.KEYCODE_BUTTON_SELECT, KeyEvent.KEYCODE_BUTTON_START, + KeyEvent.KEYCODE_BUTTON_L1, KeyEvent.KEYCODE_BUTTON_R1) // CHIAKI_QUIT_REASON_SESSION_REQUEST_RP_IN_USE ("Remote is already in use") + // Remote Play over the internet (PSN hole-punch): sent by the phone, used when the console isn't on this LAN + const val EXTRA_PSN_TOKEN = "ps5_psn_token" + const val EXTRA_DUID = "ps5_duid" // the console's PSN device id (hex) + const val EXTRA_ACCOUNT_ID = "ps5_account_id" // base64, 8 bytes + fun hex(s: String): ByteArray = + s.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + + /** The connect thread of the newest PS5 screen. A new screen waits for the last one's to end, so two never overlap. */ + @Volatile private var connectThread: Thread? = null + + /** Settings, PlayStation, Decoder: standard, low (default) or ultra. */ + const val DECODER_PREF = "ps5_decoder_latency" + + /** Settings, PlayStation, Frame pacing: latency (default), balanced or smoothness, like the PC's. */ + /** Picture, Frame pacing: one setting for PC and PS5 (Moonlight's own key). It was a separate PS5 one. */ + const val PACING_PREF = "frame_pacing" + + /** Stop a PSN connect in flight; older libraries without psnCancel just let it time out. */ + private fun cancelPsn() { + try { com.metallic.chiaki.lib.psnCancel() } catch (e: UnsatisfiedLinkError) { } + } + } + + private var session: Session? = null + private var started = false + @Volatile private var closed = false + + override fun onCreate(b: Bundle?) { + super.onCreate(b) + com.limelight.home.Usage.used(this, "ps5") // for the next anonymous check-in, if the user said yes + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + val sv = SurfaceView(this) + // Status over the (black) video surface until the first frame: waking a console over the internet takes up to a minute + status = android.widget.TextView(this).apply { + setTextColor(0xFFE8ECFF.toInt()); textSize = 22f + gravity = android.view.Gravity.CENTER + text = "Connecting to your PlayStation…" + } + val dm = resources.displayMetrics + fun dp(v: Int) = Math.round(v * dm.widthPixels / 1920f) + steps = android.widget.LinearLayout(this).apply { + orientation = android.widget.LinearLayout.HORIZONTAL + STEP_NAMES.forEach { name -> + addView(android.widget.LinearLayout(context).apply { + orientation = android.widget.LinearLayout.VERTICAL + addView(android.view.View(context).apply { setBackgroundColor(0xFF263150.toInt()) }, android.widget.LinearLayout.LayoutParams(-1, dp(5))) + addView(android.widget.TextView(context).apply { + text = name; setTextColor(0xFF97A1B8.toInt()); textSize = 15f; setPadding(0, dp(12), 0, 0) + }) + }, android.widget.LinearLayout.LayoutParams(0, -2, 1f).apply { rightMargin = dp(14) }) + } + } + setContentView(android.widget.FrameLayout(this).apply { + setBackgroundColor(0xFF000000.toInt()) + // the PS5 always sends 16:9: keep that shape on any screen (a foldable's near-square inner one too), + // following Picture shape; it re-fits by itself when the screen changes size (a fold or unfold) + addView(ShapeBox(this@Ps5StreamActivity).apply { addView(sv) }, android.widget.FrameLayout.LayoutParams(-1, -1)) + addView(status, android.widget.FrameLayout.LayoutParams(-1, -1)) + addView(steps, android.widget.FrameLayout.LayoutParams(dp(1100), -2, android.view.Gravity.CENTER_HORIZONTAL or android.view.Gravity.BOTTOM).apply { bottomMargin = dp(220) }) + }) + sv.holder.addCallback(this) + corner.apply() + } + + // Streaming status: the stats line in the corner while playing (Settings' "Stats while playing", or the Quick menu) + private val corner by lazy { com.limelight.home.StatusCorner(this) { liveStats() } } + + private var status: android.widget.TextView? = null + private fun showStatus(t: String?) = runOnUiThread { + status?.let { if (t == null) it.visibility = android.view.View.GONE else { it.text = t; it.visibility = android.view.View.VISIBLE } } + if (t == null) steps?.visibility = android.view.View.GONE + } + + // The four steps of a connect, as in the mockup: done = cyan, now = amber, to do = grey. + private var steps: android.widget.LinearLayout? = null + private fun step(n: Int) = runOnUiThread { + val row = steps ?: return@runOnUiThread + row.visibility = android.view.View.VISIBLE + for (i in 0 until row.childCount) { + val c = row.getChildAt(i) as android.widget.LinearLayout + val color = when { i < n - 1 -> 0xFF4FD6E3.toInt(); i == n - 1 -> 0xFFF2B55C.toInt(); else -> 0xFF263150.toInt() } + c.getChildAt(0).setBackgroundColor(color) + (c.getChildAt(1) as android.widget.TextView).setTextColor(if (i <= n - 1) 0xFFEEF1F7.toInt() else 0xFF97A1B8.toInt()) + } + } + + private fun connectInfo(remote: Boolean): ConnectInfo { + val host = intent.getStringExtra(EXTRA_HOST) ?: "" // every caller passes the console's host + val registKey = hex(intent.getStringExtra(EXTRA_REGIST_KEY) ?: "") + val morning = hex(intent.getStringExtra(EXTRA_MORNING) ?: "") + val ps5 = intent.getBooleanExtra(EXTRA_PS5, true) + // Settings, PlayStation (TV home): 1080p or 720p at 60, H.265 or H.264. chiaki falls back if the console + // or link can't hold it. + val prefs = android.preference.PreferenceManager.getDefaultSharedPreferences(this) + // A weak link can't carry 1080p: the picture queues on the Wi-Fi and every press shows up late (JAL TV: 2.4 GHz, + // 26-39 Mbps link, 15 Mbps asked over PSN -> dropped packets and a laggy feel). Keep 60 fps and step down pixels. + val link = linkMbps() + val weak = link in 1..59 + // Home and Away profiles (Profile.kt). Home: Settings resolution (720p on a weak link). Away: "Quality away + // from home", Auto = by the link, with its own bitrate and codec. + val away = Profile.isAway(this) + val awayQuality = prefs.getString("ps5_away_quality", "auto") + val resPreset = when { + away && awayQuality == "540" -> VideoResolutionPreset.RES_540P + away && awayQuality == "720" -> VideoResolutionPreset.RES_720P + away && awayQuality == "1080" -> VideoResolutionPreset.RES_1080P + weak || (!away && prefs.getString("ps5_resolution", "1080") == "720") -> VideoResolutionPreset.RES_720P + else -> VideoResolutionPreset.RES_1080P + } + val h264 = !ps5 || (if (away) prefs.getString("away_codec", "h265") else prefs.getString("ps5_codec", "h265")) == "h264" + // Frame rate: the PS5 streams at 30 or 60 fps only; any other rate broke the picture (#39). A Custom value + // saved by an older build uses the nearer of the two. + val fpsSel = (if (away) prefs.getString("away_fps", "60") else prefs.getString("ps5_fps_home", "60")) ?: "60" + val fps30 = if (fpsSel == "custom") + (prefs.getString(if (away) "away_fps_custom" else "ps5_fps_home_custom", "60")?.toIntOrNull() ?: 60) < 45 + else fpsSel == "30" + // Settings, PlayStation, Frame pacing: frames spaced at this stream's rate (lowest delay, balanced, smoothest) + // Moonlight's All settings also offers "cap-fps": the nearest PS5 mode for it is Balanced + val pacing = when (prefs.getString(PACING_PREF, "latency")) { "balanced", "cap-fps" -> 1; "smoothness" -> 2; else -> 0 } + try { com.metallic.chiaki.lib.decoderPacing(pacing, if (fps30) 30 else 60) } catch (e: UnsatisfiedLinkError) { } + Log.i(TAG, "frame pacing $pacing at ${if (fps30) 30 else 60} fps") + val preset = ConnectVideoProfile.preset( + resPreset, if (fps30) VideoFPSPreset.FPS_30 else VideoFPSPreset.FPS_60, + if (h264) Codec.CODEC_H264 else Codec.CODEC_H265 + ) + // Home network: Auto = 30 or 20 Mbps from this device's own link (chiaki's 1080p preset is 15), or the + // number picked in Settings, or a Custom value (2-50 Mbps). Over the internet keep the preset - the PS5 backs off by itself. + // ~30 % of the link is what a busy 2.4 GHz channel really delivers without queueing + val linkCap = if (link > 0) (link * 300).coerceIn(5000, 30000) else 30000 + fun customKbps(key: String, def: Int) = (prefs.getString("${key}_custom", def.toString())?.toIntOrNull() ?: def).coerceIn(2, 50) * 1000 + val homeSel = prefs.getString("ps5_bitrate_home", "auto") ?: "auto" + val homeKbps = when (homeSel) { + "custom" -> customKbps("ps5_bitrate_home", 20) + "auto" -> minOf(lanBitrateKbps(), linkCap) + else -> homeSel.toIntOrNull()?.times(1000) ?: minOf(lanBitrateKbps(), linkCap) + } + val awaySel = prefs.getString("away_bitrate", "auto") ?: "auto" + val awayKbps = when (awaySel) { + "custom" -> customKbps("away_bitrate", 10) + "auto" -> null + else -> awaySel.toIntOrNull()?.times(1000) + } + val profile = if (away) preset.copy(bitrate = awayKbps ?: minOf(preset.bitrate, linkCap)) else preset.copy(bitrate = homeKbps) + streamInfo = Triple(when (resPreset) { VideoResolutionPreset.RES_540P -> "540p"; VideoResolutionPreset.RES_720P -> "720p"; else -> "1080p" }, if (h264) "H.264" else "H.265", profile.bitrate / 1000) + streamRemote = remote + videoW = profile.width; videoH = profile.height + Log.i(TAG, "stream ${streamInfo.first}${profile.maxFPS} at ${profile.bitrate / 1000} Mbps (${if (remote) "PSN" else "home"}, ${if (away) "away" else "home"} profile, link ${if (link > 0) "$link Mbps" else "cable/unknown"})") + if (!remote) return ConnectInfo(ps5, host, registKey, morning, profile) + val account = intent.getStringExtra(EXTRA_ACCOUNT_ID)?.let { + try { android.util.Base64.decode(it, android.util.Base64.DEFAULT) } catch (e: Exception) { null } + } + return ConnectInfo(ps5, host, registKey, morning, profile, + psnToken = intent.getStringExtra(EXTRA_PSN_TOKEN), duid = intent.getStringExtra(EXTRA_DUID), psnAccountId = account) + } + + // chiaki's libcurl has no CA store on Android, so PSN's HTTPS calls failed ("SSL peer certificate ... was not OK"). + // Export this device's trusted roots (AndroidCAStore) as a PEM bundle; the patched lib reads MC_CA_BUNDLE. + private fun ensureCaBundle() { + try { + val f = java.io.File(filesDir, "cacert.pem") + if (!f.exists() || f.length() == 0L || System.currentTimeMillis() - f.lastModified() > 7L * 24 * 3600 * 1000) { + val ks = java.security.KeyStore.getInstance("AndroidCAStore").apply { load(null) } + val pem = StringBuilder() + for (alias in ks.aliases()) { + val cert = ks.getCertificate(alias) ?: continue + pem.append("-----BEGIN CERTIFICATE-----\n") + .append(android.util.Base64.encodeToString(cert.encoded, android.util.Base64.DEFAULT)) + .append("-----END CERTIFICATE-----\n") + } + f.writeText(pem.toString()) + } + android.system.Os.setenv("MC_CA_BUNDLE", f.absolutePath, true) + Log.i(TAG, "CA bundle for PSN: ${f.length()} bytes") + } catch (e: Exception) { Log.w(TAG, "CA bundle: $e") } + } + + // Home-network bitrate from this device's own link: 30 Mbps on Ethernet or fast 5/6 GHz Wi-Fi, 20 on 2.4 GHz / + // slow / unknown links. Measured 2026-10-16: the home Mi stick (2.4 GHz, 87 Mbps link) got ~25 Mbps at a 30 Mbps + // ask but lost 6-9 % of packets (a glitch every ~7 s), the PS5 not backing off enough. + /** The active network's capabilities; null before Android 6, where there's no active-network API (no Ethernet check). */ + private fun activeCaps(): android.net.NetworkCapabilities? { + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.M) return null + val cm = getSystemService(android.content.Context.CONNECTIVITY_SERVICE) as android.net.ConnectivityManager + return cm.getNetworkCapabilities(cm.activeNetwork) + } + + /** Wi-Fi link speed in Mbps, or 0 for Ethernet / unknown (no cap). */ + private fun linkMbps(): Int = try { + val caps = activeCaps() + if (caps != null && caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_ETHERNET)) 0 + else @Suppress("DEPRECATION") (applicationContext.getSystemService(android.content.Context.WIFI_SERVICE) as android.net.wifi.WifiManager) + .connectionInfo?.linkSpeed?.coerceAtLeast(0) ?: 0 + } catch (e: Exception) { 0 } + + private fun lanBitrateKbps(): Int { + try { + val caps = activeCaps() + if (caps != null && caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_ETHERNET)) { + Log.i(TAG, "LAN bitrate 30 Mbps (Ethernet)"); return 30000 + } + @Suppress("DEPRECATION") + val wi = (applicationContext.getSystemService(android.content.Context.WIFI_SERVICE) as android.net.wifi.WifiManager).connectionInfo + val fast = wi != null && wi.frequency >= 4900 && wi.linkSpeed >= 200 + Log.i(TAG, "LAN bitrate ${if (fast) 30 else 20} Mbps (Wi-Fi ${wi?.frequency} MHz, link ${wi?.linkSpeed} Mbps)") + return if (fast) 30000 else 20000 + } catch (e: Exception) { Log.w(TAG, "link check: $e"); return 20000 } + } + + private fun canGoRemote() = !intent.getStringExtra(EXTRA_PSN_TOKEN).isNullOrEmpty() && !intent.getStringExtra(EXTRA_DUID).isNullOrEmpty() + + // Is a PlayStation answering chiaki discovery at this address on our LAN? (Also true in standby: "620 Server Standby".) + // A different device that happens to own the same IP on another network (e.g. JAL vs home) won't answer SRCH. + private fun lanConsoleAnswers(host: String, ps5: Boolean): Boolean = try { + // Ask up to 3 times: on a lossy 2.4 GHz link ONE lost SRCH/reply sent a home session over PSN (~40-80 s instead + // of ~9 s) - seen on the home Mi stick 2026-10-16. + java.net.DatagramSocket().use { s -> + s.soTimeout = 600 + val msg = "SRCH * HTTP/1.1\ndevice-discovery-protocol-version:${if (ps5) "00030010" else "00020020"}\n".toByteArray() + val dst = java.net.InetAddress.getByName(host) + val buf = ByteArray(1024) + var ok = false + for (i in 0 until 3) { + s.send(java.net.DatagramPacket(msg, msg.size, dst, if (ps5) 9302 else 987)) + try { + val p = java.net.DatagramPacket(buf, buf.size); s.receive(p) + if (String(buf, 0, p.length).startsWith("HTTP/1.1")) { ok = true; break } + } catch (e: java.net.SocketTimeoutException) { } + } + ok + } + } catch (e: Exception) { false } + + override fun surfaceCreated(holder: SurfaceHolder) { + screenHolder = holder + // with FSR the decoder keeps its own surface: only FSR's output moves to the new screen surface + if (started) { val f = fsr; if (f != null) f.attachOutput(holder.surface) else session?.setSurface(holder.surface); return } + started = true + val surface = holder.surface + val previous = connectThread + val t = Thread({ connect(surface, previous) }, "ps5-connect") + myConnect = t + connectThread = t + t.start() + } + + private fun androidx_prefs() = android.preference.PreferenceManager.getDefaultSharedPreferences(this) + + /** This screen's connect thread (the newest screen's is [connectThread]). */ + @Volatile private var myConnect: Thread? = null + + /** Set when [homeWatch] hears the console on the home network during a PSN attempt. */ + @Volatile private var homeNow = false + + /** While a PSN attempt runs, ask the home network every few seconds; when the console answers, cancel the PSN attempt. */ + private fun homeWatch(host: String, ps5: Boolean): Thread = Thread({ + try { + while (!closed && !Thread.currentThread().isInterrupted) { + Thread.sleep(4000) + if (!closed && lanConsoleAnswers(host, ps5)) { homeNow = true; cancelPsn(); return@Thread } + } + } catch (e: InterruptedException) { } + }, "ps5-home-watch").apply { isDaemon = true; start() } + + // Wake the console from standby (harmless if already on), then connect; retry a few times while it powers up. + private fun connect(surface: android.view.Surface, previous: Thread?) { + // The last PS5 screen's attempts must be over first: one still in its PSN hole-punch would take the console + // (the PS5 then refuses this screen's stream). Its screen closed, so it's cancelled and ends within seconds. + if (previous != null && previous.isAlive) { + showStatus("Finishing the last attempt…") + cancelPsn() + try { previous.join(15000) } catch (e: InterruptedException) { } + Log.i(TAG, "previous connect " + (if (previous.isAlive) "still running after 15 s" else "ended")) + } + val host = intent.getStringExtra(EXTRA_HOST) ?: "" // every caller passes the console's host + val ps5 = intent.getBooleanExtra(EXTRA_PS5, true) + // LAN if the console answers here; otherwise over the internet via PSN (no VPN / no port-forward) when the + // phone sent a PSN token. PSN wakes the console itself when the session starts. + var remote = !lanConsoleAnswers(host, ps5) && canGoRemote() + Log.i(TAG, if (remote) "console not on this LAN -> Remote Play via PSN" else "console on LAN ($host)") + if (remote) { ensureCaBundle(); startProgressWatch() } + showStatus(if (remote) "Reaching PlayStation Network…" else "Connecting to your PlayStation…") + step(if (remote) 1 else 2) + if (!remote) wakeLan(host, ps5) + var attempt = 0 + // remote: a missed PSN start request is only fixed by a fresh attempt. Home network: a console still waking + // from rest mode refuses the session at once, so keep trying for about a minute. + // Settings, PlayStation, Decoder: how hard the device's video decoder is pushed for low latency + val latency = when (androidx_prefs().getString(DECODER_PREF, "low")) { "standard" -> 0; "ultra" -> 2; else -> 1 } + try { com.metallic.chiaki.lib.decoderLatencyMode(latency) } catch (e: UnsatisfiedLinkError) { } + Log.i(TAG, "decoder latency mode $latency") + var maxAttempts = if (remote) 4 else 20 + while (!closed && attempt < maxAttempts) { + // A console PSN woke (or one just switched on) may answer at home while we wait on PSN: switch to the home + // network then, seconds instead of a minute-long hole-punch. + val watch = if (remote) homeWatch(host, ps5) else null + try { + val s = try { Session(connectInfo(remote), null, false) } finally { watch?.interrupt() } + if (closed) { try { s.dispose() } catch (e: Exception) {}; return } // the screen closed during the hole-punch + s.eventCallback = { ev -> onEvent(ev) } + s.setSurface(decoderSurface(surface)) + // Only the first picture counts as success; a quit before that is a failed attempt -> retry. + // Remote: the data-channel hole-punch may fail. Home: the console may still be waking up. + // Armed before start() (#38): a console still waking refuses within milliseconds, often before + // start() returns, and a quit with no attempt armed closed the screen instead of retrying. + val latch = java.util.concurrent.CountDownLatch(1) + attemptQuit = false + attemptLatch = latch + val err = s.start() + if (closed) { attemptLatch = null; try { s.stop(); s.dispose() } catch (e: Exception) {}; return } // torn down mid-connect + if (err.isSuccess) { + session = s + if (!remote) step(3) + Ps5Pad.start(s) // phone drives the controller + latch.await(120, java.util.concurrent.TimeUnit.SECONDS) + attemptLatch = null + if (connected || closed) return + Log.w(TAG, "attempt ${attempt + 1}: PS5 ended the session before the picture - retrying") + Ps5Pad.stop() + session = null + synchronized(disposed) { disposed.add(s) } + try { s.stop() } catch (e: Exception) {} + synchronized(sessionLock) { try { s.dispose() } catch (e: Exception) {} } + if (inUse) { + // another device (e.g. a handheld that just moved the game here) is still letting go + inUse = false + if (inUseSince == 0L) inUseSince = System.currentTimeMillis() + if (System.currentTimeMillis() - inUseSince < IN_USE_WAIT_MS) { + showStatus("Waiting for the PS5 to finish with the other device…") + try { Thread.sleep(3000) } catch (e: InterruptedException) { break } + continue // doesn't count as an attempt + } + Log.i(TAG, "PS5 still in use by another device after ${IN_USE_WAIT_MS / 1000} s") + closed = true + showStatus("Your PS5 is already streaming to another device.\nClose it there, then try again.") + com.limelight.home.StreamReport.failed("Your PS5 is busy with another device. Close it there, then try again.") + try { Thread.sleep(6000) } catch (e: InterruptedException) { } + runOnUiThread { finish() } + return + } + attempt++ + if (attempt < maxAttempts) { + progressStage = 0 + if (remote) showStatus("Your PS5 dropped the connection.\nRetrying (attempt ${attempt + 1} of $maxAttempts)…") + else { + showStatus("Waking your PlayStation…\nThis takes up to a minute from rest mode.") + if (attempt % 4 == 0) wakeLan(host, ps5) // in case the first wake was missed + } + try { Thread.sleep(3000) } catch (e: InterruptedException) { break } + } + continue + } + attemptLatch = null + Log.w(TAG, "start attempt ${attempt + 1} failed: $err") + try { s.dispose() } catch (e: Exception) {} // always release the native session so none linger on the PS5 + } catch (e: Exception) { + attemptLatch = null + Log.w(TAG, "connect attempt ${attempt + 1}: $e") + } + if (remote && homeNow && !closed) { + Log.i(TAG, "console now answers on the home network -> switching from PSN") + remote = false; homeNow = false; maxAttempts = 20; attempt = 0 + stopProgressWatch() + showStatus("Connecting to your PlayStation…") + step(2) + continue + } + attempt++ + if (remote && attempt < maxAttempts && !closed) progressStage = 0 + if (remote && attempt < maxAttempts && !closed) + showStatus("PlayStation Network didn't answer in time.\nRetrying (attempt ${attempt + 1} of $maxAttempts)…") + try { Thread.sleep(3000) } catch (e: InterruptedException) { break } // console still waking + } + if (session == null && !closed) { + Log.e(TAG, "PS5 did not connect") + stopProgressWatch() + showStatus("Couldn't reach your PlayStation.\nCheck it's in rest mode (not off) and try again.") + com.limelight.home.StreamReport.failed("Couldn't reach the PlayStation. Check it's in rest mode (not off).") + try { Thread.sleep(4000) } catch (e: InterruptedException) { } + runOnUiThread { finish() } + } + } + + // Live status for a remote connect: the hole-punch runs in native code with no callback to us, but it logs every + // milestone. An app may read its own logcat (no permission), so follow chiaki's lines and show where we are. + @Volatile private var connected = false // first picture arrived + @Volatile private var attemptLatch: java.util.concurrent.CountDownLatch? = null // remote attempt in flight + @Volatile private var attemptQuit = false + @Volatile private var inUse = false // the last attempt ended with "Remote is already in use" + private var inUseSince = 0L + @Volatile private var progressProc: Process? = null + @Volatile private var progressStage = 0 + + private fun startProgressWatch() { + stopProgressWatch() + Thread({ + try { + val p = Runtime.getRuntime().exec(arrayOf("logcat", "-T", "1", "-v", "brief", "-s", "Chiaki:V")) + progressProc = p + p.inputStream.bufferedReader().forEachLine { line -> + if (closed || progressProc !== p) return@forEachLine + // stages only move forward within an attempt (create_offer is logged again for the data channel) + val (stage, st) = when { + "hole-punch failed" in line -> { progressStage = 0; return@forEachLine } // retry restarts + "websocket_thread_func: connecting" in line -> 1 to "Reaching PlayStation Network…" + "create_offer" in line -> 2 to + "Asking your PS5 to start Remote Play…\n(it wakes from rest mode if needed — up to a minute)" + "customdata1" in line || ("Sent response to" in line && ":9303" in line) -> 3 to "Your PS5 answered — connecting…" + "control hole punched" in line -> 4 to "Connected through PlayStation Network — signing in…" + "Login message: success" in line -> 5 to "Signed in to your PS5 — opening the video channel…" + "Punched hole for data" in line -> 6 to "Starting video…" + else -> return@forEachLine + } + if (stage > progressStage) { + progressStage = stage; showStatus(st) + step(when (stage) { 1 -> 1; 2 -> 2; 3, 4, 5 -> 3; else -> 4 }) + } + } + } catch (e: Exception) { Log.w(TAG, "progress watch: $e") } + }, "ps5-progress").start() + } + + private fun stopProgressWatch() { + val p = progressProc; progressProc = null + try { p?.destroy() } catch (e: Exception) { } + } + + private fun wakeLan(host: String, ps5: Boolean) { + try { + val cred = wakeCredential(hex(intent.getStringExtra(EXTRA_REGIST_KEY) ?: "")) ?: return + Log.i(TAG, "waking $host") + DiscoveryService.wakeup(null, host, cred, ps5) + } catch (e: Exception) { Log.w(TAG, "wake: $e") } + } + + // chiaki's wake credential = the registration key's ASCII (up to the null pad) parsed as a hex ULong. + private fun wakeCredential(registKey: ByteArray): ULong? { + val end = registKey.indexOfFirst { it == 0.toByte() }.let { if (it >= 0) it else registKey.size } + val str = String(registKey, 0, end, Charsets.UTF_8) + return try { str.toULong(16) } catch (e: NumberFormatException) { null } + } + + private fun onEvent(ev: Event) { + when (ev) { + is ConnectedEvent -> { + Log.i(TAG, "PS5 connected"); connected = true + com.limelight.home.StreamReport.playing() + stopProgressWatch(); showStatus(null); attemptLatch?.countDown() + } + is QuitEvent -> { + // The PS5 allows ONE Remote Play session: if another device (e.g. the Odin's own player) still has it, + // say so instead of closing silently - retrying can't help until that one ends. + val inUseQuit = ev.reason.value == QUIT_RP_IN_USE || ev.reasonString?.contains("in use", ignoreCase = true) == true + if (!connected && inUseQuit && attemptLatch != null) { + // Remote Play is still busy with another device: retry every 3 s for up to 30 s (connect() loop) + Log.i(TAG, "PS5 is busy with another device: waiting for it") + inUse = true + attemptQuit = true + attemptLatch?.countDown() + return + } + if (!connected && inUseQuit) { + Log.i(TAG, "PS5 is already streaming to another device") + closed = true + attemptLatch?.countDown() + stopProgressWatch() + showStatus("Your PS5 is already streaming to another device.\nClose it there, then try again.") + com.limelight.home.StreamReport.failed("Your PS5 is busy with another device. Close it there, then try again.") + Thread({ try { Thread.sleep(6000) } catch (e: InterruptedException) { }; runOnUiThread { finish() } }, "ps5-inuse").start() + return + } + val latch = attemptLatch + if (!connected && latch != null) { + // a remote attempt failed before the picture: let connect() retry it instead of closing the screen + Log.i(TAG, "PS5 quit before the picture: ${ev.reasonString}") + attemptQuit = true + latch.countDown() + return + } + // Don't dispose here: this runs on chiaki's own callback thread, and dispose()/join would deadlock. + // Just close the screen; onDestroy tears the session down on a separate thread. + Log.i(TAG, "PS5 quit: ${ev.reasonString}") + closed = true + if (!connected) { + // never got a picture: say why instead of dropping back to the home screen without a word + stopProgressWatch() + showStatus("Couldn't reach your PlayStation.\nCheck it's on or in rest mode (not fully off), then try again.") + com.limelight.home.StreamReport.failed("Couldn't reach the PlayStation. Check it's on or in rest mode (not fully off).") + Thread({ try { Thread.sleep(6000) } catch (e: InterruptedException) { }; runOnUiThread { if (!menu.isShowing) finish() } }, "ps5-noconnect").start() + return + } + runOnUiThread { finish() } + } + is RumbleEvent -> rumble(ev.left.toInt(), ev.right.toInt()) + is LoginPinRequestEvent -> { + // Settings, PlayStation, Console PIN: answer the PS5's login PIN for you (once; a wrong one isn't retried) + val pin = getSharedPreferences("mc_tv", MODE_PRIVATE).getString("ps5_login_pin", "") ?: "" + if (pin.length == 4 && !ev.pinIncorrect) { Log.i(TAG, "answering the console login PIN"); session?.setLoginPin(pin) } + else showStatus(if (ev.pinIncorrect) "The console PIN in Settings is wrong.\nFix it in Settings, PlayStation, Console PIN." + else "Your PS5 asks for its login PIN.\nSet it once in Settings, PlayStation, Console PIN.") + } + else -> {} + } + } + + // The PS5's rumble goes to the pad you're holding (the last one that sent input). Each event renews a short + // buzz at the stronger motor's level; 0 stops it, and it stops by itself if the stop never arrives. + @Volatile private var padDeviceId = -1 + private fun rumble(left: Int, right: Int) { + try { + val dev = InputDevice.getDevice(padDeviceId) ?: return + val vib = if (android.os.Build.VERSION.SDK_INT >= 31) dev.vibratorManager.defaultVibrator + else @Suppress("DEPRECATION") dev.vibrator + if (!vib.hasVibrator()) return + val strength = maxOf(left, right) + if (strength == 0) { vib.cancel(); return } + if (android.os.Build.VERSION.SDK_INT >= 26) + vib.vibrate(android.os.VibrationEffect.createOneShot(1000, strength.coerceIn(1, 255))) + else @Suppress("DEPRECATION") vib.vibrate(1000) + } catch (e: Exception) { Log.w(TAG, "rumble: $e") } + } + + // ---- the device's own controller (Odin handheld, or a pad plugged into the TV) ---- + // Until now the player only took input relayed from a phone. Map this device's gamepad straight to a DualSense + // state; same combos as the relay (an Xbox-style pad has no PS / touchpad button): L2 + Start = PS, + // Select + Start = touchpad. Sent through Ps5Pad's lock so it can never reach a session being disposed. + private val held = HashSet() + private var lx = 0f; private var ly = 0f; private var rx = 0f; private var ry = 0f + private var lt = 0f; private var rt = 0f; private var hatX = 0f; private var hatY = 0f + + private fun isPadKey(code: Int) = KeyEvent.isGamepadButton(code) || code == KEY_TOUCHPAD || code == KeyEvent.KEYCODE_DPAD_UP || + code == KeyEvent.KEYCODE_DPAD_DOWN || code == KeyEvent.KEYCODE_DPAD_LEFT || code == KeyEvent.KEYCODE_DPAD_RIGHT + + // A DualSense / DualShock 4 on an old kernel (no hid-playstation driver, e.g. the JAL AQUOS on 4.9) comes through + // Android's generic layout: every button is shifted (Square = BUTTON_A, Cross = BUTTON_B, Circle = BUTTON_C, ...) + // and the analog L2/R2 are on RX/RY. Same detection as Moonlight's ControllerHandler: Sony + a BUTTON_C key. + private val oddSony = HashMap() + private fun isOddSony(deviceId: Int): Boolean = oddSony.getOrPut(deviceId) { + val d = InputDevice.getDevice(deviceId) + d != null && d.vendorId == 0x054c && d.hasKeys(KeyEvent.KEYCODE_BUTTON_C)[0] + } + private fun oddSonyKey(scan: Int): Int? = when (scan) { + 304 -> KeyEvent.KEYCODE_BUTTON_X // Square + 305 -> KeyEvent.KEYCODE_BUTTON_A // Cross + 306 -> KeyEvent.KEYCODE_BUTTON_B // Circle + 307 -> KeyEvent.KEYCODE_BUTTON_Y // Triangle + 308 -> KeyEvent.KEYCODE_BUTTON_L1 + 309 -> KeyEvent.KEYCODE_BUTTON_R1 + 310 -> KeyEvent.KEYCODE_BUTTON_L2 // the click; the analog value comes from RX + 311 -> KeyEvent.KEYCODE_BUTTON_R2 + 312 -> KeyEvent.KEYCODE_BUTTON_SELECT // Create + 313 -> KeyEvent.KEYCODE_BUTTON_START // Options + 314 -> KeyEvent.KEYCODE_BUTTON_THUMBL + 315 -> KeyEvent.KEYCODE_BUTTON_THUMBR + 316 -> KeyEvent.KEYCODE_BUTTON_MODE // PS + 317 -> KEY_TOUCHPAD // the real touchpad click + else -> null + } + + private var backPressedClosed = false + + override fun dispatchKeyEvent(raw: KeyEvent): Boolean { + val e = if (isOddSony(raw.deviceId)) oddSonyKey(raw.scanCode)?.let { + KeyEvent(raw.downTime, raw.eventTime, raw.action, it, raw.repeatCount, raw.metaState, raw.deviceId, raw.scanCode, raw.flags, raw.source) + } ?: raw else raw + val code = e.keyCode + val fromPad = (e.source and InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD || + (e.source and InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK + if (menu.isShowing) { backPressedClosed = false; return menu.onKey(e) } + if (fromPad) padDeviceId = e.deviceId + // the system Back (Odin's back key / a TV remote) opens the Quick menu; the pad's B button is Circle + if (code == KeyEvent.KEYCODE_BACK && !fromPad) { + // #57: open only on a release whose press came while the menu was closed (Back closing the menu + // on its press used to reopen it on the release) + if (e.action == KeyEvent.ACTION_DOWN && e.repeatCount == 0) backPressedClosed = true + else if (e.action == KeyEvent.ACTION_UP) { if (backPressedClosed) openMenu(); backPressedClosed = false } + return true + } + if (fromPad && e.deviceId in mutedPads) return true + if (dpadTouch && isPadKey(code) && held.contains(KeyEvent.KEYCODE_BUTTON_SELECT) && e.action == KeyEvent.ACTION_DOWN && e.repeatCount == 0) { + when (code) { + KeyEvent.KEYCODE_DPAD_LEFT -> { swipe(-600, 0); return true } + KeyEvent.KEYCODE_DPAD_RIGHT -> { swipe(600, 0); return true } + KeyEvent.KEYCODE_DPAD_UP -> { swipe(0, -350); return true } + KeyEvent.KEYCODE_DPAD_DOWN -> { swipe(0, 350); return true } + } + } + if (isPadKey(code)) { + if (e.repeatCount == 0) { + if (e.action == KeyEvent.ACTION_DOWN) held.add(code) else if (e.action == KeyEvent.ACTION_UP) held.remove(code) + // Select + Start + LB + RB opens the Quick menu once all four are let go; the game never sees it + if (held.containsAll(MENU_COMBO)) { comboPending = true; Ps5Pad.pushLocal(ControllerState()) } + if (comboPending) { if (held.isEmpty()) { comboPending = false; openMenu() }; return true } + sendLocal() + } + return true + } + return super.dispatchKeyEvent(e) + } + + // A DualSense / DualShock 4 on a newer kernel (hid-playstation: Pixel, Retroid, Odin) shows its touchpad as a + // separate input device, a small mouse/touchpad: its click is a mouse button, not a gamepad key, so it never + // reached the PS5. Only a Sony touchpad counts, so a real mouse or the screen can't press it. + private val sonyTouchpad = HashMap() + private fun isSonyTouchpad(deviceId: Int): Boolean = sonyTouchpad.getOrPut(deviceId) { + val d = InputDevice.getDevice(deviceId) + d != null && d.vendorId == 0x054c && + (d.supportsSource(InputDevice.SOURCE_TOUCHPAD) || d.supportsSource(InputDevice.SOURCE_MOUSE)) + } + /** The touchpad's click, from either way Android reports it. True when it was the Sony touchpad. */ + private fun touchpadClick(e: MotionEvent): Boolean { + if (!isSonyTouchpad(e.deviceId)) return false + val down = when (e.actionMasked) { + MotionEvent.ACTION_BUTTON_PRESS -> true + MotionEvent.ACTION_BUTTON_RELEASE -> false + MotionEvent.ACTION_DOWN -> e.buttonState != 0 || (e.source and InputDevice.SOURCE_MOUSE) == InputDevice.SOURCE_MOUSE + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> false + else -> return true // finger moves on the pad: nothing yet, and don't let them move a pointer + } + if (down == held.contains(KEY_TOUCHPAD)) return true + if (down) held.add(KEY_TOUCHPAD) else held.remove(KEY_TOUCHPAD) + sendLocal() + return true + } + + override fun dispatchTouchEvent(e: MotionEvent): Boolean = touchpadClick(e) || super.dispatchTouchEvent(e) + + override fun dispatchGenericMotionEvent(e: MotionEvent): Boolean { + if (menu.isShowing) return menu.onMotion(e) + if (e.deviceId in mutedPads) return true + if (touchpadClick(e)) return true + if ((e.source and InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK && e.action == MotionEvent.ACTION_MOVE) { + padDeviceId = e.deviceId + fun dz(v: Float) = if (Math.abs(v) < 0.08f) 0f else v + lx = dz(e.getAxisValue(MotionEvent.AXIS_X)); ly = dz(e.getAxisValue(MotionEvent.AXIS_Y)) + rx = dz(e.getAxisValue(MotionEvent.AXIS_Z)); ry = dz(e.getAxisValue(MotionEvent.AXIS_RZ)) + if (isOddSony(e.deviceId)) { + // generic layout: L2/R2 on RX/RY, scaled -1 (released) .. 1 (pressed) + lt = (e.getAxisValue(MotionEvent.AXIS_RX) + 1f) / 2f + rt = (e.getAxisValue(MotionEvent.AXIS_RY) + 1f) / 2f + } else { + lt = maxOf(e.getAxisValue(MotionEvent.AXIS_LTRIGGER), e.getAxisValue(MotionEvent.AXIS_BRAKE)) + rt = maxOf(e.getAxisValue(MotionEvent.AXIS_RTRIGGER), e.getAxisValue(MotionEvent.AXIS_GAS)) + } + hatX = e.getAxisValue(MotionEvent.AXIS_HAT_X); hatY = e.getAxisValue(MotionEvent.AXIS_HAT_Y) + sendLocal() + return true + } + return super.dispatchGenericMotionEvent(e) + } + + // Settings, PlayStation, "D-pad as touchpad": hold Select and press a direction to swipe on the touchpad. + private val dpadTouch by lazy { + android.preference.PreferenceManager.getDefaultSharedPreferences(this).getBoolean("ps5_dpad_touchpad", false) + } + @Volatile private var touch: ControllerTouch? = null + private fun swipe(dx: Int, dy: Int) { + val h = android.os.Handler(mainLooper) + val cx = 960; val cy = 471 // the touchpad is 1920 x 942 + for (i in 0..5) h.postDelayed({ + touch = ControllerTouch((cx + dx * i / 5).toUShort(), (cy + dy * i / 5).toUShort(), 0); sendLocal() + }, i * 25L) + h.postDelayed({ touch = null; sendLocal() }, 170) + } + + // Quick menu, Controllers: pads you mute are ignored (the PS5 takes one controller; everyone shares player 1). + private val mutedPads = HashSet() + + private fun sendLocal() { + fun h(k: Int) = held.contains(k) + val l2 = maxOf(lt, if (h(KeyEvent.KEYCODE_BUTTON_L2)) 1f else 0f) + val r2 = maxOf(rt, if (h(KeyEvent.KEYCODE_BUTTON_R2)) 1f else 0f) + val select = h(KeyEvent.KEYCODE_BUTTON_SELECT); val start = h(KeyEvent.KEYCODE_BUTTON_START) + val psCombo = l2 > .5f && start + val tpCombo = select && start && !psCombo + var f = 0u + if (h(KeyEvent.KEYCODE_BUTTON_A)) f = f or (if (swapCrossCircle) ControllerState.BUTTON_MOON else ControllerState.BUTTON_CROSS) + if (h(KeyEvent.KEYCODE_BUTTON_B)) f = f or (if (swapCrossCircle) ControllerState.BUTTON_CROSS else ControllerState.BUTTON_MOON) + if (h(KeyEvent.KEYCODE_BUTTON_X)) f = f or ControllerState.BUTTON_BOX + if (h(KeyEvent.KEYCODE_BUTTON_Y)) f = f or ControllerState.BUTTON_PYRAMID + if (h(KeyEvent.KEYCODE_BUTTON_L1)) f = f or ControllerState.BUTTON_L1 + if (h(KeyEvent.KEYCODE_BUTTON_R1)) f = f or ControllerState.BUTTON_R1 + if (h(KeyEvent.KEYCODE_BUTTON_THUMBL)) f = f or ControllerState.BUTTON_L3 + if (h(KeyEvent.KEYCODE_BUTTON_THUMBR)) f = f or ControllerState.BUTTON_R3 + if (select && !tpCombo) f = f or ControllerState.BUTTON_SHARE + if (start && !psCombo && !tpCombo) f = f or ControllerState.BUTTON_OPTIONS + if (h(KeyEvent.KEYCODE_BUTTON_MODE) || psCombo) f = f or ControllerState.BUTTON_PS + if (tpCombo || h(KEY_TOUCHPAD)) f = f or ControllerState.BUTTON_TOUCHPAD + if (h(KeyEvent.KEYCODE_DPAD_UP) || hatY < -.5f) f = f or ControllerState.BUTTON_DPAD_UP + if (h(KeyEvent.KEYCODE_DPAD_DOWN) || hatY > .5f) f = f or ControllerState.BUTTON_DPAD_DOWN + if (h(KeyEvent.KEYCODE_DPAD_LEFT) || hatX < -.5f) f = f or ControllerState.BUTTON_DPAD_LEFT + if (h(KeyEvent.KEYCODE_DPAD_RIGHT) || hatX > .5f) f = f or ControllerState.BUTTON_DPAD_RIGHT + fun ax(v: Float): Short = (Math.round(v * 32767f).coerceIn(-32767, 32767)).toShort() + fun trig(v: Float) = (v.coerceIn(0f, 1f) * 255).toInt().toUByte() + val st = touch + Ps5Pad.pushLocal(ControllerState( + touches = if (st != null) arrayOf(st, ControllerTouch()) else arrayOf(ControllerTouch(), ControllerTouch()), + buttons = f, + l2State = trig(if (psCombo) 0f else l2), r2State = trig(r2), + leftX = ax(lx), leftY = ax(ly), rightX = ax(rx), rightY = ax(ry))) + } + + // ---- Quick menu ---- + private val menu by lazy { com.limelight.home.QuickMenu(this) } + private var comboPending = false + @Volatile private var streamInfo = Triple("1080p", "H.265", 0) + + // ---- Super resolution (FSR 1.0): the decoder renders into FSR's surface, FSR upscales to the screen's + @Volatile private var fsr: com.limelight.binding.video.FsrRenderer? = null + @Volatile private var screenHolder: SurfaceHolder? = null + @Volatile private var videoW = 1920 + @Volatile private var videoH = 1080 + + /** The surface the decoder renders into: the screen's, or FSR's when it's on and the stream is smaller than the screen. */ + private fun decoderSurface(screen: android.view.Surface): android.view.Surface { + val prefs = android.preference.PreferenceManager.getDefaultSharedPreferences(this) + if (prefs.getString(com.limelight.binding.video.FsrRenderer.PREF, "off") != "fsr1") return screen + val outH = screenHolder?.surfaceFrame?.height() ?: 0 + if (!com.limelight.binding.video.FsrRenderer.worthIt(videoH, outH)) { + Log.i(TAG, "FSR not needed: stream ${videoW}x$videoH, screen ${screenHolder?.surfaceFrame?.width()}x$outH") + return screen + } + fsr?.let { return it.inputSurface } + return try { + val sharp = prefs.getInt(com.limelight.binding.video.FsrRenderer.SHARPNESS_PREF, com.limelight.binding.video.FsrRenderer.DEFAULT_SHARPNESS) + val f = com.limelight.binding.video.FsrRenderer(videoW, videoH, sharp / 100f) + f.attachOutput(screen) + fsr = f + Log.i(TAG, "FSR on: ${videoW}x$videoH to ${screenHolder?.surfaceFrame?.width()}x$outH, sharpening $sharp%") + f.inputSurface + } catch (e: Exception) { + Log.w(TAG, "FSR unavailable, showing the stream directly: $e") + screen + } + } + @Volatile private var streamRemote = false + private val startedAt = System.currentTimeMillis() + private val swapCrossCircle by lazy { + android.preference.PreferenceManager.getDefaultSharedPreferences(this).getBoolean("ps5_swap_cross_circle", false) + } + + // Frame counters at the previous Quick menu poll, to turn chiaki's running totals into a rate. + private var lastShown = -1.0 + private var lastLost = 0.0 + private var lastAt = 0L + private var lastLossPct = 0.0 + + /** The Quick menu's four numbers, polled every second while it's open: frame rate, delay, bitrate, lost packets. */ + private fun liveStats(): List> { + val v = session?.stats() + if (v == null || v.size < 6) { + val min = (System.currentTimeMillis() - startedAt) / 60000 + return listOf(streamInfo.first to "Picture", streamInfo.second to "Video", + (if (streamInfo.third > 0) "${streamInfo.third} Mbps" else "Auto") to "Quality", "$min min" to "Playing for") + } + val now = System.currentTimeMillis() + val fps = if (lastShown >= 0 && now > lastAt) (v[4] - lastShown) * 1000.0 / (now - lastAt) else -1.0 + if (lastShown >= 0 && v[4] + v[5] > lastShown + lastLost) + lastLossPct = 100.0 * (v[5] - lastLost) / (v[4] + v[5] - lastShown - lastLost) + lastShown = v[4]; lastLost = v[5]; lastAt = now + val decodeMs = if (v.size > 6) v[6] else 0.0 + Log.d(TAG, "stats mbps=${v[0]} loss=${v[1]} rtt=${v[2]} rtt0=${v[3]} shown=${v[4]} lost=${v[5]} decode=${"%.2f".format(java.util.Locale.US, decodeMs)}ms") + val loss = maxOf(v[1] * 100.0, lastLossPct) + val rtt = if (v[3] > 1.0) v[3] else 0.0 // chiaki uses 1 ms when it couldn't measure + return listOf( + (if (fps < 0) "…" else "%.0f".format(java.util.Locale.US, minOf(fps, 60.0))) to "Frames per second", + (if (rtt > 0) "%.0f ms".format(java.util.Locale.US, rtt) else "…") to "Network delay", + (if (v[0] > 0) "%.1f Mbps".format(java.util.Locale.US, v[0]) else "…") to "Picture quality", + "%.1f%%".format(java.util.Locale.US, loss) to "Lost packets", + (if (decodeMs > 0) "%.1f ms".format(java.util.Locale.US, decodeMs) else "…") to "Decode time") + } + + private fun openMenu() { + lastShown = -1.0; lastLossPct = 0.0 + if (isFinishing) return + // handheld only: hand the PS5 over to the last MoonCast TV + val move = com.limelight.home.MoveToTv.item(this, com.limelight.home.MoveToTv.Target("ps", "PlayStation 5", + psHost = intent.getStringExtra(EXTRA_HOST), registKey = intent.getStringExtra(EXTRA_REGIST_KEY), + morning = intent.getStringExtra(EXTRA_MORNING), isPs5 = intent.getBooleanExtra(EXTRA_PS5, true))) + menu.show("PlayStation 5", + (if (streamRemote) "Over PlayStation Network. " else "On your home network. ") + com.limelight.home.QuickMenu.COMBO_LABEL + " opens this menu", + { liveStats() }, + listOfNotNull( + com.limelight.home.QuickMenu.Item("PS", "PS button", "L2 + Start", false) { tap(ControllerState.BUTTON_PS) }, + com.limelight.home.QuickMenu.Item("▢", "Touchpad", "Select + Start", false) { tap(ControllerState.BUTTON_TOUCHPAD) }, + com.limelight.home.QuickMenu.Item("ⓘ", "Streaming status", if (com.limelight.home.StatusCorner.enabled(this)) "On" else "Off", false) { corner.toggle() }.asToggle(), + com.limelight.home.QuickMenu.Item("✚", "Controllers", "${com.limelight.home.QuickMenu.pads().size} connected", false) { controllers() }, + move, + com.limelight.home.QuickMenu.Item("←", "Disconnect", disconnectHint(), false) { leave() }, + com.limelight.home.QuickMenu.Item("Zz", "Put PS5 to rest", "Ends the game", true) { rest() })) + } + + // What leaving does: Settings, PlayStation, "When I disconnect" (ask / rest / keep). + private fun onDisconnect() = android.preference.PreferenceManager.getDefaultSharedPreferences(this).getString("ps5_on_disconnect", "ask") + private fun disconnectHint() = when (onDisconnect()) { "rest" -> "Puts it in rest mode"; "keep" -> "Your PS5 stays on"; else -> "Asks what to do" } + + private fun leave() { + when (onDisconnect()) { + "rest" -> rest() + "keep" -> finish() + else -> menu.show("Leave your PS5?", "Rest mode saves power. Keeping it on gets you back in faster.", { emptyList() }, listOf( + com.limelight.home.QuickMenu.Item("Zz", "Put it in rest mode", "Uses less power", false) { rest() }, + com.limelight.home.QuickMenu.Item("←", "Keep it on", "Back in faster", false) { finish() }, + com.limelight.home.QuickMenu.Item("↩", "Keep playing", null, false) { })) + } + } + + /** Rest mode (chiaki's goto-bed), then leave. Give the message a moment to go out before the session stops. */ + private fun rest() { + val s = session + if (s == null) { finish(); return } + showStatus("Putting your PS5 in rest mode…") + Thread({ + val r = try { s.gotoBed() } catch (e: Throwable) { Log.w(TAG, "goto bed: $e"); null } + Log.i(TAG, "rest mode requested: $r") + try { Thread.sleep(800) } catch (e: InterruptedException) { } + runOnUiThread { finish() } + }, "ps5-rest").start() + } + + private fun controllers() { + val pads = com.limelight.home.QuickMenu.pads() + menu.show("Controllers", "The PS5 takes one controller, so every pad is player 1. Mute one to stop it interfering.", + { emptyList() }, + pads.map { d -> + val muted = d.id in mutedPads + com.limelight.home.QuickMenu.Item(if (muted) "–" else "1", d.name, + (if (muted) "Muted" else "On") + (com.limelight.home.QuickMenu.battery(d)?.let { ", $it" } ?: ""), false) { + if (muted) mutedPads.remove(d.id) else { mutedPads.add(d.id); held.clear(); sendLocal() } + controllers() + }.asToggle() + } + listOf(com.limelight.home.QuickMenu.Item("+", "Pair a controller", "Bluetooth settings", false) { + com.limelight.home.QuickMenu.pairController(this) })) + } + + /** Press and release one button, for the buttons a pad doesn't have. */ + private fun tap(button: UInt) { + Ps5Pad.pushLocal(ControllerState(buttons = button)) + android.os.Handler(mainLooper).postDelayed({ Ps5Pad.pushLocal(ControllerState()) }, 150) + } + + private fun immersive() { + @Suppress("DEPRECATION") + window.decorView.systemUiVisibility = (android.view.View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or + android.view.View.SYSTEM_UI_FLAG_FULLSCREEN or android.view.View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or + android.view.View.SYSTEM_UI_FLAG_LAYOUT_STABLE or android.view.View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or + android.view.View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION) + } + + // Leaving the player ends the PS5 session. Otherwise it keeps streaming in the background (Odin: switching back to + // MoonCast) and the PS5 - one Remote Play session at a time - refuses the next device with "Remote is already in use". + override fun onStop() { + super.onStop() + if (!isChangingConfigurations && !isFinishing) finish() + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (hasFocus) immersive() // full screen on a handheld (Odin): no status / nav bars over the game + } + + override fun surfaceChanged(h: SurfaceHolder, f: Int, w: Int, ht: Int) {} + + /** + * Holds the video at 16:9 by Settings, Video, Picture shape and Zoom (shared with the PC and Xbox players): Fit + * keeps the shape with bars, Fill covers the screen and crops, Stretch fills it ignoring the shape. + */ + private class ShapeBox(ctx: android.content.Context) : android.widget.FrameLayout(ctx) { + private val prefs = android.preference.PreferenceManager.getDefaultSharedPreferences(ctx) + override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { + val c = getChildAt(0) ?: return + val w = r - l; val h = b - t + val shape = prefs.getString("list_video_scale_mode", if (prefs.getBoolean("checkbox_stretch_video", false)) "stretch" else "fit") + val zoom = prefs.getInt("seekbar_video_zoom", 100) / 100f + var vw = w.toFloat(); var vh = h.toFloat() + if (shape != "stretch" && w > 0 && h > 0) { + val ratio = 16f / 9f + val wide = w.toFloat() / h > ratio + // Fit: the whole picture inside; Fill: the screen covered, the overflow cut off + if (wide == (shape == "fill")) { vw = w.toFloat(); vh = w / ratio } else { vh = h.toFloat(); vw = h * ratio } + } + vw *= zoom; vh *= zoom + val cw = Math.round(vw); val ch = Math.round(vh) + if (c.measuredWidth != cw || c.measuredHeight != ch) + c.measure(MeasureSpec.makeMeasureSpec(cw, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(ch, MeasureSpec.EXACTLY)) + val x = (w - cw) / 2; val y = (h - ch) / 2 + c.layout(x, y, x + cw, y + ch) + } + } + // The surface is gone once this returns, so the decoder has to let go of it first (#37): a decoder still drawing + // into a dead surface fails, and chiaki then crashed copying a frame into a missing buffer. The release runs on a + // worker (it takes chiaki's decoder lock) and the wait is bounded, so a stuck session can't freeze the app. + override fun surfaceDestroyed(holder: SurfaceHolder) { + fsr?.detachOutput() // FSR stops drawing to the screen surface before it goes; the decoder keeps FSR's + if (closed) { + if (session != null && teardownThread == null) teardown(null) // the PS5 quit: tear down now, not in onDestroy + try { teardownThread?.join(SURFACE_RELEASE_WAIT_MS) } catch (e: InterruptedException) { } + return + } + val s = session ?: return + if (fsr != null) return // the decoder draws into FSR's surface, which stays + val t = Thread({ synchronized(sessionLock) { if (!closed && synchronized(disposed) { s !in disposed }) try { s.setSurface(null) } catch (e: Exception) { } } }, + "ps5-surface") + t.start() + try { t.join(SURFACE_RELEASE_WAIT_MS) } catch (e: InterruptedException) { } + } + + @Volatile private var teardownThread: Thread? = null + + private val sessionLock = Any() + private val disposed = java.util.Collections.newSetFromMap(java.util.WeakHashMap()) + + /** + * End the session off the UI thread (Session.dispose() joins native threads and can block). stop() first, without + * the lock, so a surface call stuck on the decoder lock gets released; dispose() under the lock, so no surface + * call can run on a freed session. [done] runs on the main thread afterwards. + */ + private fun teardown(done: Runnable?) { + closed = true + // a PSN hole-punch in flight stops now, not after its timeouts; only while this screen's attempt is the newest + // (a late teardown must not cancel the next screen's attempt; that screen cancels this one itself) + if (myConnect != null && myConnect === connectThread) cancelPsn() + attemptLatch?.countDown() // release a remote attempt that's waiting for its first picture + stopProgressWatch() + val s = session + session = null + val t = Thread({ + Ps5Pad.stop() + if (s != null) { + // release the decoder first, while the surface still exists (#37) + synchronized(sessionLock) { try { s.setSurface(null) } catch (e: Exception) { } } + synchronized(disposed) { disposed.add(s) } + try { s.stop() } catch (e: Exception) { } + synchronized(sessionLock) { try { s.dispose() } catch (e: Exception) { } } + } + // after the decoder has let go of its surface + fsr?.release(); fsr = null + if (done != null) android.os.Handler(android.os.Looper.getMainLooper()).post(done) + }, "ps5-teardown") + teardownThread = t + t.start() + } + + /** "Move to TV": the PS5 takes one Remote Play client, so this one must be gone before the TV connects. */ + override fun endLocalSession(done: Runnable) { + showStatus("Handing your PS5 over to the TV…") + if (closed && session == null) { done.run(); return } + teardown(done) + } + + override fun onDestroy() { + corner.stop() + if (!closed || session != null) teardown(null) + super.onDestroy() + } +} diff --git a/app/src/main/java/com/limelight/ShortcutTrampoline.java b/app/src/main/java/com/limelight/ShortcutTrampoline.java index 6157379eca..0ba884c118 100644 --- a/app/src/main/java/com/limelight/ShortcutTrampoline.java +++ b/app/src/main/java/com/limelight/ShortcutTrampoline.java @@ -164,18 +164,13 @@ public void run() { // Close this activity finish(); - // Add the PC view at the back (and clear the task) - Intent i; - i = new Intent(ShortcutTrampoline.this, PcView.class); - i.setAction(Intent.ACTION_MAIN); + // MoonCast: no game named, so open MoonCast's home (it lists this PC's + // games), not Moonlight's PC list and game list + Intent i = getPackageManager().getLaunchIntentForPackage(getPackageName()); + if (i == null) i = new Intent(ShortcutTrampoline.this, PcView.class); i.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_NEW_TASK); intentStack.add(i); - // Take this intent's data and create an intent to start the app view - i = new Intent(getIntent()); - i.setClass(ShortcutTrampoline.this, AppView.class); - intentStack.add(i); - // If a game is running, we'll make the stream the top level activity if (details.runningGameId != 0) { intentStack.add(ServerHelper.createStartIntent(ShortcutTrampoline.this, diff --git a/app/src/main/java/com/limelight/XboxStreamActivity.kt b/app/src/main/java/com/limelight/XboxStreamActivity.kt new file mode 100644 index 0000000000..0f2fe6d0b4 --- /dev/null +++ b/app/src/main/java/com/limelight/XboxStreamActivity.kt @@ -0,0 +1,524 @@ +package com.limelight + +import android.app.Activity +import android.os.Bundle +import android.util.Log +import android.view.Gravity +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.View +import android.view.WindowManager +import android.widget.FrameLayout +import android.widget.TextView +import com.limelight.home.QuickMenu +import com.limelight.home.Xbox +import com.limelight.xbox.XboxRtc +import org.webrtc.EglBase +import org.webrtc.RendererCommon +import org.webrtc.SurfaceViewRenderer + +/** + * Xbox Remote Play on this TV over Google's WebRTC ([XboxRtc]): hardware decoding, Google's jitter buffer and + * congestion control, the same stack XStreaming's player uses. This activity signs the session in + * ([Xbox.streamAuth]), wakes the console, draws the picture and turns this device's controllers into Xbox pads. + * EXTRA_CONSOLE_ID: the console's id from Xbox.consoles(); EXTRA_NAME: its name, for the screen. + */ +class XboxStreamActivity : Activity(), com.limelight.home.MoveToTv.LocalSession { + /** + * A controller on another device (the handheld that sent this game to this TV). The TV app provides it; it + * calls the sink with W3C Standard Gamepad state: 17 buttons (0..1) and 4 axes (-1..1, Y down-positive). + */ + interface RemotePad { + fun start(sink: (buttons: DoubleArray, axes: DoubleArray) -> Unit) + fun stop() + } + + companion object { + private const val TAG = "MoonCast-Xbox" + const val EXTRA_CONSOLE_ID = "xbox_console_id" + const val EXTRA_NAME = "xbox_name" + /** Set when a phone/handheld sent this game to a TV: its controller arrives over the network (see [RemotePad]). */ + const val EXTRA_REMOTE_PAD = "xbox_remote_pad" + + @JvmStatic var remotePad: RemotePad? = null + + private val W3C_KEYS = intArrayOf(KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_BUTTON_B, KeyEvent.KEYCODE_BUTTON_X, + KeyEvent.KEYCODE_BUTTON_Y, KeyEvent.KEYCODE_BUTTON_L1, KeyEvent.KEYCODE_BUTTON_R1, 0, 0, + KeyEvent.KEYCODE_BUTTON_SELECT, KeyEvent.KEYCODE_BUTTON_START, KeyEvent.KEYCODE_BUTTON_THUMBL, + KeyEvent.KEYCODE_BUTTON_THUMBR, KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN, + KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_BUTTON_MODE) + + private val MENU_COMBO = setOf(KeyEvent.KEYCODE_BUTTON_SELECT, KeyEvent.KEYCODE_BUTTON_START, + KeyEvent.KEYCODE_BUTTON_L1, KeyEvent.KEYCODE_BUTTON_R1) + } + + private val egl by lazy { EglBase.create() } + private var renderer: SurfaceViewRenderer? = null + private var rtc: XboxRtc? = null + private var status: TextView? = null + @Volatile private var closed = false + private var started = false // the stream is up: pad input goes to the Xbox + @Volatile private var firstFrame = false + private val startedAt = System.currentTimeMillis() + private val consoleId by lazy { intent.getStringExtra(EXTRA_CONSOLE_ID) ?: "" } + private val consoleName by lazy { intent.getStringExtra(EXTRA_NAME) ?: "Xbox" } + + override fun onCreate(b: Bundle?) { + super.onCreate(b) + com.limelight.home.Usage.used(this, "xbox") // for the next anonymous check-in, if the user said yes + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + // The decoder rules the Xbox decoder borrows (MediaCodecHelper) need this first, as in Moonlight's Game + com.limelight.binding.video.MediaCodecHelper.initialize(this, + com.limelight.preferences.GlPreferences.readPreferences(this).glRenderer) + val r = SurfaceViewRenderer(this).also { renderer = it } + // Super resolution (FSR 1.0): WebRTC's renderer draws each frame through FSR's drawer. The surface then keeps + // the view's real size (no hardware scaler, which would shrink it to the frame's) so FSR has room to upscale + val fsrOn = com.limelight.binding.video.FsrRenderer.enabled(prefs) + r.init(egl.eglBaseContext, object : RendererCommon.RendererEvents { + override fun onFirstFrameRendered() { firstFrame = true; main.removeCallbacks(pictureWatchdog); showStatus(null); com.limelight.home.StreamReport.playing() } + override fun onFrameResolutionChanged(w: Int, h: Int, rotation: Int) { + Log.i(TAG, "picture ${w}x$h"); runOnUiThread { applyShape(w, h) } + } + }, org.webrtc.EglBase.CONFIG_PLAIN, + if (fsrOn) com.limelight.binding.video.FsrGlDrawer(com.limelight.binding.video.FsrRenderer.sharpness(prefs)) else org.webrtc.GlRectDrawer()) + r.setEnableHardwareScaler(!fsrOn) + if (fsrOn) Log.i(TAG, "FSR drawer on") + applyShape(0, 0) + status = TextView(this).apply { + setTextColor(0xFFE8ECFF.toInt()); textSize = 22f; gravity = Gravity.CENTER + text = "Connecting to $consoleName…" + } + setContentView(FrameLayout(this).apply { + setBackgroundColor(0xFF000000.toInt()) + addView(r, FrameLayout.LayoutParams(-1, -1, Gravity.CENTER)) + addView(status, FrameLayout.LayoutParams(-1, -1)) + }) + corner.apply() + main.postDelayed(pictureWatchdog, 60_000) + Thread { connect() }.start() + } + + private fun showStatus(t: String?) = runOnUiThread { + status?.let { if (t == null) it.visibility = View.GONE else { it.text = t; it.visibility = View.VISIBLE } } + } + + /** Sign in, wake the console, and start the WebRTC session. Runs off the UI thread. */ + private fun connect() { + try { + showStatus("Waking $consoleName…") + // Harmless if it's already on; needs the console's "Sleep" power mode and remote features. + try { Xbox.wake(this, consoleId) } catch (e: Exception) { Log.w(TAG, "wake: $e") } + showStatus("Signing in…") + val auth = Xbox.streamAuth(this) + if (closed) return + // Settings, Xbox, Picture. No bitrate is sent; the Xbox picks quality from the device type this implies: + // 720 = Android, 1080 = Windows, 1081 = Tizen. Auto: 1080, or 720 on a weak link, like the PS5. + val pick = prefs.getString("xbox_resolution", "auto") + // Auto also follows the screen: a 1440p-or-larger panel asks for the high-quality profile, like Native + val res = when (pick) { "720" -> 720; "1080" -> 1080; "1081" -> 1081 + else -> if (linkMbps() in 1..59) 720 else if (screenHeight() >= 1440) 1081 else 1080 } + Log.i(TAG, "stream $consoleName at ${res}p via ${auth.baseUri}") + val session = XboxRtc(this, egl, consoleId, auth.baseUri, auth.gsToken, auth.connectToken, res, + prefs.getString("xbox_language", "en-US") ?: "en-US", coop, prefs.getInt("xbox_bitrate", 0), + prefs.getBoolean("checkbox_ultra_low_latency", false), renderer!!, object : XboxRtc.Listener { + override fun onStatus(text: String) { if (!firstFrame && !retrying) showStatus(text) } + override fun onConnected() { runOnUiThread { if (!started) { started = true; send(); startStatsPoll(); startRemotePad() } } } + override fun onFailed(reason: String) { failed(reason) } + override fun onRumble(gamepad: Int, weak: Double, strong: Double, durationMs: Int) { + runOnUiThread { rumble(gamepad, durationMs, weak, strong) } + } + }) + rtc = session + if (closed) { session.stop(); return } + session.start() + } catch (e: Exception) { + Log.w(TAG, "connect failed", e) + if (!Xbox.signedIn(this)) { showStatus("Sign in to Xbox first: Settings, Xbox."); com.limelight.home.StreamReport.failed("Sign in to Xbox on this TV first.") } + else failed(e.message ?: e.javaClass.simpleName) + } + } + + // ---- a console that isn't ready yet: wake it and try again ---- + // Microsoft answers "Xccs : ErrorCallingWNS : Send command failed : State WaitingForServerToRegister" (and similar) + // while the console is in standby and hasn't registered with Xbox network's streaming service yet. + private var firstFailAt = 0L + private var pollingStats = false + private val main by lazy { android.os.Handler(mainLooper) } + /** A start that never reaches the picture (the console accepts the session but sends nothing): end it, say so. */ + private val pictureWatchdog = Runnable { + if (!firstFrame && !closed && !retrying) endSession("Your Xbox didn't start the picture in time. Try again, or turn the Xbox off and on.") + } + + /** Stop the console session (so it isn't left running) and show the plain-language reason. */ + private fun endSession(msg: String) { + main.removeCallbacks(pictureWatchdog) + started = false + Thread({ try { rtc?.stop() } catch (e: Throwable) { }; rtc = null }, "xbox-end").start() + showStatus(msg) + com.limelight.home.StreamReport.failed(msg) + } + @Volatile private var retrying = false + + private fun notReady(reason: String): Boolean { + val r = reason.lowercase() + return listOf("waitingforservertoregister", "errorcallingwns", "xccs", "connectedstandby", "not registered", + "offline", "unavailable", "notready", "not ready").any { r.contains(it) } + } + + /** The session failed: before the first picture, retry a console that isn't ready (wake every 5 s, ~60 s). */ + private fun failed(reason: String) { + if (closed) return + Log.w(TAG, "xbox failed: $reason") + val now = System.currentTimeMillis() + if (firstFailAt == 0L) firstFailAt = now + if (!firstFrame && notReady(reason) && now - firstFailAt < 60_000) { + main.removeCallbacks(pictureWatchdog) + if (retrying) return + retrying = true + showStatus("Waking your Xbox…") + Thread({ + try { rtc?.stop() } catch (e: Throwable) { } + rtc = null + started = false + try { Xbox.wake(this, consoleId) } catch (e: Exception) { Log.w(TAG, "wake: $e") } + try { Thread.sleep(5000) } catch (e: InterruptedException) { } + retrying = false + if (!closed) { main.postDelayed(pictureWatchdog, 60_000); connect() } + }, "xbox-retry").start() + return + } + val msg = when { + notReady(reason) -> "Your Xbox didn't answer. Turn it on, or check Settings > Devices & connections > Remote features on the Xbox." + reason.contains("no picture") -> "Your Xbox connected but sent no picture. Turn the Xbox off and on, then try again." + else -> "The Xbox stream stopped. $reason" + } + endSession(msg) + } + + private fun screenHeight(): Int { + val d = windowManager.defaultDisplay + return if (android.os.Build.VERSION.SDK_INT >= 23) minOf(d.mode.physicalWidth, d.mode.physicalHeight) + else android.graphics.Point().also { d.getRealSize(it) }.let { minOf(it.x, it.y) } + } + + /** + * Settings, Video, Picture shape and Zoom (shared with the PC player): Fit keeps the shape with bars, Fill crops to + * cover the screen, Stretch fills it ignoring the shape; Zoom enlarges for TVs that cut off the edges. + */ + private var shapeW = 0 + private var shapeWatched = false + private var shapeH = 0 + + private fun applyShape(videoW: Int, videoH: Int) { + val r = renderer ?: return + if (videoW > 0 && videoH > 0) { shapeW = videoW; shapeH = videoH } + // Stretch is worked out from the screen's size: work it out again when that changes (a fold or unfold) + if (!shapeWatched) { + shapeWatched = true + (r.parent as? View)?.addOnLayoutChangeListener { _, l, t, rr, b, ol, ot, or, ob -> + if (rr - l != or - ol || b - t != ob - ot) applyShape(shapeW, shapeH) + } + } + val shape = prefs.getString("list_video_scale_mode", if (prefs.getBoolean("checkbox_stretch_video", false)) "stretch" else "fit") + val zoom = prefs.getInt("seekbar_video_zoom", 100) / 100f + r.setScalingType(if (shape == "fill") RendererCommon.ScalingType.SCALE_ASPECT_FILL else RendererCommon.ScalingType.SCALE_ASPECT_FIT) + r.post { + var sx = zoom; var sy = zoom + val parent = r.parent as? View + if (shape == "stretch" && parent != null && r.width > 0 && r.height > 0 && videoW > 0 && videoH > 0) { + sx *= parent.width.toFloat() / r.width; sy *= parent.height.toFloat() / r.height + } + r.scaleX = sx; r.scaleY = sy + } + } + + private fun linkMbps(): Int = try { + val wm = applicationContext.getSystemService(WIFI_SERVICE) as android.net.wifi.WifiManager + @Suppress("DEPRECATION") wm.connectionInfo?.linkSpeed ?: 0 + } catch (e: Exception) { 0 } + + override fun onStop() { super.onStop(); if (!isChangingConfigurations) finish() } + + /** "Move to TV": the Xbox session here is fully closed before the TV starts its own. */ + override fun endLocalSession(done: Runnable) { + closed = true + sendNeutral() + val r = rtc + if (r == null) { done.run(); return } + val main = android.os.Handler(mainLooper) + try { r.stop { main.post(done) } } catch (e: Throwable) { done.run() } + } + + override fun onDestroy() { + corner.stop() + closed = true + if (remoteOn) try { remotePad?.stop() } catch (e: Throwable) { } + try { rtc?.stop() } catch (e: Throwable) { } + try { renderer?.release() } catch (e: Throwable) { } + try { egl.release() } catch (e: Throwable) { } + super.onDestroy() + } + + // ---- this device's controllers -> Xbox pads ---- + // Settings, Xbox: deadzone, edge compensation, Swap A/B and X/Y, short triggers, co-op (second pad = player 2). + private val prefs by lazy { android.preference.PreferenceManager.getDefaultSharedPreferences(this) } + private val deadzone by lazy { prefs.getInt("xbox_deadzone", 8) / 100f } + private val edge by lazy { prefs.getBoolean("xbox_edge", false) } + private val swapAB by lazy { prefs.getBoolean("xbox_swap_ab", false) } + private val swapXY by lazy { prefs.getBoolean("xbox_swap_xy", false) } + private val shortTriggers by lazy { prefs.getBoolean("xbox_short_triggers", false) } + private val coop by lazy { prefs.getBoolean("xbox_coop", false) } + + /** One pad's live state. index = the Xbox player slot (0, or 1 for the second pad in co-op). */ + private class Pad(val index: Int) { + val held = HashSet() + var lx = 0f; var ly = 0f; var rx = 0f; var ry = 0f + var lt = 0f; var rt = 0f; var hatX = 0f; var hatY = 0f + } + private val pads = LinkedHashMap() + private var padDeviceId = -1 + private var comboPending = false + private val mutedPads = HashSet() + + /** The pad for this device: player 1, or player 2 for a second pad when co-op is on (more pads share player 1). */ + private fun pad(deviceId: Int): Pad { + val d = InputDevice.getDevice(deviceId) + val isPad = d != null && (d.sources and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD || + d.sources and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK) + // The TV remote's arrows drive player 1 too, but never take the co-op slot. + if (!isPad) return pads.getOrPut(-2) { Pad(0) } + return pads.getOrPut(deviceId) { + Pad(if (coop && pads.any { it.key != -2 } && pads.values.none { it.index == 1 }) 1 else 0) + } + } + + private fun isPadKey(code: Int) = KeyEvent.isGamepadButton(code) || code == KeyEvent.KEYCODE_DPAD_UP || + code == KeyEvent.KEYCODE_DPAD_DOWN || code == KeyEvent.KEYCODE_DPAD_LEFT || code == KeyEvent.KEYCODE_DPAD_RIGHT + + // A DualSense / DualShock 4 on an old kernel (the home Mi stick, Android 9) uses Android's generic layout: every + // button shifted and L2/R2 on RX/RY. Same detection and remap as the PS5 player. + private val oddSony = HashMap() + private fun isOddSony(deviceId: Int): Boolean = oddSony.getOrPut(deviceId) { + val d = InputDevice.getDevice(deviceId) + d != null && d.vendorId == 0x054c && d.hasKeys(KeyEvent.KEYCODE_BUTTON_C)[0] + } + private fun oddSonyKey(scan: Int): Int? = when (scan) { + 304 -> KeyEvent.KEYCODE_BUTTON_X // Square -> X + 305 -> KeyEvent.KEYCODE_BUTTON_A // Cross -> A + 306 -> KeyEvent.KEYCODE_BUTTON_B // Circle -> B + 307 -> KeyEvent.KEYCODE_BUTTON_Y // Triangle -> Y + 308 -> KeyEvent.KEYCODE_BUTTON_L1 + 309 -> KeyEvent.KEYCODE_BUTTON_R1 + 310 -> KeyEvent.KEYCODE_BUTTON_L2 + 311 -> KeyEvent.KEYCODE_BUTTON_R2 + 312 -> KeyEvent.KEYCODE_BUTTON_SELECT // Create -> View + 313 -> KeyEvent.KEYCODE_BUTTON_START // Options -> Menu + 314 -> KeyEvent.KEYCODE_BUTTON_THUMBL + 315 -> KeyEvent.KEYCODE_BUTTON_THUMBR + 316 -> KeyEvent.KEYCODE_BUTTON_MODE // PS -> Xbox button + else -> null + } + + private var backPressedClosed = false + + override fun dispatchKeyEvent(raw: KeyEvent): Boolean { + val e = if (isOddSony(raw.deviceId)) oddSonyKey(raw.scanCode)?.let { + KeyEvent(raw.downTime, raw.eventTime, raw.action, it, raw.repeatCount, raw.metaState, raw.deviceId, raw.scanCode, raw.flags, raw.source) + } ?: raw else raw + val code = e.keyCode + val fromPad = (e.source and InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD || + (e.source and InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK + if (menu.isShowing) { backPressedClosed = false; return menu.onKey(e) } + if (fromPad) padDeviceId = e.deviceId + if (code == KeyEvent.KEYCODE_BACK && !fromPad) { // TV remote / Odin back: Quick menu (the pad's B is B) + // #57: open only on a release whose press came while the menu was closed (Back closing the menu + // on its press used to reopen it on the release) + if (e.action == KeyEvent.ACTION_DOWN && e.repeatCount == 0) backPressedClosed = true + else if (e.action == KeyEvent.ACTION_UP) { if (backPressedClosed) openMenu(); backPressedClosed = false } + return true + } + if (fromPad && e.deviceId in mutedPads) return true + if (isPadKey(code)) { + if (e.repeatCount == 0) { + val p = pad(e.deviceId) + if (e.action == KeyEvent.ACTION_DOWN) p.held.add(code) else if (e.action == KeyEvent.ACTION_UP) p.held.remove(code) + if (p.held.containsAll(MENU_COMBO)) { comboPending = true; sendNeutral() } + if (comboPending) { if (p.held.isEmpty()) { comboPending = false; openMenu() }; return true } + send(p) + } + return true + } + return super.dispatchKeyEvent(e) + } + + /** Deadzone, then optional edge compensation: rescale so the stick still reaches 100% at its edge. */ + private fun stick(v: Float): Float { + val a = Math.abs(v) + if (a < deadzone) return 0f + val out = if (edge) ((a - deadzone) / (1f - deadzone)).coerceAtMost(1f) else a + return if (v < 0) -out else out + } + + override fun dispatchGenericMotionEvent(e: MotionEvent): Boolean { + if (menu.isShowing) return menu.onMotion(e) + if (e.deviceId in mutedPads) return true + if ((e.source and InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK && e.action == MotionEvent.ACTION_MOVE) { + padDeviceId = e.deviceId + val p = pad(e.deviceId) + p.lx = stick(e.getAxisValue(MotionEvent.AXIS_X)); p.ly = stick(e.getAxisValue(MotionEvent.AXIS_Y)) + p.rx = stick(e.getAxisValue(MotionEvent.AXIS_Z)); p.ry = stick(e.getAxisValue(MotionEvent.AXIS_RZ)) + if (isOddSony(e.deviceId)) { // generic layout: L2/R2 on RX/RY, -1 (released) .. 1 (pressed) + p.lt = (e.getAxisValue(MotionEvent.AXIS_RX) + 1f) / 2f + p.rt = (e.getAxisValue(MotionEvent.AXIS_RY) + 1f) / 2f + } else { + p.lt = maxOf(e.getAxisValue(MotionEvent.AXIS_LTRIGGER), e.getAxisValue(MotionEvent.AXIS_BRAKE)) + p.rt = maxOf(e.getAxisValue(MotionEvent.AXIS_RTRIGGER), e.getAxisValue(MotionEvent.AXIS_GAS)) + } + p.hatX = e.getAxisValue(MotionEvent.AXIS_HAT_X); p.hatY = e.getAxisValue(MotionEvent.AXIS_HAT_Y) + send(p) + return true + } + return super.dispatchGenericMotionEvent(e) + } + + // ---- the sender's controller (a handheld that sent this game to the TV) -> player 1 ---- + private var remoteOn = false + + private fun startRemotePad() { + if (!intent.getBooleanExtra(EXTRA_REMOTE_PAD, false)) return + val rp = remotePad ?: return + remoteOn = true + rp.start { b, a -> runOnUiThread { if (!closed) applyRemote(b, a) } } + } + + private fun applyRemote(b: DoubleArray, a: DoubleArray) { + if (menu.isShowing) return + val p = pads.getOrPut(-3) { Pad(0) } + p.held.clear() + for (i in W3C_KEYS.indices) if (W3C_KEYS[i] != 0 && i < b.size && b[i] > .5) p.held.add(W3C_KEYS[i]) + fun ax(i: Int) = if (i < a.size) a[i].toFloat().coerceIn(-1f, 1f) else 0f + p.lx = ax(0); p.ly = ax(1); p.rx = ax(2); p.ry = ax(3) + p.lt = if (b.size > 6) b[6].toFloat().coerceIn(0f, 1f) else 0f + p.rt = if (b.size > 7) b[7].toFloat().coerceIn(0f, 1f) else 0f + p.hatX = 0f; p.hatY = 0f + send() + } + + /** Hand every pad's latest state to the session; it sends them 62.5 times a second. */ + private fun send() { + val r = rtc ?: return + r.pads = if (pads.isEmpty()) listOf(XboxRtc.PadFrame(0)) else pads.values.groupBy { it.index }.map { (idx, ps) -> frame(idx, ps) } + } + + private fun send(@Suppress("UNUSED_PARAMETER") p: Pad) = send() + + /** One Xbox pad slot from the pads that drive it (the TV remote shares player 1). */ + private fun frame(index: Int, ps: List): XboxRtc.PadFrame { + fun h(k: Int) = ps.any { it.held.contains(k) } + fun trig(v: Float) = if (shortTriggers) (if (v > 0.1f) 1f else 0f) else v + val main = ps.firstOrNull { it.lx != 0f || it.ly != 0f || it.rx != 0f || it.ry != 0f || it.lt > 0f || it.rt > 0f } ?: ps.first() + val a = h(KeyEvent.KEYCODE_BUTTON_A); val b = h(KeyEvent.KEYCODE_BUTTON_B) + val x = h(KeyEvent.KEYCODE_BUTTON_X); val y = h(KeyEvent.KEYCODE_BUTTON_Y) + return XboxRtc.PadFrame(index).apply { + nexus = h(KeyEvent.KEYCODE_BUTTON_MODE); menu = h(KeyEvent.KEYCODE_BUTTON_START); view = h(KeyEvent.KEYCODE_BUTTON_SELECT) + this.a = if (swapAB) b else a; this.b = if (swapAB) a else b + this.x = if (swapXY) y else x; this.y = if (swapXY) x else y + up = h(KeyEvent.KEYCODE_DPAD_UP) || ps.any { it.hatY < -.5f }; down = h(KeyEvent.KEYCODE_DPAD_DOWN) || ps.any { it.hatY > .5f } + left = h(KeyEvent.KEYCODE_DPAD_LEFT) || ps.any { it.hatX < -.5f }; right = h(KeyEvent.KEYCODE_DPAD_RIGHT) || ps.any { it.hatX > .5f } + lb = h(KeyEvent.KEYCODE_BUTTON_L1); rb = h(KeyEvent.KEYCODE_BUTTON_R1) + ls = h(KeyEvent.KEYCODE_BUTTON_THUMBL); rs = h(KeyEvent.KEYCODE_BUTTON_THUMBR) + lx = main.lx; ly = main.ly; rx = main.rx; ry = main.ry + lt = trig(maxOf(main.lt, if (h(KeyEvent.KEYCODE_BUTTON_L2)) 1f else 0f)) + rt = trig(maxOf(main.rt, if (h(KeyEvent.KEYCODE_BUTTON_R2)) 1f else 0f)) + } + } + + private fun sendNeutral() { + val r = rtc ?: return + val slots = if (pads.isEmpty()) listOf(0) else pads.values.map { it.index }.distinct() + r.pads = slots.map { XboxRtc.PadFrame(it) } + } + + /** Rumble on the pad playing that slot (player 1: the pad that played last), like the PS5 player. */ + private fun rumble(slot: Int, durationMs: Int, weak: Double, strong: Double) { + val dev = if (slot == 0) padDeviceId else pads.entries.firstOrNull { it.value.index == slot }?.key ?: return + val d = InputDevice.getDevice(dev) ?: return + // Settings, Xbox, Rumble: Off / Weak / Medium / Strong, like XStreaming's vibration intensity + val scale = when (prefs.getString("xbox_rumble", "strong")) { "off" -> 0.0; "weak" -> 0.35; "medium" -> 0.65; else -> 1.0 } + if (scale == 0.0) return + val amp = (maxOf(weak, strong).coerceIn(0.0, 1.0) * scale * 255).toInt() + try { + @Suppress("DEPRECATION") val v = d.vibrator + if (!v.hasVibrator()) return + if (amp <= 0) { v.cancel(); return } + if (android.os.Build.VERSION.SDK_INT >= 26) + v.vibrate(android.os.VibrationEffect.createOneShot(maxOf(durationMs, 20).toLong(), amp.coerceIn(1, 255))) + else @Suppress("DEPRECATION") v.vibrate(maxOf(durationMs, 20).toLong()) + } catch (e: Exception) { } + } + + // ---- Quick menu ---- + private val menu by lazy { QuickMenu(this) } + // Streaming status: the stats line in the corner while playing (Settings' "Stats while playing", or the Quick menu) + private val corner by lazy { com.limelight.home.StatusCorner(this) { liveStats() } } + + // Live numbers from WebRTC's own stats, refreshed every second (and logged every 5 s for diagnosis) + @Volatile private var last: XboxRtc.Stats? = null + private var statTicks = 0 + private fun startStatsPoll() { if (!pollingStats) { pollingStats = true; pollStats() } } + + private fun pollStats() { + val h = android.os.Handler(mainLooper) + h.post(object : Runnable { + override fun run() { + if (closed) return + rtc?.stats { st -> + last = st + if (statTicks++ % 5 == 0) Log.i(TAG, "stats ${st.width}x${st.height} fps=${st.fps} mbps=${"%.2f".format(java.util.Locale.US, st.mbps)} loss=${"%.2f".format(java.util.Locale.US, st.lossPct)}% rtt=${st.rttMs}") + } + h.postDelayed(this, 1000) + } + }) + } + + private fun liveStats(): List> { + fun f(v: Double, fmt: String) = if (v < 0) "…" else fmt.format(java.util.Locale.US, v) + val st = last + if (st == null || !firstFrame) { + val min = (System.currentTimeMillis() - startedAt) / 60000 + return listOf((if (started) "Connected" else "Starting") to "Stage", "$min min" to "Waiting for") + } + return listOf( + f(minOf(st.fps, 60.0), "%.0f") to "Frames per second", + f(st.rttMs, "%.0f ms") to "Network delay", + f(st.mbps, "%.1f Mbps") to "Picture quality", + f(st.lossPct, "%.1f%%") to "Lost packets") + } + + private fun openMenu() { + if (isFinishing) return + sendNeutral() + // handheld only: hand the Xbox over to the last MoonCast TV (it signs in with its own Xbox account) + val move = com.limelight.home.MoveToTv.item(this, com.limelight.home.MoveToTv.Target("xb", consoleName, xboxId = consoleId)) + menu.show(consoleName, "Xbox Remote Play. " + QuickMenu.COMBO_LABEL + " opens this menu", { liveStats() }, listOfNotNull( + QuickMenu.Item("⊗", "Xbox button", "Opens the guide", false) { tapGuide() }, + QuickMenu.Item("ⓘ", "Streaming status", if (com.limelight.home.StatusCorner.enabled(this)) "On" else "Off", false) { corner.toggle() }.asToggle(), + move, + QuickMenu.Item("←", "Disconnect", "The game keeps running", false) { finish() }, + QuickMenu.Item("⏻", "Turn off the Xbox", "Ends the game", true) { turnOff() })) + } + + private fun tapGuide() { + menu.hide() + val h = android.os.Handler(mainLooper) + val p = pads.values.firstOrNull { it.index == 0 } ?: pad(padDeviceId) + h.post { p.held.add(KeyEvent.KEYCODE_BUTTON_MODE); send(p) } + h.postDelayed({ p.held.remove(KeyEvent.KEYCODE_BUTTON_MODE); send(p) }, 120) + } + + private fun turnOff() { + menu.hide() + Thread { try { Xbox.turnOff(this, consoleId) } catch (e: Exception) { Log.w(TAG, "turn off: $e") }; runOnUiThread { finish() } }.start() + } +} diff --git a/app/src/main/java/com/limelight/binding/crypto/AndroidCryptoProvider.java b/app/src/main/java/com/limelight/binding/crypto/AndroidCryptoProvider.java index b3252dba50..1e475c6e35 100644 --- a/app/src/main/java/com/limelight/binding/crypto/AndroidCryptoProvider.java +++ b/app/src/main/java/com/limelight/binding/crypto/AndroidCryptoProvider.java @@ -58,8 +58,11 @@ public class AndroidCryptoProvider implements LimelightCryptoProvider { public AndroidCryptoProvider(Context c) { String dataPath = c.getFilesDir().getAbsolutePath(); - certFile = new File(dataPath + File.separator + "client.crt"); - keyFile = new File(dataPath + File.separator + "client.key"); + // A session identity (sent by another MoonCast device, removed when the session ends) takes precedence. + File sessionCert = new File(dataPath + File.separator + "session-client.crt"); + File sessionKey = new File(dataPath + File.separator + "session-client.key"); + certFile = sessionCert.isFile() ? sessionCert : new File(dataPath + File.separator + "client.crt"); + keyFile = sessionKey.isFile() ? sessionKey : new File(dataPath + File.separator + "client.key"); } private byte[] loadFileToBytes(File f) { diff --git a/app/src/main/java/com/limelight/binding/input/ControllerHandler.java b/app/src/main/java/com/limelight/binding/input/ControllerHandler.java index 0fffe5d0d9..e22a04b322 100644 --- a/app/src/main/java/com/limelight/binding/input/ControllerHandler.java +++ b/app/src/main/java/com/limelight/binding/input/ControllerHandler.java @@ -2552,8 +2552,9 @@ public boolean handleButtonUp(KeyEvent event) { sendControllerInputPacket(context); if (context.pendingExit && context.inputMap == 0) { - // All buttons from the quit combo are lifted. Finish the activity now. - activityContext.finish(); + // All buttons from the combo are lifted. MoonCast: open the Quick menu (it has Disconnect and Quit). + context.pendingExit = false; + gestures.openQuickMenu(); } return true; @@ -2784,10 +2785,29 @@ else if (context.needsClickpadEmulation) { return true; } + // MoonCast: a skin tells the PC which pad it is (PlayStation or Xbox), so games show its symbols + // and its extra buttons work (touchpad click, Share). Only while no real controller is attached (they own their type). + private byte oscType = MoonBridge.LI_CTYPE_UNKNOWN; + private boolean oscArrived; + + public void setOscType(byte type) { + oscType = type; + } + public void reportOscState(int buttonFlags, short leftStickX, short leftStickY, short rightStickX, short rightStickY, byte leftTrigger, byte rightTrigger) { + if (oscType != MoonBridge.LI_CTYPE_UNKNOWN && !oscArrived && currentControllers == 0 && initialControllers == 0) { + oscArrived = true; + conn.sendControllerArrivalEvent((byte) 0, getActiveControllerMask(), oscType, + ControllerPacket.A_FLAG | ControllerPacket.B_FLAG | ControllerPacket.X_FLAG | ControllerPacket.Y_FLAG | + ControllerPacket.UP_FLAG | ControllerPacket.DOWN_FLAG | ControllerPacket.LEFT_FLAG | ControllerPacket.RIGHT_FLAG | + ControllerPacket.LB_FLAG | ControllerPacket.RB_FLAG | ControllerPacket.PLAY_FLAG | ControllerPacket.BACK_FLAG | + ControllerPacket.LS_CLK_FLAG | ControllerPacket.RS_CLK_FLAG | ControllerPacket.SPECIAL_BUTTON_FLAG | + (oscType == MoonBridge.LI_CTYPE_PS ? ControllerPacket.TOUCHPAD_FLAG : ControllerPacket.MISC_FLAG), + MoonBridge.LI_CCAP_RUMBLE); + } defaultContext.leftStickX = leftStickX; defaultContext.leftStickY = leftStickY; diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/AnalogStick.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/AnalogStick.java index ceec138dc7..eb9c77832e 100644 --- a/app/src/main/java/com/limelight/binding/input/virtual_controller/AnalogStick.java +++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/AnalogStick.java @@ -219,6 +219,14 @@ protected void onSizeChanged(int w, int h, int oldw, int oldh) { @Override protected void onElementDraw(Canvas canvas) { + if (skin() != null) { + boolean moved = stick_state != STICK_STATE.NO_MOVEMENT; + skin().stick(canvas, getWidth(), getHeight(), + moved ? position_stick_x : getWidth() / 2f, moved ? position_stick_y : getHeight() / 2f, + radius_complete - radius_analog_stick, isPressed()); + return; + } + // set transparent background canvas.drawColor(Color.TRANSPARENT); @@ -321,6 +329,7 @@ public boolean onElementTouchEvent(MotionEvent event) { timeLastClick = event.getEventTime(); // set item pressed and update setPressed(true); + if (skin() != null) skin().tick(); break; } // up event (revoke touch) diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/DigitalButton.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/DigitalButton.java index e24484b096..c6d7dce4a9 100644 --- a/app/src/main/java/com/limelight/binding/input/virtual_controller/DigitalButton.java +++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/DigitalButton.java @@ -56,6 +56,7 @@ public void run() { private final RectF rect = new RectF(); private int layer; + private int skinShape; private DigitalButton movingButton = null; boolean inRange(float x, float y) { @@ -128,6 +129,11 @@ public void setText(String text) { invalidate(); } + public void setSkinShape(int shape) { + this.skinShape = shape; + invalidate(); + } + public void setIcon(int id) { this.icon = id; invalidate(); @@ -135,6 +141,11 @@ public void setIcon(int id) { @Override protected void onElementDraw(Canvas canvas) { + if (skin() != null && skinShape != 0) { + skin().button(canvas, skinShape, getWidth(), getHeight(), isPressed(), text); + return; + } + // set transparent background canvas.drawColor(Color.TRANSPARENT); @@ -164,6 +175,7 @@ protected void onElementDraw(Canvas canvas) { private void onClickCallback() { _DBG("clicked"); + if (skin() != null) skin().tick(); // notify listeners for (DigitalButtonListener listener : listeners) { listener.onClick(); diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/DigitalPad.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/DigitalPad.java index 1f3d9fed8c..4a69b6351f 100644 --- a/app/src/main/java/com/limelight/binding/input/virtual_controller/DigitalPad.java +++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/DigitalPad.java @@ -36,6 +36,11 @@ public void addDigitalPadListener(DigitalPadListener listener) { @Override protected void onElementDraw(Canvas canvas) { + if (skin() != null) { + skin().dpad(canvas, getWidth(), getHeight(), direction); + return; + } + // set transparent background canvas.drawColor(Color.TRANSPARENT); @@ -163,6 +168,7 @@ public boolean onElementTouchEvent(MotionEvent event) { switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: case MotionEvent.ACTION_MOVE: { + int before = direction; direction = 0; if (event.getX() < getPercent(getWidth(), 33)) { @@ -177,6 +183,8 @@ public boolean onElementTouchEvent(MotionEvent event) { if (event.getY() < getPercent(getHeight(), 33)) { direction |= DIGITAL_PAD_DIRECTION_UP; } + // a tick for each new direction the thumb rolls onto + if (skin() != null && (direction & ~before) != 0) skin().tick(); newDirectionCallback(direction); invalidate(); diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/PadPlate.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/PadPlate.java new file mode 100644 index 0000000000..f0d1ffd08d --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/PadPlate.java @@ -0,0 +1,27 @@ +package com.limelight.binding.input.virtual_controller; + +import android.content.Context; +import android.graphics.Canvas; +import android.view.MotionEvent; + +/** The see-through plate under a skin's face buttons. Only drawn: touches go to the keys or the game. */ +public class PadPlate extends VirtualControllerElement { + public PadPlate(VirtualController controller, Context context, int elementId) { + super(controller, context, elementId); + } + + @Override + protected void onElementDraw(Canvas canvas) { + if (skin() != null) skin().plate(canvas, getWidth() / 2f, getHeight() / 2f, Math.min(getWidth(), getHeight()) / 2f * .97f); + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + return false; + } + + @Override + public boolean onElementTouchEvent(MotionEvent event) { + return false; + } +} diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/PadSkin.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/PadSkin.java new file mode 100644 index 0000000000..ab741a8233 --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/PadSkin.java @@ -0,0 +1,442 @@ +package com.limelight.binding.input.virtual_controller; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.LinearGradient; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.RadialGradient; +import android.graphics.RectF; +import android.graphics.Shader; +import android.graphics.Typeface; +import android.os.Build; +import android.os.VibrationEffect; +import android.os.Vibrator; + +/** + * MoonCast's drawn skins for the on-screen controller (#74). The engine (touch, sliding between + * buttons, what's sent to the PC) is Moonlight's; a skin only changes how the keys look and where + * they sit. PlayStation: dark keys over faint see-through plates, laid out on Delta's positions. + */ +public final class PadSkin { + public static final String PREF = "mc_osc_style"; + public static final String NONE = "classic"; + public static final String PS = "ps"; + public static final String XBOX = "xbox"; + /** How visible the skin is, in percent (50 = half see-through). */ + public static final String OPACITY_PREF = "mc_osc_opacity"; + public static final int DEFAULT_OPACITY = 50; + + public static int opacity(Context context) { + return android.preference.PreferenceManager.getDefaultSharedPreferences(context).getInt(OPACITY_PREF, DEFAULT_OPACITY); + } + + // what a button is drawn as + public static final int TRIANGLE = 1, CIRCLE = 2, SQUARE = 3, CROSS = 4; + public static final int SHOULDER_L = 5, SHOULDER = 6, SHOULDER_R = 7; + public static final int CREATE = 8, OPTIONS = 9, TOUCHPAD = 10, MENU = 11, PSHOME = 12; + // Xbox + public static final int XA = 13, XB = 14, XX = 15, XY = 16, TRIGGER_L = 17, TRIGGER_R = 18, XHOME = 19, VIEW = 20, XMENU = 21, SHARE = 22; + + private static final int KEY_TOP = 0xFF55565B, KEY_MID = 0xFF3B3C40, KEY_BOTTOM = 0xFF2A2B2E; + private static final int KEY_EDGE = 0xFF1D1E21, LABEL = 0xFFD2D4D9; + + private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Path path = new Path(); + private final RectF rect = new RectF(); + private final Vibrator vibrator; + /** Xbox look (A B X Y, one-piece d-pad, triggers) instead of PlayStation. */ + final boolean xbox; + + PadSkin(Context context, boolean xbox) { + this.xbox = xbox; + line.setStyle(Paint.Style.STROKE); + line.setStrokeJoin(Paint.Join.ROUND); + line.setStrokeCap(Paint.Cap.ROUND); + text.setTextAlign(Paint.Align.CENTER); + text.setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD)); + vibrator = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE); + } + + /** A very light tick under the finger, like a key on the phone's keyboard. */ + void tick() { + if (vibrator == null || !vibrator.hasVibrator()) return; + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK)); + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + vibrator.vibrate(VibrationEffect.createOneShot(6, vibrator.hasAmplitudeControl() ? 50 : VibrationEffect.DEFAULT_AMPLITUDE)); + } else { + vibrator.vibrate(6); + } + } catch (RuntimeException ignored) { + } + } + + // ---- shared pieces + + private void keyFill(float top, float bottom, boolean pressed) { + int lift = pressed ? 0x00202020 : 0; + fill.setShader(new LinearGradient(0, top, 0, bottom, + new int[]{KEY_TOP + lift, KEY_MID + lift, KEY_BOTTOM + lift}, new float[]{0, .55f, 1}, Shader.TileMode.CLAMP)); + fill.setShadowLayer(pressed ? 2 : 6, 0, pressed ? 1 : 4, 0x73000000); + } + + private void capFill(float cx, float cy, float r, boolean pressed) { + int lift = pressed ? 0x00242424 : 0; + fill.setShader(new RadialGradient(cx - r * .1f, cy - r * .3f, r * 1.5f, + new int[]{KEY_TOP + lift, 0xFF36373B + lift, 0xFF202124 + lift}, new float[]{0, .7f, 1}, Shader.TileMode.CLAMP)); + fill.setShadowLayer(pressed ? 2 : 6, 0, pressed ? 1 : 4, 0x73000000); + } + + private void plainFill(int color) { + fill.setShader(null); + fill.clearShadowLayer(); + fill.setColor(color); + } + + private void edge(Canvas c, int color, float width) { + line.setColor(color); + line.setStrokeWidth(width); + c.drawPath(path, line); + } + + private void label(Canvas c, String s, float x, float y, float size, int color) { + text.setTextSize(size); + text.setColor(color); + if (color == LABEL) text.setShadowLayer(Math.max(2, size * .12f), 0, 1, 0xE6000000); else text.clearShadowLayer(); + Paint.FontMetrics m = text.getFontMetrics(); + c.drawText(s, x, y - (m.ascent + m.descent) / 2, text); + } + + /** The faint cross-shaped plate the d-pad and the shape buttons sit on (see-through). */ + void plate(Canvas c, float cx, float cy, float r) { + float a = r * .48f; + path.reset(); + path.moveTo(cx - a, cy - r); + path.lineTo(cx + a, cy - r); path.lineTo(cx + a, cy - a); path.lineTo(cx + r, cy - a); + path.lineTo(cx + r, cy + a); path.lineTo(cx + a, cy + a); path.lineTo(cx + a, cy + r); + path.lineTo(cx - a, cy + r); path.lineTo(cx - a, cy + a); path.lineTo(cx - r, cy + a); + path.lineTo(cx - r, cy - a); path.lineTo(cx - a, cy - a); path.close(); + plainFill(0x61B0B2B7); + c.drawPath(path, fill); + edge(c, 0x73FFFFFF, Math.max(2, r * .012f)); + } + + // ---- d-pad: four arrow keys on the plate + + void dpad(Canvas c, float w, float h, int direction) { + if (xbox) { + xdpad(c, w, h, direction); + return; + } + float cx = w / 2, cy = h / 2, r = Math.min(w, h) / 2 / 1.36f; + plate(c, cx, cy, r * 1.32f); + for (int i = 0; i < 4; i++) { + // the small moulded arrows at the plate's ends + c.save(); + c.rotate(90 * i, cx, cy); + path.reset(); + path.moveTo(cx, cy - r * 1.24f); + path.lineTo(cx - r * .12f, cy - r * 1.12f); + path.lineTo(cx + r * .12f, cy - r * 1.12f); + path.close(); + plainFill(0xCCC8CACE); + c.drawPath(path, fill); + c.restore(); + } + int[] bit = {DigitalPad.DIGITAL_PAD_DIRECTION_UP, DigitalPad.DIGITAL_PAD_DIRECTION_RIGHT, + DigitalPad.DIGITAL_PAD_DIRECTION_DOWN, DigitalPad.DIGITAL_PAD_DIRECTION_LEFT}; + for (int i = 0; i < 4; i++) { + boolean down = (direction & bit[i]) != 0; + c.save(); + c.rotate(90 * i, cx, cy); + if (down) c.translate(0, r * .03f); + float kw = r * .30f, top = cy - r * .98f, side = cy - r * .92f + r * .62f * .62f; + path.reset(); + path.moveTo(cx - kw, top + r * .06f); + path.quadTo(cx - kw, top, cx - kw + r * .06f, top); + path.lineTo(cx + kw - r * .06f, top); + path.quadTo(cx + kw, top, cx + kw, top + r * .06f); + path.lineTo(cx + kw, side); + path.lineTo(cx, cy - r * .18f); + path.lineTo(cx - kw, side); + path.close(); + keyFill(top, cy, down); + c.drawPath(path, fill); + edge(c, KEY_EDGE, Math.max(2, r * .02f)); + c.restore(); + } + } + + // ---- buttons + + void button(Canvas c, int shape, float w, float h, boolean pressed, String text) { + switch (shape) { + case TRIANGLE: shapeKey(c, w, h, shape, 0xFF3FD3A0, pressed); break; + case CIRCLE: shapeKey(c, w, h, shape, 0xFFF25C66, pressed); break; + case SQUARE: shapeKey(c, w, h, shape, 0xFFE58BD8, pressed); break; + case CROSS: shapeKey(c, w, h, shape, 0xFF7BA4F2, pressed); break; + case SHOULDER_L: case SHOULDER: case SHOULDER_R: shoulder(c, shape, w, h, pressed, text); break; + case CREATE: create(c, w, h, pressed); break; + case OPTIONS: options(c, w, h, pressed); break; + case TOUCHPAD: touchpad(c, w, h, pressed); break; + case MENU: menu(c, w, h, pressed); break; + case PSHOME: psHome(c, w, h, pressed); break; + case XA: letterKey(c, w, h, "A", 0xFF5FCB5A, pressed); break; + case XB: letterKey(c, w, h, "B", 0xFFF0574F, pressed); break; + case XX: letterKey(c, w, h, "X", 0xFF4D96FF, pressed); break; + case XY: letterKey(c, w, h, "Y", 0xFFF2C230, pressed); break; + case TRIGGER_L: case TRIGGER_R: trigger(c, shape == TRIGGER_L, w, h, pressed, text); break; + case XHOME: xHome(c, w, h, pressed); break; + case VIEW: case XMENU: smallKey(c, shape, w, h, pressed); break; + case SHARE: share(c, w, h, pressed); break; + } + } + + private void shapeKey(Canvas c, float w, float h, int shape, int color, boolean pressed) { + float cx = w / 2, cy = h / 2 + (pressed ? 1.5f : 0), r = Math.min(w, h) / 2 * .9f; + capFill(cx, cy, r, pressed); + c.drawCircle(cx, cy, r, fill); + line.setColor(0xFF141518); line.setStrokeWidth(Math.max(2, r * .04f)); + c.drawCircle(cx, cy, r, line); + line.setColor(0x14FFFFFF); line.setStrokeWidth(Math.max(2, r * .04f)); + c.drawCircle(cx, cy, r * .93f, line); + float q = r * .46f; + int col = pressed ? (color | 0xFF000000) : color; + line.setColor(col); + line.setStrokeWidth(r * .11f); + path.reset(); + switch (shape) { + case TRIANGLE: + path.moveTo(cx, cy - q * 1.15f); path.lineTo(cx + q * 1.1f, cy + q * .75f); path.lineTo(cx - q * 1.1f, cy + q * .75f); path.close(); + c.drawPath(path, line); break; + case CIRCLE: + c.drawCircle(cx, cy, q * 1.05f, line); break; + case SQUARE: + c.drawRect(cx - q * .95f, cy - q * .95f, cx + q * .95f, cy + q * .95f, line); break; + case CROSS: + c.drawLine(cx - q, cy - q, cx + q, cy + q, line); c.drawLine(cx + q, cy - q, cx - q, cy + q, line); break; + } + if (pressed) { + // the symbol glows a little while held + plainFill((col & 0x00FFFFFF) | 0x26000000); + c.drawCircle(cx, cy, r, fill); + } + } + + private void shoulder(Canvas c, int shape, float w, float h, boolean pressed, String s) { + float pad = h * .1f, x = pad, y = pad + (pressed ? 1.5f : 0), kw = w - 2 * pad, kh = h - 2 * pad; + float rl = shape == SHOULDER_L ? kh * .5f : kh * .2f, rr = shape == SHOULDER_R ? kh * .5f : kh * .2f, rb = kh * .18f; + path.reset(); + path.moveTo(x + rl, y); + path.lineTo(x + kw - rr, y); path.quadTo(x + kw, y, x + kw, y + rr); + path.lineTo(x + kw, y + kh - rb); path.quadTo(x + kw, y + kh, x + kw - rb, y + kh); + path.lineTo(x + rb, y + kh); path.quadTo(x, y + kh, x, y + kh - rb); + path.lineTo(x, y + rl); path.quadTo(x, y, x + rl, y); path.close(); + keyFill(y, y + kh, pressed); + c.drawPath(path, fill); + edge(c, KEY_EDGE, Math.max(2, kh * .03f)); + label(c, s, x + kw / 2, y + kh / 2 + 1.5f, kh * .42f, 0xFF151517); + label(c, s, x + kw / 2, y + kh / 2, kh * .42f, LABEL); + } + + private void create(Canvas c, float w, float h, boolean pressed) { + float s = Math.min(w / 2.2f, h / 2.2f), cx = w / 2, cy = h * .36f + (pressed ? 1.5f : 0); + rect.set(cx - s * .7f, cy - s * .4f, cx + s * .7f, cy + s * .4f); + keyFill(rect.top, rect.bottom, pressed); + c.drawRoundRect(rect, s * .18f, s * .18f, fill); + line.setColor(KEY_EDGE); line.setStrokeWidth(2); + c.drawRoundRect(rect, s * .18f, s * .18f, line); + label(c, "CREATE", cx, h * .36f + s * 1.05f, s * .5f, LABEL); + } + + private void options(Canvas c, float w, float h, boolean pressed) { + float s = Math.min(w / 2.2f, h / 2.2f), cx = w / 2, cy = h * .36f + (pressed ? 1.5f : 0); + path.reset(); + path.moveTo(cx - s * .7f, cy - s * .45f); + path.lineTo(cx + s * .75f, cy); + path.lineTo(cx - s * .7f, cy + s * .45f); + path.close(); + keyFill(cy - s * .45f, cy + s * .45f, pressed); + c.drawPath(path, fill); + edge(c, KEY_EDGE, 2); + label(c, "OPTIONS", cx, h * .36f + s * 1.05f, s * .5f, LABEL); + } + + private void touchpad(Canvas c, float w, float h, boolean pressed) { + float pad = h * .08f; + rect.set(pad, pad + (pressed ? 1.5f : 0), w - pad, h - pad); + float rr = rect.height() * .35f; + plainFill(pressed ? 0xC0B4B6BB : 0x999EA0A5); + fill.setShadowLayer(3, 0, 2, 0x2E000000); + c.drawRoundRect(rect, rr, rr, fill); + line.setColor(0x9986888D); line.setStrokeWidth(2); + c.drawRoundRect(rect, rr, rr, line); + label(c, "TOUCHPAD", w / 2, rect.centerY(), Math.min(rect.height() * .30f, rect.width() * .11f), LABEL); + } + + private void menu(Canvas c, float w, float h, boolean pressed) { + float cx = w / 2, cy = h / 2, r = Math.min(w, h) / 2 * .8f; + keyFill(cy - r, cy + r, pressed); + c.drawCircle(cx, cy, r, fill); + line.setColor(KEY_EDGE); line.setStrokeWidth(2); + c.drawCircle(cx, cy, r, line); + plainFill(0xFFC9CBD0); + for (int i = -1; i <= 1; i++) c.drawCircle(cx + i * r * .32f, cy, r * .09f, fill); + } + + private void psHome(Canvas c, float w, float h, boolean pressed) { + float cx = w / 2, cy = h / 2 + (pressed ? 1.5f : 0), r = Math.min(w, h) / 2 * .85f; + keyFill(cy - r, cy + r, pressed); + c.drawCircle(cx, cy, r, fill); + line.setColor(KEY_EDGE); line.setStrokeWidth(2); + c.drawCircle(cx, cy, r, line); + label(c, "PS", cx, cy + 1.5f, r * .78f, 0xFF151517); + label(c, "PS", cx, cy, r * .78f, LABEL); + } + + // ---- Xbox + + private void letterKey(Canvas c, float w, float h, String letter, int color, boolean pressed) { + float cx = w / 2, cy = h / 2 + (pressed ? 1.5f : 0), r = Math.min(w, h) / 2 * .9f; + capFill(cx, cy, r, pressed); + c.drawCircle(cx, cy, r, fill); + line.setColor(0xFF141518); line.setStrokeWidth(Math.max(2, r * .04f)); + c.drawCircle(cx, cy, r, line); + line.setColor(0x14FFFFFF); + c.drawCircle(cx, cy, r * .93f, line); + label(c, letter, cx, cy + r * .04f, r * .95f, color); + if (pressed) { + plainFill((color & 0x00FFFFFF) | 0x26000000); + c.drawCircle(cx, cy, r, fill); + } + } + + /** One-piece cross on a faint round base; the pressed arm darkens and dips. */ + private void xdpad(Canvas c, float w, float h, int direction) { + float cx = w / 2, cy = h / 2, r = Math.min(w, h) / 2 / 1.14f; + plainFill(0x598F9196); + c.drawCircle(cx, cy, r * 1.12f, fill); + line.setColor(0x73FFFFFF); line.setStrokeWidth(Math.max(2, r * .015f)); + c.drawCircle(cx, cy, r * 1.12f, line); + float a = r * .34f, k = a * .4f; + path.reset(); + path.moveTo(cx - a, cy - r + k); + path.quadTo(cx - a, cy - r, cx - a + k, cy - r); path.lineTo(cx + a - k, cy - r); path.quadTo(cx + a, cy - r, cx + a, cy - r + k); + path.lineTo(cx + a, cy - a); path.lineTo(cx + r - k, cy - a); path.quadTo(cx + r, cy - a, cx + r, cy - a + k); + path.lineTo(cx + r, cy + a - k); path.quadTo(cx + r, cy + a, cx + r - k, cy + a); path.lineTo(cx + a, cy + a); + path.lineTo(cx + a, cy + r - k); path.quadTo(cx + a, cy + r, cx + a - k, cy + r); path.lineTo(cx - a + k, cy + r); + path.quadTo(cx - a, cy + r, cx - a, cy + r - k); path.lineTo(cx - a, cy + a); path.lineTo(cx - r + k, cy + a); + path.quadTo(cx - r, cy + a, cx - r, cy + a - k); path.lineTo(cx - r, cy - a + k); path.quadTo(cx - r, cy - a, cx - r + k, cy - a); + path.lineTo(cx - a, cy - a); path.close(); + keyFill(cy - r, cy + r, false); + c.drawPath(path, fill); + edge(c, KEY_EDGE, Math.max(2, r * .02f)); + int[] bit = {DigitalPad.DIGITAL_PAD_DIRECTION_UP, DigitalPad.DIGITAL_PAD_DIRECTION_RIGHT, + DigitalPad.DIGITAL_PAD_DIRECTION_DOWN, DigitalPad.DIGITAL_PAD_DIRECTION_LEFT}; + for (int i = 0; i < 4; i++) { + boolean down = (direction & bit[i]) != 0; + c.save(); + c.rotate(90 * i, cx, cy); + if (down) { + // the pressed arm lightens, like the PlayStation keys + plainFill(0x33FFFFFF); + c.drawRect(cx - a, cy - r, cx + a, cy - a, fill); + } + path.reset(); + path.moveTo(cx, cy - r * .84f); + path.lineTo(cx - r * .11f, cy - r * .69f); + path.lineTo(cx + r * .11f, cy - r * .69f); + path.close(); + plainFill(down ? 0xFFD2D4D9 : 0xFF8A8C91); + c.drawPath(path, fill); + c.restore(); + } + } + + private void trigger(Canvas c, boolean left, float w, float h, boolean pressed, String s) { + float pad = h * .1f, x = pad, y = pad + (pressed ? 1.5f : 0), kw = w - 2 * pad, kh = h - 2 * pad, k = kh * .55f; + path.reset(); + if (left) { + path.moveTo(x, y + kh); path.lineTo(x, y + k); path.quadTo(x, y, x + k, y); + path.lineTo(x + kw - k * .3f, y); path.quadTo(x + kw, y, x + kw, y + kh * .5f); path.lineTo(x + kw, y + kh); + } else { + path.moveTo(x + kw, y + kh); path.lineTo(x + kw, y + k); path.quadTo(x + kw, y, x + kw - k, y); + path.lineTo(x + k * .3f, y); path.quadTo(x, y, x, y + kh * .5f); path.lineTo(x, y + kh); + } + path.close(); + keyFill(y, y + kh, pressed); + c.drawPath(path, fill); + edge(c, KEY_EDGE, Math.max(2, kh * .03f)); + label(c, s, x + kw / 2, y + kh * .55f + 1.5f, kh * .38f, 0xFF151517); + label(c, s, x + kw / 2, y + kh * .55f, kh * .38f, LABEL); + } + + private void xHome(Canvas c, float w, float h, boolean pressed) { + float cx = w / 2, cy = h / 2 + (pressed ? 1.5f : 0), r = Math.min(w, h) / 2 * .78f; + line.setColor(pressed ? 0xE6FFFFFF : 0x8CFFFFFF); line.setStrokeWidth(r * .12f); + c.drawCircle(cx, cy, r * 1.18f, line); + keyFill(cy - r, cy + r, pressed); + c.drawCircle(cx, cy, r, fill); + line.setColor(KEY_EDGE); line.setStrokeWidth(2); + c.drawCircle(cx, cy, r, line); + line.setColor(LABEL); line.setStrokeWidth(r * .13f); + c.drawCircle(cx, cy, r * .5f, line); + } + + /** View (two windows) or Menu (three lines): a small round key with its name under it. */ + private void smallKey(Canvas c, int shape, float w, float h, boolean pressed) { + float cx = w / 2, cy = h * .38f + (pressed ? 1.5f : 0), r = w * .27f; + keyFill(cy - r, cy + r, pressed); + c.drawCircle(cx, cy, r, fill); + line.setColor(KEY_EDGE); line.setStrokeWidth(2); + c.drawCircle(cx, cy, r, line); + line.setColor(0xFFC9CBD0); line.setStrokeWidth(r * .1f); + if (shape == VIEW) { + rect.set(cx - r * .45f, cy - r * .36f, cx + r * .07f, cy + r * .06f); + c.drawRoundRect(rect, 3, 3, line); + rect.set(cx - r * .15f, cy - r * .08f, cx + r * .37f, cy + r * .34f); + plainFill(0xFF3B3C40); + c.drawRoundRect(rect, 3, 3, fill); + c.drawRoundRect(rect, 3, 3, line); + } else { + for (int i = -1; i <= 1; i++) c.drawLine(cx - r * .38f, cy + i * r * .26f, cx + r * .38f, cy + i * r * .26f, line); + } + label(c, shape == VIEW ? "VIEW" : "MENU", cx, h * .85f, w * .2f, LABEL); + } + + private void share(Canvas c, float w, float h, boolean pressed) { + float cx = w / 2, cy = h * .38f + (pressed ? 1.5f : 0), pw = w * .62f, ph = w * .38f; + rect.set(cx - pw / 2, cy - ph / 2, cx + pw / 2, cy + ph / 2); + keyFill(rect.top, rect.bottom, pressed); + c.drawRoundRect(rect, ph / 2, ph / 2, fill); + line.setColor(KEY_EDGE); line.setStrokeWidth(2); + c.drawRoundRect(rect, ph / 2, ph / 2, line); + line.setColor(LABEL); line.setStrokeWidth(ph * .09f); + c.drawLine(cx, cy - ph * .28f, cx, cy + ph * .2f, line); + c.drawLine(cx - ph * .18f, cy - ph * .1f, cx, cy - ph * .28f, line); + c.drawLine(cx + ph * .18f, cy - ph * .1f, cx, cy - ph * .28f, line); + label(c, "SHARE", cx, h * .85f, w * .2f, LABEL); + } + + // ---- stick: a faint well and a dark cap that follows the thumb a little + + void stick(Canvas c, float w, float h, float thumbX, float thumbY, float travel, boolean pressed) { + float cx = w / 2, cy = h / 2, r = Math.min(w, h) / 2 / 1.9f; + plainFill(0x598F9196); + c.drawCircle(cx, cy, r * 1.25f, fill); + line.setColor(0x73FFFFFF); line.setStrokeWidth(Math.max(2, r * .02f)); + c.drawCircle(cx, cy, r * 1.25f, line); + float k = travel > 0 ? r * .45f / travel : 0; + float x = cx + (thumbX - cx) * k, y = cy + (thumbY - cy) * k; + capFill(x, y, r, pressed); + c.drawCircle(x, y, r, fill); + line.setColor(0xFF121315); line.setStrokeWidth(Math.max(3, r * .05f)); + c.drawCircle(x, y, r, line); + line.setColor(0x40000000); line.setStrokeWidth(r * .05f); + c.drawCircle(x, y, r * .8f, line); + } +} diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualController.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualController.java index ce9f4014e8..329e0d99ef 100644 --- a/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualController.java +++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualController.java @@ -22,7 +22,7 @@ public class VirtualController { public static class ControllerInputContext { - public short inputMap = 0x0000; + public int inputMap = 0x0000; public byte leftTrigger = 0x00; public byte rightTrigger = 0x00; public short rightStickX = 0x0000; @@ -59,12 +59,21 @@ public void run() { private List elements = new ArrayList<>(); + // MoonCast's drawn skin (null = Moonlight's outline look) + PadSkin skin; + public VirtualController(final ControllerHandler controllerHandler, FrameLayout layout, final Context context) { this.controllerHandler = controllerHandler; this.frame_layout = layout; this.context = context; this.handler = new Handler(Looper.getMainLooper()); + String style = android.preference.PreferenceManager.getDefaultSharedPreferences(context).getString(PadSkin.PREF, PadSkin.NONE); + if (PadSkin.PS.equals(style) || PadSkin.XBOX.equals(style)) { + skin = new PadSkin(context, PadSkin.XBOX.equals(style)); + if (controllerHandler != null) controllerHandler.setOscType(skin.xbox ? com.limelight.nvstream.jni.MoonBridge.LI_CTYPE_XBOX : com.limelight.nvstream.jni.MoonBridge.LI_CTYPE_PS); + } + buttonConfigure = new Button(context); buttonConfigure.setAlpha(0.25f); buttonConfigure.setFocusable(false); @@ -115,7 +124,7 @@ public void show() { element.setVisibility(View.VISIBLE); } - buttonConfigure.setVisibility(View.VISIBLE); + buttonConfigure.setVisibility(skin != null ? View.GONE : View.VISIBLE); } public void removeElements() { @@ -163,6 +172,14 @@ public void refreshLayout() { params.topMargin = 15; frame_layout.addView(buttonConfigure, params); + if (skin != null) { + // the skin has its own layout; the gear would sit on L1, so the Quick menu key replaces it + buttonConfigure.setVisibility(View.GONE); + if (skin.xbox) VirtualControllerConfigurationLoader.createXboxLayout(this, context); + else VirtualControllerConfigurationLoader.createPlayStationLayout(this, context); + return; + } + // Start with the default layout VirtualControllerConfigurationLoader.createDefaultLayout(this, context); diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualControllerConfigurationLoader.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualControllerConfigurationLoader.java index d66ca8362e..4b38fe17a9 100644 --- a/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualControllerConfigurationLoader.java +++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualControllerConfigurationLoader.java @@ -350,6 +350,143 @@ public static void createDefaultLayout(final VirtualController controller, final controller.setOpacity(config.oscOpacity); } + // ---- MoonCast PlayStation skin: Delta's landscape positions, as fractions of the screen + + private static void place(VirtualController controller, VirtualControllerElement element, float cx, float cy, float w, float h) { + controller.addElement(element, Math.round(cx - w / 2), Math.round(cy - h / 2), Math.round(w), Math.round(h)); + } + + private static DigitalButton skinned(DigitalButton button, int shape) { + button.setSkinShape(shape); + return button; + } + + public static void createPlayStationLayout(final VirtualController controller, final Context context) { + DisplayMetrics screen = context.getResources().getDisplayMetrics(); + PreferenceConfiguration config = PreferenceConfiguration.readPreferences(context); + float W = Math.max(screen.widthPixels, screen.heightPixels), H = Math.min(screen.widthPixels, screen.heightPixels); + if (screen.heightPixels > screen.widthPixels) { + // upright: same keys, over the lower part of the screen + W = screen.widthPixels; + H = screen.heightPixels; + } + float u = Math.min(W, H); + + // shoulders stacked in the top corners: L2 over L1, R2 over R1 (the drawn key sits inside a small margin) + float sh = u * .096f * 1.1f, sw = W * .12f * 1.1f, gap = H * .017f + sh * .05f, m = sh * .125f; + float top = H * .045f + sh / 2, low = top + sh + gap, lx = W * .024f + sw / 2, rx = W * .976f - sw / 2; + place(controller, skinned(createLeftTrigger(1, "L2", -1, controller, context), PadSkin.SHOULDER_L), lx, top, sw + 2 * m, sh + 2 * m); + place(controller, skinned(createDigitalButton(VirtualControllerElement.EID_LB, ControllerPacket.LB_FLAG, 0, 1, "L1", -1, controller, context), PadSkin.SHOULDER_L), + lx, low, sw + 2 * m, sh + 2 * m); + place(controller, skinned(createRightTrigger(1, "R2", -1, controller, context), PadSkin.SHOULDER_R), rx, top, sw + 2 * m, sh + 2 * m); + place(controller, skinned(createDigitalButton(VirtualControllerElement.EID_RB, ControllerPacket.RB_FLAG, 0, 1, "R1", -1, controller, context), PadSkin.SHOULDER_R), + rx, low, sw + 2 * m, sh + 2 * m); + + // MoonCast's menu key, top middle: opens the Quick menu + DigitalButton menu = skinned(createDigitalButton(VirtualControllerElement.EID_MENU, 0, 0, 4, "", -1, controller, context), PadSkin.MENU); + menu.addDigitalButtonListener(new DigitalButton.DigitalButtonListener() { + @Override public void onClick() {} + @Override public void onLongClick() {} + @Override public void onRelease() { + if (context instanceof com.limelight.Game) ((com.limelight.Game) context).openQuickMenu(); + } + }); + place(controller, menu, W * .5f, H * .085f, u * .075f, u * .075f); + + // d-pad, left; 10% bigger than Delta's so a thumb lands on one direction + float dr = u * .118f * 1.1f; + place(controller, createDigitalPad(controller, context), W * .125f, H * .60f, 2 * 1.36f * dr, 2 * 1.36f * dr); + + // shape buttons on their plate, right: 10% bigger and 5% further apart. Triangle up, circle right, cross down, square left + float fr = u * .107f, fx = W * .875f, fy = H * .60f, d = fr * .98f * 1.05f, br = fr * .46f * 1.1f, b = 2 * br / .9f; + float plate = 2 * (d + br * 1.12f) / .97f; + place(controller, new PadPlate(controller, context, VirtualControllerElement.EID_PLATE), fx, fy, plate, plate); + place(controller, skinned(createDigitalButton(VirtualControllerElement.EID_Y, ControllerPacket.Y_FLAG, 0, 1, "Y", -1, controller, context), PadSkin.TRIANGLE), fx, fy - d, b, b); + place(controller, skinned(createDigitalButton(VirtualControllerElement.EID_B, ControllerPacket.B_FLAG, 0, 1, "B", -1, controller, context), PadSkin.CIRCLE), fx + d, fy, b, b); + place(controller, skinned(createDigitalButton(VirtualControllerElement.EID_A, ControllerPacket.A_FLAG, 0, 1, "A", -1, controller, context), PadSkin.CROSS), fx, fy + d, b, b); + place(controller, skinned(createDigitalButton(VirtualControllerElement.EID_X, ControllerPacket.X_FLAG, 0, 1, "X", -1, controller, context), PadSkin.SQUARE), fx - d, fy, b, b); + + // sticks low, close in under the clusters; the touch area is wider than the drawn well. Double tap = L3 / R3 + float st = 2 * 1.9f * u * .075f; + place(controller, createLeftStick(controller, context), W * .28f, H * .82f, st, st); + place(controller, createRightStick(controller, context), W * .72f, H * .82f, st, st); + + // PS button between the sticks, above the touchpad, as on the DualSense + place(controller, skinned(createDigitalButton(VirtualControllerElement.EID_PSHOME, ControllerPacket.SPECIAL_BUTTON_FLAG, 0, 2, "PS", -1, controller, context), PadSkin.PSHOME), + W * .5f, H * .79f, u * .06f, u * .06f); + + // Create, touchpad, Options along the bottom middle + float k = u * .03f * 2.6f; + place(controller, skinned(createDigitalButton(VirtualControllerElement.EID_BACK, ControllerPacket.BACK_FLAG, 0, 2, "CREATE", -1, controller, context), PadSkin.CREATE), + W * .405f, H * .875f + k * .14f, k, k); + place(controller, skinned(createDigitalButton(VirtualControllerElement.EID_TOUCHPAD, ControllerPacket.TOUCHPAD_FLAG, 0, 2, "TOUCHPAD", -1, controller, context), PadSkin.TOUCHPAD), + W * .5f, H * .90f, W * .095f + u * .01f, u * .062f / .84f); + place(controller, skinned(createDigitalButton(VirtualControllerElement.EID_START, ControllerPacket.PLAY_FLAG, 0, 3, "OPTIONS", -1, controller, context), PadSkin.OPTIONS), + W * .595f, H * .875f + k * .14f, k, k); + + controller.setOpacity(PadSkin.opacity(context)); + } + + // ---- MoonCast Xbox skin: the real Xbox arrangement (left stick up, d-pad down beside it), same sizes as the PlayStation skin + + public static void createXboxLayout(final VirtualController controller, final Context context) { + DisplayMetrics screen = context.getResources().getDisplayMetrics(); + float W = Math.max(screen.widthPixels, screen.heightPixels), H = Math.min(screen.widthPixels, screen.heightPixels); + if (screen.heightPixels > screen.widthPixels) { + W = screen.widthPixels; + H = screen.heightPixels; + } + float u = Math.min(W, H); + + // LT over LB, RT over RB + float sh = u * .096f * 1.1f, sw = W * .12f * 1.1f, gap = H * .017f + sh * .05f, m = sh * .125f; + float top = H * .045f + sh / 2, low = top + sh + gap, lx = W * .024f + sw / 2, rx = W * .976f - sw / 2; + place(controller, skinned(createLeftTrigger(1, "LT", -1, controller, context), PadSkin.TRIGGER_L), lx, top, sw + 2 * m, sh + 2 * m); + place(controller, skinned(createDigitalButton(VirtualControllerElement.EID_LB, ControllerPacket.LB_FLAG, 0, 1, "LB", -1, controller, context), PadSkin.SHOULDER_L), + lx, low, sw + 2 * m, sh + 2 * m); + place(controller, skinned(createRightTrigger(1, "RT", -1, controller, context), PadSkin.TRIGGER_R), rx, top, sw + 2 * m, sh + 2 * m); + place(controller, skinned(createDigitalButton(VirtualControllerElement.EID_RB, ControllerPacket.RB_FLAG, 0, 1, "RB", -1, controller, context), PadSkin.SHOULDER_R), + rx, low, sw + 2 * m, sh + 2 * m); + + DigitalButton menu = skinned(createDigitalButton(VirtualControllerElement.EID_MENU, 0, 0, 4, "", -1, controller, context), PadSkin.MENU); + menu.addDigitalButtonListener(new DigitalButton.DigitalButtonListener() { + @Override public void onClick() {} + @Override public void onLongClick() {} + @Override public void onRelease() { + if (context instanceof com.limelight.Game) ((com.limelight.Game) context).openQuickMenu(); + } + }); + place(controller, menu, W * .5f, H * .085f, u * .075f, u * .075f); + + // left stick up on the left (a little bigger: the thumb rests there), d-pad lower, beside it + float ls = 2 * 1.9f * u * .08f; + place(controller, createLeftStick(controller, context), W * .125f, H * .60f, ls, ls); + float dr = u * .105f; + place(controller, createDigitalPad(controller, context), W * .285f, H * .78f, 2 * 1.14f * dr, 2 * 1.14f * dr); + + // A B X Y on their plate, right: Y up, B right, A down, X left + float fr = u * .107f, fx = W * .875f, fy = H * .60f, d = fr * .98f * 1.05f, br = fr * .46f * 1.1f, b = 2 * br / .9f; + float plate = 2 * (d + br * 1.12f) / .97f; + place(controller, new PadPlate(controller, context, VirtualControllerElement.EID_PLATE), fx, fy, plate, plate); + place(controller, skinned(createDigitalButton(VirtualControllerElement.EID_Y, ControllerPacket.Y_FLAG, 0, 1, "Y", -1, controller, context), PadSkin.XY), fx, fy - d, b, b); + place(controller, skinned(createDigitalButton(VirtualControllerElement.EID_B, ControllerPacket.B_FLAG, 0, 1, "B", -1, controller, context), PadSkin.XB), fx + d, fy, b, b); + place(controller, skinned(createDigitalButton(VirtualControllerElement.EID_A, ControllerPacket.A_FLAG, 0, 1, "A", -1, controller, context), PadSkin.XA), fx, fy + d, b, b); + place(controller, skinned(createDigitalButton(VirtualControllerElement.EID_X, ControllerPacket.X_FLAG, 0, 1, "X", -1, controller, context), PadSkin.XX), fx - d, fy, b, b); + + float st = 2 * 1.9f * u * .075f; + place(controller, createRightStick(controller, context), W * .72f, H * .82f, st, st); + + // Xbox (guide) button between the sticks; View, Share, Menu along the bottom middle + place(controller, skinned(createDigitalButton(VirtualControllerElement.EID_PSHOME, ControllerPacket.SPECIAL_BUTTON_FLAG, 0, 2, "", -1, controller, context), PadSkin.XHOME), + W * .5f, H * .79f, u * .065f, u * .065f); + float k = u * .09f, ky = H * .875f + u * .012f; + place(controller, skinned(createDigitalButton(VirtualControllerElement.EID_BACK, ControllerPacket.BACK_FLAG, 0, 2, "VIEW", -1, controller, context), PadSkin.VIEW), W * .405f, ky, k, k); + place(controller, skinned(createDigitalButton(VirtualControllerElement.EID_SHARE, ControllerPacket.MISC_FLAG, 0, 2, "SHARE", -1, controller, context), PadSkin.SHARE), W * .5f, ky, k, k); + place(controller, skinned(createDigitalButton(VirtualControllerElement.EID_START, ControllerPacket.PLAY_FLAG, 0, 3, "MENU", -1, controller, context), PadSkin.XMENU), W * .595f, ky, k, k); + + controller.setOpacity(PadSkin.opacity(context)); + } + public static void saveProfile(final VirtualController controller, final Context context) { SharedPreferences.Editor prefEditor = context.getSharedPreferences(OSC_PREFERENCE, Activity.MODE_PRIVATE).edit(); diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualControllerElement.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualControllerElement.java index e45e9ddc75..b690619fdc 100644 --- a/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualControllerElement.java +++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualControllerElement.java @@ -36,6 +36,12 @@ public abstract class VirtualControllerElement extends View { public static final int EID_LSB = 14; public static final int EID_RSB = 15; public static final int EID_GDB = 16; + // MoonCast skins + public static final int EID_MENU = 17; + public static final int EID_TOUCHPAD = 18; + public static final int EID_PSHOME = 19; + public static final int EID_SHARE = 20; + public static final int EID_PLATE = 100; protected VirtualController virtualController; protected final int elementId; @@ -286,6 +292,10 @@ else if (virtualController.getControllerMode() == VirtualController.ControllerMo return true; } + protected PadSkin skin() { + return virtualController.skin; + } + abstract protected void onElementDraw(Canvas canvas); abstract public boolean onElementTouchEvent(MotionEvent event); @@ -305,6 +315,11 @@ public void setColors(int normalColor, int pressedColor) { public void setOpacity(int opacity) { + if (virtualController.skin != null) { + // a drawn skin fades as a whole, so the game shows through it evenly + setAlpha(opacity / 100f); + return; + } int hexOpacity = opacity * 255 / 100; this.normalColor = (hexOpacity << 24) | (normalColor & 0x00FFFFFF); this.pressedColor = (hexOpacity << 24) | (pressedColor & 0x00FFFFFF); diff --git a/app/src/main/java/com/limelight/binding/video/FsrRenderer.kt b/app/src/main/java/com/limelight/binding/video/FsrRenderer.kt new file mode 100644 index 0000000000..6ed3aba833 --- /dev/null +++ b/app/src/main/java/com/limelight/binding/video/FsrRenderer.kt @@ -0,0 +1,521 @@ +package com.limelight.binding.video + +import android.graphics.SurfaceTexture +import android.opengl.EGL14 +import android.opengl.EGLConfig +import android.opengl.EGLContext +import android.opengl.EGLDisplay +import android.opengl.EGLExt +import android.opengl.EGLSurface +import android.opengl.GLES11Ext +import android.opengl.GLES30 +import android.os.Handler +import android.os.HandlerThread +import android.util.Log +import android.view.Surface +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +// Super resolution with AMD FidelityFX Super Resolution 1.0 (FSR 1, MIT licence, see assets/licences). +// The shaders are ports of ffx_fsr1.h's 32-bit FsrEasuF and FsrRcasF to OpenGL ES 3.0. ES 3.0 has no textureGather, +// so each gather is four texelFetches in gather order. + +/** + * The three FSR passes on the current GL context (OpenGL ES 3.0): copy an external OES frame into a plain texture at + * the frame's size, EASU (AMD's edge-adaptive Lanczos) to the output size, RCAS sharpening into [targetFb]'s viewport. + * GL objects are made on first use and remade when a size changes. Used by [FsrRenderer] (PS5, PC) and [FsrGlDrawer] + * (Xbox, inside WebRTC's renderer). + */ +class FsrPasses(sharpness: Float) { + private var copyProg = 0 + private var easuProg = 0 + private var rcasProg = 0 + private var copyTex = 0 + private var copyFbo = 0 + private var copyW = 0 + private var copyH = 0 + private var easuTex = 0 + private var easuFbo = 0 + private var easuW = 0 + private var easuH = 0 + private val vao = IntArray(1) + private val sharp = Math.pow(2.0, -((1.0 - sharpness.coerceIn(0f, 1f)) * 2.0)).toFloat() // stops: 100% = 0, 0% = 2 + + /** Draws one frame. [oesTex] with [texMatrix] is the frame ([inW] x [inH]); the result fills the viewport. */ + fun draw(oesTex: Int, texMatrix: FloatArray, inW: Int, inH: Int, targetFb: Int, vx: Int, vy: Int, outW: Int, outH: Int) { + if (inW <= 0 || inH <= 0 || outW <= 0 || outH <= 0) return + if (copyProg == 0) { + copyProg = program(VERTEX, COPY); easuProg = program(VERTEX, EASU); rcasProg = program(VERTEX, RCAS) + GLES30.glGenVertexArrays(1, vao, 0) + } + if (copyW != inW || copyH != inH) { + free(copyTex, copyFbo) + val (t, f) = target(inW, inH); copyTex = t; copyFbo = f; copyW = inW; copyH = inH + } + if (easuW != outW || easuH != outH) { + free(easuTex, easuFbo) + val (t, f) = target(outW, outH); easuTex = t; easuFbo = f; easuW = outW; easuH = outH + Log.i(TAG, "output ${outW}x$outH (from ${inW}x$inH)") + } + GLES30.glBindVertexArray(vao[0]) + GLES30.glDisable(GLES30.GL_BLEND) + GLES30.glActiveTexture(GLES30.GL_TEXTURE0) + + // 1. the frame into a plain texture at its own size + GLES30.glBindFramebuffer(GLES30.GL_FRAMEBUFFER, copyFbo) + GLES30.glViewport(0, 0, inW, inH) + GLES30.glUseProgram(copyProg) + GLES30.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, oesTex) + GLES30.glUniform1i(GLES30.glGetUniformLocation(copyProg, "uTex"), 0) + GLES30.glUniformMatrix4fv(GLES30.glGetUniformLocation(copyProg, "uSt"), 1, false, texMatrix, 0) + GLES30.glDrawArrays(GLES30.GL_TRIANGLES, 0, 3) + + // 2. EASU up to the output size (constants as FsrEasuCon, kept as floats) + GLES30.glBindFramebuffer(GLES30.GL_FRAMEBUFFER, easuFbo) + GLES30.glViewport(0, 0, outW, outH) + GLES30.glUseProgram(easuProg) + GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, copyTex) + GLES30.glUniform1i(GLES30.glGetUniformLocation(easuProg, "uTex"), 0) + val iw = inW.toFloat(); val ih = inH.toFloat() + GLES30.glUniform4f(GLES30.glGetUniformLocation(easuProg, "uCon0"), iw / outW, ih / outH, 0.5f * iw / outW - 0.5f, 0.5f * ih / outH - 0.5f) + GLES30.glUniform4f(GLES30.glGetUniformLocation(easuProg, "uCon1"), 1f / iw, 1f / ih, 1f / iw, -1f / ih) + GLES30.glUniform4f(GLES30.glGetUniformLocation(easuProg, "uCon2"), -1f / iw, 2f / ih, 1f / iw, 2f / ih) + GLES30.glUniform4f(GLES30.glGetUniformLocation(easuProg, "uCon3"), 0f, 4f / ih, 0f, 0f) + GLES30.glUniform2i(GLES30.glGetUniformLocation(easuProg, "uInSize"), inW, inH) + GLES30.glDrawArrays(GLES30.GL_TRIANGLES, 0, 3) + + // 3. RCAS sharpening into the target + GLES30.glBindFramebuffer(GLES30.GL_FRAMEBUFFER, targetFb) + GLES30.glViewport(vx, vy, outW, outH) + GLES30.glUseProgram(rcasProg) + GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, easuTex) + GLES30.glUniform1i(GLES30.glGetUniformLocation(rcasProg, "uTex"), 0) + GLES30.glUniform1f(GLES30.glGetUniformLocation(rcasProg, "uSharp"), sharp) + GLES30.glUniform2i(GLES30.glGetUniformLocation(rcasProg, "uSize"), outW, outH) + GLES30.glUniform2i(GLES30.glGetUniformLocation(rcasProg, "uOrigin"), vx, vy) + GLES30.glDrawArrays(GLES30.GL_TRIANGLES, 0, 3) + GLES30.glBindVertexArray(0) + GLES30.glUseProgram(0) + } + + /** Frees the GL objects; call on the same GL context. */ + fun release() { + free(copyTex, copyFbo); free(easuTex, easuFbo) + copyTex = 0; easuTex = 0; copyW = 0; easuW = 0 + if (copyProg != 0) { GLES30.glDeleteProgram(copyProg); GLES30.glDeleteProgram(easuProg); GLES30.glDeleteProgram(rcasProg); copyProg = 0 } + if (vao[0] != 0) { GLES30.glDeleteVertexArrays(1, vao, 0); vao[0] = 0 } + } + + private fun free(tex: Int, fbo: Int) { + if (tex != 0) GLES30.glDeleteTextures(1, intArrayOf(tex), 0) + if (fbo != 0) GLES30.glDeleteFramebuffers(1, intArrayOf(fbo), 0) + } + + private fun target(w: Int, h: Int): Pair { + val t = IntArray(1); val f = IntArray(1) + GLES30.glGenTextures(1, t, 0) + GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, t[0]) + GLES30.glTexStorage2D(GLES30.GL_TEXTURE_2D, 1, GLES30.GL_RGBA8, w, h) + GLES30.glTexParameteri(GLES30.GL_TEXTURE_2D, GLES30.GL_TEXTURE_MIN_FILTER, GLES30.GL_NEAREST) + GLES30.glTexParameteri(GLES30.GL_TEXTURE_2D, GLES30.GL_TEXTURE_MAG_FILTER, GLES30.GL_NEAREST) + GLES30.glGenFramebuffers(1, f, 0) + GLES30.glBindFramebuffer(GLES30.GL_FRAMEBUFFER, f[0]) + GLES30.glFramebufferTexture2D(GLES30.GL_FRAMEBUFFER, GLES30.GL_COLOR_ATTACHMENT0, GLES30.GL_TEXTURE_2D, t[0], 0) + check(GLES30.glCheckFramebufferStatus(GLES30.GL_FRAMEBUFFER) == GLES30.GL_FRAMEBUFFER_COMPLETE) { "framebuffer ${w}x$h" } + return t[0] to f[0] + } + + private fun program(vs: String, fs: String): Int { + fun shader(type: Int, src: String): Int { + val s = GLES30.glCreateShader(type) + GLES30.glShaderSource(s, src) + GLES30.glCompileShader(s) + val ok = IntArray(1) + GLES30.glGetShaderiv(s, GLES30.GL_COMPILE_STATUS, ok, 0) + check(ok[0] != 0) { "shader: " + GLES30.glGetShaderInfoLog(s) } + return s + } + val p = GLES30.glCreateProgram() + GLES30.glAttachShader(p, shader(GLES30.GL_VERTEX_SHADER, vs)) + GLES30.glAttachShader(p, shader(GLES30.GL_FRAGMENT_SHADER, fs)) + GLES30.glLinkProgram(p) + val ok = IntArray(1) + GLES30.glGetProgramiv(p, GLES30.GL_LINK_STATUS, ok, 0) + check(ok[0] != 0) { "link: " + GLES30.glGetProgramInfoLog(p) } + return p + } + + companion object { + private const val TAG = "MoonCast-FSR" + // a full-screen triangle; no vertex buffers + private const val VERTEX = """#version 300 es +out vec2 vUv; +void main() { + vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); + vUv = p; + gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); +}""" + + private const val COPY = """#version 300 es +#extension GL_OES_EGL_image_external_essl3 : require +precision mediump float; +uniform samplerExternalOES uTex; +uniform mat4 uSt; +in vec2 vUv; +out vec4 o; +void main() { o = vec4(texture(uTex, (uSt * vec4(vUv, 0.0, 1.0)).xy).rgb, 1.0); }""" + + // ffx_fsr1.h FsrEasuF (32-bit), with FsrEasuTapF and FsrEasuSetF; textureGather replaced by 4 texelFetches + private const val EASU = """#version 300 es +precision highp float; +precision highp int; +uniform highp sampler2D uTex; +uniform vec4 uCon0; +uniform vec4 uCon1; +uniform vec4 uCon2; +uniform vec4 uCon3; +uniform ivec2 uInSize; +out vec4 o; + +vec3 texel(ivec2 p) { return texelFetch(uTex, clamp(p, ivec2(0), uInSize - 1), 0).rgb; } +// textureGather at a texel corner p: x = (left, upper+1), y = (left+1, upper+1), z = (left+1, upper), w = (left, upper) +void gather(vec2 p, out vec3 x, out vec3 y, out vec3 z, out vec3 w) { + ivec2 tl = ivec2(floor(p * vec2(uInSize) + 0.5)) - 1; + x = texel(tl + ivec2(0, 1)); y = texel(tl + ivec2(1, 1)); z = texel(tl + ivec2(1, 0)); w = texel(tl); +} +float luma(vec3 c) { return c.b * 0.5 + (c.r * 0.5 + c.g); } +float rcp(float x) { return 1.0 / max(x, 1.0e-8); } + +void tap(inout vec3 aC, inout float aW, vec2 off, vec2 dir, vec2 len, float lob, float clp, vec3 c) { + vec2 v; + v.x = (off.x * ( dir.x)) + (off.y * dir.y); + v.y = (off.x * (-dir.y)) + (off.y * dir.x); + v *= len; + float d2 = min(v.x * v.x + v.y * v.y, clp); + float wB = 2.0 / 5.0 * d2 - 1.0; + float wA = lob * d2 - 1.0; + wB *= wB; + wA *= wA; + wB = 25.0 / 16.0 * wB - (25.0 / 16.0 - 1.0); + float w = wB * wA; + aC += c * w; aW += w; +} + +void set(inout vec2 dir, inout float len, vec2 pp, int corner, float lA, float lB, float lC, float lD, float lE) { + float w = 0.0; + if (corner == 0) w = (1.0 - pp.x) * (1.0 - pp.y); + if (corner == 1) w = pp.x * (1.0 - pp.y); + if (corner == 2) w = (1.0 - pp.x) * pp.y; + if (corner == 3) w = pp.x * pp.y; + float dc = lD - lC; + float cb = lC - lB; + float lenX = rcp(max(abs(dc), abs(cb))); + float dirX = lD - lB; + dir.x += dirX * w; + lenX = clamp(abs(dirX) * lenX, 0.0, 1.0); + lenX *= lenX; + len += lenX * w; + float ec = lE - lC; + float ca = lC - lA; + float lenY = rcp(max(abs(ec), abs(ca))); + float dirY = lE - lA; + dir.y += dirY * w; + lenY = clamp(abs(dirY) * lenY, 0.0, 1.0); + lenY *= lenY; + len += lenY * w; +} + +void main() { + vec2 pp = floor(gl_FragCoord.xy) * uCon0.xy + uCon0.zw; + vec2 fp = floor(pp); + pp -= fp; + vec2 p0 = fp * uCon1.xy + uCon1.zw; + vec2 p1 = p0 + uCon2.xy; + vec2 p2 = p0 + uCon2.zw; + vec2 p3 = p0 + uCon3.xy; + vec3 b, c, z0, w0, i, j, f, e, k, l, h, g, x3, y3, oo, n; + gather(p0, b, c, z0, w0); + gather(p1, i, j, f, e); + gather(p2, k, l, h, g); + gather(p3, x3, y3, oo, n); + float bL = luma(b), cL = luma(c), iL = luma(i), jL = luma(j), fL = luma(f), eL = luma(e); + float kL = luma(k), lL = luma(l), hL = luma(h), gL = luma(g), oL = luma(oo), nL = luma(n); + vec2 dir = vec2(0.0); + float len = 0.0; + set(dir, len, pp, 0, bL, eL, fL, gL, jL); + set(dir, len, pp, 1, cL, fL, gL, hL, kL); + set(dir, len, pp, 2, fL, iL, jL, kL, nL); + set(dir, len, pp, 3, gL, jL, kL, lL, oL); + vec2 dir2 = dir * dir; + float dirR = dir2.x + dir2.y; + bool zro = dirR < 1.0 / 32768.0; + dirR = inversesqrt(max(dirR, 1.0e-12)); + dirR = zro ? 1.0 : dirR; + dir.x = zro ? 1.0 : dir.x; + dir *= vec2(dirR); + len = len * 0.5; + len *= len; + float stretch = (dir.x * dir.x + dir.y * dir.y) * rcp(max(abs(dir.x), abs(dir.y))); + vec2 len2 = vec2(1.0 + (stretch - 1.0) * len, 1.0 - 0.5 * len); + float lob = 0.5 + ((1.0 / 4.0 - 0.04) - 0.5) * len; + float clp = rcp(lob); + vec3 min4 = min(min(min(f, g), j), k); + vec3 max4 = max(max(max(f, g), j), k); + vec3 aC = vec3(0.0); + float aW = 0.0; + tap(aC, aW, vec2( 0.0, -1.0) - pp, dir, len2, lob, clp, b); + tap(aC, aW, vec2( 1.0, -1.0) - pp, dir, len2, lob, clp, c); + tap(aC, aW, vec2(-1.0, 1.0) - pp, dir, len2, lob, clp, i); + tap(aC, aW, vec2( 0.0, 1.0) - pp, dir, len2, lob, clp, j); + tap(aC, aW, vec2( 0.0, 0.0) - pp, dir, len2, lob, clp, f); + tap(aC, aW, vec2(-1.0, 0.0) - pp, dir, len2, lob, clp, e); + tap(aC, aW, vec2( 1.0, 1.0) - pp, dir, len2, lob, clp, k); + tap(aC, aW, vec2( 2.0, 1.0) - pp, dir, len2, lob, clp, l); + tap(aC, aW, vec2( 2.0, 0.0) - pp, dir, len2, lob, clp, h); + tap(aC, aW, vec2( 1.0, 0.0) - pp, dir, len2, lob, clp, g); + tap(aC, aW, vec2( 1.0, 2.0) - pp, dir, len2, lob, clp, oo); + tap(aC, aW, vec2( 0.0, 2.0) - pp, dir, len2, lob, clp, n); + o = vec4(min(max4, max(min4, aC * rcp(aW))), 1.0); +}""" + + // ffx_fsr1.h FsrRcasF (32-bit, no denoise) + private const val RCAS = """#version 300 es +precision highp float; +precision highp int; +uniform highp sampler2D uTex; +uniform float uSharp; +uniform ivec2 uSize; +out vec4 o; +uniform ivec2 uOrigin; +vec3 texel(ivec2 p) { return texelFetch(uTex, clamp(p, ivec2(0), uSize - 1), 0).rgb; } +void main() { + ivec2 sp = ivec2(gl_FragCoord.xy) - uOrigin; + vec3 b = texel(sp + ivec2(0, -1)); + vec3 d = texel(sp + ivec2(-1, 0)); + vec3 e = texel(sp); + vec3 f = texel(sp + ivec2(1, 0)); + vec3 h = texel(sp + ivec2(0, 1)); + vec3 mn4 = min(min(b, d), min(f, h)); + vec3 mx4 = max(max(b, d), max(f, h)); + vec3 hitMin = min(mn4, e) / max(4.0 * mx4, vec3(1.0e-5)); + vec3 hitMax = (1.0 - max(mx4, e)) / min(4.0 * mn4 - 4.0, vec3(-1.0e-5)); + vec3 lobeRGB = max(-hitMin, hitMax); + float lobe = max(-(0.25 - 1.0 / 16.0), min(max(lobeRGB.r, max(lobeRGB.g, lobeRGB.b)), 0.0)) * uSharp; + o = vec4((lobe * (b + d + h + f) + e) / (4.0 * lobe + 1.0), 1.0); +}""" + } +} + +/** + * Super resolution for a decoder that renders into a Surface (PS5 via chiaki, PC via MediaCodec): the decoder renders + * into [inputSurface] (a SurfaceTexture) and each frame is drawn to the screen surface through [FsrPasses], on our own + * GL thread. The frame keeps the decoder's presentation time, so frame pacing still applies. The screen surface can + * come and go (a foldable's fold) with [attachOutput]/[detachOutput] while the decoder keeps its own surface. + */ +class FsrRenderer(private val videoWidth: Int, private val videoHeight: Int, sharpness: Float) { + private val thread = HandlerThread("fsr-gl").apply { start() } + private val handler = Handler(thread.looper) + private var display: EGLDisplay = EGL14.EGL_NO_DISPLAY + private var context: EGLContext = EGL14.EGL_NO_CONTEXT + private var config: EGLConfig? = null + private var pbuffer: EGLSurface = EGL14.EGL_NO_SURFACE + private var window: EGLSurface = EGL14.EGL_NO_SURFACE + private var oesTex = 0 + private val passes = FsrPasses(sharpness) + private lateinit var texture: SurfaceTexture + private val stMatrix = FloatArray(16) + @Volatile private var released = false + var frames = 0L + private set + + /** The surface the decoder renders into. */ + lateinit var inputSurface: Surface + private set + + init { + val ready = CountDownLatch(1) + var error: Throwable? = null + handler.post { + try { setUp() } catch (t: Throwable) { error = t } + ready.countDown() + } + ready.await(5, TimeUnit.SECONDS) + error?.let { release(); throw RuntimeException("FSR setup failed: ${it.message}", it) } + if (!this::inputSurface.isInitialized) { release(); throw RuntimeException("FSR setup timed out") } + } + + private fun setUp() { + display = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY) + val ver = IntArray(2) + check(EGL14.eglInitialize(display, ver, 0, ver, 1)) { "eglInitialize" } + // GPUs differ in what they offer (the JAL TV's Mali had no ES 3 config that does both window and pbuffer): + // ask for the most useful first, then less. Without a pbuffer, a surfaceless context stands in for it. + val es3 = EGLExt.EGL_OPENGL_ES3_BIT_KHR + val es2 = EGL14.EGL_OPENGL_ES2_BIT + val both = EGL14.EGL_WINDOW_BIT or EGL14.EGL_PBUFFER_BIT + val tries = listOf(es3 to both, es3 to EGL14.EGL_WINDOW_BIT, es2 to both, es2 to EGL14.EGL_WINDOW_BIT) + val configs = arrayOfNulls(1) + val n = IntArray(1) + var surfaces = 0 + for ((renderable, surfaceType) in tries) { + val attrs = intArrayOf(EGL14.EGL_RED_SIZE, 8, EGL14.EGL_GREEN_SIZE, 8, EGL14.EGL_BLUE_SIZE, 8, + EGL14.EGL_RENDERABLE_TYPE, renderable, EGL14.EGL_SURFACE_TYPE, surfaceType, EGL14.EGL_NONE) + if (EGL14.eglChooseConfig(display, attrs, 0, configs, 0, 1, n, 0) && n[0] > 0) { config = configs[0]; surfaces = surfaceType; break } + } + check(config != null) { "eglChooseConfig: no RGB888 window config (${EGL14.eglGetError()})" } + context = EGL14.eglCreateContext(display, config, EGL14.EGL_NO_CONTEXT, intArrayOf(EGL14.EGL_CONTEXT_CLIENT_VERSION, 3, EGL14.EGL_NONE), 0) + check(context != EGL14.EGL_NO_CONTEXT) { "eglCreateContext ES 3 (${EGL14.eglGetError()})" } + if (surfaces and EGL14.EGL_PBUFFER_BIT != 0) + pbuffer = EGL14.eglCreatePbufferSurface(display, config, intArrayOf(EGL14.EGL_WIDTH, 1, EGL14.EGL_HEIGHT, 1, EGL14.EGL_NONE), 0) + check(EGL14.eglMakeCurrent(display, pbuffer, pbuffer, context)) { "eglMakeCurrent ${if (pbuffer == EGL14.EGL_NO_SURFACE) "surfaceless" else "pbuffer"} (${EGL14.eglGetError()})" } + Log.i(TAG, "EGL: ${if (surfaces and EGL14.EGL_PBUFFER_BIT != 0) "pbuffer" else "surfaceless"}, GL ${GLES30.glGetString(GLES30.GL_VERSION)}") + val t = IntArray(1) + GLES30.glGenTextures(1, t, 0); oesTex = t[0] + GLES30.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, oesTex) + GLES30.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES30.GL_TEXTURE_MIN_FILTER, GLES30.GL_LINEAR) + GLES30.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES30.GL_TEXTURE_MAG_FILTER, GLES30.GL_LINEAR) + GLES30.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES30.GL_TEXTURE_WRAP_S, GLES30.GL_CLAMP_TO_EDGE) + GLES30.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES30.GL_TEXTURE_WRAP_T, GLES30.GL_CLAMP_TO_EDGE) + // compile the shaders now: a GPU that can't run them fails here, and the caller shows the stream directly + passes.draw(oesTex, IDENTITY, 2, 2, 0, 0, 0, 2, 2) + texture = SurfaceTexture(oesTex).apply { + setDefaultBufferSize(videoWidth, videoHeight) + setOnFrameAvailableListener({ draw() }, handler) + } + inputSurface = Surface(texture) + Log.i(TAG, "ready: ${videoWidth}x$videoHeight, GL ${GLES30.glGetString(GLES30.GL_RENDERER)}") + } + + /** Draw to this screen surface from now on (blocks until it's in use). */ + fun attachOutput(surface: Surface) = onGl { + destroyWindow() + window = EGL14.eglCreateWindowSurface(display, config, surface, intArrayOf(EGL14.EGL_NONE), 0) + if (window == EGL14.EGL_NO_SURFACE) Log.w(TAG, "eglCreateWindowSurface failed: ${EGL14.eglGetError()}") + } + + /** Stop drawing to the screen surface before it goes away (blocks); frames are dropped until [attachOutput]. */ + fun detachOutput() = onGl { destroyWindow() } + + private fun destroyWindow() { + if (window != EGL14.EGL_NO_SURFACE) { + EGL14.eglMakeCurrent(display, pbuffer, pbuffer, context) + EGL14.eglDestroySurface(display, window) + window = EGL14.EGL_NO_SURFACE + } + } + + private fun draw() { + if (released) return + if (window == EGL14.EGL_NO_SURFACE) { + // nowhere to show it: take the frame anyway so the decoder doesn't stall + EGL14.eglMakeCurrent(display, pbuffer, pbuffer, context) + texture.updateTexImage() + return + } + if (!EGL14.eglMakeCurrent(display, window, window, context)) { Log.w(TAG, "eglMakeCurrent: ${EGL14.eglGetError()}"); return } + texture.updateTexImage() + texture.getTransformMatrix(stMatrix) + val q = IntArray(1) + EGL14.eglQuerySurface(display, window, EGL14.EGL_WIDTH, q, 0); val outW = q[0] + EGL14.eglQuerySurface(display, window, EGL14.EGL_HEIGHT, q, 0); val outH = q[0] + passes.draw(oesTex, stMatrix, videoWidth, videoHeight, 0, 0, 0, outW, outH) + // the decoder's presentation time: frame pacing keeps working + EGLExt.eglPresentationTimeANDROID(display, window, texture.timestamp) + if (!EGL14.eglSwapBuffers(display, window)) Log.w(TAG, "eglSwapBuffers: ${EGL14.eglGetError()}") + frames++ + } + + /** End it (blocks briefly). Call after the decoder has let go of [inputSurface]. */ + fun release() { + if (released) return + released = true + onGl { + destroyWindow() + if (display != EGL14.EGL_NO_DISPLAY && context != EGL14.EGL_NO_CONTEXT) { + EGL14.eglMakeCurrent(display, pbuffer, pbuffer, context) + passes.release() + } + if (this::inputSurface.isInitialized) inputSurface.release() + if (this::texture.isInitialized) texture.release() + if (display != EGL14.EGL_NO_DISPLAY) { + EGL14.eglMakeCurrent(display, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT) + if (pbuffer != EGL14.EGL_NO_SURFACE) EGL14.eglDestroySurface(display, pbuffer) + if (context != EGL14.EGL_NO_CONTEXT) EGL14.eglDestroyContext(display, context) + EGL14.eglTerminate(display) + } + display = EGL14.EGL_NO_DISPLAY + } + thread.quitSafely() + } + + private fun onGl(block: () -> Unit) { + if (Thread.currentThread() === thread) { block(); return } + val done = CountDownLatch(1) + if (!handler.post { try { block() } finally { done.countDown() } }) return + done.await(2, TimeUnit.SECONDS) + } + + companion object { + private const val TAG = "MoonCast-FSR" + + /** Settings: Super resolution (off / fsr1) and its Sharpening (0..100). */ + const val PREF = "mc_super_resolution" + const val SHARPNESS_PREF = "mc_fsr_sharpness" + const val DEFAULT_SHARPNESS = 60 + + private val IDENTITY = floatArrayOf(1f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 1f) + + /** Worth it only when the stream is clearly smaller than the screen surface. */ + fun worthIt(videoHeight: Int, outHeight: Int) = outHeight > videoHeight * 1.1 + + /** Settings, Picture, Super resolution is FSR 1.0. */ + fun enabled(prefs: android.content.SharedPreferences) = prefs.getString(PREF, "off") == "fsr1" + + /** Settings, Picture, Sharpening as 0..1. */ + fun sharpness(prefs: android.content.SharedPreferences) = prefs.getInt(SHARPNESS_PREF, DEFAULT_SHARPNESS) / 100f + } +} + +/** + * Super resolution for Xbox: WebRTC's SurfaceViewRenderer draws each frame through this GlDrawer, on its own GL thread. + * Hardware-decoded frames (OES) take the FSR passes when they're clearly smaller than the view; everything else, and + * any GPU without OpenGL ES 3, goes to WebRTC's own drawer. + */ +class FsrGlDrawer(sharpness: Float) : org.webrtc.RendererCommon.GlDrawer { + private val plain = org.webrtc.GlRectDrawer() + private val passes = FsrPasses(sharpness) + private var es3: Boolean? = null + private var logged = false + + override fun drawOes(oesTextureId: Int, texMatrix: FloatArray, frameWidth: Int, frameHeight: Int, + viewportX: Int, viewportY: Int, viewportWidth: Int, viewportHeight: Int) { + if (es3 == null) { + val v = GLES30.glGetString(GLES30.GL_VERSION) ?: "" + es3 = Regex("OpenGL ES (\\d+)").find(v)?.groupValues?.get(1)?.toIntOrNull()?.let { it >= 3 } ?: false + Log.i("MoonCast-FSR", "Xbox drawer: $v -> ${if (es3 == true) "FSR" else "plain"}") + } + if (es3 == true && FsrRenderer.worthIt(frameHeight, viewportHeight)) { + try { + if (!logged) { Log.i("MoonCast-FSR", "Xbox: ${frameWidth}x$frameHeight to ${viewportWidth}x$viewportHeight"); logged = true } + passes.draw(oesTextureId, texMatrix, frameWidth, frameHeight, 0, viewportX, viewportY, viewportWidth, viewportHeight) + return + } catch (e: RuntimeException) { + Log.w("MoonCast-FSR", "Xbox FSR failed, drawing plain: $e"); es3 = false + } + } + plain.drawOes(oesTextureId, texMatrix, frameWidth, frameHeight, viewportX, viewportY, viewportWidth, viewportHeight) + } + + override fun drawRgb(textureId: Int, texMatrix: FloatArray, frameWidth: Int, frameHeight: Int, + viewportX: Int, viewportY: Int, viewportWidth: Int, viewportHeight: Int) = + plain.drawRgb(textureId, texMatrix, frameWidth, frameHeight, viewportX, viewportY, viewportWidth, viewportHeight) + + override fun drawYuv(yuvTextures: IntArray, texMatrix: FloatArray, frameWidth: Int, frameHeight: Int, + viewportX: Int, viewportY: Int, viewportWidth: Int, viewportHeight: Int) = + plain.drawYuv(yuvTextures, texMatrix, frameWidth, frameHeight, viewportX, viewportY, viewportWidth, viewportHeight) + + override fun release() { + plain.release() + if (es3 == true) try { passes.release() } catch (e: RuntimeException) { } + } +} diff --git a/app/src/main/java/com/limelight/binding/video/MediaCodecDecoderRenderer.java b/app/src/main/java/com/limelight/binding/video/MediaCodecDecoderRenderer.java index 5d4e44ecfb..42969f4e78 100644 --- a/app/src/main/java/com/limelight/binding/video/MediaCodecDecoderRenderer.java +++ b/app/src/main/java/com/limelight/binding/video/MediaCodecDecoderRenderer.java @@ -273,6 +273,13 @@ public void setRenderTarget(SurfaceHolder renderTarget) { this.renderTarget = renderTarget; } + // MoonCast: Super resolution. The decoder renders into FSR's surface instead of the screen's + private android.view.Surface surfaceOverride; + + public void setSurfaceOverride(android.view.Surface surface) { + this.surfaceOverride = surface; + } + public MediaCodecDecoderRenderer(Activity activity, PreferenceConfiguration prefs, CrashListener crashListener, int consecutiveCrashCount, boolean meteredData, boolean requestedHdr, @@ -405,6 +412,11 @@ public boolean isAv1Main10Supported() { return false; } + /** The number of the last frame that arrived from the PC (0 before the first): unchanged means no video coming in. */ + public int getLastFrameNumber() { + return lastFrameNumber; + } + public int getPreferredColorSpace() { // Default to Rec 709 which is probably better supported on modern devices. // @@ -516,7 +528,7 @@ else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { LimeLog.info("Configuring with format: "+format); - videoDecoder.configure(format, renderTarget.getSurface(), null, 0); + videoDecoder.configure(format, surfaceOverride != null ? surfaceOverride : renderTarget.getSurface(), null, 0); configuredFormat = format; @@ -643,7 +655,7 @@ else if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_AV1) != 0) { MediaFormat mediaFormat = createBaseMediaFormat(mimeType); // This will try low latency options until we find one that works (or we give up). - boolean newFormat = MediaCodecHelper.setDecoderLowLatencyOptions(mediaFormat, selectedDecoderInfo, tryNumber); + boolean newFormat = MediaCodecHelper.setDecoderLowLatencyOptions(mediaFormat, selectedDecoderInfo, prefs.ultraLowLatency, tryNumber); // Throw the underlying codec exception on the last attempt if the caller requested it if (tryConfigureDecoder(selectedDecoderInfo, mediaFormat, !newFormat && throwOnCodecError)) { @@ -1024,6 +1036,7 @@ public void run() { if (outIndex >= 0) { long presentationTimeUs = info.presentationTimeUs; int lastIndex = outIndex; + noteDecoded(presentationTimeUs); numFramesOut++; @@ -1037,6 +1050,7 @@ public void run() { lastIndex = outIndex; presentationTimeUs = info.presentationTimeUs; + noteDecoded(presentationTimeUs); } if (prefs.framePacing == PreferenceConfiguration.FRAME_PACING_MAX_SMOOTHNESS || @@ -1280,10 +1294,29 @@ else if (enabled && hdrMetadata != null && !Arrays.equals(currentHdrMetadata, hd } } + /** MoonCast Quick menu: when each frame went into the decoder, so the time to come out can be measured. */ + private final java.util.HashMap decodeStartNs = new java.util.HashMap<>(); + private long decodeNsSum; + private int decodeFrames; + + private void noteDecodeStart(long ts) { + synchronized (decodeStartNs) { + if (decodeStartNs.size() > 128) decodeStartNs.clear(); + decodeStartNs.put(ts, System.nanoTime()); + } + } + + private void noteDecoded(long ts) { + Long t0; + synchronized (decodeStartNs) { t0 = decodeStartNs.remove(ts); } + if (t0 != null) { decodeNsSum += System.nanoTime() - t0; decodeFrames++; } + } + private boolean queueNextInputBuffer(long timestampUs, int codecFlags) { boolean codecRecovered; try { + noteDecodeStart(timestampUs); videoDecoder.queueInputBuffer(nextInputBufferIndex, 0, nextInputBuffer.position(), timestampUs, codecFlags); @@ -1372,6 +1405,18 @@ public int submitDecodeUnit(byte[] decodeUnitData, int decodeUnitLength, int dec // Flip stats windows roughly every second if (SystemClock.uptimeMillis() >= activeWindowVideoStats.measurementStartTimestamp + 1000) { + { + // MoonCast Quick menu: keep the headline numbers even when the overlay is off + VideoStats q = new VideoStats(); + q.add(lastWindowVideoStats); + q.add(activeWindowVideoStats); + long rtt = MoonBridge.getEstimatedRttInfo(); + com.limelight.home.StreamStats.update(q.getFps().renderedFps, (int)(rtt >> 32), + q.totalFrames > 0 ? (float)q.framesLost / q.totalFrames * 100 : 0f, initialWidth + "x" + initialHeight, + decodeFrames > 0 ? (float)decodeNsSum / decodeFrames / 1000000f : 0f); + decodeNsSum = 0; + decodeFrames = 0; + } if (prefs.enablePerfOverlay) { VideoStats lastTwo = new VideoStats(); lastTwo.add(lastWindowVideoStats); diff --git a/app/src/main/java/com/limelight/binding/video/MediaCodecHelper.java b/app/src/main/java/com/limelight/binding/video/MediaCodecHelper.java index 24d1f01ab0..ea88a174a2 100644 --- a/app/src/main/java/com/limelight/binding/video/MediaCodecHelper.java +++ b/app/src/main/java/com/limelight/binding/video/MediaCodecHelper.java @@ -43,6 +43,25 @@ public class MediaCodecHelper { private static final List kirinDecoderPrefixes; private static final List exynosDecoderPrefixes; private static final List amlogicDecoderPrefixes; + private static final List mtkDecoderPrefixes; // MoonCast, from Artemis + private static final List tegraDecoderPrefixes; // MoonCast, from Artemis + + static { + mtkDecoderPrefixes = new LinkedList<>(); + mtkDecoderPrefixes.add("omx.mtk"); + mtkDecoderPrefixes.add("c2.mtk"); + tegraDecoderPrefixes = new LinkedList<>(); + tegraDecoderPrefixes.add("omx.nvidia"); + tegraDecoderPrefixes.add("c2.nvidia"); + } + + /** Set a vendor key without failing on decoders that reject it (Artemis). */ + private static void safeSet(MediaFormat format, String key, int value) { + try { + format.setInteger(key, value); + } catch (Throwable ignored) { + } + } private static final List knownVendorLowLatencyOptions; public static final boolean SHOULD_BYPASS_SOFTWARE_BLOCK = @@ -492,7 +511,12 @@ private static boolean decoderSupportsMaxOperatingRate(String decoderName) { !isAdreno620; } - public static boolean setDecoderLowLatencyOptions(MediaFormat videoFormat, MediaCodecInfo decoderInfo, int tryNumber) { + /** + * @param ultraLowLatency MoonCast: Settings, Video, "Ultra low latency" (from Artemis, github.com/ClassicOldSong/moonlight-android, + * GPL-3.0). Adds output fencing on Snapdragon 8 Gen 2 / 8 Gen 3 / 8 Elite, a low-latency preset on + * MediaTek and output-reorder off on NVIDIA Tegra. Off by default: experimental. + */ + public static boolean setDecoderLowLatencyOptions(MediaFormat videoFormat, MediaCodecInfo decoderInfo, boolean ultraLowLatency, int tryNumber) { // Options here should be tried in the order of most to least risky. The decoder will use // the first MediaFormat that doesn't fail in configure(). @@ -505,7 +529,8 @@ public static boolean setDecoderLowLatencyOptions(MediaFormat videoFormat, Media // If this decoder officially supports FEATURE_LowLatency, we will just use that alone // for try 0. Otherwise, we'll include it as best effort with other options. - if (decoderSupportsAndroidRLowLatency(decoderInfo, videoFormat.getString(MediaFormat.KEY_MIME))) { + // With ultra low latency, keep going: the vendor keys below matter more than the standard one (Artemis) + if (!ultraLowLatency && decoderSupportsAndroidRLowLatency(decoderInfo, videoFormat.getString(MediaFormat.KEY_MIME))) { return true; } } @@ -557,7 +582,18 @@ else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { // // We will first try both, then try vendor.qti-ext-dec-low-latency.enable alone if that fails if (tryNumber < 4) { - videoFormat.setInteger("vendor.qti-ext-dec-picture-order.enable", 1); + if (ultraLowLatency) { + // Artemis: picture order off for OMX.qcom, on for c2.qcom; software output fencing is what + // cuts latency on Snapdragon 8 Gen 2, 8s Gen 3 and 8 Elite (confirmed there by its authors) + boolean omxQcom = decoderInfo.getName().toLowerCase(java.util.Locale.US).startsWith("omx.qcom"); + safeSet(videoFormat, "vendor.qti-ext-dec-picture-order.enable", omxQcom ? 0 : 1); + safeSet(videoFormat, "vendor.qti-ext-output-sw-fence-enable.value", 1); // 8 Gen 2 + safeSet(videoFormat, "vendor.qti-ext-output-fence.enable", 1); // 8s Gen 3, 8 Elite + safeSet(videoFormat, "vendor.qti-ext-output-fence.fence_type", 1); // 1 = software + } + else { + videoFormat.setInteger("vendor.qti-ext-dec-picture-order.enable", 1); + } setNewOption = true; } if (tryNumber < 5) { @@ -565,6 +601,42 @@ else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { setNewOption = true; } } + else if (ultraLowLatency && isDecoderInList(mtkDecoderPrefixes, decoderInfo.getName())) { + if (tryNumber < 4) { + // Artemis' MediaTek preset: low-latency decode path, no idle clock-down, shallow queues + safeSet(videoFormat, "vdec-lowlatency", 1); + safeSet(videoFormat, "vendor.mtk.vdec.cpu.boost.mode", 1); + safeSet(videoFormat, "vendor.mtk.vdec.cpu.boost.mode.value", 1); + safeSet(videoFormat, "vendor.mtk.vdec.dvfs.mode", 1); + safeSet(videoFormat, "vendor.mtk.vdec.dvfs.level", 1); + safeSet(videoFormat, "vendor.mtk.vdec.low-latency.mode", 1); + safeSet(videoFormat, "vendor.mtk.vdec.ultra-low-latency", 0); // off for stability (Artemis) + safeSet(videoFormat, "vendor.mtk.vdec.disable-idle", 1); + safeSet(videoFormat, "vendor.mtk.vdec.preload.frame.count", 1); + safeSet(videoFormat, "vendor.mtk.vdec.buffer.fetch.timeout.ms", 4); + safeSet(videoFormat, "vendor.mtk.vdec.bq.guard.interval.time", 4); + safeSet(videoFormat, "vendor.mtk.vdec.input.max.queue.depth", 3); + safeSet(videoFormat, "vendor.mtk.vdec.output.max.queue.depth", 3); + safeSet(videoFormat, "vendor.mtk.vdec.vsync.adjust.enable", 0); + safeSet(videoFormat, "vendor.mtk.vdec.nvop.skip", 1); + safeSet(videoFormat, "vendor.mtk.vdec.skip.mode", 0); + safeSet(videoFormat, "vendor.mtk.vdec.drop.nonref.frame", 0); + safeSet(videoFormat, "vendor.mtk.vdec.frame-drop.policy", 0); + safeSet(videoFormat, MediaFormat.KEY_OPERATING_RATE, (int) Short.MAX_VALUE); + safeSet(videoFormat, MediaFormat.KEY_PRIORITY, 0); + setNewOption = true; + } + } + else if (ultraLowLatency && isDecoderInList(tegraDecoderPrefixes, decoderInfo.getName())) { + if (tryNumber < 4) { + // Artemis: NVIDIA Tegra (Shield) low latency, no output reordering + safeSet(videoFormat, "media.low-latency.enable", 1); + safeSet(videoFormat, "vendor.low-latency.enable", 1); + safeSet(videoFormat, "disable-output-reorder", 1); + safeSet(videoFormat, "vendor.nvidia.disable-output-reorder", 1); + setNewOption = true; + } + } else if (isDecoderInList(kirinDecoderPrefixes, decoderInfo.getName())) { if (tryNumber < 4) { // Kirin low latency options diff --git a/app/src/main/java/com/limelight/computers/ComputerManagerService.java b/app/src/main/java/com/limelight/computers/ComputerManagerService.java index 990a8953f2..0d5878d797 100644 --- a/app/src/main/java/com/limelight/computers/ComputerManagerService.java +++ b/app/src/main/java/com/limelight/computers/ComputerManagerService.java @@ -290,6 +290,35 @@ public ComputerDetails getComputer(String uuid) { return null; } + /** + * Sets (or clears, with null) the address a PC is reached at from outside its network, without needing it to + * answer there now: at home the internet address usually can't be tried. Saved with the PC's other addresses. + */ + public boolean setManualAddress(String uuid, ComputerDetails.AddressTuple address) { + synchronized (pollingTuples) { + for (PollingTuple tuple : pollingTuples) { + if (uuid.equals(tuple.computer.uuid)) { + synchronized (tuple.networkLock) { + tuple.computer.manualAddress = address; + if (getLocalDatabaseReference()) { + try { + ComputerDetails saved = dbManager.getComputerByUUID(uuid); + if (saved != null) { + saved.manualAddress = address; + dbManager.updateComputer(saved); + } + } finally { + releaseLocalDatabaseReference(); + } + } + } + return true; + } + } + } + return false; + } + public void invalidateStateForComputer(String uuid) { synchronized (pollingTuples) { for (PollingTuple tuple : pollingTuples) { @@ -713,8 +742,24 @@ private boolean pollComputer(ComputerDetails details) throws InterruptedExceptio } } + /** The running service, so a PC added for a session (identity transfer) can join its list without a restart. */ + private static volatile ComputerManagerService running; + + /** Adds a PC to the running service's list. It is polled and can be started at once. */ + public static void addSessionComputer(ComputerDetails pc) { + ComputerManagerService svc = running; + if (svc != null) svc.addTuple(pc); + } + + /** Takes a session PC out of the running service's list. */ + public static void removeSessionComputer(ComputerDetails pc) { + ComputerManagerService svc = running; + if (svc != null) svc.removeComputer(pc); + } + @Override public void onCreate() { + running = this; // Bind to the discovery service bindService(new Intent(this, DiscoveryService.class), discoveryServiceConnection, Service.BIND_AUTO_CREATE); @@ -775,6 +820,7 @@ public void onLost(Network network) { @Override public void onDestroy() { + running = null; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { ConnectivityManager connMgr = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE); connMgr.unregisterNetworkCallback(networkCallback); diff --git a/app/src/main/java/com/limelight/computers/LegacyDatabaseReader.java b/app/src/main/java/com/limelight/computers/LegacyDatabaseReader.java index 27a7f1a35a..ddc9e6e4e4 100644 --- a/app/src/main/java/com/limelight/computers/LegacyDatabaseReader.java +++ b/app/src/main/java/com/limelight/computers/LegacyDatabaseReader.java @@ -87,6 +87,8 @@ private static List getAllComputers(SQLiteDatabase db) { } public static List migrateAllComputers(Context c) { + // MoonCast (#44): nothing to migrate on a fresh install; opening a missing DB only logged an exception + if (!c.getDatabasePath(COMPUTER_DB_NAME).exists()) return new LinkedList(); try (final SQLiteDatabase computerDb = SQLiteDatabase.openDatabase( c.getDatabasePath(COMPUTER_DB_NAME).getPath(), null, SQLiteDatabase.OPEN_READONLY) diff --git a/app/src/main/java/com/limelight/computers/LegacyDatabaseReader2.java b/app/src/main/java/com/limelight/computers/LegacyDatabaseReader2.java index 55ad59a4a8..ef4d01aa4f 100644 --- a/app/src/main/java/com/limelight/computers/LegacyDatabaseReader2.java +++ b/app/src/main/java/com/limelight/computers/LegacyDatabaseReader2.java @@ -68,6 +68,8 @@ public static List getAllComputers(SQLiteDatabase computerDb) { } public static List migrateAllComputers(Context c) { + // MoonCast (#44): nothing to migrate on a fresh install; opening a missing DB only logged an exception + if (!c.getDatabasePath(COMPUTER_DB_NAME).exists()) return new LinkedList(); try (final SQLiteDatabase computerDb = SQLiteDatabase.openDatabase( c.getDatabasePath(COMPUTER_DB_NAME).getPath(), null, SQLiteDatabase.OPEN_READONLY) diff --git a/app/src/main/java/com/limelight/computers/LegacyDatabaseReader3.java b/app/src/main/java/com/limelight/computers/LegacyDatabaseReader3.java index aca6d1e1c7..b1b3f30131 100644 --- a/app/src/main/java/com/limelight/computers/LegacyDatabaseReader3.java +++ b/app/src/main/java/com/limelight/computers/LegacyDatabaseReader3.java @@ -107,6 +107,8 @@ public static List getAllComputers(SQLiteDatabase computerDb) { } public static List migrateAllComputers(Context c) { + // MoonCast (#44): nothing to migrate on a fresh install; opening a missing DB only logged an exception + if (!c.getDatabasePath(COMPUTER_DB_NAME).exists()) return new LinkedList(); try (final SQLiteDatabase computerDb = SQLiteDatabase.openDatabase( c.getDatabasePath(COMPUTER_DB_NAME).getPath(), null, SQLiteDatabase.OPEN_READONLY) diff --git a/app/src/main/java/com/limelight/grid/assets/CachedAppAssetLoader.java b/app/src/main/java/com/limelight/grid/assets/CachedAppAssetLoader.java index 83253b241a..689f51bb90 100644 --- a/app/src/main/java/com/limelight/grid/assets/CachedAppAssetLoader.java +++ b/app/src/main/java/com/limelight/grid/assets/CachedAppAssetLoader.java @@ -220,7 +220,10 @@ protected void onPostExecute(final ScaledBitmap bitmap) { // Fade in the box art if (bitmap != null) { // Show the text if it's a placeholder - textView.setVisibility(isBitmapPlaceholder(bitmap) ? View.VISIBLE : View.GONE); + // MoonCast: the label is held weakly and may be gone (a caller with no label of its own) + if (textView != null) { + textView.setVisibility(isBitmapPlaceholder(bitmap) ? View.VISIBLE : View.GONE); + } if (imageView.getVisibility() == View.VISIBLE) { // Fade out the placeholder first diff --git a/app/src/main/java/com/limelight/home/Chip.kt b/app/src/main/java/com/limelight/home/Chip.kt new file mode 100644 index 0000000000..38f1429765 --- /dev/null +++ b/app/src/main/java/com/limelight/home/Chip.kt @@ -0,0 +1,30 @@ +package com.limelight.home + +import android.os.Build + +/** This device's chip by its marketing name ("Snapdragon 8 Gen 2"), for the Ultra low latency hint in both apps. */ +object Chip { + private val QUALCOMM = mapOf( + "8750" to "Snapdragon 8 Elite", "8650" to "Snapdragon 8 Gen 3", "8635" to "Snapdragon 8s Gen 3", + "8550" to "Snapdragon 8 Gen 2", "8475" to "Snapdragon 8+ Gen 1", "8450" to "Snapdragon 8 Gen 1", + "8350" to "Snapdragon 888", "8250" to "Snapdragon 865", "8150" to "Snapdragon 855", + "7675" to "Snapdragon 7+ Gen 3", "7550" to "Snapdragon 7 Gen 3", "7475" to "Snapdragon 7+ Gen 2", + "7450" to "Snapdragon 7 Gen 1", "6375" to "Snapdragon 695") + private val MEDIATEK = mapOf( + "MT6991" to "Dimensity 9400", "MT6989" to "Dimensity 9300", "MT6985" to "Dimensity 9200", + "MT6983" to "Dimensity 9000", "MT6897" to "Dimensity 8300", "MT6896" to "Dimensity 8200", + "MT6895" to "Dimensity 8100", "MT6893" to "Dimensity 1200") + + /** The chip's name, with its model id in brackets when it maps ("Snapdragon 8 Gen 2 (QCS8550)"); else the raw id. */ + @JvmStatic fun name(): String { + val model = if (Build.VERSION.SDK_INT >= 31) Build.SOC_MODEL.trim() else "" + val maker = if (Build.VERSION.SDK_INT >= 31) Build.SOC_MANUFACTURER.trim() else "" + if (model.isBlank() || model.equals("unknown", true)) return Build.HARDWARE + val up = model.uppercase() + // SM8550, QCS8550, SM8550P, SM8750-AB: the four digits after the letters name the chip + Regex("^(SM|QCS|QCM|SDM)(\\d{4})").find(up)?.let { m -> QUALCOMM[m.groupValues[2]]?.let { return "$it ($model)" } } + MEDIATEK.entries.firstOrNull { up.startsWith(it.key) }?.let { return "${it.value} ($model)" } + val m = when { maker.equals("QTI", true) -> "Qualcomm"; else -> maker } + return "$m $model".trim() + } +} diff --git a/app/src/main/java/com/limelight/home/CustomArt.kt b/app/src/main/java/com/limelight/home/CustomArt.kt new file mode 100644 index 0000000000..4e90bb579f --- /dev/null +++ b/app/src/main/java/com/limelight/home/CustomArt.kt @@ -0,0 +1,72 @@ +package com.limelight.home + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.os.Build +import android.util.LruCache +import java.io.File +import java.security.MessageDigest + +/** + * Your own picture for a game tile (a PC game, a PS5, an Xbox or an Android app): picked from the device's photos, + * kept as a resized copy in the app's own storage (never shared), keyed by the tile's game key. + */ +object CustomArt { + private const val MAX = 900 // the longest side kept: sharp on the biggest tiles, small on disk + // pictures by bytes (about 24 MB), not by count: 24 pictures of 900 px could take 50 MB + private val cache = object : LruCache(24 * 1024 * 1024) { + override fun sizeOf(key: String, value: Bitmap) = value.byteCount + } + // which keys have a picture, read from disk once: asking the disk for every tile on every redraw cost 100 file checks + private var known: MutableSet? = null + private val colors = HashMap() + + private fun knownKeys(c: Context): MutableSet = known ?: HashSet().also { set -> + File(c.filesDir, "tile-art").list()?.forEach { set += it.removeSuffix(".jpg") } + known = set + } + + private fun hash(key: String) = MessageDigest.getInstance("SHA-1").digest(key.toByteArray()).joinToString("") { "%02x".format(it) } + + private fun file(c: Context, key: String) = File(File(c.filesDir, "tile-art").apply { mkdirs() }, "${hash(key)}.jpg") + + @JvmStatic fun has(c: Context, key: String) = hash(key) in knownKeys(c) + + /** The picture for [key], or null when there's none. */ + @JvmStatic fun bitmap(c: Context, key: String): Bitmap? { + cache.get(key)?.let { return it } + if (!has(c, key)) return null + val f = file(c, key) + return BitmapFactory.decodeFile(f.path)?.also { cache.put(key, it) } + } + + /** Copies the picked image in, resized; false when it can't be read. Call it off the main thread. */ + @JvmStatic fun set(c: Context, key: String, uri: Uri): Boolean = try { + val bmp: Bitmap = if (Build.VERSION.SDK_INT >= 28) { + android.graphics.ImageDecoder.decodeBitmap(android.graphics.ImageDecoder.createSource(c.contentResolver, uri)) { d, info, _ -> + val s = maxOf(info.size.width, info.size.height) + if (s > MAX) d.setTargetSize(info.size.width * MAX / s, info.size.height * MAX / s) + d.allocator = android.graphics.ImageDecoder.ALLOCATOR_SOFTWARE + } + } else { + val o = BitmapFactory.Options().apply { inJustDecodeBounds = true } + c.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, o) } + var sample = 1 + while (maxOf(o.outWidth, o.outHeight) / (sample * 2) >= MAX) sample *= 2 + c.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, BitmapFactory.Options().apply { inSampleSize = sample }) } + ?: throw IllegalStateException("unreadable") + } + file(c, key).outputStream().use { bmp.compress(Bitmap.CompressFormat.JPEG, 88, it) } + cache.put(key, bmp); knownKeys(c) += hash(key); colors.remove(key) + true + } catch (e: Exception) { + android.util.Log.w("MoonCast", "custom picture: $e"); false + } + + @JvmStatic fun clear(c: Context, key: String) { file(c, key).delete(); cache.remove(key); knownKeys(c) -= hash(key); colors.remove(key) } + + /** The picture's average colour, for the background wash (worked out once per picture). */ + @JvmStatic fun color(key: String, b: Bitmap): Int = colors.getOrPut(key) { Bitmap.createScaledBitmap(b, 1, 1, true).getPixel(0, 0) } +} diff --git a/app/src/main/java/com/limelight/home/DeviceApps.kt b/app/src/main/java/com/limelight/home/DeviceApps.kt new file mode 100644 index 0000000000..04e33f9a56 --- /dev/null +++ b/app/src/main/java/com/limelight/home/DeviceApps.kt @@ -0,0 +1,125 @@ +package com.limelight.home + +import android.content.Context +import android.content.Intent +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import android.graphics.drawable.Drawable +import android.os.Build +import org.json.JSONArray + +/** + * "On this device": Android apps you pick to show in MoonCast next to your PC, PS5 and Xbox games (like ES-DE's + * Android apps). Nothing is added by itself: the user ticks apps in a list of what's installed, games first, and can + * remove them again. Only apps with a launcher entry are listed (the manifest's , not QUERY_ALL_PACKAGES). + */ +object DeviceApps { + class App(val pkg: String, val label: String, val isGame: Boolean) + + private const val PREF = "mc_device_apps" // in mc_tv: a JSON array of package names, in the order added + + private fun store(c: Context) = c.getSharedPreferences("mc_tv", Context.MODE_PRIVATE) + + // Kept in memory: the home screen asks on every redraw, and each answer was a JSON parse plus a PackageManager + // call per app. refresh() (on resume) drops what may have changed outside MoonCast: installs, updates, removals. + private var pinnedCache: List? = null + private val infoCache = java.util.Collections.synchronizedMap(HashMap()) + private var installedCache: List? = null + private val iconCache = android.util.LruCache(64) + + @JvmStatic fun refresh() { + infoCache.clear(); installedCache = null; iconCache.evictAll(); colors.clear() + } + + /** The picked apps that are still installed, in the order they were added. */ + @JvmStatic fun pinned(c: Context): List = pinnedPkgs(c).mapNotNull { info(c, it) } + + private fun pinnedPkgs(c: Context): List = pinnedCache ?: try { + val a = JSONArray(store(c).getString(PREF, "[]")); (0 until a.length()).map { a.getString(it) } + } catch (e: Exception) { emptyList() }.also { pinnedCache = it } + + @JvmStatic fun isPinned(c: Context, pkg: String) = pkg in pinnedPkgs(c) + + @JvmStatic fun add(c: Context, pkg: String) { + val l = pinnedPkgs(c); if (pkg !in l) save(c, l + pkg) + } + + @JvmStatic fun remove(c: Context, pkg: String) = save(c, pinnedPkgs(c) - pkg) + + private fun save(c: Context, l: List) { pinnedCache = l; store(c).edit().putString(PREF, JSONArray(l).toString()).apply() } + + /** Every app with a launcher entry (phone or TV launcher), not MoonCast (any of its copies): games first, then A to Z. */ + @JvmStatic fun installed(c: Context): List = installedCache ?: listInstalled(c).also { installedCache = it } + + private fun listInstalled(c: Context): List { + val pm = c.packageManager + val pkgs = LinkedHashSet() + for (cat in listOf(Intent.CATEGORY_LAUNCHER, Intent.CATEGORY_LEANBACK_LAUNCHER)) { + @Suppress("DEPRECATION") + pm.queryIntentActivities(Intent(Intent.ACTION_MAIN).addCategory(cat), 0).forEach { pkgs += it.activityInfo.packageName } + } + pkgs.removeAll { it == c.packageName || it.startsWith("me.mooncast.") } // MoonCast itself, and its beta or test copy + return pkgs.mapNotNull { info(c, it) }.sortedWith(compareByDescending { it.isGame }.thenBy { it.label.lowercase() }) + } + + @JvmStatic fun info(c: Context, pkg: String): App? = infoCache.getOrPut(pkg) { + try { + @Suppress("DEPRECATION") val ai = c.packageManager.getApplicationInfo(pkg, 0) + App(pkg, c.packageManager.getApplicationLabel(ai).toString(), isGame(ai)) + } catch (e: PackageManager.NameNotFoundException) { null } + } + + @Suppress("DEPRECATION") + private fun isGame(ai: ApplicationInfo) = + (Build.VERSION.SDK_INT >= 26 && ai.category == ApplicationInfo.CATEGORY_GAME) || (ai.flags and ApplicationInfo.FLAG_IS_GAME) != 0 + + /** The app's icon; on a TV its wide banner when it has one. */ + @JvmStatic fun icon(c: Context, pkg: String, tvBanner: Boolean = false): Drawable? { + val k = if (tvBanner) "$pkg#banner" else pkg + iconCache.get(k)?.let { return it.constantState?.newDrawable() ?: it } + return try { + val pm = c.packageManager + ((if (tvBanner) pm.getApplicationBanner(pkg) else null) ?: pm.getApplicationIcon(pkg)).also { iconCache.put(k, it) } + } catch (e: Exception) { null } + } + + private val colors = java.util.Collections.synchronizedMap(HashMap()) + + /** + * The app's own colour, from its icon: the strongest hue among its vivid pixels, toned down so white text reads + * on it. 0 for a grey or white icon (the caller uses its usual cover then). + */ + @JvmStatic fun color(c: Context, pkg: String): Int = colors.getOrPut(pkg) { + val d = icon(c, pkg) ?: return@getOrPut 0 + val n = 32 + val bmp = android.graphics.Bitmap.createBitmap(n, n, android.graphics.Bitmap.Config.ARGB_8888) + d.setBounds(0, 0, n, n); d.draw(android.graphics.Canvas(bmp)) + val weight = FloatArray(12); val sum = Array(12) { FloatArray(3) } + val hsv = FloatArray(3) + for (y in 0 until n) for (x in 0 until n) { + val px = bmp.getPixel(x, y) + if (android.graphics.Color.alpha(px) < 200) continue + android.graphics.Color.colorToHSV(px, hsv) + if (hsv[1] < 0.25f || hsv[2] < 0.2f) continue // greys, whites and near-blacks don't say much + val bin = ((hsv[0] / 30f).toInt()).coerceIn(0, 11) + val w = hsv[1] * hsv[2] + weight[bin] += w + sum[bin][0] += android.graphics.Color.red(px) * w; sum[bin][1] += android.graphics.Color.green(px) * w; sum[bin][2] += android.graphics.Color.blue(px) * w + } + bmp.recycle() + val best = weight.indices.maxByOrNull { weight[it] } ?: return@getOrPut 0 + if (weight[best] < 8f) return@getOrPut 0 + val avg = android.graphics.Color.rgb((sum[best][0] / weight[best]).toInt(), (sum[best][1] / weight[best]).toInt(), (sum[best][2] / weight[best]).toInt()) + android.graphics.Color.colorToHSV(avg, hsv) + hsv[1] = hsv[1].coerceIn(0.45f, 0.85f); hsv[2] = hsv[2].coerceIn(0.45f, 0.72f) + android.graphics.Color.HSVToColor(hsv) + } + + /** Opens the app; false when it can't be opened (uninstalled, or no launcher entry any more). */ + @JvmStatic fun launch(c: Context, pkg: String): Boolean { + val pm = c.packageManager + val i = (if (com.limelight.BuildConfig.TV_APP) pm.getLeanbackLaunchIntentForPackage(pkg) else null) + ?: pm.getLaunchIntentForPackage(pkg) ?: pm.getLeanbackLaunchIntentForPackage(pkg) ?: return false + return try { c.startActivity(i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); true } catch (e: Exception) { false } + } +} diff --git a/app/src/main/java/com/limelight/home/Feel.kt b/app/src/main/java/com/limelight/home/Feel.kt new file mode 100644 index 0000000000..3f4b709dd4 --- /dev/null +++ b/app/src/main/java/com/limelight/home/Feel.kt @@ -0,0 +1,392 @@ +package com.limelight.home + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF +import android.media.AudioAttributes +import android.media.SoundPool +import android.os.Build +import android.os.VibrationEffect +import android.provider.Settings +import android.view.InputDevice +import android.view.KeyEvent +import android.view.View +import android.view.ViewGroup +import android.view.ViewTreeObserver +import android.view.animation.Interpolator +import android.view.animation.OvershootInterpolator +import android.view.animation.PathInterpolator +import android.widget.FrameLayout +import com.limelight.R + +/** + * The feel engine: menu sounds, a focus highlight that glides between items, and a small shake at the end of a row. + * Shared by both apps; the craft rules come from ps5-homebrew-ui's CRAFT.md and SOUND.md. + * + * - Sounds are cues ("focus", "select"), not files. Each cue rotates through its recordings, detunes by up to 3 % so a + * held direction never sounds mechanical, and pans a little towards where the focus is on screen. + * - Focus ticks are the quietest and most frequent sound; refusals are soft and a held direction stays quiet. + * - One sound per action: [FeelSounds.serial] lets a caller tell whether something already answered a press. + */ +object FeelSounds { + enum class Cue(val files: IntArray, val gain: Float) { + FOCUS(intArrayOf(R.raw.mc_focus_01, R.raw.mc_focus_02), 0.55f), + SELECT(intArrayOf(R.raw.mc_select_01), 0.8f), + BACK(intArrayOf(R.raw.mc_back_01), 0.8f), + OPEN(intArrayOf(R.raw.mc_modal_open_01), 0.8f), + CLOSE(intArrayOf(R.raw.mc_modal_close_01), 0.8f), + ERROR(intArrayOf(R.raw.mc_error_01), 0.35f), + LAUNCH(intArrayOf(R.raw.mc_launch_01), 0.8f), + TOGGLE(intArrayOf(R.raw.mc_toggle_01, R.raw.mc_toggle_02, R.raw.mc_toggle_03), 0.8f), + TAB(intArrayOf(R.raw.mc_tab_01), 0.7f), + } + + const val PREF = "menu_sounds" + private const val PREFS_FILE = "mc_tv" + private const val MAX_PAN = 0.55f + + @Volatile private var pool: SoundPool? = null + @Volatile private var loading = false + private val ids = HashMap() // sound ids per cue, 0 until loaded + private val ready = HashSet() // sound ids that finished loading + private val rot = HashMap() + private var app: Context? = null + + /** Bumps every time a cue is asked for (played or not), so callers can avoid stacking two sounds on one press. */ + @Volatile var serial = 0 + private set + + fun enabled(ctx: Context): Boolean = + ctx.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE).getBoolean(PREF, true) + + /** Start loading the sounds in the background. Safe to call often; play() calls it too. */ + @JvmStatic fun warm(ctx: Context) { + if (pool != null || loading) return + loading = true + val c = ctx.applicationContext + app = c + Thread({ + try { + val attrs = AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION).build() + val p = SoundPool.Builder().setMaxStreams(6).setAudioAttributes(attrs).build() + p.setOnLoadCompleteListener { _, id, status -> if (status == 0) synchronized(ready) { ready.add(id) } } + for (cue in Cue.values()) { + ids[cue] = cue.files.map { res -> + try { p.load(c, res, 1) } catch (e: Exception) { 0 } // a missing file just leaves the cue quiet + }.toIntArray() + } + pool = p + } catch (e: Throwable) { + // no sound on this device; the UI works the same without it + } finally { loading = false } + }, "mc-sounds").start() + } + + /** Pan (-1 left .. 1 right) for a view, from where its centre sits on the screen. */ + @JvmStatic fun panFor(v: View?): Float { + if (v == null || v.width == 0) return 0f + val loc = IntArray(2) + v.getLocationOnScreen(loc) + val w = v.resources.displayMetrics.widthPixels.coerceAtLeast(1) + return (((loc[0] + v.width / 2f) / w) - 0.5f) * 1.2f + } + + @JvmStatic @JvmOverloads fun play(ctx: Context, cue: Cue, pan: Float = 0f, gain: Float = cue.gain) { + serial++ + if (!enabled(ctx)) return + val p = pool ?: run { warm(ctx); return } + val list = ids[cue] ?: return + val loaded = synchronized(ready) { list.filter { it != 0 && it in ready } } + if (loaded.isEmpty()) return + val i = ((rot[cue] ?: -1) + 1) % loaded.size + rot[cue] = i + val pn = pan.coerceIn(-MAX_PAN, MAX_PAN) + val g = gain.coerceIn(0f, 1f) + val left = g * (if (pn > 0) 1f - pn else 1f) + val right = g * (if (pn < 0) 1f + pn else 1f) + val rate = 1f + (Math.random().toFloat() * 2f - 1f) * 0.03f + try { p.play(loaded[i], left, right, 1, 0, rate) } catch (e: Exception) { } + } + + /** The focus tick for a view, panned by where it is. */ + @JvmStatic fun focus(v: View) = play(v.context, Cue.FOCUS, panFor(v)) +} + +/** + * One highlight that glides from item to item instead of each item lighting up on its own. It lives as a full-size + * overlay in the activity's root FrameLayout and draws a rounded ring at the focused view's bounds. + * + * In [ghost] mode it complements the items' own focus rings: it travels from the old item to the new one and fades as + * it lands, so the move reads as one motion while the item keeps its usual look. Without ghost mode it stays put and is + * the focus ring. + */ +class FocusGlide(private val host: FrameLayout) : View(host.context) { + var strokeColor = Tv.CORONA + set(v) { field = v; paint.color = v; invalidate() } + var strokeWidth = 4f + set(v) { field = v; paint.strokeWidth = v; invalidate() } + /** Corner radius in px; it never exceeds half the ring's height, so pills stay pills. */ + var cornerRadius = 16f + /** Per-view radius (px), for screens whose items have different shapes. Return null to use [cornerRadius]. */ + var radiusFor: ((View) -> Float?)? = null + /** Grow the ring a little past the item, in px (covers grow on focus). */ + var outset = 0f + var ghost = false + var duration = 190L + /** + * Handheld (Switch) style: a band of [gapWidth] px in [gapColor] inside the ring, so the ring reads as a double + * ring standing off the item. 0 (the TV) draws the single ring only. + */ + var gapWidth = 0f + var gapColor = 0 + /** The ring breathes slowly between its colour and halfway to [gapColor] (handheld style). Off on the TV. */ + var breathe = false + set(v) { field = v; if (v) startBreath() else { breath?.cancel(); breath = null; paint.color = strokeColor; invalidate() } } + /** Return false for views the glide should leave alone (text fields, etc.). */ + var accept: (View) -> Boolean = { true } + + private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE; color = strokeColor; strokeWidth = this@FocusGlide.strokeWidth } + private val interp: Interpolator = if (Build.VERSION.SDK_INT >= 21) PathInterpolator(0.2f, 0.9f, 0.3f, 1.12f) else OvershootInterpolator(0.6f) + private val cur = RectF() + private val from = RectF() + private var radius = 16f + private var target: View? = null + private var anim: ValueAnimator? = null + private var shown = false + private var breath: ValueAnimator? = null + private var popScale = 1f + private val gapPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE } + + private val focusListener = ViewTreeObserver.OnGlobalFocusChangeListener { _, new -> moveTo(new) } + private val follow = ViewTreeObserver.OnScrollChangedListener { track() } + private val layoutFollow = ViewTreeObserver.OnGlobalLayoutListener { + val p = pending + if (p != null) { pending = null; if (p.isFocused) moveTo(p) } else track() + } + private var pending: View? = null + + init { + isFocusable = false; isClickable = false + importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO + setWillNotDraw(false) + alpha = 0f + } + + /** Add the overlay on top of everything in [host] and start following focus. */ + fun attach(): FocusGlide { + if (parent == null) host.addView(this, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)) + val vto = host.viewTreeObserver + vto.addOnGlobalFocusChangeListener(focusListener) + vto.addOnScrollChangedListener(follow) + vto.addOnGlobalLayoutListener(layoutFollow) + return this + } + + fun detach() { + val vto = host.viewTreeObserver + if (vto.isAlive) { + vto.removeOnGlobalFocusChangeListener(focusListener) + vto.removeOnScrollChangedListener(follow) + vto.removeOnGlobalLayoutListener(layoutFollow) + } + anim?.cancel() + breath?.cancel(); breath = null + (parent as? ViewGroup)?.removeView(this) + } + + /** Stop the breathing while the screen isn't in front: it redrew the ring 60 times a second in the background. */ + fun pauseBreath() { breath?.pause() } + fun resumeBreath() { breath?.resume() } + + private fun startBreath() { + breath?.cancel(); breath = null + if (reduced()) return + breath = ValueAnimator.ofFloat(0f, 1f).apply { + duration = 900 + repeatMode = ValueAnimator.REVERSE + repeatCount = ValueAnimator.INFINITE + addUpdateListener { a -> + paint.color = mix(strokeColor, gapColor, (a.animatedValue as Float) * 0.5f) + if (shown) invalidate() + } + start() + } + } + + private fun mix(a: Int, b: Int, t: Float): Int { + fun ch(x: Int, s: Int) = (x shr s) and 0xFF + fun lerp(s: Int) = (ch(a, s) + (ch(b, s) - ch(a, s)) * t).toInt() and 0xFF + return (0xFF shl 24) or (lerp(16) shl 16) or (lerp(8) shl 8) or lerp(0) + } + + /** The select answer: the ring swells a little and settles (360 ms). */ + fun pop() { + if (!shown || reduced()) return + ValueAnimator.ofFloat(0f, 1f).apply { + duration = 360 + addUpdateListener { a -> + val t = a.animatedValue as Float + popScale = 1f + 0.05f * (if (t < 0.3f) t / 0.3f else (1f - t) / 0.7f) + invalidate() + } + start() + } + } + + /** Keep the overlay the last child, after something else was added to the host. */ + fun bringUp() { if (parent === host && host.indexOfChild(this) != host.childCount - 1) bringToFront() } + + private fun reduced(): Boolean = try { + Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f + } catch (e: Exception) { false } + + /** The view's bounds in the overlay's coordinates, or false when it's not on screen. */ + private fun boundsOf(v: View, out: RectF): Boolean { + if (!v.isAttachedToWindow || !v.isShown || v.width == 0) return false + val a = IntArray(2); val b = IntArray(2) + v.getLocationInWindow(a); getLocationInWindow(b) + // getLocationInWindow follows transforms, so a lifted (scaled) cover reports its grown top-left + val w = v.width * v.scaleX; val h = v.height * v.scaleY + out.set(a[0] - b[0] - outset, a[1] - b[1] - outset, a[0] - b[0] + w + outset, a[1] - b[1] + h + outset) + return true + } + + fun moveTo(v: View?) { + if (v == null || v === this || v.isInTouchMode || !accept(v) || !isDescendant(v)) { hide(); return } + val next = RectF() + if (!boundsOf(v, next)) { + hide() + // focused before its first layout (a dialog that just opened, a page rebuilt on resume): land on it then + if (v.isAttachedToWindow && v.isFocused) pending = v + return + } + pending = null + target = v + radius = (radiusFor?.invoke(v) ?: cornerRadius) + anim?.cancel() + if (!shown || reduced() || duration <= 0) { + cur.set(next); shown = true + animate().cancel() + alpha = if (ghost) 0f else 1f + invalidate() + return + } + from.set(cur) + animate().cancel() + alpha = 1f + anim = ValueAnimator.ofFloat(0f, 1f).apply { + duration = this@FocusGlide.duration + interpolator = interp + addUpdateListener { a -> + val t = a.animatedValue as Float + val to = RectF() + // re-read the target every frame, so a row that scrolls while we glide is followed, not missed + if (target?.let { boundsOf(it, to) } != true) to.set(next) + cur.set(from.left + (to.left - from.left) * t, from.top + (to.top - from.top) * t, + from.right + (to.right - from.right) * t, from.bottom + (to.bottom - from.bottom) * t) + invalidate() + } + addListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(a: Animator) { + if (anim !== a) return + anim = null + track() + if (ghost) animate().alpha(0f).setDuration(140).start() + } + }) + start() + } + } + + private fun isDescendant(v: View): Boolean { + var p = v.parent + while (p != null) { if (p === host) return true; p = p.parent } + return false + } + + /** Snap to the focused view after a scroll or layout (not while gliding, the animator does it then). */ + private fun track() { + if (anim != null || !shown) return + val t = target ?: return + if (!boundsOf(t, cur)) { hide(); return } + invalidate() + } + + fun hide() { + anim?.cancel(); anim = null + target = null; shown = false + animate().cancel(); alpha = 0f + } + + override fun onDraw(c: Canvas) { + if (!shown) return + val r = radius.coerceAtMost(cur.height() / 2f) + val half = strokeWidth / 2f + if (popScale != 1f) { c.save(); c.scale(popScale, popScale, cur.centerX(), cur.centerY()) } + c.drawRoundRect(cur.left + half, cur.top + half, cur.right - half, cur.bottom - half, r, r, paint) + if (gapWidth > 0f) { + gapPaint.color = gapColor; gapPaint.strokeWidth = gapWidth + val g = strokeWidth + gapWidth / 2f + val gr = (r - strokeWidth).coerceAtLeast(0f) + c.drawRoundRect(cur.left + g, cur.top + g, cur.right - g, cur.bottom - g, gr, gr, gapPaint) + } + if (popScale != 1f) c.restore() + } +} + +/** The end-of-row answer: a small shake, a soft refusal sound and, from a pad, a light rumble. */ +object Feel { + /** [by] is the shake distance in px (about 14 design px). Quiet and still for a held direction. */ + @JvmStatic fun edgeNudge(view: View?, direction: Int, e: KeyEvent?, by: Float) { + if (view == null || (e != null && e.repeatCount > 0)) return + FeelSounds.play(view.context, FeelSounds.Cue.ERROR) + val horizontal = direction == View.FOCUS_LEFT || direction == View.FOCUS_RIGHT + val sign = if (direction == View.FOCUS_LEFT || direction == View.FOCUS_UP) -1f else 1f + val reduced = try { + Settings.Global.getFloat(view.context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f + } catch (x: Exception) { false } + if (!reduced) { + // out, back past the start, settle: 260 ms in all + val k = floatArrayOf(0f, by * sign, -by * 0.45f * sign, by * 0.2f * sign, 0f) + ValueAnimator.ofFloat(*k).apply { + duration = 260 + addUpdateListener { a -> val v = a.animatedValue as Float; if (horizontal) view.translationX = v else view.translationY = v } + addListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(a: Animator) { view.translationX = 0f; view.translationY = 0f } + override fun onAnimationCancel(a: Animator) { view.translationX = 0f; view.translationY = 0f } + }) + start() + } + } + if (e != null) rumble(e) + } + + /** A short, light buzz on the pad that pressed, if it has a motor. */ + @JvmStatic fun rumble(e: KeyEvent) { + val pad = (e.source and InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD || + (e.source and InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK + if (!pad) return + try { + @Suppress("DEPRECATION") + val vib = e.device?.vibrator ?: return + if (!vib.hasVibrator()) return + if (Build.VERSION.SDK_INT >= 26) vib.vibrate(VibrationEffect.createOneShot(28, 60)) + else @Suppress("DEPRECATION") vib.vibrate(28) + } catch (x: Exception) { } + } + + /** Map a d-pad key to a focus direction, or 0. */ + @JvmStatic fun direction(code: Int): Int = when (code) { + KeyEvent.KEYCODE_DPAD_LEFT -> View.FOCUS_LEFT + KeyEvent.KEYCODE_DPAD_RIGHT -> View.FOCUS_RIGHT + KeyEvent.KEYCODE_DPAD_UP -> View.FOCUS_UP + KeyEvent.KEYCODE_DPAD_DOWN -> View.FOCUS_DOWN + else -> 0 + } +} diff --git a/app/src/main/java/com/limelight/home/Launch.kt b/app/src/main/java/com/limelight/home/Launch.kt new file mode 100644 index 0000000000..cb5dee2149 --- /dev/null +++ b/app/src/main/java/com/limelight/home/Launch.kt @@ -0,0 +1,57 @@ +package com.limelight.home + +import android.app.Activity +import android.content.Context +import android.content.Intent +import com.limelight.Ps5StreamActivity +import com.limelight.XboxStreamActivity + +/** + * Starting a PS5 or Xbox stream on this device, shared by both homes (the TV and the handheld) and the TV's phone + * channel. PC games start through Moonlight's ServerHelper.doStart. + */ +object Launch { + @JvmStatic + fun ps5Intent(ctx: Context, host: String, registKey: String, morning: String, isPs5: Boolean, + psnToken: String?, duid: String?, accountId: String?): Intent = + Intent(ctx, Ps5StreamActivity::class.java).apply { + putExtra(Ps5StreamActivity.EXTRA_HOST, host) + putExtra(Ps5StreamActivity.EXTRA_REGIST_KEY, registKey) + putExtra(Ps5StreamActivity.EXTRA_MORNING, morning) + putExtra(Ps5StreamActivity.EXTRA_PS5, isPs5) + if (!psnToken.isNullOrEmpty()) putExtra(Ps5StreamActivity.EXTRA_PSN_TOKEN, psnToken) + if (!duid.isNullOrEmpty()) putExtra(Ps5StreamActivity.EXTRA_DUID, duid) + if (!accountId.isNullOrEmpty()) putExtra(Ps5StreamActivity.EXTRA_ACCOUNT_ID, accountId) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + @JvmStatic + fun xboxIntent(ctx: Context, c: Xbox.Console): Intent = + Intent(ctx, XboxStreamActivity::class.java) + .putExtra(XboxStreamActivity.EXTRA_CONSOLE_ID, c.id).putExtra(XboxStreamActivity.EXTRA_NAME, c.name) + + /** + * Play a registered PS5: on the home network directly; away from home through PlayStation Network when signed in + * (the player still tries the home network first). + */ + @JvmStatic + fun playPs(act: Activity, c: Ps5Store.Console, power: Ps5Store.Power, toast: (String) -> Unit) { + if (power != Ps5Store.Power.NOT_FOUND || !Psn.signedIn(act)) { + if (power == Ps5Store.Power.NOT_FOUND) + return toast("${c.name} isn't on this network. Sign in to PlayStation Network in Settings to play it from here.") + FeelSounds.play(act, FeelSounds.Cue.LAUNCH) + act.startActivity(ps5Intent(act, c.host, c.registKey, c.morning, c.ps5, null, null, null)) + return + } + Thread { + val r = Psn.remoteFor(act, c.name) + act.runOnUiThread { + if (r == null) toast("PlayStation Network doesn't list ${c.name} for Remote Play. Check Remote Play is on in its settings.") + else { + FeelSounds.play(act, FeelSounds.Cue.LAUNCH) + act.startActivity(ps5Intent(act, c.host, c.registKey, c.morning, c.ps5, r.first, r.second, r.third)) + } + } + }.start() + } +} diff --git a/app/src/main/java/com/limelight/home/Licences.kt b/app/src/main/java/com/limelight/home/Licences.kt new file mode 100644 index 0000000000..e8cbcd99e2 --- /dev/null +++ b/app/src/main/java/com/limelight/home/Licences.kt @@ -0,0 +1,93 @@ +package com.limelight.home + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.graphics.Typeface +import android.os.Bundle +import android.view.Gravity +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView + +/** + * The open-source projects MoonCast is built on, for About, Open-source licences (both apps). Each licence's full + * text is in assets/licences; the big shared ones (GPL, AGPL, Apache) are there once. NOTICE in the repo lists the same. + */ +object Licences { + class Part(val group: String, val name: String, val what: String, val licence: String, val file: String, val url: String) + + const val SOURCE = "https://github.com/MoonCastDev" + const val SOURCE_LICENCE = "GPL-3.0, PS5 and Xbox parts AGPL-3.0" + + val parts = listOf( + Part("The app", "Moonlight for Android", "The app MoonCast grew from: PC streaming, the on-screen controller", "GPL-3.0", "gpl-3.0.txt", "https://github.com/moonlight-stream/moonlight-android"), + Part("The app", "moonlight-common-c", "The PC streaming protocol", "GPL-3.0", "gpl-3.0.txt", "https://github.com/moonlight-stream/moonlight-common-c"), + Part("The app", "AMD FidelityFX Super Resolution 1.0", "Super resolution: the upscaling and sharpening (FSR 1)", "MIT", "fsr1.txt", "https://github.com/GPUOpen-Effects/FidelityFX-FSR"), + Part("The app", "ENet", "Inside moonlight-common-c: the network link", "MIT", "enet.txt", "https://github.com/lsalzman/enet"), + Part("The app", "nanors", "Inside moonlight-common-c: repairing lost video", "MIT", "nanors.txt", "https://github.com/sleepybishop/nanors"), + Part("PS5", "chiaki-ng", "PS5 Remote Play, with MoonCast's changes (MoonCastDev/chiaki-ng-mooncast)", "AGPL-3.0", "agpl-3.0.txt", "https://github.com/streetpea/chiaki-ng"), + Part("PS5", "OpenSSL", "Inside chiaki-ng: encryption", "Apache-2.0", "apache-2.0.txt", "https://www.openssl.org"), + Part("PS5", "curl", "Inside chiaki-ng: PSN sign-in and play away from home", "curl", "curl.txt", "https://curl.se"), + Part("PS5", "json-c", "Inside chiaki-ng: PSN messages", "MIT", "json-c.txt", "https://github.com/json-c/json-c"), + Part("PS5", "miniupnpc", "Inside chiaki-ng: router port setup (UPnP)", "BSD-3-Clause", "miniupnpc.txt", "https://github.com/miniupnp/miniupnp"), + Part("PS5", "Jerasure", "Inside chiaki-ng: repairing lost video", "BSD-3-Clause", "jerasure.txt", "https://github.com/streetpea/jerasure"), + Part("PS5", "GF-Complete", "Inside chiaki-ng: the maths behind the repair", "BSD-3-Clause", "gf-complete.txt", "https://github.com/streetpea/gf-complete"), + Part("PS5", "nanopb", "Inside chiaki-ng: messages with the console", "Zlib", "nanopb.txt", "https://github.com/nanopb/nanopb"), + Part("PS5", "Oboe", "Inside chiaki-ng: sound", "Apache-2.0", "apache-2.0.txt", "https://github.com/google/oboe"), + Part("PS5", "LLVM libc++", "The C++ runtime chiaki-ng runs on", "Apache-2.0 with LLVM exception", "libcxx.txt", "https://libcxx.llvm.org"), + Part("Xbox", "XStreaming", "The Xbox Remote Play protocol MoonCast follows", "AGPL-3.0", "agpl-3.0.txt", "https://github.com/Geocld/XStreaming"), + Part("Xbox", "WebRTC", "Xbox video and sound", "BSD-3-Clause", "webrtc.txt", "https://webrtc.org"), + Part("More", "Bouncy Castle", "Pairing with a PC: its certificates", "MIT", "bouncycastle.txt", "https://www.bouncycastle.org"), + Part("More", "OkHttp", "Talking to PCs and Xbox and PlayStation services", "Apache-2.0", "apache-2.0.txt", "https://square.github.io/okhttp"), + Part("More", "JmDNS", "Finding PCs on the network", "Apache-2.0", "apache-2.0.txt", "https://github.com/jmdns/jmdns"), + Part("More", "JCodec", "Reading video stream settings", "BSD-2-Clause", "jcodec.txt", "https://github.com/jcodec/jcodec"), + Part("More", "Shield Controller Extensions", "Rumble and battery on NVIDIA Shield controllers", "MIT", "shield-controller-extensions.txt", "https://github.com/cgutman/ShieldControllerExtensions"), + Part("More", "Figtree", "The menus' font", "OFL-1.1", "figtree.txt", "https://github.com/erikdkennedy/figtree"), + Part("More", "Unbounded", "The headings' font", "OFL-1.1", "unbounded.txt", "https://github.com/googlefonts/unbounded"), + ) + + fun open(context: Context, part: Part) { + context.startActivity(Intent(context, LicenceActivity::class.java).putExtra(LicenceActivity.EXTRA_NAME, part.name)) + } +} + +/** One licence's full text. Scrolls with touch or a D-pad; Back closes it. */ +class LicenceActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val part = Licences.parts.firstOrNull { it.name == intent.getStringExtra(EXTRA_NAME) } ?: run { finish(); return } + val text = try { assets.open("licences/" + part.file).bufferedReader().use { it.readText() } } catch (e: Exception) { "" } + val d = resources.displayMetrics.density + fun px(v: Int) = (v * d).toInt() + val col = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(px(28), px(24), px(28), px(24)) + addView(TextView(this@LicenceActivity).apply { + this.text = part.name; textSize = 22f; setTypeface(typeface, Typeface.BOLD); setTextColor(0xFFFFFFFF.toInt()) + }) + addView(TextView(this@LicenceActivity).apply { + this.text = "${part.licence} · ${part.url}"; textSize = 14f; setTextColor(0xFF2FD3E6.toInt()) + setPadding(0, px(4), 0, px(16)) + }) + addView(TextView(this@LicenceActivity).apply { + this.text = text; textSize = 13f; typeface = Typeface.MONOSPACE; setTextColor(0xFFD9DCE1.toInt()) + setLineSpacing(0f, 1.15f); setTextIsSelectable(false) + }) + } + setContentView(ScrollView(this).apply { + setBackgroundColor(0xFF2B2D31.toInt()) + isFocusable = true; isFocusableInTouchMode = true // a D-pad scrolls it + fitsSystemWindows = true + addView(col) + post { requestFocus() } + }) + title = part.name + window.decorView.textAlignment = android.view.View.TEXT_ALIGNMENT_VIEW_START + col.gravity = Gravity.START + } + + companion object { + const val EXTRA_NAME = "name" + } +} diff --git a/app/src/main/java/com/limelight/home/MoveToTv.kt b/app/src/main/java/com/limelight/home/MoveToTv.kt new file mode 100644 index 0000000000..3c82d7b71c --- /dev/null +++ b/app/src/main/java/com/limelight/home/MoveToTv.kt @@ -0,0 +1,73 @@ +package com.limelight.home + +import android.app.Activity +import android.content.Context +import android.content.Intent + +/** + * "Move to " in the in-game Quick menu. The players are shared by both apps, but only the handheld (the sender) + * can send a game to a MoonCast TV, so the work is done by a [Handler] the handheld app registers; on the TV app it is + * never set and the item never shows. + */ +object MoveToTv { + /** What is playing, enough for the TV to start the same game. */ + class Target( + @JvmField val kind: String, // "pc", "ps" or "xb" + @JvmField val title: String, + @JvmField val pcUuid: String? = null, @JvmField val pcName: String? = null, @JvmField val appId: Int = 0, + @JvmField val psHost: String? = null, @JvmField val registKey: String? = null, @JvmField val morning: String? = null, + @JvmField val isPs5: Boolean = true, + @JvmField val xboxId: String? = null, + ) { + /** The handheld home's key for this game ("pc::", "ps:", "xb:"). */ + val key: String get() = when (kind) { "pc" -> "pc:$pcUuid:$appId"; "ps" -> "ps:$psHost"; else -> "xb:$xboxId" } + + fun put(i: Intent): Intent = i.putExtra("mt_kind", kind).putExtra("mt_title", title) + .putExtra("mt_pc_uuid", pcUuid).putExtra("mt_pc_name", pcName).putExtra("mt_app_id", appId) + .putExtra("mt_ps_host", psHost).putExtra("mt_regist", registKey).putExtra("mt_morning", morning) + .putExtra("mt_is_ps5", isPs5).putExtra("mt_xbox_id", xboxId) + + companion object { + @JvmStatic fun from(i: Intent): Target? { + val k = i.getStringExtra("mt_kind") ?: return null + return Target(k, i.getStringExtra("mt_title") ?: "Your game", + i.getStringExtra("mt_pc_uuid"), i.getStringExtra("mt_pc_name"), i.getIntExtra("mt_app_id", 0), + i.getStringExtra("mt_ps_host"), i.getStringExtra("mt_regist"), i.getStringExtra("mt_morning"), + i.getBooleanExtra("mt_is_ps5", true), i.getStringExtra("mt_xbox_id")) + } + } + } + + interface Handler { + /** The TV a game would move to (the last one used, found on this network now), or null. */ + fun tvName(ctx: Context): String? + /** Send [t] to that TV and show controller mode. The player leaves (disconnects) right after this. */ + fun move(act: Activity, t: Target) + } + + @JvmStatic var handler: Handler? = null + + /** + * A player whose session must be completely over before the TV takes the game: a PS5 allows one Remote Play + * client, and finishing the screen alone left chiaki running (and froze the UI on its decoder lock). + * [done] runs on the main thread once the session is closed. + */ + interface LocalSession { fun endLocalSession(done: Runnable) } + + /** The Quick menu item, or null when there's no TV to move to (always null on the TV app). */ + @JvmStatic + fun item(act: Activity, t: Target): QuickMenu.Item? { + if (com.limelight.BuildConfig.TV_APP) return null + val h = handler ?: return null + val name = h.tvName(act) ?: return null + return QuickMenu.Item("TV", "Move to $name", "Keeps playing there. This device becomes the controller", false) { + // end this device's session first (disconnect, don't quit), THEN send: the TV picks the game up + val once = java.util.concurrent.atomic.AtomicBoolean(false) + val go = Runnable { if (once.compareAndSet(false, true) && !act.isDestroyed) { h.move(act, t); act.finish() } } + if (act is LocalSession) { + act.endLocalSession(go) + android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(go, 3000) // never wait longer than ~3 s + } else go.run() + } + } +} diff --git a/app/src/main/java/com/limelight/home/PadTestActivity.kt b/app/src/main/java/com/limelight/home/PadTestActivity.kt new file mode 100644 index 0000000000..dc04015a1e --- /dev/null +++ b/app/src/main/java/com/limelight/home/PadTestActivity.kt @@ -0,0 +1,187 @@ +package com.limelight.home + +import android.app.Activity +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF +import android.os.Build +import android.os.Bundle +import android.os.VibrationEffect +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.View +import android.view.WindowManager + +/** + * Settings, Controllers, "Test a controller" (after Artemis' gamepad test page): every connected pad drawn live, its + * buttons, sticks, triggers and D-pad, the last key with its key code and scan code (handy when a pad maps oddly), and + * a rumble test. The TV remote's OK tries the rumble; Back on the remote, or holding Select + Start, leaves. + */ +class PadTestActivity : Activity() { + private class Pad(val device: InputDevice) { + val held = HashSet() + val axes = HashMap() + var lastKey = "" + } + + private val pads = LinkedHashMap() + private lateinit var view: View + private var leaveAt = 0L + + override fun onCreate(b: Bundle?) { + super.onCreate(b) + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + val oled = getSharedPreferences("mc_tv", Context.MODE_PRIVATE).getBoolean("oled", false) + view = Board(this, oled) + setContentView(view) + scan() + } + + /** Pick up pads that are connected but haven't been pressed yet. */ + private fun scan() { + // Same filter as the Quick menu: real, numbered game controllers (not the TV's "virtual-search" input) + QuickMenu.pads().forEach { d -> pads.getOrPut(d.id) { Pad(d) } } + } + + private fun isPad(e: KeyEvent) = e.source and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD || + e.source and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK + + override fun dispatchKeyEvent(e: KeyEvent): Boolean { + if (!isPad(e)) { // the TV remote: OK = rumble test, Back = leave + if (e.action == KeyEvent.ACTION_UP) when (e.keyCode) { + KeyEvent.KEYCODE_BACK -> finish() + KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER -> rumbleAll() + } + return true + } + val d = e.device ?: return true + if (QuickMenu.pads().none { it.id == d.id }) return true + val p = pads.getOrPut(e.deviceId) { Pad(d) } + if (e.action == KeyEvent.ACTION_DOWN) { + p.held.add(e.keyCode) + p.lastKey = "${KeyEvent.keyCodeToString(e.keyCode).removePrefix("KEYCODE_")} (key ${e.keyCode}, scan ${e.scanCode})" + if (p.held.contains(KeyEvent.KEYCODE_BUTTON_SELECT) && p.held.contains(KeyEvent.KEYCODE_BUTTON_START)) { + leaveAt = System.currentTimeMillis() + 1500 + view.postDelayed({ if (leaveAt != 0L && System.currentTimeMillis() >= leaveAt) finish() }, 1550) + } + if (e.keyCode == KeyEvent.KEYCODE_BUTTON_Y && p.held.contains(KeyEvent.KEYCODE_BUTTON_X)) rumble(p.device, 255) + } else if (e.action == KeyEvent.ACTION_UP) { + p.held.remove(e.keyCode); leaveAt = 0L + } + view.invalidate() + return true + } + + override fun dispatchGenericMotionEvent(e: MotionEvent): Boolean { + if (e.source and InputDevice.SOURCE_JOYSTICK != InputDevice.SOURCE_JOYSTICK) return super.dispatchGenericMotionEvent(e) + val d = e.device ?: return true + if (QuickMenu.pads().none { it.id == d.id }) return true + val p = pads.getOrPut(e.deviceId) { Pad(d) } + d.motionRanges.forEach { r -> p.axes[r.axis] = e.getAxisValue(r.axis) } + view.invalidate() + return true + } + + private fun rumbleAll() = pads.values.forEach { rumble(it.device, 200) } + + private fun rumble(d: InputDevice, amp: Int) { + try { + @Suppress("DEPRECATION") val v = d.vibrator + if (!v.hasVibrator()) return + if (Build.VERSION.SDK_INT >= 26) v.vibrate(VibrationEffect.createOneShot(400, amp)) else @Suppress("DEPRECATION") v.vibrate(400) + } catch (e: Exception) { } + } + + /** Draws each pad's state. Sizes follow the 1920-wide design grid of the TV home. */ + private inner class Board(ctx: Context, private val oled: Boolean) : View(ctx) { + private val bg = if (oled) 0xFF000000.toInt() else 0xFF090D18.toInt() + private val moon = 0xFFEEF1F7.toInt(); private val dust = 0xFF97A1B8.toInt() + private val line = 0xFF263150.toInt(); private val beam = 0xFF4FD6E3.toInt(); private val corona = 0xFFF2B55C.toInt() + private val fill = Paint(Paint.ANTI_ALIAS_FLAG) + private val stroke = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE } + private val text = Paint(Paint.ANTI_ALIAS_FLAG) + private var k = 1f + private fun u(v: Number) = v.toFloat() * k + + override fun onDraw(c: Canvas) { + k = width / 1920f + c.drawColor(bg) + label(c, "Test a controller", 96, 110, 52, moon, true) + label(c, "Press anything on a pad. OK on the remote tries the rumble (or X + Y on the pad). Back, or hold Select + Start, to leave.", + 96, 165, 24, dust) + if (pads.isEmpty()) { label(c, "No controller connected. Pair one in Settings, Controllers.", 96, 300, 30, moon); return } + pads.values.take(2).forEachIndexed { i, p -> drawPad(c, p, 96f + i * 880f, 236f) } + } + + private fun label(c: Canvas, s: String, x: Number, y: Number, size: Number, color: Int, bold: Boolean = false) { + text.color = color; text.textSize = u(size); text.isFakeBoldText = bold + c.drawText(s, u(x), u(y), text) + } + + private fun button(c: Canvas, s: String, x: Float, y: Float, on: Boolean, w: Float = 92f) { + val r = RectF(u(x), u(y), u(x + w), u(y + 58)) + fill.color = if (on) beam else 0x00000000 + c.drawRoundRect(r, u(14), u(14), fill) + stroke.color = if (on) beam else line; stroke.strokeWidth = u(2) + c.drawRoundRect(r, u(14), u(14), stroke) + text.color = if (on) bg else moon; text.textSize = u(24); text.isFakeBoldText = true + val tw = text.measureText(s) + c.drawText(s, r.centerX() - tw / 2, r.centerY() + u(9), text) + } + + private fun stick(c: Canvas, s: String, cx: Float, cy: Float, x: Float, y: Float, pressed: Boolean) { + stroke.color = if (pressed) beam else line; stroke.strokeWidth = u(3) + c.drawCircle(u(cx), u(cy), u(70), stroke) + fill.color = if (Math.abs(x) > 0.08f || Math.abs(y) > 0.08f) corona else dust + c.drawCircle(u(cx + x * 56), u(cy + y * 56), u(16), fill) + label(c, "$s %+.2f %+.2f".format(x, y), cx - 70, cy + 110, 20, dust) + } + + private fun bar(c: Canvas, s: String, x: Float, y: Float, v: Float) { + stroke.color = line; stroke.strokeWidth = u(2) + c.drawRoundRect(RectF(u(x), u(y), u(x + 220), u(y + 26)), u(13), u(13), stroke) + fill.color = beam + if (v > 0.01f) c.drawRoundRect(RectF(u(x), u(y), u(x + 220 * v.coerceIn(0f, 1f)), u(y + 26)), u(13), u(13), fill) + label(c, "$s %.2f".format(v), x + 236, y + 21, 20, dust) + } + + private fun drawPad(c: Canvas, p: Pad, x: Float, y: Float) { + val d = p.device + label(c, d.name, x, y, 30, moon, true) + label(c, "Vendor %04x, product %04x".format(d.vendorId, d.productId), x, y + 36, 20, dust) + fun h(vararg k: Int) = k.any { p.held.contains(it) } + fun a(axis: Int) = p.axes[axis] ?: 0f + val ty = y + 64 + button(c, "LB", x, ty, h(KeyEvent.KEYCODE_BUTTON_L1)); button(c, "RB", x + 620, ty, h(KeyEvent.KEYCODE_BUTTON_R1)) + button(c, "LT", x, ty + 70, h(KeyEvent.KEYCODE_BUTTON_L2) || a(MotionEvent.AXIS_LTRIGGER) > .5f || a(MotionEvent.AXIS_BRAKE) > .5f) + button(c, "RT", x + 620, ty + 70, h(KeyEvent.KEYCODE_BUTTON_R2) || a(MotionEvent.AXIS_RTRIGGER) > .5f || a(MotionEvent.AXIS_GAS) > .5f) + button(c, "View", x + 210, ty + 35, h(KeyEvent.KEYCODE_BUTTON_SELECT), 110f) + button(c, "Home", x + 330, ty + 35, h(KeyEvent.KEYCODE_BUTTON_MODE), 110f) + button(c, "Menu", x + 450, ty + 35, h(KeyEvent.KEYCODE_BUTTON_START), 110f) + // D-pad: keys or hat axes + val dy = ty + 160 + button(c, "↑", x + 70, dy, h(KeyEvent.KEYCODE_DPAD_UP) || a(MotionEvent.AXIS_HAT_Y) < -.5f, 64f) + button(c, "←", x, dy + 66, h(KeyEvent.KEYCODE_DPAD_LEFT) || a(MotionEvent.AXIS_HAT_X) < -.5f, 64f) + button(c, "→", x + 140, dy + 66, h(KeyEvent.KEYCODE_DPAD_RIGHT) || a(MotionEvent.AXIS_HAT_X) > .5f, 64f) + button(c, "↓", x + 70, dy + 132, h(KeyEvent.KEYCODE_DPAD_DOWN) || a(MotionEvent.AXIS_HAT_Y) > .5f, 64f) + // Face buttons + button(c, "Y", x + 580, dy, h(KeyEvent.KEYCODE_BUTTON_Y), 64f) + button(c, "X", x + 510, dy + 66, h(KeyEvent.KEYCODE_BUTTON_X), 64f) + button(c, "B", x + 650, dy + 66, h(KeyEvent.KEYCODE_BUTTON_B), 64f) + button(c, "A", x + 580, dy + 132, h(KeyEvent.KEYCODE_BUTTON_A), 64f) + // Sticks + stick(c, "Left", x + 180, dy + 290, a(MotionEvent.AXIS_X), a(MotionEvent.AXIS_Y), h(KeyEvent.KEYCODE_BUTTON_THUMBL)) + stick(c, "Right", x + 520, dy + 290, a(MotionEvent.AXIS_Z), a(MotionEvent.AXIS_RZ), h(KeyEvent.KEYCODE_BUTTON_THUMBR)) + // Analog triggers, whichever axes this pad reports (DualSense on old kernels: RX / RY) + val lt = maxOf(a(MotionEvent.AXIS_LTRIGGER), a(MotionEvent.AXIS_BRAKE)) + val rt = maxOf(a(MotionEvent.AXIS_RTRIGGER), a(MotionEvent.AXIS_GAS)) + bar(c, "LT", x, dy + 420, if (lt > 0f || p.axes[MotionEvent.AXIS_RX] == null) lt else (a(MotionEvent.AXIS_RX) + 1f) / 2f) + bar(c, "RT", x + 400, dy + 420, if (rt > 0f || p.axes[MotionEvent.AXIS_RY] == null) rt else (a(MotionEvent.AXIS_RY) + 1f) / 2f) + label(c, "Last: " + p.lastKey.ifBlank { "nothing yet" }, x, dy + 490, 22, moon) + @Suppress("DEPRECATION") val rumble = try { d.vibrator.hasVibrator() } catch (e: Exception) { false } + label(c, if (rumble) "Rumble: supported" else "Rumble: not reported by this pad", x, dy + 524, 20, dust) + } + } +} diff --git a/app/src/main/java/com/limelight/home/Profile.kt b/app/src/main/java/com/limelight/home/Profile.kt new file mode 100644 index 0000000000..4dfbe2b7aa --- /dev/null +++ b/app/src/main/java/com/limelight/home/Profile.kt @@ -0,0 +1,63 @@ +package com.limelight.home + +import android.content.Context +import android.preference.PreferenceManager + +/** + * Home and Away streaming profiles, shared by the TV and handheld apps (issue #35). Home uses the Home settings, + * Away uses the Away settings. Auto picks Away only when the console is reached through PlayStation Network. + * Games, consoles, pairing and sign-ins are not part of a profile; only streaming settings are. + */ +object Profile { + const val PREF = "stream_profile" + + /** "home" or "away", as chosen in Settings. Anything saved earlier as "auto" counts as Home. */ + @JvmStatic + fun mode(ctx: Context): String = + if (PreferenceManager.getDefaultSharedPreferences(ctx).getString(PREF, "home") == "away") "away" else "home" + + /** True when the stream should use the Away settings. */ + @JvmStatic + fun isAway(ctx: Context): Boolean = mode(ctx) == "away" + + /** Streaming settings that belong to a profile (PC, audio and Xbox picture). A switch saves and restores these. */ + private val PC_KEYS = listOf( + // Moonlight video + "list_resolution", "list_fps", "seekbar_bitrate_kbps", "video_format", "checkbox_enable_hdr", "checkbox_full_range", + "list_video_scale_mode", "checkbox_stretch_video", "seekbar_video_zoom", "frame_pacing", "checkbox_ultra_low_latency", + "checkbox_enable_sops", "checkbox_unlock_fps", "checkbox_reduce_refresh_rate", + // audio + "list_audio_config", "checkbox_host_audio", "checkbox_enable_audiofx", + // Xbox picture + "xbox_resolution") + private const val APPLIED = "pc_profile_applied" + + /** + * Keeps the PC's video settings in step with the General profile. Leaving a profile saves its values; entering one + * restores the values saved for it (or Moonlight's defaults the first time). The PC has no PlayStation-style route, + * so Auto means Home here, and Away only when it is chosen. + */ + @JvmStatic + fun syncPcSettings(ctx: Context) { + val p = PreferenceManager.getDefaultSharedPreferences(ctx) + val want = if (mode(ctx) == "away") "away" else "home" + val applied = p.getString(APPLIED, "home") ?: "home" + if (applied == want) return + val ed = p.edit() + for (k in PC_KEYS) { + val v = p.all[k] + ed.putString("pcprof_${applied}_$k", when (v) { is Int -> "i:$v"; is Boolean -> "b:$v"; is String -> "s:$v"; else -> "" }) + } + for (k in PC_KEYS) { + val s = p.getString("pcprof_${want}_$k", null) + when { + s == null || s.isEmpty() -> ed.remove(k) + s.startsWith("i:") -> s.substring(2).toIntOrNull()?.let { ed.putInt(k, it) } ?: ed.remove(k) + s.startsWith("s:") -> ed.putString(k, s.substring(2)) + s.startsWith("b:") -> ed.putBoolean(k, s.substring(2).toBoolean()) + else -> ed.remove(k) + } + } + ed.putString(APPLIED, want).apply() + } +} diff --git a/app/src/main/java/com/limelight/home/Ps5Store.kt b/app/src/main/java/com/limelight/home/Ps5Store.kt new file mode 100644 index 0000000000..cf1ca0c638 --- /dev/null +++ b/app/src/main/java/com/limelight/home/Ps5Store.kt @@ -0,0 +1,128 @@ +package com.limelight.home + +import android.content.Context +import org.json.JSONArray +import org.json.JSONObject +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.InetAddress + +/** + * PlayStation consoles this TV knows. The TV never signs in to PSN itself: the Odin registers a console through this + * TV (POST /register) or sends its keys with a game (POST /play), and we keep them so the console shows up on the + * shelf and can be woken and played from the TV at home. + */ +object Ps5Store { + data class Console(val host: String, val name: String, val registKey: String, val morning: String, val ps5: Boolean) + + enum class Power { ON, REST, NOT_FOUND, CHECKING } + data class Status(val power: Power, val runningApp: String? = null, val hostName: String? = null) + + private const val PREFS = "mc_ps5" + private const val KEY = "consoles" + + fun all(ctx: Context): List { + val raw = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY, "[]") + return try { + val a = JSONArray(raw) + (0 until a.length()).map { a.getJSONObject(it) }.map { + Console(it.getString("host"), it.optString("name", "PS5"), it.getString("registKey"), + it.getString("morning"), it.optBoolean("ps5", true)) + } + } catch (e: Exception) { emptyList() } + } + + /** Add or update a console. Same registration key = same console (its address can change). */ + @Synchronized fun save(ctx: Context, c: Console) { + val list = all(ctx).filter { it.registKey != c.registKey && it.host != c.host }.toMutableList() + val old = all(ctx).firstOrNull { it.registKey == c.registKey } + list.add(0, if (old != null && (c.name.isBlank() || c.name == "PS5")) c.copy(name = old.name) else c) + write(ctx, list) + } + + @Synchronized fun rename(ctx: Context, host: String, name: String) { + val list = all(ctx) + if (list.none { it.host == host && it.name != name }) return + write(ctx, list.map { if (it.host == host) it.copy(name = name) else it }) + } + + @Synchronized fun remove(ctx: Context, host: String) = write(ctx, all(ctx).filter { it.host != host }) + + private fun write(ctx: Context, list: List) { + val a = JSONArray() + list.forEach { + a.put(JSONObject().put("host", it.host).put("name", it.name).put("registKey", it.registKey) + .put("morning", it.morning).put("ps5", it.ps5)) + } + ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putString(KEY, a.toString()).apply() + } + + /** + * Ask the console how it is: the same SRCH probe Remote Play uses. "200 Ok" = on, "620 Server Standby" = rest mode. + * When a game is running the reply also names it (running-app-name). No reply = not on this network. + */ + fun probe(host: String, ps5: Boolean): Status = try { + DatagramSocket().use { s -> + s.soTimeout = 700 + val msg = "SRCH * HTTP/1.1\ndevice-discovery-protocol-version:${if (ps5) "00030010" else "00020020"}\n".toByteArray() + val dst = InetAddress.getByName(host) + var reply: String? = null + for (i in 0 until 2) { + s.send(DatagramPacket(msg, msg.size, dst, if (ps5) 9302 else 987)) + try { + val buf = ByteArray(2048); val pk = DatagramPacket(buf, buf.size) + s.receive(pk) + reply = String(buf, 0, pk.length, Charsets.UTF_8); break + } catch (e: java.net.SocketTimeoutException) { } + } + if (reply == null) Status(Power.NOT_FOUND) + else { + fun field(k: String) = reply.lineSequence().firstOrNull { it.startsWith("$k:") }?.substringAfter(':')?.trim() + val first = reply.lineSequence().firstOrNull() ?: "" + Status(if (first.contains(" 200")) Power.ON else Power.REST, field("running-app-name"), field("host-name")) + } + } + } catch (e: Exception) { Status(Power.NOT_FOUND) } + + data class Found(val host: String, val name: String, val on: Boolean) + + /** Every PlayStation that answers on this network (on or in rest mode). Blocking, about 2 s. */ + fun discover(): List { + val out = LinkedHashMap() + try { + DatagramSocket().use { s -> + s.broadcast = true + s.soTimeout = 300 + val msg = "SRCH * HTTP/1.1\ndevice-discovery-protocol-version:00030010\n".toByteArray() + val bc = InetAddress.getByName("255.255.255.255") + val end = System.currentTimeMillis() + 2000 + var sent = 0 + while (System.currentTimeMillis() < end) { + if (sent < 3) { s.send(DatagramPacket(msg, msg.size, bc, 9302)); sent++ } + try { + val buf = ByteArray(2048); val pk = DatagramPacket(buf, buf.size) + s.receive(pk) + val r = String(buf, 0, pk.length, Charsets.UTF_8) + val host = pk.address.hostAddress ?: continue + val name = r.lineSequence().firstOrNull { it.startsWith("host-name:") }?.substringAfter(':')?.trim() ?: "PS5" + out[host] = Found(host, name, r.lineSequence().first().contains(" 200")) + } catch (e: java.net.SocketTimeoutException) { } + } + } + } catch (e: Exception) { } + return out.values.toList() + } + + /** chiaki's wake credential: the registration key's ASCII (up to the null pad) parsed as hex. */ + fun wakeCredential(registKeyHex: String): ULong? = try { + val bytes = registKeyHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + val end = bytes.indexOfFirst { it == 0.toByte() }.let { if (it >= 0) it else bytes.size } + String(bytes, 0, end, Charsets.UTF_8).toULong(16) + } catch (e: Exception) { null } + + /** Wake from rest mode. Harmless if it's already on. */ + fun wake(c: Console): Boolean { + val cred = wakeCredential(c.registKey) ?: return false + return try { com.metallic.chiaki.lib.DiscoveryService.wakeup(null, c.host, cred, c.ps5); true } catch (e: Exception) { false } + } +} diff --git a/app/src/main/java/com/limelight/home/Psn.kt b/app/src/main/java/com/limelight/home/Psn.kt new file mode 100644 index 0000000000..952a201dee --- /dev/null +++ b/app/src/main/java/com/limelight/home/Psn.kt @@ -0,0 +1,152 @@ +package com.limelight.home + +import android.content.Context +import android.net.Uri +import android.util.Base64 +import org.json.JSONArray +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLEncoder + +/** + * PlayStation Network sign-in for this TV, the same way the phone app does it (chiaki's "Remote Play" OAuth client): + * Sony's sign-in page -> code -> token -> account id. The account id registers a PS5 here; the token (renewed with + * the refresh token) lets this TV reach the PS5 over the internet with no VPN or port forwarding. + * + * Kept in this app's private storage, like the phone app keeps its own. + */ +object Psn { + private const val CLIENT_ID = "ba495a24-818c-472b-b12d-ff231c1b5745" + private const val CLIENT_SECRET = "mvaiZkRsAsI1IBkY" + private const val TOKEN_URL = "https://auth.api.sonyentertainmentnetwork.com/2.0/oauth/token" + const val REDIRECT = "https://remoteplay.dl.playstation.net/remoteplay/redirect" + // Remote Play over the internet needs this broader scope, minted against a stable client duid. + private const val SCOPE = "psn:clientapp%20referenceDataService:countryConfig.read" + + "%20pushNotification:webSocket.desktop.connect%20sessionManager:remotePlaySession.system.update" + private const val DUID_PREFIX = "0000000700410080" // chiaki DUID_PREFIX + private const val PREFS = "mc_psn" + + data class Device(val name: String, val duid: String, val remotePlay: Boolean) + + private fun prefs(ctx: Context) = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + fun signedIn(ctx: Context) = prefs(ctx).getString("account_id", null) != null + fun accountId(ctx: Context): String? = prefs(ctx).getString("account_id", null) + fun onlineId(ctx: Context): String? = prefs(ctx).getString("online_id", null) + + fun signOut(ctx: Context) = prefs(ctx).edit().clear().apply() + + /** This install's client device id, stable so a refreshed token stays valid. */ + private fun duid(ctx: Context): String { + prefs(ctx).getString("duid", null)?.let { return it } + val b = ByteArray(16).also { java.security.SecureRandom().nextBytes(it) } + val d = DUID_PREFIX + b.joinToString("") { "%02x".format(it) } + prefs(ctx).edit().putString("duid", d).apply() + return d + } + + fun authorizeUrl(ctx: Context) = + "https://auth.api.sonyentertainmentnetwork.com/2.0/oauth/authorize?service_entity=urn:service-entity:psn" + + "&response_type=code&client_id=$CLIENT_ID&redirect_uri=$REDIRECT&scope=$SCOPE" + + "&request_locale=en_US&ui=pr&service_logo=ps&layout_type=popup&smcid=remoteplay" + + "&duid=${duid(ctx)}&prompt=always&PlatformPrivacyWs1=minimal&" + + /** The code from Sony's redirect, or null if this URL isn't it. Empty string = cancelled. */ + fun codeFrom(url: String?): String? { + if (url == null || !url.startsWith(REDIRECT)) return null + return Uri.parse(url).getQueryParameter("code") ?: "" + } + + private val basic get() = "Basic " + Base64.encodeToString("$CLIENT_ID:$CLIENT_SECRET".toByteArray(), Base64.NO_WRAP) + + private fun post(form: String): JSONObject { + val c = URL(TOKEN_URL).openConnection() as HttpURLConnection + try { + c.requestMethod = "POST"; c.doOutput = true + c.connectTimeout = 10000; c.readTimeout = 10000 + c.setRequestProperty("Authorization", basic) + c.setRequestProperty("Content-Type", "application/x-www-form-urlencoded") + c.outputStream.use { it.write(form.toByteArray()) } + return JSONObject(c.inputStream.bufferedReader().readText()) + } finally { c.disconnect() } + } + + private fun saveToken(ctx: Context, j: JSONObject) { + prefs(ctx).edit().putString("token", j.getString("access_token")) + .putString("refresh", j.optString("refresh_token", prefs(ctx).getString("refresh", ""))) + .putLong("token_at", System.currentTimeMillis()).apply() + } + + /** Finish sign-in: code -> token -> account id (and the consoles reachable over PSN). Blocking; call off the UI thread. */ + fun finishSignIn(ctx: Context, code: String) { + val tok = post("grant_type=authorization_code&code=${URLEncoder.encode(code, "UTF-8")}" + + "&redirect_uri=${URLEncoder.encode(REDIRECT, "UTF-8")}") + saveToken(ctx, tok) + val token = tok.getString("access_token") + val c = URL("$TOKEN_URL/${URLEncoder.encode(token, "UTF-8")}").openConnection() as HttpURLConnection + val info = try { + c.setRequestProperty("Authorization", basic) + JSONObject(c.inputStream.bufferedReader().readText()) + } finally { c.disconnect() } + // user_id -> 8 little-endian bytes -> base64: chiaki's account id + val uid = info.getString("user_id").toLong() + val le = ByteArray(8) { i -> (uid shr (8 * i)).toByte() } + prefs(ctx).edit().putString("account_id", Base64.encodeToString(le, Base64.NO_WRAP)) + .putString("online_id", info.optString("online_id", "")).apply() + try { refreshDevices(ctx) } catch (e: Exception) { } + } + + /** The token, renewed when it's near its ~1 h expiry. */ + fun freshToken(ctx: Context): String? { + val p = prefs(ctx) + val tok = p.getString("token", null) ?: return null + val refresh = p.getString("refresh", "") ?: "" + if (System.currentTimeMillis() - p.getLong("token_at", 0) < 50 * 60 * 1000L || refresh.isEmpty()) return tok + return try { + saveToken(ctx, post("grant_type=refresh_token&refresh_token=${URLEncoder.encode(refresh, "UTF-8")}" + + "&scope=$SCOPE&redirect_uri=${URLEncoder.encode(REDIRECT, "UTF-8")}")) + p.getString("token", tok) + } catch (e: Exception) { tok } + } + + /** Ask PSN which consoles on this account can be reached over the internet. Blocking. */ + fun refreshDevices(ctx: Context): List { + val tok = freshToken(ctx) ?: return emptyList() + val c = URL("https://web.np.playstation.com/api/cloudAssistedNavigation/v2/users/me/clients" + + "?platform=PS5&includeFields=device&limit=10&offset=0").openConnection() as HttpURLConnection + val out = JSONArray() + try { + c.setRequestProperty("Authorization", "Bearer $tok") + c.setRequestProperty("Accept-Language", "jp") + c.connectTimeout = 8000; c.readTimeout = 8000 + if (c.responseCode == 200) { + val clients = JSONObject(c.inputStream.bufferedReader().readText()).optJSONArray("clients") ?: JSONArray() + for (i in 0 until clients.length()) { + val cl = clients.getJSONObject(i); val dev = cl.optJSONObject("device") + val f = dev?.optJSONArray("enabledFeatures") + val remote = f != null && (0 until f.length()).any { f.optString(it) == "remotePlay" } + out.put(JSONObject().put("name", dev?.optString("name") ?: "").put("duid", cl.optString("duid")).put("remotePlay", remote)) + } + } + } finally { c.disconnect() } + prefs(ctx).edit().putString("devices", out.toString()).apply() + return devices(ctx) + } + + fun devices(ctx: Context): List = try { + val a = JSONArray(prefs(ctx).getString("devices", "[]")) + (0 until a.length()).map { a.getJSONObject(it) }.map { Device(it.optString("name"), it.optString("duid"), it.optBoolean("remotePlay")) } + } catch (e: Exception) { emptyList() } + + /** (token, duid, accountId) to reach this console over PSN, or null. Blocking (may renew the token). */ + fun remoteFor(ctx: Context, consoleName: String): Triple? { + val acc = accountId(ctx) ?: return null + var devs = devices(ctx) + if (devs.isEmpty()) devs = try { refreshDevices(ctx) } catch (e: Exception) { emptyList() } + val remote = devs.filter { it.remotePlay && it.duid.isNotEmpty() } + val d = remote.firstOrNull { it.name.equals(consoleName, ignoreCase = true) } ?: remote.singleOrNull() ?: return null + val tok = freshToken(ctx) ?: return null + return Triple(tok, d.duid, acc) + } +} diff --git a/app/src/main/java/com/limelight/home/PsnSignInActivity.kt b/app/src/main/java/com/limelight/home/PsnSignInActivity.kt new file mode 100644 index 0000000000..7f0370edde --- /dev/null +++ b/app/src/main/java/com/limelight/home/PsnSignInActivity.kt @@ -0,0 +1,96 @@ +package com.limelight.home + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.os.Bundle +import android.view.Gravity +import android.view.View +import android.webkit.CookieManager +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.FrameLayout +import android.widget.LinearLayout + +/** + * Sony's sign-in page, full screen, for this TV. Use the remote or a pad to move between fields and the TV's keyboard + * to type. When Sony redirects back with a code, we finish the sign-in and return RESULT_OK. + */ +class PsnSignInActivity : Activity() { + private lateinit var tv: Tv + private var done = false + private var web: WebView? = null + + @SuppressLint("SetJavaScriptEnabled") + override fun onCreate(b: Bundle?) { + super.onCreate(b) + tv = Tv(this, getSharedPreferences("mc_tv", Context.MODE_PRIVATE).getBoolean("oled", false)) + val root = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL; setBackgroundColor(tv.p.night) } + + val bar = tv.row().apply { setPadding(tv.px(96), tv.px(28), tv.px(96), tv.px(20)) } + bar.addView(Crescent(this, tv.p.moon), tv.lp(28, 28).apply { rightMargin = tv.px(14) }) + bar.addView(tv.text("Sign in to PlayStation Network", 28, tv.p.moon, tv.display(600))) + bar.addView(tv.grow()) + bar.addView(tv.text("Only once. It lets this TV add and reach your PS5.", 21, tv.p.dust, tv.ui(600))) + root.addView(bar, tv.lp(-1, -2)) + + val status = tv.text("", 26, tv.p.moon, tv.ui(700)).apply { gravity = Gravity.CENTER; visibility = View.GONE } + val web = WebView(this).also { this.web = it }.apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + setBackgroundColor(0xFFFFFFFF.toInt()) + isFocusable = true; isFocusableInTouchMode = true + CookieManager.getInstance().setAcceptThirdPartyCookies(this, true) + webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading(v: WebView, r: WebResourceRequest) = handle(r.url.toString(), v, status) + @Deprecated("Deprecated in Java") + override fun shouldOverrideUrlLoading(v: WebView, url: String) = handle(url, v, status) + override fun onPageStarted(v: WebView, url: String, f: android.graphics.Bitmap?) { handle(url, v, status) } + } + } + // Don't clear cookies here: Sony's bot check sets one while it runs, and an async wipe racing it made the + // page fail with "The connection to the server timed out". prompt=always asks for the password anyway. + CookieManager.getInstance().setAcceptCookie(true) + val body = FrameLayout(this) + body.addView(web, FrameLayout.LayoutParams(-1, -1)) + body.addView(status, FrameLayout.LayoutParams(-1, -1)) + root.addView(body, LinearLayout.LayoutParams(-1, 0, 1f)) + + val hints = tv.row().apply { setPadding(tv.px(96), tv.px(14), tv.px(96), tv.px(22)) } + hints.addView(tv.hint("A", "Select or type")) + hints.addView(tv.grow()) + hints.addView(tv.hint("B", "Back")) + root.addView(hints, tv.lp(-1, -2)) + + setContentView(root) + web.loadUrl(Psn.authorizeUrl(this)) + web.requestFocus() + } + + private fun handle(url: String?, web: WebView, status: android.widget.TextView): Boolean { + val code = Psn.codeFrom(url) ?: return false + if (done) return true + done = true + if (code.isEmpty()) { setResult(RESULT_CANCELED); finish(); return true } + web.visibility = View.INVISIBLE + status.visibility = View.VISIBLE + status.text = "Signing in…" + Thread { + val ok = try { Psn.finishSignIn(this, code); true } catch (e: Exception) { false } + runOnUiThread { + if (ok) { setResult(RESULT_OK); finish() } + else { status.text = "Sign-in didn't finish. Press B and try again."; done = false } + } + }.start() + return true + } + + @Deprecated("Deprecated in Java") + override fun onBackPressed() { + val w = web + if (w != null && w.canGoBack() && !done) { w.goBack(); return } // e.g. back from Sony's "forgot password" + setResult(RESULT_CANCELED) + super.onBackPressed() + } +} diff --git a/app/src/main/java/com/limelight/home/QuickMenu.kt b/app/src/main/java/com/limelight/home/QuickMenu.kt new file mode 100644 index 0000000000..978a156bdf --- /dev/null +++ b/app/src/main/java/com/limelight/home/QuickMenu.kt @@ -0,0 +1,380 @@ +package com.limelight.home + +import android.app.Activity +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.view.Gravity +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.GridLayout +import android.widget.LinearLayout +import android.widget.TextView + +/** + * The in-game Quick menu, shared by the PC player (Moonlight) and the PS5 player (chiaki): a panel on the right with + * live stats and a short list of actions. Opened with Select + Start + LB + RB on any pad, or Back on the TV remote. + * + * The game keeps the screen, so the menu does its own input instead of relying on Android focus: the players eat + * every pad event, and a pad's d-pad often arrives as hat motion, not keys. While the menu is open the player + * forwards key and motion events here and sends nothing to the game. + */ +class QuickMenu(private val act: Activity) { + class Item @JvmOverloads constructor(val icon: String, val label: String, val detail: String? = null, + val danger: Boolean = false, val action: Runnable = Runnable { }) { + /** The item flips something on or off (it plays the toggle sound instead of select). */ + var toggle = false + fun asToggle(): Item = apply { toggle = true } + } + + /** (value, label) pairs for the stat tiles, read every second while the menu is open. */ + fun interface Stats { fun get(): List> } + + private val tv = Tv(act, act.getSharedPreferences("mc_tv", Context.MODE_PRIVATE).getBoolean("oled", false)) + private val ui = Handler(Looper.getMainLooper()) + private var root: FrameLayout? = null + private var rows = listOf() + private var items = listOf() + private var sel = 0 + private var lastMove = 0L + private var stats: Stats? = null + private var statsViews = listOf() + private var chosenAt = 0L + + val isShowing get() = root != null + + /** Show the menu. [stats] is polled every second while it's open: (value, label) pairs, up to five. */ + fun show(title: String, subtitle: String, stats: Stats, items: List) { + FeelSounds.warm(act) + // one sound per action: an item that reopens the menu (a sub-list) already played its own + if (!isShowing && SystemClock.uptimeMillis() - chosenAt > 400) FeelSounds.play(act, FeelSounds.Cue.OPEN, PAN) + if (isShowing) close() + this.items = items; this.stats = stats; sel = 0 + if (switchStyle) { showSwitch(title, subtitle, stats, items); return } + + val panel = tv.col().apply { + background = tv.round(translucent(tv.p.deep), 0) + setPadding(tv.px(52), tv.px(64), tv.px(52), tv.px(48)) + } + panel.addView(tv.text(title, 30, tv.p.moon, tv.display(600), 2)) + panel.addView(tv.text(subtitle, 21, tv.p.dust, tv.ui(500), 2), tv.lp(-1, -2).apply { topMargin = tv.px(8) }) + + val grid = GridLayout(act).apply { columnCount = 2 } + val first = stats.get() + statsViews = (0 until minOf(5, first.size)).map { i -> + val n = tv.text(first[i].first, 34, tv.p.moon, tv.display(600)) + grid.addView(tv.col().apply { + background = tv.round(tv.p.night, 16, tv.p.line, 1) + setPadding(tv.px(18), tv.px(14), tv.px(18), tv.px(14)) + addView(n) + addView(tv.text(first[i].second, 18, tv.p.dust, tv.ui(600)), tv.lp(-2, -2).apply { topMargin = tv.px(4) }) + }, GridLayout.LayoutParams().apply { + width = tv.px(272); rightMargin = if (i % 2 == 0) tv.px(12) else 0; bottomMargin = tv.px(12) + }) + n + } + if (statsViews.isNotEmpty()) panel.addView(grid, tv.lp(-1, -2).apply { topMargin = tv.px(28) }) + + val list = tv.col() + rows = items.mapIndexed { i, it -> + tv.row().apply { + setPadding(tv.px(20), 0, tv.px(20), 0) + addView(tv.text(it.icon, 19, tv.p.moon, tv.ui(700)).apply { + gravity = Gravity.CENTER; background = tv.round(tv.p.raise, 10, tv.p.line, 1) + }, tv.lp(44, 44).apply { rightMargin = tv.px(18) }) + addView(tv.text(it.label, 24, if (it.danger) Tv.DANGER else tv.p.moon, tv.ui(700)), LinearLayout.LayoutParams(0, -2, 1f)) + if (it.detail != null) addView(tv.text(it.detail, 19, tv.p.dust, tv.ui(600))) + setOnClickListener { _ -> choose(i) } // touch screens (Odin) + minimumHeight = tv.px(80) // at least 80; a long label with its detail grows instead of being cut + setPadding(tv.px(20), tv.px(10), tv.px(20), tv.px(10)) + list.addView(this, tv.lp(-1, -2)) + } + } + // the list takes the room between the stats and the hints, and scrolls when the items don't fit + val scroll = android.widget.ScrollView(act).apply { isVerticalScrollBarEnabled = false; addView(list) } + panel.addView(scroll, LinearLayout.LayoutParams(-1, 0, 1f).apply { topMargin = tv.px(16) }) + panel.addView(tv.row().apply { + addView(tv.hint("A", "Select")); addView(tv.hint("B", "Back to the game")) + }) + + val r = FrameLayout(act).apply { + setBackgroundColor(0x99000000.toInt()) + isClickable = true + setOnClickListener { hide() } + addView(panel, FrameLayout.LayoutParams(tv.px(660), -1, Gravity.END).apply { }) + panel.isClickable = true + } + noFocus(r) + (act.window.decorView as ViewGroup).addView(r, ViewGroup.LayoutParams(-1, -1)) + root = r + paint() + ui.post(tick) + } + + // ---- the handheld's look: the Switch-style quick settings of mooncast-handheld-v2.html ---- + + /** The handheld app (not the TV) uses the Switch look: a right panel in landscape, a bottom sheet upright. */ + private val switchStyle = !com.limelight.BuildConfig.TV_APP + private val light = act.getSharedPreferences("mc_tv", Context.MODE_PRIVATE).getString("hh_theme", "dark") == "light" + private val sBg = if (light) 0xFFEBEBEB.toInt() else 0xFF2B2D31.toInt() + private val sPanel = if (light) 0xFFFFFFFF.toInt() else 0xFF36393F.toInt() + // the panel is see-through so the game stays visible behind it (alpha only, no blur: Android 9 sticks) + private val panelFill = translucent(if (light) 0xFFFFFFFF.toInt() else 0xFF0C111D.toInt()) + private val sInk = if (light) 0xFF2D2D2D.toInt() else 0xFFFFFFFF.toInt() + private val sSub = if (light) 0xFF6E727A.toInt() else 0xFFB5BAC3.toInt() + private val sRing = if (light) 0xFF00A9C2.toInt() else 0xFF2FD3E6.toInt() + private var st: Tv = tv + + /** 80% opaque: the picture shows through the panel. */ + private fun translucent(c: Int): Int = (0xCC shl 24) or (c and 0xFFFFFF) + + private fun showSwitch(title: String, subtitle: String, stats: Stats, items: List) { + val dm = act.resources.displayMetrics + val upright = dm.heightPixels > dm.widthPixels + st = Tv(act, false, if (upright) 1080 else 1920, if (upright) 1920 else 1080) + val t = st + fun txt(v: CharSequence, size: Number, color: Int, w: Int, lines: Int = 1) = t.text(v, size, color, t.ui(w), lines) + + val panel = t.col().apply { + background = if (upright) android.graphics.drawable.GradientDrawable().apply { + setColor(panelFill); val r = t.pxf(36); cornerRadii = floatArrayOf(r, r, r, r, 0f, 0f, 0f, 0f) } + else android.graphics.drawable.ColorDrawable(panelFill) + setPadding(t.px(44), t.px(if (upright) 26 else 44), t.px(44), t.px(if (upright) 60 else 40)) + } + if (upright) panel.addView(View(act).apply { background = t.round(if (light) 0xFFCDCFD4.toInt() else 0xFF474B53.toInt(), 5) }, + LinearLayout.LayoutParams(t.px(120), t.px(10)).apply { gravity = Gravity.CENTER_HORIZONTAL; bottomMargin = t.px(30) }) + // the game's name on its own line, then what it's on (wrapping to two lines, never cut) + panel.addView(txt(title, 36, sInk, 800, 2), t.lp(-1, -2)) + panel.addView(txt(subtitle, 24, sSub, 700, 2), t.lp(-1, -2).apply { topMargin = t.px(8) }) + + val first = stats.get().map { short(it) } + val statRow = t.row() + statsViews = (0 until minOf(5, first.size)).map { i -> + val n = txt(first[i].first, statSize(first[i].first), sInk, 800).apply { ellipsize = null } + statRow.addView(t.col().apply { + background = t.round(sBg, 10) + setPadding(t.px(16), t.px(18), t.px(16), t.px(18)) + addView(n) + addView(txt(first[i].second, 20, sSub, 600), t.lp(-2, -2).apply { topMargin = t.px(4) }) + }, LinearLayout.LayoutParams(0, -2, 1f).apply { if (i > 0) leftMargin = t.px(8) }) + n + } + if (statsViews.isNotEmpty()) panel.addView(statRow, t.lp(-1, -2).apply { topMargin = t.px(30); bottomMargin = t.px(20) }) + + val list = t.col() + rows = items.mapIndexed { i, it -> + t.row().apply { + setPadding(t.px(24), t.px(14), t.px(24), t.px(14)) + minimumHeight = t.px(100) + addView(txt(it.icon, 18, 0xFFFFFFFF.toInt(), 800).apply { + gravity = Gravity.CENTER; background = t.round(if (it.danger) 0xFFE9605A.toInt() else 0xFF5B6170.toInt(), 12) + }, t.lp(52, 52).apply { rightMargin = t.px(22) }) + addView(t.col().apply { + addView(txt(it.label, 28, if (it.danger) 0xFFE9605A.toInt() else sInk, 800)) // red like the TV's + if (it.detail != null) addView(txt(it.detail, 21, sSub, 600), t.lp(-2, -2).apply { topMargin = t.px(4) }) + }, LinearLayout.LayoutParams(0, -2, 1f)) + setOnClickListener { _ -> choose(i) } + list.addView(this, t.lp(-1, -2).apply { topMargin = t.px(10) }) + } + } + val scroll = android.widget.ScrollView(act).apply { isVerticalScrollBarEnabled = false; addView(list) } + // the list takes the room left, so the hints stay on the sheet however many items there are + panel.addView(scroll, LinearLayout.LayoutParams(-1, 0, 1f)) + panel.addView(t.row().apply { + addView(View(act), LinearLayout.LayoutParams(0, 1, 1f)) + listOf("A" to "OK", "B" to "Back to the game").forEach { (k, l) -> // A first, as on the TV + addView(txt(k, 24, sBg, 800).apply { gravity = Gravity.CENTER; background = t.round(sInk, 23) }, t.lp(46, 46).apply { leftMargin = t.px(32); rightMargin = t.px(12) }) + addView(txt(l, 24, sInk, 700)) + } + }, t.lp(-1, -2).apply { topMargin = t.px(24) }) + + val r = FrameLayout(act).apply { + setBackgroundColor(0x80000000.toInt()) + isClickable = true + setOnClickListener { hide() } + if (upright) addView(panel, FrameLayout.LayoutParams(-1, (dm.heightPixels * 0.62f).toInt(), Gravity.BOTTOM)) + else addView(panel, FrameLayout.LayoutParams(t.px(680), -1, Gravity.END)) + panel.isClickable = true + } + noFocus(r) + (act.window.decorView as ViewGroup).addView(r, ViewGroup.LayoutParams(-1, -1)) + root = r + paint() + ui.post(tick) + } + + /** Switch-style stat tiles are narrow: "60" over "fps", "14" over "Mbps", as in the mockup. */ + private fun short(p: Pair): Pair { + val (v, l) = p + return when (l.lowercase()) { + "frames per second" -> v to "fps" + "network delay" -> v to "delay" + "picture quality", "quality" -> if (v.endsWith(" Mbps")) v.removeSuffix(" Mbps") to "Mbps" else v to "quality" + "lost frames", "lost packets" -> v to "lost" + "playing for" -> v to "playing" + "waiting for" -> v to "waiting" + else -> v to l.lowercase().substringBefore(' ') + } + } + + /** Value size that fits a quarter of the panel: big for "60", smaller for "Connected". */ + private fun statSize(v: String): Int = when { v.length <= 4 -> 34; v.length <= 6 -> 28; v.length <= 8 -> 23; else -> 19 } + + /** The selected row in the Switch look: the cyan double ring (a gap in the panel colour, then cyan). */ + private fun switchRow(on: Boolean): android.graphics.drawable.Drawable { + val t = st + if (!on) return t.round(sBg, 10) + return android.graphics.drawable.LayerDrawable(arrayOf(t.round(sRing, 10), t.round(sPanel, 8), t.round(sBg, 6))).apply { + setLayerInset(1, t.px(6), t.px(6), t.px(6), t.px(6)) + setLayerInset(2, t.px(11), t.px(11), t.px(11), t.px(11)) + } + } + + /** + * The menu does its own selection, so none of its views may take Android focus: its rows are clickable (for + * touch), which makes them focusable, and the first d-pad press after a touch then only left touch mode and + * focused a row - it never reached [onKey] (two presses to move once). + */ + private fun noFocus(r: ViewGroup) { + fun walk(v: View) { v.isFocusable = false; v.isFocusableInTouchMode = false; if (v is ViewGroup) for (i in 0 until v.childCount) walk(v.getChildAt(i)) } + walk(r) + r.descendantFocusability = ViewGroup.FOCUS_BLOCK_DESCENDANTS + // the menu itself holds focus while it's open: with a view focused, Android passes the first d-pad press on + // instead of spending it on leaving touch mode (keys still reach the player first, which forwards them here) + r.isFocusable = true; r.isFocusableInTouchMode = true + ui.post { if (root === r) r.requestFocus() } + } + + /** Close the menu, with its close sound. */ + fun hide() { + if (isShowing) FeelSounds.play(act, FeelSounds.Cue.CLOSE, PAN) + close() + } + + private fun close() { + ui.removeCallbacks(tick) + root?.let { (it.parent as? ViewGroup)?.removeView(it) } + root = null + } + + private val tick = object : Runnable { + override fun run() { + val s = stats?.get() ?: return + statsViews.forEachIndexed { i, v -> + if (i >= s.size) return@forEachIndexed + if (switchStyle) { + val sv = short(s[i]).first + if (v.text.toString() != sv) { v.text = sv; v.setTextSize(android.util.TypedValue.COMPLEX_UNIT_PX, st.pxf(statSize(sv))) } + } else if (v.text.toString() != s[i].first) v.text = s[i].first + } + ui.postDelayed(this, 1000) + } + } + + private fun paint() { + rows.forEachIndexed { i, v -> + v.background = if (switchStyle) switchRow(i == sel) else if (i == sel) tv.round(tv.p.raise, 16, Tv.CORONA, 3) else null + } + // keep the selected row in view: with more items than fit, the last ones were out of reach (test242) + val row = rows.getOrNull(sel) ?: return + val sc = row.parent?.parent as? android.widget.ScrollView ?: return + sc.post { + val gap = row.height / 2 + if (row.top - gap < sc.scrollY) sc.smoothScrollTo(0, maxOf(0, row.top - gap)) + else if (row.bottom + gap > sc.scrollY + sc.height) sc.smoothScrollTo(0, row.bottom + gap - sc.height) + } + } + + private fun move(d: Int) { + if (rows.isEmpty()) return + sel = (sel + d + rows.size) % rows.size + paint() + FeelSounds.play(act, FeelSounds.Cue.FOCUS, PAN) + } + + private fun choose(i: Int) { + val it = items.getOrNull(i) ?: return + FeelSounds.play(act, if (it.toggle) FeelSounds.Cue.TOGGLE else FeelSounds.Cue.SELECT, PAN) + chosenAt = SystemClock.uptimeMillis() + close() + it.action.run() + } + + /** Key events while the menu is open. Always consumes them. */ + fun onKey(e: KeyEvent): Boolean { + if (e.action != KeyEvent.ACTION_DOWN) return true + when (e.keyCode) { + KeyEvent.KEYCODE_DPAD_UP -> move(-1) + KeyEvent.KEYCODE_DPAD_DOWN -> move(1) + KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER -> if (e.repeatCount == 0) choose(sel) + KeyEvent.KEYCODE_BUTTON_B, KeyEvent.KEYCODE_BACK -> if (e.repeatCount == 0) hide() + } + return true + } + + /** Pad d-pad (hat) and left stick, for pads that don't send d-pad keys. Always consumes them. */ + fun onMotion(e: MotionEvent): Boolean { + if ((e.source and InputDevice.SOURCE_JOYSTICK) != InputDevice.SOURCE_JOYSTICK) return true + val y = e.getAxisValue(MotionEvent.AXIS_HAT_Y).let { if (Math.abs(it) > .5f) it else e.getAxisValue(MotionEvent.AXIS_Y) } + val now = SystemClock.uptimeMillis() + if (Math.abs(y) > .6f && now - lastMove > 220) { lastMove = now; move(if (y > 0) 1 else -1) } + if (Math.abs(y) < .3f) lastMove = 0 + return true + } + + companion object { + /** The panel sits on the right, so its sounds lean right. */ + private const val PAN = 0.4f + + /** Physical game pads connected now (not the TV remote). */ + @JvmStatic fun pads(): List = InputDevice.getDeviceIds().toList().mapNotNull { InputDevice.getDevice(it) }.filter { d -> + // A real pad has sticks (joystick source) or at least a maker id; the TV's "virtual-search" input claims + // to be a numbered gamepad on some sticks (Mi stick, Android 9) but has neither. + val joystick = (d.sources and InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK + val gamepad = (d.sources and InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD + !d.isVirtual && d.controllerNumber > 0 && (joystick || (gamepad && d.vendorId != 0)) + } + + /** "80 %" where Android knows the pad's battery (Android 12+), else null. */ + @JvmStatic fun battery(d: InputDevice): String? = try { + if (android.os.Build.VERSION.SDK_INT >= 31 && d.batteryState.isPresent) "${Math.round(d.batteryState.capacity * 100)} %" else null + } catch (e: Exception) { null } + + @JvmStatic fun pairController(a: Activity) { + for (action in listOf(android.provider.Settings.ACTION_BLUETOOTH_SETTINGS, android.provider.Settings.ACTION_SETTINGS)) + try { a.startActivity(android.content.Intent(action)); return } catch (e: Exception) { } + } + + @JvmStatic fun stat(value: String, label: String) = Pair(value, label) + + /** The combo that opens the menu in both players. */ + const val COMBO_LABEL = "Select + Start + LB + RB" + + /** Remote Back (not a pad's B button) opens the menu. */ + @JvmStatic fun isRemoteBack(e: KeyEvent): Boolean { + if (e.keyCode != KeyEvent.KEYCODE_BACK) return false + val s = e.source + return (s and InputDevice.SOURCE_GAMEPAD) != InputDevice.SOURCE_GAMEPAD && + (s and InputDevice.SOURCE_JOYSTICK) != InputDevice.SOURCE_JOYSTICK && + (s and InputDevice.SOURCE_MOUSE) != InputDevice.SOURCE_MOUSE + } + } +} + +/** Live numbers from the PC player's decoder, for the Quick menu (filled whether or not the overlay is on). */ +object StreamStats { + @Volatile var fps = 0f + @Volatile var rttMs = 0 + @Volatile var lossPct = 0f + @Volatile var size = "" + /** Average time the decoder spends on one frame, in ms. */ + @Volatile var decodeMs = 0f + @JvmStatic fun update(fps: Float, rttMs: Int, lossPct: Float, size: String, decodeMs: Float) { + this.fps = fps; this.rttMs = rttMs; this.lossPct = lossPct; this.size = size; this.decodeMs = decodeMs + } +} diff --git a/app/src/main/java/com/limelight/home/StatusCorner.kt b/app/src/main/java/com/limelight/home/StatusCorner.kt new file mode 100644 index 0000000000..c3ea6f6764 --- /dev/null +++ b/app/src/main/java/com/limelight/home/StatusCorner.kt @@ -0,0 +1,85 @@ +package com.limelight.home + +import android.app.Activity +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.util.TypedValue +import android.view.Gravity +import android.widget.FrameLayout +import android.widget.TextView + +/** + * Streaming status for the PS5 and Xbox players: one line of live numbers in the top-left corner while playing + * (the PC player has Moonlight's own). It follows Settings' "Stats while playing", and the Quick menu flips it. + * [stats] gives the Quick menu's (value, label) tiles; the line shows them once the stream has its first numbers. + */ +class StatusCorner(private val act: Activity, private val stats: () -> List>) { + private val ui = Handler(Looper.getMainLooper()) + private var view: TextView? = null + private val tick = object : Runnable { + override fun run() { + val v = view ?: return + val line = line() + if (v.text.toString() != line) v.text = line // a new text lays the player out again: only when it changed + val vis = if (line.isEmpty()) android.view.View.GONE else android.view.View.VISIBLE + if (v.visibility != vis) v.visibility = vis + ui.postDelayed(this, 1000) + } + } + + /** Show or hide it as Settings say (call once the player's screen is up). */ + fun apply() = set(enabled(act)) + + /** The Quick menu's toggle: flips it now and saves the choice. */ + fun toggle() { + val on = !enabled(act) + android.preference.PreferenceManager.getDefaultSharedPreferences(act).edit().putBoolean(PREF, on).apply() + set(on) + } + + fun stop() { ui.removeCallbacks(tick) } + + private fun set(on: Boolean) { + if (on == (view != null)) return + if (!on) { + ui.removeCallbacks(tick) + (view?.parent as? FrameLayout)?.removeView(view) + view = null + return + } + val root = act.findViewById(android.R.id.content) ?: return + val d = act.resources.displayMetrics.density + view = TextView(act).apply { + setTextColor(0xFFE8ECFF.toInt()) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f) + setBackgroundColor(0x99000000.toInt()) + setPadding((10 * d).toInt(), (5 * d).toInt(), (10 * d).toInt(), (5 * d).toInt()) + visibility = android.view.View.GONE + } + root.addView(view, FrameLayout.LayoutParams(-2, -2, Gravity.TOP or Gravity.START).apply { + leftMargin = (12 * d).toInt(); topMargin = (12 * d).toInt() + }) + ui.post(tick) + } + + private fun line(): String { + val s = stats() + if (s.firstOrNull()?.second != "Frames per second") return "" // still connecting + return s.filter { it.first != "…" }.joinToString(" ") { (v, label) -> + when (label) { + "Frames per second" -> "$v fps" + "Network delay" -> "$v delay" + "Lost packets" -> "$v lost" + "Decode time" -> "$v decode" + else -> v + } + } + } + + companion object { + /** Moonlight's own "Stats while playing" key, so one switch covers PC, PS5 and Xbox. */ + const val PREF = "checkbox_enable_perf_overlay" + fun enabled(c: Context) = android.preference.PreferenceManager.getDefaultSharedPreferences(c).getBoolean(PREF, false) + } +} diff --git a/app/src/main/java/com/limelight/home/StreamReport.kt b/app/src/main/java/com/limelight/home/StreamReport.kt new file mode 100644 index 0000000000..19d1d5b83c --- /dev/null +++ b/app/src/main/java/com/limelight/home/StreamReport.kt @@ -0,0 +1,16 @@ +package com.limelight.home + +/** + * How the stream on this device is going, for whoever started it from another device: the TV app answers the + * handheld's GET /status with it, so the handheld can show the TV's own error ("your Xbox didn't answer") instead of + * guessing. The players (PC, PS5, Xbox) report; nothing listens on the handheld app. + */ +object StreamReport { + /** (state, message): state is "starting", "playing" or "failed". Called on any thread. */ + @JvmStatic var listener: ((String, String?) -> Unit)? = null + + @JvmStatic fun starting() { listener?.invoke("starting", null) } + @JvmStatic fun playing() { listener?.invoke("playing", null) } + /** The player gave up: [message] in plain words, as shown on its own screen. */ + @JvmStatic fun failed(message: String) { listener?.invoke("failed", message.replace('\n', ' ').trim()) } +} diff --git a/app/src/main/java/com/limelight/home/Tv.kt b/app/src/main/java/com/limelight/home/Tv.kt new file mode 100644 index 0000000000..a1b86537c9 --- /dev/null +++ b/app/src/main/java/com/limelight/home/Tv.kt @@ -0,0 +1,257 @@ +package com.limelight.home + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Path +import android.graphics.Typeface +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.StateListDrawable +import android.os.Build +import android.util.TypedValue +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.TextView + +/** + * The MoonCast TV design kit: tokens from the Big Picture mockup, and small builders for the views every screen uses. + * + * Everything is sized in "design pixels" on a 1920-wide canvas and scaled to the real screen, so a 720p stick and a + * 4K TV show the same layout. OLED mode swaps to true black, drops large white fills and dims the static chrome. + */ +class Tv @JvmOverloads constructor(val ctx: Context, val oled: Boolean, designW: Int = 1920, designH: Int = 0) { + class Palette(val night: Int, val deep: Int, val raise: Int, val line: Int, val moon: Int, val dust: Int, val soft: Int) + + companion object { + const val CORONA = 0xFFF2B55C.toInt() // focus, the one accent + const val BEAM = 0xFF4FD6E3.toInt() // live / connected only + const val DANGER = 0xFFFF8F84.toInt() + val STANDARD = Palette(0xFF090D18.toInt(), 0xFF111829.toInt(), 0xFF18213A.toInt(), 0xFF263150.toInt(), + 0xFFEEF1F7.toInt(), 0xFF97A1B8.toInt(), 0xFFCBD2E1.toInt()) + val OLED = Palette(0xFF000000.toInt(), 0xFF0A0C11.toInt(), 0xFF141822.toInt(), 0xFF1D2230.toInt(), + 0xFFE2E5EC.toInt(), 0xFF858EA3.toInt(), 0xFFB9C0CE.toInt()) + + // controller face-button colours (A green, B red, X blue, Y yellow) + val FACE = mapOf("A" to 0xFF7FD97A.toInt(), "B" to 0xFFF27979.toInt(), "X" to 0xFF79A9F2.toInt(), "Y" to 0xFFF2D479.toInt()) + + private val fonts = HashMap() + } + + val p = if (oled) OLED else STANDARD + // TV screens are 16:9, so width alone sets the scale. The handheld passes a design height too and fits both, + // so a 20:9 phone or a portrait screen keeps the whole layout on screen. + private val scale = ctx.resources.displayMetrics.let { m -> + val byW = m.widthPixels.coerceAtLeast(1) / designW.toFloat() + if (designH > 0) minOf(byW, m.heightPixels.coerceAtLeast(1) / designH.toFloat()) else byW + } + + /** design px (1920-wide canvas) -> real px */ + fun px(v: Number): Int = Math.round(v.toFloat() * scale) + fun pxf(v: Number): Float = v.toFloat() * scale + + // ---- type ---- + private fun face(file: String, weight: Int): Typeface { + val key = "$file:$weight" + fonts[key]?.let { return it } + val tf = try { + if (Build.VERSION.SDK_INT >= 26) + Typeface.Builder(ctx.assets, "fonts/$file").setFontVariationSettings("'wght' $weight").build() + else Typeface.create(Typeface.SANS_SERIF, if (weight >= 600) Typeface.BOLD else Typeface.NORMAL) + } catch (e: Exception) { Typeface.DEFAULT } + fonts[key] = tf + return tf + } + fun display(weight: Int = 700) = face("Unbounded.ttf", weight) + fun ui(weight: Int = 600) = face("Figtree.ttf", weight) + + fun text(s: CharSequence, size: Number, color: Int = p.moon, tf: Typeface = ui(), lines: Int = 1): TextView = + TextView(ctx).apply { + text = s + setTextSize(TypedValue.COMPLEX_UNIT_PX, pxf(size)) + setTextColor(color) + typeface = tf + includeFontPadding = false + if (lines > 0) { maxLines = lines; ellipsize = android.text.TextUtils.TruncateAt.END } + setLineSpacing(0f, 1.12f) + // without font padding the last line's descenders (g, y, p) get cut on wrapped text + if (lines != 1) setPadding(0, 0, 0, px(size.toFloat() * 0.18f)) + } + + // ---- surfaces ---- + fun round(fill: Int, radius: Number, stroke: Int = 0, strokeW: Number = 1): GradientDrawable = + GradientDrawable().apply { + setColor(fill); cornerRadius = pxf(radius) + if (stroke != 0) setStroke(px(strokeW).coerceAtLeast(1), stroke) + } + + /** Background that shows the corona focus ring when the view has focus. */ + fun focusable(fill: Int, radius: Number, outline: Boolean = true, focusFill: Int = p.raise): StateListDrawable = + StateListDrawable().apply { + addState(intArrayOf(android.R.attr.state_focused), round(focusFill, radius, CORONA, 3)) + addState(intArrayOf(android.R.attr.state_pressed), round(focusFill, radius, CORONA, 3)) + addState(intArrayOf(), round(fill, radius, if (outline) p.line else 0, 1)) + } + + /** A 1 px divider along the top edge, whatever the row's height. */ + fun topLine(): Drawable = object : Drawable() { + private val paint = Paint().apply { color = p.line } + override fun draw(c: Canvas) { c.drawRect(bounds.left.toFloat(), bounds.top.toFloat(), bounds.right.toFloat(), bounds.top + px(1).coerceAtLeast(1).toFloat(), paint) } + override fun setAlpha(alpha: Int) {} + override fun setColorFilter(cf: android.graphics.ColorFilter?) {} + @Deprecated("Deprecated in Java") override fun getOpacity() = android.graphics.PixelFormat.TRANSLUCENT + } + + /** A ring-only foreground, for covers whose content is an image. */ + fun focusRing(radius: Number): StateListDrawable = StateListDrawable().apply { + addState(intArrayOf(android.R.attr.state_focused), round(0, radius, CORONA, 4)) + addState(intArrayOf(), ColorDrawable(0)) + } + + /** Big Picture's small lift on focus: the item you're on grows a little. */ + fun lift(v: View, by: Float = 1.04f) { + v.setOnFocusChangeListener { view, has -> + view.animate().scaleX(if (has) by else 1f).scaleY(if (has) by else 1f).setDuration(120).start() + } + } + + fun focusableView(v: View) { + v.isFocusable = true; v.isClickable = true + // with MoonCast's menu sounds on, its select sound replaces the system click + v.isSoundEffectsEnabled = !FeelSounds.enabled(ctx) + } + + // ---- small pieces ---- + /** The round face-button glyph used in hints and buttons: A, B, X, Y, or a square menu key. */ + fun glyph(key: String, size: Number = 36): TextView = text(key, size.toFloat() * 0.42f, p.night, display(700)).apply { + gravity = Gravity.CENTER + val c = FACE[key] + background = if (c != null) round(if (oled) dim(c) else c, size.toFloat() / 2) + else round(p.raise, 10, p.line, 1) + if (c == null) setTextColor(p.moon) + layoutParams = LinearLayout.LayoutParams(px(if (c == null) size.toFloat() * 1.22f else size), px(size)) + } + + private fun dim(c: Int): Int { + val f = 0.82f + return (c and 0xFF000000.toInt()) or + ((((c shr 16) and 0xFF) * f).toInt() shl 16) or ((((c shr 8) and 0xFF) * f).toInt() shl 8) or ((c and 0xFF) * f).toInt() + } + + /** A status pill: "live" (beam fill), "rest" (corona outline), or plain. */ + fun status(label: String, kind: String = ""): TextView = text(label, 18, p.moon, ui(700)).apply { + gravity = Gravity.CENTER + setPadding(px(14), 0, px(14), 0) + when (kind) { + "live" -> { background = round(BEAM, 17); setTextColor(p.night) } + "rest" -> { background = round(0, 17, CORONA, 1.5f); setTextColor(CORONA) } + else -> background = round(p.raise, 17, p.line, 1) + } + layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, px(34)).apply { rightMargin = px(8) } + } + + /** A focusable action pill with a face-button glyph. Primary is filled white (outlined on OLED). */ + fun pill(label: String, key: String?, primary: Boolean, onClick: () -> Unit): LinearLayout = + LinearLayout(ctx).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setPadding(px(if (key != null) 20 else 32), 0, px(32), 0) + val fill = if (primary && !oled) p.moon else p.raise + val txt = if (primary && !oled) p.night else p.moon + background = StateListDrawable().apply { + val ringed = round(fill, 36, CORONA, 3) + addState(intArrayOf(android.R.attr.state_focused), ringed) + addState(intArrayOf(android.R.attr.state_pressed), ringed) + addState(intArrayOf(), round(fill, 36, if (primary && oled) p.moon else p.line, if (primary && oled) 2 else 1)) + } + if (key != null) addView(glyph(key), LinearLayout.LayoutParams(px(36), px(36)).apply { rightMargin = px(14) }) + addView(text(label, 26, txt, ui(800))) + focusableView(this) + setOnClickListener { onClick() } + layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, px(72)).apply { rightMargin = px(16) } + } + + /** A hint for the bottom bar: glyph + what it does. */ + fun hint(key: String, label: String): LinearLayout = LinearLayout(ctx).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + addView(glyph(key), LinearLayout.LayoutParams(px(if (FACE.containsKey(key)) 36 else 44), px(36)).apply { rightMargin = px(12) }) + addView(text(label, 21, p.dust, ui(600))) + layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { rightMargin = px(40) } + } + + fun row(gravity: Int = Gravity.CENTER_VERTICAL) = LinearLayout(ctx).apply { orientation = LinearLayout.HORIZONTAL; this.gravity = gravity } + fun col() = LinearLayout(ctx).apply { orientation = LinearLayout.VERTICAL } + fun space(w: Number = 0, h: Number = 0) = View(ctx).apply { layoutParams = LinearLayout.LayoutParams(px(w), px(h)) } + fun grow() = View(ctx).apply { layoutParams = LinearLayout.LayoutParams(0, 1, 1f) } + + fun lp(w: Number, h: Number) = LinearLayout.LayoutParams(dim(w), dim(h)) + fun flp(w: Number, h: Number, gravity: Int = Gravity.NO_GRAVITY) = FrameLayout.LayoutParams(dim(w), dim(h), gravity) + private fun dim(v: Number) = when (v.toInt()) { -1 -> ViewGroup.LayoutParams.MATCH_PARENT; -2 -> ViewGroup.LayoutParams.WRAP_CONTENT; else -> px(v) } + + /** + * Two-tone cover art for things with no box art (PS5, unknown apps), as in the mockup. The colours are the + * handheld's (Covers): the same game has the same colour on the TV and the Odin. + */ + fun twoTone(seed: String): Drawable { + val c = Covers.ART[Covers.index(seed)] + return TwoTone(c[0], c[1], pxf(16), if (oled) 0.9f else 1f) + } + + private class TwoTone(val a: Int, val b: Int, val r: Float, val bright: Float) : Drawable() { + private val paint = Paint(Paint.ANTI_ALIAS_FLAG) + override fun draw(c: Canvas) { + val w = bounds.width().toFloat(); val h = bounds.height().toFloat() + val clip = Path().apply { addRoundRect(0f, 0f, w, h, r, r, Path.Direction.CW) } + c.save(); c.clipPath(clip) + paint.color = a; c.drawRect(0f, 0f, w, h, paint) + paint.color = b + c.drawPath(Path().apply { moveTo(w, h * 0.16f); lineTo(w, h); lineTo(w * 0.1f, h); close() }, paint) + paint.color = 0x29FFFFFF + c.drawCircle(w * 0.9f, h * 0.48f, w * 0.5f, paint) + if (bright < 1f) { paint.color = (((1f - bright) * 255).toInt() shl 24); c.drawRect(0f, 0f, w, h, paint) } + c.restore() + } + override fun setAlpha(alpha: Int) {} + override fun setColorFilter(cf: android.graphics.ColorFilter?) {} + @Deprecated("Deprecated in Java") override fun getOpacity() = android.graphics.PixelFormat.TRANSLUCENT + } +} + +/** The MoonCast mark: a crescent. */ +class Crescent(ctx: Context, private val color: Int) : View(ctx) { + private val paint = Paint(Paint.ANTI_ALIAS_FLAG) + override fun onDraw(c: Canvas) { + val w = width.toFloat(); val h = height.toFloat() + c.saveLayer(0f, 0f, w, h, null) + paint.color = color; paint.xfermode = null + c.drawCircle(w / 2, h / 2, w / 2, paint) + paint.xfermode = android.graphics.PorterDuffXfermode(android.graphics.PorterDuff.Mode.CLEAR) + c.drawCircle(w * 0.66f, h * 0.34f, w * 0.41f, paint) + c.restore() + } +} + +/** Cover colours for games with no box art, shared by the TV and the handheld (a1..a8): highlight, body, shadow. */ +object Covers { + @JvmField val ART = arrayOf( + intArrayOf(0xFFF05A45.toInt(), 0xFFA01E16.toInt(), 0xFF4A0B08.toInt()), + intArrayOf(0xFF3D86FF.toInt(), 0xFF1035A0.toInt(), 0xFF071644.toInt()), + intArrayOf(0xFF3CCB63.toInt(), 0xFF0E7A2C.toInt(), 0xFF06300F.toInt()), + intArrayOf(0xFFF4C04E.toInt(), 0xFFB87414.toInt(), 0xFF4C2C06.toInt()), + intArrayOf(0xFFA06BF0.toInt(), 0xFF5223A8.toInt(), 0xFF1F0B47.toInt()), + intArrayOf(0xFF4FD6E3.toInt(), 0xFF127C8A.toInt(), 0xFF06323A.toInt()), + intArrayOf(0xFFF06BA8.toInt(), 0xFFA3205E.toInt(), 0xFF420A25.toInt()), + intArrayOf(0xFFB9C0CC.toInt(), 0xFF5B6372.toInt(), 0xFF22262E.toInt())) + + /** PlayStation is always blue, Xbox always green, anything else picks by name. */ + @JvmStatic fun index(seed: String): Int = when (seed) { + "ps" -> 1 + "xb" -> 2 + else -> Math.abs(seed.hashCode() % ART.size).let { if (it == 1 || it == 2) it + 4 else it } + } +} diff --git a/app/src/main/java/com/limelight/home/Usage.kt b/app/src/main/java/com/limelight/home/Usage.kt new file mode 100644 index 0000000000..aa54c4423f --- /dev/null +++ b/app/src/main/java/com/limelight/home/Usage.kt @@ -0,0 +1,112 @@ +package com.limelight.home + +import android.content.Context +import android.os.Build +import android.preference.PreferenceManager +import android.util.Log +import com.limelight.BuildConfig +import org.json.JSONArray +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL +import java.security.MessageDigest +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.UUID + +/** + * Anonymous usage check-in, opt-in, so the project can count daily and monthly active users (TelemetryDeck). + * + * At most once a day, when MoonCast's home opens, and only if the user said yes: the app version, TV or handheld, + * the Android version and device model, and whether PC, PS5 or Xbox was played since the last check-in. Never names, + * addresses, games, accounts or sign-ins. The install is a random ID made on this device, sent hashed. + * + * Only official builds have an app ID (`-PmcTelemetryAppId` or MOONCAST_TELEMETRY_APP_ID at build time, with the + * organization's namespace from `-PmcTelemetryNamespace` or MOONCAST_TELEMETRY_NAMESPACE): forks and + * self-built copies never ask and never send. Debug builds send as test mode, which TelemetryDeck keeps out of the + * real numbers. A failed send is simply tried again on a later day; it never affects the app. + */ +object Usage { + const val PREF = "mc_usage_checkin" // the user's answer (true = send); absent = not asked yet + private const val ID = "mc_usage_id" + private const val LAST = "mc_usage_last" // yyyy-MM-dd of the last check-in sent + private const val USED = "mc_usage_used_" // + pc / ps5 / xbox: played since the last check-in + // TelemetryDeck's ingest API v2; with the organization's namespace, its own path (as TelemetryDeck's SDKs do) + private val URL_INGEST get() = if (BuildConfig.TELEMETRY_NAMESPACE.isEmpty()) "https://nom.telemetrydeck.com/v2/" + else "https://nom.telemetrydeck.com/v2/namespace/${BuildConfig.TELEMETRY_NAMESPACE}/" + + /** This build can send at all (official builds only). */ + @JvmStatic val available get() = BuildConfig.TELEMETRY_APP_ID.isNotEmpty() + + private fun prefs(c: Context) = PreferenceManager.getDefaultSharedPreferences(c) + + /** Show the question: an official build that hasn't asked yet. */ + @JvmStatic fun shouldAsk(c: Context) = available && !prefs(c).contains(PREF) + + @JvmStatic fun answer(c: Context, yes: Boolean) { + prefs(c).edit().putBoolean(PREF, yes).apply() + if (yes) checkIn(c) + } + + @JvmStatic fun enabled(c: Context) = available && prefs(c).getBoolean(PREF, false) + + /** A stream of this kind started ("pc", "ps5" or "xbox"); goes with the next check-in. */ + @JvmStatic fun used(c: Context, kind: String) { + if (enabled(c)) prefs(c).edit().putBoolean(USED + kind, true).apply() + } + + /** Sends today's check-in if the user said yes and none was sent today. Off the main thread; never throws. */ + @JvmStatic fun checkIn(c: Context) { + if (!enabled(c)) return + val p = prefs(c) + val today = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date()) + if (p.getString(LAST, null) == today) return + val app = c.applicationContext + Thread({ + try { + val id = p.getString(ID, null) ?: UUID.randomUUID().toString().also { p.edit().putString(ID, it).apply() } + val payload = JSONObject() + .put("TelemetryDeck.AppInfo.version", BuildConfig.VERSION_NAME) + .put("TelemetryDeck.Device.platform", "Android") + .put("TelemetryDeck.Device.operatingSystem", "Android") + .put("TelemetryDeck.Device.systemVersion", Build.VERSION.RELEASE ?: "") + .put("TelemetryDeck.Device.modelName", "${Build.MANUFACTURER} ${Build.MODEL}") + .put("MoonCast.app", if (BuildConfig.TV_APP) "tv" else "handheld") + for (k in listOf("pc", "ps5", "xbox")) payload.put("MoonCast.used.$k", p.getBoolean(USED + k, false).toString()) + val signal = JSONObject() + .put("appID", BuildConfig.TELEMETRY_APP_ID) + .put("clientUser", sha256(id)) + .put("sessionID", UUID.randomUUID().toString()) + .put("type", "MoonCast.dailyCheckIn") + .put("isTestMode", BuildConfig.DEBUG) + .put("payload", payload) + val conn = URL(URL_INGEST).openConnection() as HttpURLConnection + conn.requestMethod = "POST" + conn.connectTimeout = 10_000; conn.readTimeout = 10_000 + conn.doOutput = true + conn.setRequestProperty("Content-Type", "application/json; charset=utf-8") + conn.outputStream.use { it.write(JSONArray().put(signal).toString().toByteArray()) } + val code = conn.responseCode + conn.disconnect() + if (code in 200..299) { + val e = p.edit().putString(LAST, today) + for (k in listOf("pc", "ps5", "xbox")) e.remove(USED + k) + e.apply() + Log.i("MoonCast", "usage check-in sent") + } else Log.i("MoonCast", "usage check-in: HTTP $code") + } catch (e: Exception) { + Log.i("MoonCast", "usage check-in failed: $e") + } + }, "mc-usage").start() + } + + private fun sha256(s: String) = MessageDigest.getInstance("SHA-256").digest(s.toByteArray()) + .joinToString("") { "%02x".format(it) } + + /** The question, the same words on the handheld and the TV. */ + const val QUESTION = "Help improve MoonCast?" + const val EXPLAIN = "Send an anonymous check-in once a day: the app version, the device model and Android version, " + + "and whether you played PC, PS5 or Xbox. Never names, games, accounts or addresses. " + + "You can change this in Settings, About." +} diff --git a/app/src/main/java/com/limelight/home/Xbox.kt b/app/src/main/java/com/limelight/home/Xbox.kt new file mode 100644 index 0000000000..b5ec0e88ad --- /dev/null +++ b/app/src/main/java/com/limelight/home/Xbox.kt @@ -0,0 +1,183 @@ +package com.limelight.home + +import android.content.Context +import org.json.JSONArray +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLEncoder +import java.util.UUID + +/** + * Xbox sign-in and console control for this TV, the way XStreaming (github.com/Geocld/XStreaming, AGPL-3.0) does it: + * Microsoft's device-code sign-in (the TV shows a code, you enter it at microsoft.com/link on a phone) -> Xbox Live user + * token -> XSTS tokens for the console list (xccs) and for Remote Play (gssv). The stream itself runs in nano-rs. + * + * Kept in this app's private storage ("mc_xbox"), like the PSN sign-in. + */ +object Xbox { + private const val CLIENT_ID = "1f907974-e22b-4810-a9de-d9647380c97e" + private const val SCOPE = "xboxlive.signin openid profile offline_access" + private const val MS_TOKEN = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token" + private const val PREFS = "mc_xbox" + + data class DeviceCode(val deviceCode: String, val userCode: String, val url: String, val expiresIn: Int, val interval: Int) + data class Console(val id: String, val name: String, val type: String, val powerState: String) + /** What a stream needs: the console, the region's Remote Play server, its token, and the token for /connect. */ + data class StreamAuth(val baseUri: String, val gsToken: String, val connectToken: String) + + private fun prefs(ctx: Context) = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + fun signedIn(ctx: Context) = prefs(ctx).getString("refresh", null) != null + fun gamertag(ctx: Context): String? = prefs(ctx).getString("gamertag", null) + fun signOut(ctx: Context) = prefs(ctx).edit().clear().apply() + + // ---------------------------------------------------------------- HTTP + + private class HttpError(val code: Int, val body: String) : Exception("HTTP $code: ${body.take(200)}") + + private fun request(url: String, method: String, body: String?, headers: Map): String { + val c = URL(url).openConnection() as HttpURLConnection + try { + c.requestMethod = method + c.connectTimeout = 10000; c.readTimeout = 15000 + headers.forEach { (k, v) -> c.setRequestProperty(k, v) } + if (body != null) { c.doOutput = true; c.outputStream.use { it.write(body.toByteArray()) } } + val code = c.responseCode + val text = (if (code in 200..299) c.inputStream else c.errorStream)?.bufferedReader()?.readText() ?: "" + if (code !in 200..299) throw HttpError(code, text) + return text + } finally { c.disconnect() } + } + + private fun form(vararg kv: Pair) = kv.joinToString("&") { "${it.first}=${URLEncoder.encode(it.second, "UTF-8")}" } + private val formHeaders = mapOf("Content-Type" to "application/x-www-form-urlencoded") + private val xblHeaders = mapOf("x-xbl-contract-version" to "1", "Content-Type" to "application/json", + "Cache-Control" to "no-cache", "Origin" to "https://www.xbox.com", "Referer" to "https://www.xbox.com/") + + // ---------------------------------------------------------------- sign-in + + /** Step 1: ask Microsoft for a code to show on the TV. Blocking. */ + fun startSignIn(): DeviceCode { + val j = JSONObject(request("https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode", "POST", + form("client_id" to CLIENT_ID, "scope" to SCOPE), formHeaders)) + return DeviceCode(j.getString("device_code"), j.getString("user_code"), + j.optString("verification_uri", "https://www.microsoft.com/link"), j.optInt("expires_in", 900), j.optInt("interval", 5)) + } + + /** Step 2: one poll. true = signed in, false = still waiting for the user. Throws if the code expired or was declined. */ + fun pollSignIn(ctx: Context, dc: DeviceCode): Boolean { + return try { + val j = JSONObject(request(MS_TOKEN, "POST", form("grant_type" to "urn:ietf:params:oauth:grant-type:device_code", + "client_id" to CLIENT_ID, "device_code" to dc.deviceCode), formHeaders)) + saveUserToken(ctx, j) + try { webAuth(ctx) } catch (e: Exception) { } // fetches the gamertag + true + } catch (e: HttpError) { + val err = try { JSONObject(e.body).optString("error") } catch (x: Exception) { "" } + if (err == "authorization_pending" || err == "slow_down") false else throw e + } + } + + private fun saveUserToken(ctx: Context, j: JSONObject) { + prefs(ctx).edit().putString("access", j.getString("access_token")) + .putString("refresh", j.optString("refresh_token", prefs(ctx).getString("refresh", ""))) + .putLong("access_until", System.currentTimeMillis() + j.optLong("expires_in", 3600) * 1000).apply() + } + + /** The Microsoft account token, renewed a minute before it expires. */ + private fun userToken(ctx: Context): String { + val p = prefs(ctx) + if (p.getLong("access_until", 0) - System.currentTimeMillis() > 60_000) p.getString("access", null)?.let { return it } + val refresh = p.getString("refresh", null) ?: throw IllegalStateException("Not signed in to Xbox") + saveUserToken(ctx, JSONObject(request(MS_TOKEN, "POST", form("client_id" to CLIENT_ID, "grant_type" to "refresh_token", + "refresh_token" to refresh, "scope" to SCOPE), formHeaders))) + return p.getString("access", null)!! + } + + /** Xbox Live user token (valid ~16 h); everything else is authorised from it. */ + private fun xblUser(ctx: Context): String { + val p = prefs(ctx) + if (p.getLong("xbl_until", 0) - System.currentTimeMillis() > 60_000) p.getString("xbl", null)?.let { return it } + val j = JSONObject(request("https://user.auth.xboxlive.com/user/authenticate", "POST", JSONObject() + .put("Properties", JSONObject().put("AuthMethod", "RPS").put("RpsTicket", "d=" + userToken(ctx)) + .put("SiteName", "user.auth.xboxlive.com")) + .put("RelyingParty", "http://auth.xboxlive.com").put("TokenType", "JWT").toString(), xblHeaders)) + p.edit().putString("xbl", j.getString("Token")).putLong("xbl_until", expiry(j)).apply() + return j.getString("Token") + } + + /** XSTS "NotAfter" (e.g. 2026-10-17T15:20:00.1234567Z) in ms; seconds precision is plenty. */ + private fun expiry(j: JSONObject): Long = try { + java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", java.util.Locale.US) + .apply { timeZone = java.util.TimeZone.getTimeZone("UTC") }.parse(j.getString("NotAfter").take(19))!!.time + } catch (e: Exception) { System.currentTimeMillis() + 3600_000L } + + private fun xsts(ctx: Context, relyingParty: String): JSONObject = JSONObject(request("https://xsts.auth.xboxlive.com/xsts/authorize", "POST", + JSONObject().put("Properties", JSONObject().put("SandboxId", "RETAIL").put("UserTokens", JSONArray().put(xblUser(ctx)))) + .put("RelyingParty", relyingParty).put("TokenType", "JWT").toString(), xblHeaders)) + + /** "XBL3.0 x=;" for xccs (console list, power, text). Also remembers the gamertag. */ + private fun webAuth(ctx: Context): String { + val p = prefs(ctx) + if (p.getLong("web_until", 0) - System.currentTimeMillis() > 60_000) p.getString("web", null)?.let { return it } + val j = xsts(ctx, "http://xboxlive.com") + val xui = j.getJSONObject("DisplayClaims").getJSONArray("xui").getJSONObject(0) + val auth = "XBL3.0 x=${xui.getString("uhs")};${j.getString("Token")}" + p.edit().putString("web", auth).putLong("web_until", expiry(j)).apply { + xui.optString("gtg").takeIf { it.isNotBlank() }?.let { putString("gamertag", it) } + }.apply() + return auth + } + + /** Remote Play ("xhome") server and token for this account's region, plus the console-transfer token for /connect. Blocking. */ + fun streamAuth(ctx: Context): StreamAuth { + val gssv = xsts(ctx, "http://gssv.xboxlive.com/").getString("Token") + val j = JSONObject(request("https://xhome.gssv-play-prod.xboxlive.com/v2/login/user", "POST", + JSONObject().put("token", gssv).put("offeringId", "xhome").toString(), + mapOf("Content-Type" to "application/json", "Cache-Control" to "no-store, must-revalidate, no-cache", + "x-gssv-client" to "XboxComBrowser"))) + val regions = j.getJSONObject("offeringSettings").getJSONArray("regions") + val all = (0 until regions.length()).map { regions.getJSONObject(it) } + val region = all.firstOrNull { it.optBoolean("isDefault") } ?: all.first() + // /connect wants a Passport "console transfer" token, minted from the same refresh token at login.live.com + val lpt = JSONObject(request("https://login.live.com/oauth20_token.srf", "POST", form("client_id" to CLIENT_ID, + "scope" to "service::http://Passport.NET/purpose::PURPOSE_XBOX_CLOUD_CONSOLE_TRANSFER_TOKEN", + "grant_type" to "refresh_token", "refresh_token" to (prefs(ctx).getString("refresh", null) ?: "")), formHeaders)) + return StreamAuth(region.getString("baseUri"), j.getString("gsToken"), lpt.getString("access_token")) + } + + // ---------------------------------------------------------------- consoles + + private fun xccsHeaders(ctx: Context, contract: String) = mapOf("Authorization" to webAuth(ctx), "Accept-Language" to "en-US", + "x-xbl-contract-version" to contract, "x-xbl-client-name" to "XboxApp", "x-xbl-client-type" to "UWA", + "x-xbl-client-version" to "39.39.22001.0", "skillplatform" to "RemoteManagement", "Content-Type" to "application/json") + + /** Consoles on this account, cached in prefs for the shelf. Blocking. */ + fun refreshConsoles(ctx: Context): List { + val j = JSONObject(request("https://xccs.xboxlive.com/lists/devices?queryCurrentDevice=false&includeStorageDevices=true", + "GET", null, xccsHeaders(ctx, "2"))) + val r = j.optJSONArray("result") ?: JSONArray() + prefs(ctx).edit().putString("consoles", r.toString()).apply() + return consoles(ctx) + } + + fun consoles(ctx: Context): List = try { + val a = JSONArray(prefs(ctx).getString("consoles", "[]")) + (0 until a.length()).map { a.getJSONObject(it) }.map { + Console(it.getString("id"), it.optString("name", "Xbox"), it.optString("consoleType", "Xbox"), it.optString("powerState", "")) + } + } catch (e: Exception) { emptyList() } + + private fun command(ctx: Context, consoleId: String, type: String, command: String, params: JSONArray = JSONArray()) { + request("https://xccs.xboxlive.com/commands", "POST", JSONObject().put("destination", "Xbox").put("type", type) + .put("command", command).put("sessionId", UUID.randomUUID().toString()).put("sourceId", "com.microsoft.smartglass") + .put("parameters", params).put("linkedXboxId", consoleId).toString(), xccsHeaders(ctx, "4")) + } + + /** Wake from sleep (the console needs "Sleep" power mode and remote features on). Blocking. */ + fun wake(ctx: Context, consoleId: String) = command(ctx, consoleId, "Power", "WakeUp") + fun turnOff(ctx: Context, consoleId: String) = command(ctx, consoleId, "Power", "TurnOff") + /** Type into whatever text box is open on the console. Blocking. */ + fun sendText(ctx: Context, consoleId: String, text: String) = + command(ctx, consoleId, "Shell", "InjectString", JSONArray().put(JSONObject().put("replacementString", text))) +} diff --git a/app/src/main/java/com/limelight/identity/IdentityTransfer.java b/app/src/main/java/com/limelight/identity/IdentityTransfer.java new file mode 100644 index 0000000000..908bacb8df --- /dev/null +++ b/app/src/main/java/com/limelight/identity/IdentityTransfer.java @@ -0,0 +1,183 @@ +package com.limelight.identity; + +import android.content.Context; +import android.util.Base64; + +import com.limelight.nvstream.http.ComputerDetails; + +import org.json.JSONObject; + +import org.bouncycastle.jce.provider.BouncyCastleProvider; + +import java.io.File; +import java.io.FileInputStream; + +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.security.GeneralSecurityException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.Provider; +import java.security.PublicKey; +import java.security.SecureRandom; +import java.util.Arrays; + +import javax.crypto.Cipher; +import javax.crypto.KeyAgreement; +import javax.crypto.Mac; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +/** + * Moves a Sunshine client identity (certificate and private key) from one MoonCast device to another, encrypted to the + * receiver's public key so the LAN only carries ciphertext. The receiver decrypts it into memory only. + * + * Wire format of an encrypted blob: ephemeral X25519 public key (32) | GCM nonce (12) | AES-256-GCM ciphertext. + * The key is HKDF-SHA256 over the X25519 shared secret, salted with both public keys. + */ +public final class IdentityTransfer { + private static final Provider BC = new BouncyCastleProvider(); + private static final String INFO = "mooncast-identity-v1"; + private static final int RAW_PUB = 32; + private static final int NONCE = 12; + private static final int TAG_BITS = 128; + // ASN.1 prefix of an X25519 SubjectPublicKeyInfo; the raw 32-byte key follows it. + private static final byte[] SPKI_PREFIX = { + 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x6e, 0x03, 0x21, 0x00 + }; + + private IdentityTransfer() {} + + /** A new key pair for a receiver. The private half stays on the receiver; only the raw public key is shared. */ + public static KeyPair newReceiverKeys() throws GeneralSecurityException { + return KeyPairGenerator.getInstance("X25519", BC).generateKeyPair(); + } + + /** The 32-byte raw X25519 public key, as published to senders. */ + public static byte[] rawPublic(PublicKey pub) { + byte[] encoded = pub.getEncoded(); + return Arrays.copyOfRange(encoded, encoded.length - RAW_PUB, encoded.length); + } + + /** Encrypts [plaintext] so only the holder of the receiver's private key can read it. */ + public static byte[] encrypt(byte[] receiverRawPub, byte[] plaintext) throws GeneralSecurityException { + KeyPair eph = newReceiverKeys(); + byte[] ephRaw = rawPublic(eph.getPublic()); + byte[] key = deriveKey(sharedSecret(eph.getPrivate(), receiverRawPub), ephRaw, receiverRawPub); + + byte[] nonce = new byte[NONCE]; + new SecureRandom().nextBytes(nonce); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(TAG_BITS, nonce)); + cipher.updateAAD(INFO.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + byte[] ct = cipher.doFinal(plaintext); + + return ByteBuffer.allocate(RAW_PUB + NONCE + ct.length).put(ephRaw).put(nonce).put(ct).array(); + } + + /** Decrypts a blob made by [encrypt] for this receiver. Throws if it was tampered with or meant for someone else. */ + public static byte[] decrypt(PrivateKey receiverPriv, byte[] receiverRawPub, byte[] blob) throws GeneralSecurityException { + if (blob.length < RAW_PUB + NONCE + TAG_BITS / 8) throw new GeneralSecurityException("short blob"); + byte[] ephRaw = Arrays.copyOfRange(blob, 0, RAW_PUB); + byte[] nonce = Arrays.copyOfRange(blob, RAW_PUB, RAW_PUB + NONCE); + byte[] ct = Arrays.copyOfRange(blob, RAW_PUB + NONCE, blob.length); + byte[] key = deriveKey(sharedSecret(receiverPriv, ephRaw), ephRaw, receiverRawPub); + + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(TAG_BITS, nonce)); + cipher.updateAAD(INFO.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + return cipher.doFinal(ct); + } + + /** + * This device's Sunshine client identity and one PC's details, sealed for a receiver. [receiverKeyB64] is the + * receiver's published key. Returns base64 of the encrypted blob, or null if this device has no identity or the PC + * has no certificate yet (it has never paired). + */ + public static String sealIdentity(Context ctx, String receiverKeyB64, ComputerDetails pc) { + try { + byte[] cert = readFile(new File(ctx.getFilesDir(), "client.crt")); + byte[] key = readFile(new File(ctx.getFilesDir(), "client.key")); + if (cert == null || key == null || pc == null || pc.serverCert == null) return null; + ComputerDetails.AddressTuple addr = pc.localAddress != null ? pc.localAddress : pc.activeAddress; + if (addr == null) return null; + JSONObject j = new JSONObject() + .put("cert", Base64.encodeToString(cert, Base64.NO_WRAP)) + .put("key", Base64.encodeToString(key, Base64.NO_WRAP)) + .put("uuid", pc.uuid).put("name", pc.name) + .put("address", addr.address).put("port", addr.port) + .put("serverCert", Base64.encodeToString(pc.serverCert.getEncoded(), Base64.NO_WRAP)); + byte[] recvRaw = Base64.decode(receiverKeyB64, Base64.DEFAULT); + return Base64.encodeToString(encrypt(recvRaw, j.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8)), Base64.NO_WRAP); + } catch (Exception e) { + return null; + } + } + + /** Opens a blob from [sealIdentity] on the receiver. Returns the JSON: cert, key, uuid, name, address, port, serverCert. */ + public static JSONObject openIdentity(PrivateKey receiverPriv, byte[] receiverRawPub, String blobB64) throws Exception { + byte[] plain = decrypt(receiverPriv, receiverRawPub, Base64.decode(blobB64, Base64.DEFAULT)); + return new JSONObject(new String(plain, java.nio.charset.StandardCharsets.UTF_8)); + } + + private static byte[] readFile(File f) throws java.io.IOException { + if (!f.isFile()) return null; + byte[] out = new byte[(int) f.length()]; + try (FileInputStream in = new FileInputStream(f)) { + int off = 0; + while (off < out.length) { + int n = in.read(out, off, out.length - off); + if (n < 0) break; + off += n; + } + } + return out; + } + + /** Packs a certificate and its private key into one plaintext: length-prefixed, DER certificate then PKCS#8 key. */ + public static byte[] pack(byte[] certDer, byte[] keyPkcs8) { + return ByteBuffer.allocate(8 + certDer.length + keyPkcs8.length) + .putInt(certDer.length).put(certDer).putInt(keyPkcs8.length).put(keyPkcs8).array(); + } + + /** Reverses [pack]. Returns {certificate DER, key PKCS#8}. */ + public static byte[][] unpack(byte[] packed) throws GeneralSecurityException { + ByteBuffer b = ByteBuffer.wrap(packed); + if (b.remaining() < 4) throw new GeneralSecurityException("short identity"); + byte[] cert = new byte[b.getInt()]; + if (b.remaining() < cert.length + 4) throw new GeneralSecurityException("short identity"); + b.get(cert); + byte[] key = new byte[b.getInt()]; + if (b.remaining() != key.length) throw new GeneralSecurityException("bad identity length"); + b.get(key); + return new byte[][] { cert, key }; + } + + private static byte[] sharedSecret(PrivateKey priv, byte[] peerRawPub) throws GeneralSecurityException { + KeyAgreement ka = KeyAgreement.getInstance("X25519", BC); + ka.init(priv); + ka.doPhase(publicFromRaw(peerRawPub), true); + return ka.generateSecret(); + } + + private static PublicKey publicFromRaw(byte[] raw) throws GeneralSecurityException { + ByteArrayOutputStream spki = new ByteArrayOutputStream(); + spki.write(SPKI_PREFIX, 0, SPKI_PREFIX.length); + spki.write(raw, 0, raw.length); + return java.security.KeyFactory.getInstance("X25519", BC) + .generatePublic(new java.security.spec.X509EncodedKeySpec(spki.toByteArray())); + } + + /** HKDF-SHA256 (RFC 5869), salted with both public keys so the key is bound to this exchange. */ + private static byte[] deriveKey(byte[] shared, byte[] ephRaw, byte[] recvRaw) throws GeneralSecurityException { + byte[] salt = ByteBuffer.allocate(RAW_PUB * 2).put(ephRaw).put(recvRaw).array(); + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(salt, "HmacSHA256")); + byte[] prk = mac.doFinal(shared); + mac.init(new SecretKeySpec(prk, "HmacSHA256")); + mac.update(INFO.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + mac.update((byte) 1); + return Arrays.copyOf(mac.doFinal(), 32); + } +} diff --git a/app/src/main/java/com/limelight/nvstream/NvConnection.java b/app/src/main/java/com/limelight/nvstream/NvConnection.java index f29563f1c1..18d526fd57 100644 --- a/app/src/main/java/com/limelight/nvstream/NvConnection.java +++ b/app/src/main/java/com/limelight/nvstream/NvConnection.java @@ -48,6 +48,23 @@ public class NvConnection { private static Semaphore connectionAllowed = new Semaphore(1); private final boolean isMonkey; private final Context appContext; + private volatile boolean resumeOnly; + + /** + * Only join a game that is already running (a second, controller-only session next to another device's stream): + * never launch an app and never quit a different one. Fails instead. + */ + public void setResumeOnly(boolean resumeOnly) { this.resumeOnly = resumeOnly; } + + private volatile boolean controllersBlocked; + + /** + * Send no gamepad at all on this session (no arrival, input, touch, motion or battery events), so it never takes a + * pad slot on the PC: another device has its own controller session for that (a handheld sending a game to a TV). + * Keyboard and mouse are unaffected. Use with an attached-gamepad mask of 0. + */ + public void setControllersBlocked(boolean blocked) { this.controllersBlocked = blocked; } + public boolean controllersBlocked() { return controllersBlocked; } public NvConnection(Context appContext, ComputerDetails.AddressTuple host, int httpsPort, String uniqueId, StreamConfiguration config, LimelightCryptoProvider cryptoProvider, X509Certificate serverCert) { @@ -307,6 +324,11 @@ else if (context.streamConfig.getHeight() >= 2160 && !h.supports4K(serverInfo)) } } + if (resumeOnly && h.getCurrentGame(serverInfo) != app.getAppId()) { + context.connListener.displayMessage("The game isn't running on the PC"); + return false; + } + // If there's a game running, resume it if (h.getCurrentGame(serverInfo) != 0) { try { @@ -491,7 +513,7 @@ public void sendControllerInput(final short controllerNumber, final short leftStickX, final short leftStickY, final short rightStickX, final short rightStickY) { - if (!isMonkey) { + if (!isMonkey && !controllersBlocked) { MoonBridge.sendMultiControllerInput(controllerNumber, activeGamepadMask, buttonFlags, leftTrigger, rightTrigger, leftStickX, leftStickY, rightStickX, rightStickY); } @@ -552,12 +574,13 @@ public int sendPenEvent(byte eventType, byte toolType, byte penButtons, float x, public int sendControllerArrivalEvent(byte controllerNumber, short activeGamepadMask, byte type, int supportedButtonFlags, short capabilities) { + if (controllersBlocked) return 0; return MoonBridge.sendControllerArrivalEvent(controllerNumber, activeGamepadMask, type, supportedButtonFlags, capabilities); } public int sendControllerTouchEvent(byte controllerNumber, byte eventType, int pointerId, float x, float y, float pressure) { - if (!isMonkey) { + if (!isMonkey && !controllersBlocked) { return MoonBridge.sendControllerTouchEvent(controllerNumber, eventType, pointerId, x, y, pressure); } else { @@ -567,7 +590,7 @@ public int sendControllerTouchEvent(byte controllerNumber, byte eventType, int p public int sendControllerMotionEvent(byte controllerNumber, byte motionType, float x, float y, float z) { - if (!isMonkey) { + if (!isMonkey && !controllersBlocked) { return MoonBridge.sendControllerMotionEvent(controllerNumber, motionType, x, y, z); } else { @@ -576,6 +599,7 @@ public int sendControllerMotionEvent(byte controllerNumber, byte motionType, } public void sendControllerBatteryEvent(byte controllerNumber, byte batteryState, byte batteryPercentage) { + if (controllersBlocked) return; MoonBridge.sendControllerBatteryEvent(controllerNumber, batteryState, batteryPercentage); } diff --git a/app/src/main/java/com/limelight/preferences/PreferenceConfiguration.java b/app/src/main/java/com/limelight/preferences/PreferenceConfiguration.java index 8ed01e3610..e4fcbd3048 100644 --- a/app/src/main/java/com/limelight/preferences/PreferenceConfiguration.java +++ b/app/src/main/java/com/limelight/preferences/PreferenceConfiguration.java @@ -6,6 +6,7 @@ import android.os.Build; import android.preference.PreferenceManager; import android.view.Display; +import android.view.WindowManager; import com.limelight.nvstream.jni.MoonBridge; @@ -31,6 +32,10 @@ public enum AnalogStickForScrolling { static final String BITRATE_PREF_STRING = "seekbar_bitrate_kbps"; private static final String BITRATE_PREF_OLD_STRING = "seekbar_bitrate"; private static final String STRETCH_PREF_STRING = "checkbox_stretch_video"; + // MoonCast, from Artemis: ultra low latency decoding, picture scale mode (fit / fill / stretch) and zoom + private static final String ULTRA_LOW_LATENCY_PREF_STRING = "checkbox_ultra_low_latency"; + private static final String SCALE_MODE_PREF_STRING = "list_video_scale_mode"; + private static final String ZOOM_PREF_STRING = "seekbar_video_zoom"; private static final String SOPS_PREF_STRING = "checkbox_enable_sops"; private static final String DISABLE_TOASTS_PREF_STRING = "checkbox_disable_warnings"; private static final String HOST_AUDIO_PREF_STRING = "checkbox_host_audio"; @@ -128,6 +133,8 @@ public enum AnalogStickForScrolling { public int deadzonePercentage; public int oscOpacity; public boolean stretchVideo, enableSops, playHostAudio, disableWarnings; + public boolean ultraLowLatency, fillVideo; + public int videoZoomPercent; public String language; public boolean smallIconMode, multiController, usbDriver, flipFaceButtons; public boolean onscreenController; @@ -443,6 +450,90 @@ public static boolean isShieldAtvFirmwareWithBrokenHdr() { Build.FINGERPRINT.contains("PPR1.180610.011/4079208_2235.1395"); } + /** MoonCast: when true, the stream resolution is [nativeResolution] of the screen it starts on. */ + public static final String NATIVE_RES_PREF_STRING = "mc_native_res"; + /** True when the stream is Native: the whole screen in its own shape, so no bars (a near-square foldable too). */ + public static boolean nativeWholeScreen(Context context) { + return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(NATIVE_RES_PREF_STRING, false); + } + + /** + * Native for the screen [context] is on: the screen's own size, rounded down to multiples of 8 for the PC's + * display modes (the Fold: 2448x1848 open, 1968x1248 folded). The PC draws its desktop and menus in that shape. + */ + public static String nativeResolution(Context context) { + rememberScreen(context); + String shared = foldableResolution(context); + if (shared != null) return shared; + int[] wh = screenSize(context); + return (wh[0] & ~7) + "x" + (wh[1] & ~7); + } + + /** MoonCast: the screen sizes this device has run on ("2448x1848;1968x1248"): a foldable has two shapes. */ + public static final String SCREENS_PREF_STRING = "mc_screens"; + + /** Note the screen [context] is on, so a foldable's two screens are known (a few at most). */ + public static void rememberScreen(Context context) { + int[] wh = screenSize(context); + if (wh[0] < 640 || wh[1] < 360) return; + String me = wh[0] + "x" + wh[1]; + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + String all = prefs.getString(SCREENS_PREF_STRING, ""); + java.util.List list = new java.util.ArrayList<>(); + for (String x : all.split(";")) if (!x.isEmpty()) list.add(x); + if (list.contains(me)) return; + list.add(0, me); + while (list.size() > 4) list.remove(list.size() - 1); + prefs.edit().putString(SCREENS_PREF_STRING, android.text.TextUtils.join(";", list)).apply(); + } + + /** + * A foldable (two screens of clearly different shapes, both seen here): one stream shape between the two, so a + * fold or unfold only re-fits the picture (Picture shape: Fill trims a little, Stretch bends a little, Fit leaves + * thin bars) instead of reconnecting or restarting the game. Its width is the biggest screen's long side, its + * shape the geometric mean of the widest and the squarest (the Fold: 1.58 and 1.32 make 1.45, 2448x1696). + * Null on a device with one screen shape. + */ + public static String foldableResolution(Context context) { + String all = PreferenceManager.getDefaultSharedPreferences(context).getString(SCREENS_PREF_STRING, ""); + double lo = 0, hi = 0; int wmax = 0; + for (String x : all.split(";")) { + int w, h; + try { w = getWidthFromResolutionString(x); h = getHeightFromResolutionString(x); } catch (RuntimeException e) { continue; } + if (w <= 0 || h <= 0) continue; + double r = (double) w / h; + if (lo == 0 || r < lo) lo = r; + if (r > hi) hi = r; + wmax = Math.max(wmax, w); + } + if (lo == 0 || hi / lo < 1.08) return null; + double shape = Math.sqrt(lo * hi); + int w = wmax & ~7; + int h = (int) Math.round(w / shape / 8.0) * 8; + return w + "x" + h; + } + + /** The physical size of the screen [context] is on, the long side first. */ + private static int[] screenSize(Context context) { + // An activity's own window, full screen, follows a foldable's switch together with its configuration + // (the display's mode can still report the other panel for a moment) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && context instanceof android.app.Activity) { + android.graphics.Rect b = ((android.app.Activity) context).getWindowManager().getMaximumWindowMetrics().getBounds(); + int w = b.width(), h = b.height(); + if (w > 0 && h > 0) return new int[] { Math.max(w, h), Math.min(w, h) }; + } + Display d = null; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + try { d = context.getDisplay(); } catch (UnsupportedOperationException ignored) { } + } + if (d == null) d = ((WindowManager) context.getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay(); + int w, h; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { w = d.getMode().getPhysicalWidth(); h = d.getMode().getPhysicalHeight(); } + else { android.graphics.Point p = new android.graphics.Point(); d.getRealSize(p); w = p.x; h = p.y; } + if (h > w) { int t = w; w = h; h = t; } + return new int[] { w, h }; + } + public static PreferenceConfiguration readPreferences(Context context) { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); PreferenceConfiguration config = new PreferenceConfiguration(); @@ -527,6 +618,14 @@ else if (str.equals("4K60")) { config.fps = Integer.parseInt(prefs.getString(FPS_PREF_STRING, PreferenceConfiguration.DEFAULT_FPS)); } + // MoonCast: "Native" follows the screen the stream starts on (a foldable's cover or its inner screen), + // instead of the size of whichever screen it was first set on. + if (prefs.getBoolean(NATIVE_RES_PREF_STRING, false)) { + String n = nativeResolution(context); + config.width = getWidthFromResolutionString(n); + config.height = getHeightFromResolutionString(n); + } + if (!prefs.contains(SMALL_ICONS_PREF_STRING)) { // We need to write small icon mode's default to disk for the settings page to display // the current state of the option properly @@ -574,6 +673,11 @@ else if (audioConfig.equals("51")) { config.disableWarnings = prefs.getBoolean(DISABLE_TOASTS_PREF_STRING, DEFAULT_DISABLE_TOASTS); config.enableSops = prefs.getBoolean(SOPS_PREF_STRING, DEFAULT_SOPS); config.stretchVideo = prefs.getBoolean(STRETCH_PREF_STRING, DEFAULT_STRETCH); + config.ultraLowLatency = prefs.getBoolean(ULTRA_LOW_LATENCY_PREF_STRING, false); + String scaleMode = prefs.getString(SCALE_MODE_PREF_STRING, config.stretchVideo ? "stretch" : "fit"); + config.stretchVideo = "stretch".equals(scaleMode); + config.fillVideo = "fill".equals(scaleMode); + config.videoZoomPercent = prefs.getInt(ZOOM_PREF_STRING, 100); config.playHostAudio = prefs.getBoolean(HOST_AUDIO_PREF_STRING, DEFAULT_HOST_AUDIO); config.smallIconMode = prefs.getBoolean(SMALL_ICONS_PREF_STRING, getDefaultSmallMode(context)); config.multiController = prefs.getBoolean(MULTI_CONTROLLER_PREF_STRING, DEFAULT_MULTI_CONTROLLER); diff --git a/app/src/main/java/com/limelight/preferences/StreamSettings.java b/app/src/main/java/com/limelight/preferences/StreamSettings.java index 7070104168..9c966c7bf5 100644 --- a/app/src/main/java/com/limelight/preferences/StreamSettings.java +++ b/app/src/main/java/com/limelight/preferences/StreamSettings.java @@ -113,10 +113,12 @@ public void onBackPressed() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { PreferenceConfiguration newPrefs = PreferenceConfiguration.readPreferences(this); if (!newPrefs.language.equals(previousPrefs.language)) { - // Restart the PC view to apply UI changes - Intent intent = new Intent(this, PcView.class); - intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_NEW_TASK); - startActivity(intent, null); + // Restart MoonCast's home to apply UI changes (not Moonlight's PC list) + Intent intent = getPackageManager().getLaunchIntentForPackage(getPackageName()); + if (intent != null) { + intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(intent, null); + } } } } diff --git a/app/src/main/java/com/limelight/ui/GameGestures.java b/app/src/main/java/com/limelight/ui/GameGestures.java index 74dd7b056f..b39a2964eb 100644 --- a/app/src/main/java/com/limelight/ui/GameGestures.java +++ b/app/src/main/java/com/limelight/ui/GameGestures.java @@ -2,4 +2,5 @@ public interface GameGestures { void toggleKeyboard(); + void openQuickMenu(); } diff --git a/app/src/main/java/com/limelight/ui/StreamView.java b/app/src/main/java/com/limelight/ui/StreamView.java index a11b416684..93cac799f0 100644 --- a/app/src/main/java/com/limelight/ui/StreamView.java +++ b/app/src/main/java/com/limelight/ui/StreamView.java @@ -8,6 +8,11 @@ public class StreamView extends SurfaceView { private double desiredAspectRatio; + private boolean fill; // MoonCast: Fill keeps the shape but covers the screen; the parent crops the overflow + + public void setFill(boolean fill) { + this.fill = fill; + } private InputCallbacks inputCallbacks; public void setDesiredAspectRatio(double aspectRatio) { @@ -47,7 +52,13 @@ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int heightSize = MeasureSpec.getSize(heightMeasureSpec); int measuredHeight, measuredWidth; - if (widthSize > heightSize * desiredAspectRatio) { + if (fill == (widthSize > heightSize * desiredAspectRatio)) { + measuredWidth = widthSize; + measuredHeight = (int)(measuredWidth / desiredAspectRatio); + } else if (fill) { + measuredHeight = heightSize; + measuredWidth = (int)(measuredHeight * desiredAspectRatio); + } else if (widthSize > heightSize * desiredAspectRatio) { measuredHeight = heightSize; measuredWidth = (int)(measuredHeight * desiredAspectRatio); } else { diff --git a/app/src/main/java/com/limelight/utils/Dialog.java b/app/src/main/java/com/limelight/utils/Dialog.java index 7b3f9fd7dc..025286dab7 100644 --- a/app/src/main/java/com/limelight/utils/Dialog.java +++ b/app/src/main/java/com/limelight/utils/Dialog.java @@ -80,18 +80,7 @@ public void onClick(DialogInterface dialog, int which) { runOnDismiss.run(); } }); - alert.setButton(AlertDialog.BUTTON_NEUTRAL, activity.getResources().getText(R.string.help), new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int which) { - synchronized (rundownDialogs) { - rundownDialogs.remove(Dialog.this); - alert.dismiss(); - } - - runOnDismiss.run(); - - HelpLauncher.launchTroubleshooting(activity); - } - }); + // MoonCast: no Help button (it opened Moonlight's troubleshooting wiki) alert.setOnShowListener(new DialogInterface.OnShowListener(){ @Override diff --git a/app/src/main/java/com/limelight/utils/HelpLauncher.java b/app/src/main/java/com/limelight/utils/HelpLauncher.java index cae80cdde8..97394eab4c 100644 --- a/app/src/main/java/com/limelight/utils/HelpLauncher.java +++ b/app/src/main/java/com/limelight/utils/HelpLauncher.java @@ -36,16 +36,4 @@ public static void launchUrl(Context context, String url) { i.setData(Uri.parse(url)); context.startActivity(i); } - - public static void launchSetupGuide(Context context) { - launchUrl(context, "https://github.com/moonlight-stream/moonlight-docs/wiki/Setup-Guide"); - } - - public static void launchTroubleshooting(Context context) { - launchUrl(context, "https://github.com/moonlight-stream/moonlight-docs/wiki/Troubleshooting"); - } - - public static void launchGameStreamEolFaq(Context context) { - launchUrl(context, "https://github.com/moonlight-stream/moonlight-docs/wiki/NVIDIA-GameStream-End-Of-Service-Announcement-FAQ"); - } } diff --git a/app/src/main/java/com/limelight/utils/ServerHelper.java b/app/src/main/java/com/limelight/utils/ServerHelper.java index ef5a790297..5e23622016 100644 --- a/app/src/main/java/com/limelight/utils/ServerHelper.java +++ b/app/src/main/java/com/limelight/utils/ServerHelper.java @@ -1,6 +1,7 @@ package com.limelight.utils; import android.app.Activity; +import android.content.Context; import android.content.Intent; import android.widget.Toast; @@ -83,6 +84,20 @@ public static void doStart(Activity parent, NvApp app, ComputerDetails computer, parent.startActivity(createStartIntent(parent, app, computer, managerBinder)); } + /** Runs the network test (blocking: call it off the UI thread) and returns what to tell the user. */ + public static String networkTestSummary(Context context) { + int ret = MoonBridge.testClientConnectivity(CONNECTION_TEST_SERVER, 443, MoonBridge.ML_PORT_FLAG_ALL); + if (ret == MoonBridge.ML_TEST_RESULT_INCONCLUSIVE) { + return context.getResources().getString(R.string.nettest_text_inconclusive); + } + else if (ret == 0) { + return context.getResources().getString(R.string.nettest_text_success); + } + else { + return context.getResources().getString(R.string.nettest_text_failure) + MoonBridge.stringifyPortFlags(ret, "\n"); + } + } + public static void doNetworkTest(final Activity parent) { new Thread(new Runnable() { @Override @@ -92,21 +107,9 @@ public void run() { parent.getResources().getString(R.string.nettest_text_waiting), false); - int ret = MoonBridge.testClientConnectivity(CONNECTION_TEST_SERVER, 443, MoonBridge.ML_PORT_FLAG_ALL); + String dialogSummary = networkTestSummary(parent); spinnerDialog.dismiss(); - String dialogSummary; - if (ret == MoonBridge.ML_TEST_RESULT_INCONCLUSIVE) { - dialogSummary = parent.getResources().getString(R.string.nettest_text_inconclusive); - } - else if (ret == 0) { - dialogSummary = parent.getResources().getString(R.string.nettest_text_success); - } - else { - dialogSummary = parent.getResources().getString(R.string.nettest_text_failure); - dialogSummary += MoonBridge.stringifyPortFlags(ret, "\n"); - } - Dialog.displayDialog(parent, parent.getResources().getString(R.string.nettest_title_done), dialogSummary, diff --git a/app/src/main/java/com/limelight/utils/TvChannelHelper.java b/app/src/main/java/com/limelight/utils/TvChannelHelper.java index 0a144b20d7..71b3793385 100644 --- a/app/src/main/java/com/limelight/utils/TvChannelHelper.java +++ b/app/src/main/java/com/limelight/utils/TvChannelHelper.java @@ -38,6 +38,13 @@ public TvChannelHelper(Activity context) { this.context = context; } + /** MoonCast: put one game on this PC's row on the TV's home screen (creates the row if needed). */ + public void addGameToHomeScreen(ComputerDetails computer, com.limelight.nvstream.http.NvApp app) { + createTvChannel(computer); + addGameToChannel(computer, app); + requestChannelOnHomeScreen(computer); + } + void requestChannelOnHomeScreen(ComputerDetails computer) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (!isAndroidTV()) { diff --git a/app/src/main/java/com/limelight/xbox/XboxDecoders.kt b/app/src/main/java/com/limelight/xbox/XboxDecoders.kt new file mode 100644 index 0000000000..761a076fbe --- /dev/null +++ b/app/src/main/java/com/limelight/xbox/XboxDecoders.kt @@ -0,0 +1,134 @@ +package com.limelight.xbox + +import android.media.MediaCodecInfo +import android.media.MediaCodecList +import android.media.MediaFormat +import android.util.Log +import com.limelight.binding.video.MediaCodecHelper +import org.webrtc.EglBase +import org.webrtc.EncodedImage +import org.webrtc.HardwareVideoDecoderFactory +import org.webrtc.DefaultVideoDecoderFactory +import org.webrtc.VideoCodecInfo +import org.webrtc.VideoCodecStatus +import org.webrtc.VideoDecoder +import org.webrtc.VideoDecoderFactory +import java.lang.reflect.InvocationHandler +import java.lang.reflect.InvocationTargetException +import java.lang.reflect.Method +import java.lang.reflect.Proxy + +/** + * Hardware decoders for the Xbox player with MoonCast's low-latency decoder settings, the same ones the PC player uses + * (Moonlight's vendor keys plus Artemis' ultra-low-latency set when Settings, Video, Ultra low latency is on). + * + * libwebrtc configures MediaCodec itself, so, like XStreaming's LowLatencyVideoDecoder, we swap the decoder's + * MediaCodecWrapperFactory for a proxy and add our keys to the MediaFormat when it calls configure(). + */ +class XboxDecoderFactory(egl: EglBase.Context, private val ultraLowLatency: Boolean) : VideoDecoderFactory { + // The codec list (and so the offer the Xbox sees) must stay exactly libwebrtc's default: with our own list the + // Xbox accepted the connection but never sent video (test127). Only the H.264 decoder itself is ours. + private val default = DefaultVideoDecoderFactory(egl) + private val hw = HardwareVideoDecoderFactory(egl) + + override fun createDecoder(info: VideoCodecInfo): VideoDecoder? { + // Our extra decoder settings only with Settings, Video, Ultra low latency: with them on the Amlogic stick, + // libwebrtc's decoding queue aborted right after the decoder started (test131). Default = libwebrtc as-is. + if (ultraLowLatency && info.name.equals("H264", true)) hw.createDecoder(info)?.let { + Log.i("MoonCast-XboxDec", "H.264 decoder: ${it.implementationName} (low latency, ultra=$ultraLowLatency)") + return LowLatencyDecoder(it, ultraLowLatency) + } + return default.createDecoder(info) + } + + override fun getSupportedCodecs(): Array { + // The Xbox answers H.264 at profile-level-id 42e02a (level 4.2) when the offer only has 42e01f (level 3.1), and + // libwebrtc then drops that payload type: the packets arrive, no codec is mapped, no frame is ever decoded + // (test150). So the offer also lists the same H.264 at 42e02a. Everything else stays libwebrtc's default list. + val base = default.supportedCodecs + val extra = base.filter { it.name.equals("H264", true) && it.params["profile-level-id"] == "42e01f" } + .map { VideoCodecInfo("H264", HashMap(it.params).apply { put("profile-level-id", "42e02a") }) } + .filter { e -> base.none { it.name == e.name && it.params == e.params } } + val list = (base.toList() + extra).toTypedArray() + Log.i("MoonCast-XboxDec", "offering " + list.joinToString { it.name + it.params }) + return list + } +} + +private class LowLatencyDecoder(private val delegate: VideoDecoder, private val ultra: Boolean) : VideoDecoder { + private var patched = false + + override fun initDecode(settings: VideoDecoder.Settings, callback: VideoDecoder.Callback): VideoCodecStatus { + if (!patched) { patched = true; patch() } + return delegate.initDecode(settings, callback) + } + override fun release(): VideoCodecStatus = delegate.release() + override fun decode(frame: EncodedImage, info: VideoDecoder.DecodeInfo): VideoCodecStatus = delegate.decode(frame, info) + override fun getImplementationName(): String = delegate.implementationName + "+mooncast-low-latency" + + private fun patch() { + try { + var c: Class<*>? = delegate.javaClass + var field: java.lang.reflect.Field? = null + while (c != null && field == null) { + field = c.declaredFields.firstOrNull { it.type.name == "org.webrtc.MediaCodecWrapperFactory" } + c = c.superclass + } + val f = field ?: run { Log.w(TAG, "no MediaCodecWrapperFactory on ${delegate.javaClass.name}"); return } + f.isAccessible = true + val original = f.get(delegate) ?: return + f.set(delegate, Proxy.newProxyInstance(f.type.classLoader, arrayOf(f.type), FactoryHandler(original, ultra))) + } catch (t: Throwable) { + Log.w(TAG, "couldn't add low-latency settings", t) + } + } + + private class FactoryHandler(private val original: Any, private val ultra: Boolean) : InvocationHandler { + override fun invoke(proxy: Any, m: Method, args: Array?): Any? { + val result = call(original, m, args) + if (m.name != "createByCodecName" || result == null) return result + val name = args?.getOrNull(0) as? String ?: "" + val iface = result.javaClass.interfaces.firstOrNull { it.name == "org.webrtc.MediaCodecWrapper" } ?: return result + return Proxy.newProxyInstance(iface.classLoader, arrayOf(iface), CodecHandler(result, name, ultra)) + } + } + + private class CodecHandler(private val codec: Any, private val name: String, private val ultra: Boolean) : InvocationHandler { + override fun invoke(proxy: Any, m: Method, args: Array?): Any? { + if (m.name == "configure" && args?.getOrNull(0) is MediaFormat) { + val fmt = args[0] as MediaFormat + val info = codecInfo(name) + // never let our extra settings stop the decoder: if they can't be applied, configure as libwebrtc would + val applied = info != null && try { + MediaCodecHelper.setDecoderLowLatencyOptions(fmt, info, ultra, 0); true + } catch (t: Throwable) { Log.w(TAG, "low-latency settings skipped: $t"); false } + if (applied) { + Log.i(TAG, "low-latency settings on $name (ultra=$ultra)") + try { return call(codec, m, args) } catch (t: Throwable) { + // Some decoder refused a key: fall back to libwebrtc's own settings rather than fail the stream + Log.w(TAG, "$name refused the low-latency settings, using its defaults: $t") + val plain = MediaFormat.createVideoFormat(fmt.getString(MediaFormat.KEY_MIME)!!, + fmt.getInteger(MediaFormat.KEY_WIDTH), fmt.getInteger(MediaFormat.KEY_HEIGHT)) + if (fmt.containsKey(MediaFormat.KEY_COLOR_FORMAT)) plain.setInteger(MediaFormat.KEY_COLOR_FORMAT, fmt.getInteger(MediaFormat.KEY_COLOR_FORMAT)) + val retry = args.toMutableList().also { it[0] = plain }.toTypedArray() + return call(codec, m, retry) + } + } + } + return call(codec, m, args) + } + } + + companion object { + private const val TAG = "MoonCast-XboxDec" + + private fun codecInfo(name: String): MediaCodecInfo? = try { + MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos.firstOrNull { it.name.equals(name, true) } + } catch (t: Throwable) { null } + + private fun call(target: Any, m: Method, args: Array?): Any? = try { + m.isAccessible = true + m.invoke(target, *(args ?: emptyArray())) + } catch (e: InvocationTargetException) { throw e.cause ?: e } + } +} diff --git a/app/src/main/java/com/limelight/xbox/XboxRtc.kt b/app/src/main/java/com/limelight/xbox/XboxRtc.kt new file mode 100644 index 0000000000..8ce23df834 --- /dev/null +++ b/app/src/main/java/com/limelight/xbox/XboxRtc.kt @@ -0,0 +1,688 @@ +package com.limelight.xbox + +import android.content.Context +import android.os.Handler +import android.os.HandlerThread +import android.util.Log +import org.json.JSONArray +import org.json.JSONObject +import org.webrtc.DataChannel +import org.webrtc.DefaultVideoEncoderFactory +import org.webrtc.EglBase +import org.webrtc.IceCandidate +import org.webrtc.MediaConstraints +import org.webrtc.MediaStream +import org.webrtc.MediaStreamTrack +import org.webrtc.PeerConnection +import org.webrtc.PeerConnectionFactory +import org.webrtc.RtpReceiver +import org.webrtc.RtpTransceiver +import org.webrtc.SdpObserver +import org.webrtc.SessionDescription +import org.webrtc.VideoSink +import org.webrtc.VideoTrack +import org.webrtc.audio.JavaAudioDeviceModule +import java.net.HttpURLConnection +import java.net.URL +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.UUID +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +/** + * Xbox Remote Play over Google's WebRTC (libwebrtc, the library Chrome and XStreaming's default player use): hardware + * decoding through MediaCodec, Google's jitter buffer, congestion control and loss recovery. The Xbox protocol on top + * (session signalling, the input/control/message/chat channels and the input packet format) follows XStreaming + * (github.com/Geocld/XStreaming, AGPL-3.0): src/webrtc and nano-rs. + * + * Lifecycle: [start] runs the session on a worker thread; video goes to [videoSink]; [stop] ends it. + */ +class XboxRtc( + private val ctx: Context, + private val eglBase: EglBase, + private val consoleId: String, + private val baseUri: String, + private val gsToken: String, + private val connectToken: String, + /** 720 = "android", 1080 = "windows", 1081 = "tizen": the device type the Xbox sets quality from. */ + private val resolution: Int, + private val language: String, + private val coop: Boolean, + /** Settings, Xbox, Bitrate: a cap in Mbps (0 = let the Xbox decide), sent as b=AS in the offer like XStreaming. */ + private val maxMbps: Int, + /** Settings, Video, Ultra low latency: Artemis' vendor decoder keys on top of Moonlight's. */ + private val ultraLowLatency: Boolean, + private val videoSink: VideoSink, + private val listener: Listener, +) { + interface Listener { + fun onStatus(text: String) + fun onConnected() + fun onFailed(reason: String) + fun onRumble(gamepad: Int, weak: Double, strong: Double, durationMs: Int) + } + + /** One controller's state for one input packet. Axes -1..1 (raw Android: Y down is positive), triggers 0..1. */ + class PadFrame(val index: Int) { + var nexus = false; var menu = false; var view = false + var a = false; var b = false; var x = false; var y = false + var up = false; var down = false; var left = false; var right = false + var lb = false; var rb = false; var ls = false; var rs = false + var lx = 0f; var ly = 0f; var rx = 0f; var ry = 0f; var lt = 0f; var rt = 0f + } + + companion object { + private const val TAG = "MoonCast-XboxRtc" + private const val ACCESS_KEY = "4BDB3609-C1F1-4195-9B37-FEFF45DA8B8E" + private const val NANO_VERSION = "V3;WebrtcTransport.dll" + @Volatile private var factoryReady = false + + private val ICE_SERVERS = listOf( + "stun:worldaz.relay.teams.microsoft.com:3478", "stun:stun.l.google.com:19302", "stun:stun1.l.google.com:19302") + } + + private val worker = HandlerThread("XboxRtc").apply { start() } + private val work = Handler(worker.looper) + @Volatile private var closed = false + private var factory: PeerConnectionFactory? = null + private var pc: PeerConnection? = null + private val channels = HashMap() + private var sessionPath: String? = null + private val localCandidates = ArrayList() + private val gatheringDone = CountDownLatch(1) + @Volatile private var inputStarted = false + private val inputSequence = AtomicInteger(0) + private val messageCv = AtomicInteger(0) + private var videoTrackSeen = false + private var audioTrackSeen = false + private var keyframeAsked = false + @Volatile var connected = false; private set + + /** Latest state of each pad, sent 62.5 times a second once the input channel is ready (XStreaming's poll rate). */ + @Volatile var pads: List = emptyList() + + // ------------------------------------------------------------------ lifecycle + + fun start() = work.post { try { run() } catch (e: Exception) { if (!closed) { Log.w(TAG, "session failed", e); listener.onFailed(e.message ?: e.javaClass.simpleName) } } } + + /** End the session. [done] (optional) runs on the worker once everything is closed. */ + @JvmOverloads + fun stop(done: Runnable? = null) { + if (closed) { done?.run(); return } + closed = true + work.removeCallbacksAndMessages(null) + work.post { + try { sessionPath?.let { http("DELETE", it, null) } } catch (e: Exception) { } + try { channels.values.forEach { it.unregisterObserver(); it.close() } } catch (e: Exception) { } + try { pc?.close() } catch (e: Exception) { } + try { pc?.dispose() } catch (e: Exception) { } + try { factory?.dispose() } catch (e: Exception) { } + worker.quitSafely() + done?.run() + } + } + + // ------------------------------------------------------------------ signalling (xHome: /v5/sessions/home/...) + + private fun osName() = when (resolution) { 1080 -> "windows"; 1081, 1440 -> "tizen"; else -> "android" } + + private fun http(method: String, url: String, body: String?, deviceInfo: String? = null): String { + val c = URL(url).openConnection() as HttpURLConnection + try { + c.requestMethod = method + c.connectTimeout = 10000; c.readTimeout = 15000 + c.setRequestProperty("Authorization", "Bearer $gsToken") + c.setRequestProperty("Content-Type", "application/json") + if (method == "POST") c.setRequestProperty("Accept", "application/json") + deviceInfo?.let { c.setRequestProperty("X-MS-Device-Info", it) } + if (body != null) { c.doOutput = true; c.outputStream.use { it.write(body.toByteArray()) } } + val code = c.responseCode + val text = (if (code in 200..299) c.inputStream else c.errorStream)?.bufferedReader()?.readText() ?: "" + if (code !in 200..299) throw IllegalStateException("HTTP $code ${url.substringAfterLast('/')}: ${text.take(160)}") + return text + } finally { c.disconnect() } + } + + private fun deviceInfo() = JSONObject() + .put("appInfo", JSONObject().put("env", JSONObject().put("clientAppId", "www.xbox.com").put("clientAppType", "browser") + .put("clientAppVersion", "29.9.35").put("clientSdkVersion", "10.6.8").put("httpEnvironment", "prod").put("sdkInstallId", ""))) + .put("dev", JSONObject() + .put("hw", JSONObject().put("make", "Microsoft").put("model", "unknown").put("platformType", "desktop").put("sdktype", "web")) + .put("os", JSONObject().put("name", osName()).put("ver", "22631.2715").put("platform", "desktop")) + .put("displayInfo", JSONObject().put("dimensions", JSONObject().put("widthInPixels", 4096).put("heightInPixels", 2160)) + .put("pixelDensity", JSONObject().put("dpiX", 1).put("dpiY", 1))) + .put("browser", JSONObject().put("browserName", "edge").put("browserVersion", "140.0.3485.66"))) + .toString() + + private fun run() { + val base = baseUri.trimEnd('/') + listener.onStatus("Starting a session on your Xbox…") + val play = JSONObject(http("POST", "$base/v5/sessions/home/play", JSONObject() + .put("clientSessionId", "").put("titleId", "").put("systemUpdateGroup", "") + .put("settings", JSONObject().put("nanoVersion", NANO_VERSION).put("enableTextToSpeech", false).put("highContrast", 0) + .put("locale", language).put("useIceConnection", false).put("timezoneOffsetMinutes", 240).put("sdkType", "web") + .put("osName", osName())) + .put("serverId", consoleId).put("fallbackRegionNames", JSONArray()).toString(), deviceInfo())) + val id = play.getString("sessionPath").split('/').getOrNull(3) ?: throw IllegalStateException("No session id") + val path = "$base/v5/sessions/home/$id" + sessionPath = path + + // Wait until the console has the session ready ("ReadyToConnect" wants the user token first) + var connectSent = false + while (!closed) { + val st = JSONObject(http("GET", "$path/state", null)) + when (val s = st.optString("state")) { + "Provisioned" -> break + "ReadyToConnect" -> if (!connectSent) { + http("POST", "$path/connect", JSONObject().put("userToken", connectToken).toString()); connectSent = true + } + "Failed" -> throw IllegalStateException(st.optJSONObject("errorDetails")?.optString("message") ?: "The Xbox couldn't start the session") + else -> Log.i(TAG, "state $s") + } + Thread.sleep(1000) + } + if (closed) return + val pulse = try { JSONObject(http("GET", "$path/configuration", null)).optInt("keepAlivePulseInSeconds", 20) } catch (e: Exception) { 20 } + + listener.onStatus("Connecting…") + createPeer() + val offer = createOffer() + http("POST", "$path/sdp", JSONObject().put("messageType", "offer").put("sdp", offer.description) + .put("configuration", JSONObject() + .put("chatConfiguration", JSONObject().put("bytesPerSample", 2).put("expectedClipDurationMs", 20) + .put("format", JSONObject().put("codec", "opus").put("container", "webm")) + .put("numChannels", 1).put("sampleFrequencyHz", 24000)) + .put("chat", range(1, 1)).put("control", range(1, 3)).put("input", range(1, 8)).put("message", range(1, 1))) + .toString()) + + var answer: String? = null + while (!closed && answer == null) { + answer = sdpFrom(http("GET", "$path/sdp", null)) + if (answer == null) Thread.sleep(1000) + } + if (closed) return + // what the Xbox agreed to: the whole video section of the answer, so codec parameters (fmtp: profile, mode) + // can be checked against the offer. A payload type whose parameters don't match the offer is never mapped. + sectionLines(answer!!, "video").forEach { Log.i(TAG, "answer $it") } + sectionLines(answer, "audio").filter { it.startsWith("a=rtpmap") || it.startsWith("a=sendrecv") }.forEach { Log.i(TAG, "answer $it") } + setRemote(answer) + // which way each media section was negotiated (a recvonly/inactive video section never gets media) + pc?.transceivers?.forEach { t -> + Log.i(TAG, "transceiver ${t.mediaType} direction=${t.direction} current=${t.currentDirection}") + } + + // Local candidates (gathering finishes quickly on a LAN; don't wait forever) + gatheringDone.await(5, TimeUnit.SECONDS) + val cands = JSONArray() + synchronized(localCandidates) { + localCandidates.filterNot { it.sdp.contains("UDP") && it.sdp.contains("tcptype") }.forEach { + cands.put(JSONObject().put("candidate", it.sdp.removePrefix("a=")).put("messageType", "iceCandidate") + .put("sdpMLineIndex", it.sdpMLineIndex.toString()).put("sdpMid", it.sdpMid ?: "0")) + } + } + http("POST", "$path/ice", JSONObject().put("messageType", "iceCandidate").put("candidate", cands).toString()) + + var remote: JSONArray? = null + while (!closed && (remote == null || remote.length() == 0)) { + remote = iceFrom(http("GET", "$path/ice", null)) + if (remote == null || remote.length() == 0) Thread.sleep(1000) + } + if (closed) return + val list = (0 until remote!!.length()).map { remote.getJSONObject(it) } + for (o in expandTeredo(list)) { + val cand = o.optString("candidate").trim() + if (cand.isBlank() || cand == "a=end-of-candidates" || (cand.contains("UDP") && cand.contains("tcptype"))) continue + pc?.addIceCandidate(IceCandidate(o.optString("sdpMid", "0"), o.optString("sdpMLineIndex", "0").toIntOrNull() ?: 0, + cand.removePrefix("a="))) + } + Log.i(TAG, "remote candidates ${list.size}") + if (!connectSent) http("POST", "$path/connect", JSONObject().put("userToken", connectToken).toString()) + + // Keep the session alive for as long as we play + val keep = object : Runnable { + override fun run() { + if (closed) return + try { http("POST", "$path/keepalive", "{}") } catch (e: Exception) { Log.w(TAG, "keepalive: $e") } + work.postDelayed(this, maxOf(5, pulse) * 1000L) + } + } + work.postDelayed(keep, maxOf(5, pulse) * 1000L) + } + + /** + * The Xbox often offers a Teredo IPv6 address (2001:0::/32). Add the IPv4 host candidates hidden inside it (Teredo + * server and the client's mapped address and port, plus the Xbox game port 3074), as XStreaming does for xHome, so + * an IPv4-only network can still reach the console. + */ + private fun expandTeredo(list: List): List { + val out = ArrayList() + list.forEachIndexed { i, o -> + out.add(o) + val tok = o.optString("candidate").trim().split(Regex("\\s+")) + val ipIdx = tok.indexOfFirst { it.startsWith("2001:0:") || it.startsWith("2001:0000:") } + if (ipIdx < 0) return@forEachIndexed + val addr = try { java.net.InetAddress.getByName(tok[ipIdx]).address } catch (e: Exception) { return@forEachIndexed } + if (addr.size != 16) return@forEachIndexed + fun v4(bs: List) = bs.joinToString(".") + val server4 = v4((4..7).map { addr[it].toInt() and 0xff }) + val client4 = v4((12..15).map { (addr[it].toInt() and 0xff) xor 0xff }) + val udp = (((addr[10].toInt() and 0xff) shl 8) or (addr[11].toInt() and 0xff)) xor 0xffff + val port = tok.getOrNull(ipIdx + 1)?.toIntOrNull() ?: 9002 + val adds = mutableListOf(client4 to udp, server4 to port, client4 to port) + if (port != 3074) { adds += server4 to 3074; adds += client4 to 3074 } + adds.forEachIndexed { k, (ip, pt) -> + out.add(JSONObject().put("candidate", "a=candidate:${i + 2 + k} 1 UDP 100 $ip $pt typ host ") + .put("sdpMid", o.optString("sdpMid", "0")).put("sdpMLineIndex", o.optString("sdpMLineIndex", "0"))) + } + } + return out + } + + private fun range(min: Int, max: Int) = JSONObject().put("minVersion", min).put("maxVersion", max) + + /** The answer SDP, or null while the console hasn't answered yet. */ + private fun sdpFrom(body: String): String? = try { + val j = JSONObject(body) + val ex = j.opt("exchangeResponse") + when { + ex is String -> try { JSONObject(ex).optString("sdp").ifBlank { ex } } catch (e: Exception) { ex } + ex is JSONObject -> ex.optString("sdp").ifBlank { null } + else -> j.optString("sdp").ifBlank { null } + } + } catch (e: Exception) { null } + + private fun iceFrom(body: String): JSONArray? = try { + val j = JSONObject(body) + when (val ex = j.opt("exchangeResponse") ?: j.opt("candidate")) { + is String -> JSONArray(ex) + is JSONArray -> ex + else -> null + } + } catch (e: Exception) { null } + + // ------------------------------------------------------------------ peer connection + + private fun createPeer() { + if (!factoryReady) { + PeerConnectionFactory.initialize(PeerConnectionFactory.InitializationOptions.builder(ctx.applicationContext).createInitializationOptions()) + factoryReady = true + } + val adm = JavaAudioDeviceModule.builder(ctx.applicationContext).setUseStereoOutput(true).createAudioDeviceModule() + val f = PeerConnectionFactory.builder() + .setVideoDecoderFactory(XboxDecoderFactory(eglBase.eglBaseContext, ultraLowLatency)) + .setVideoEncoderFactory(DefaultVideoEncoderFactory(eglBase.eglBaseContext, false, false)) + .setAudioDeviceModule(adm) + .createPeerConnectionFactory() + adm.release() + factory = f + val cfg = PeerConnection.RTCConfiguration(ICE_SERVERS.map { PeerConnection.IceServer.builder(it).createIceServer() }).apply { + sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN + bundlePolicy = PeerConnection.BundlePolicy.MAXBUNDLE + rtcpMuxPolicy = PeerConnection.RtcpMuxPolicy.REQUIRE + } + val p = f.createPeerConnection(cfg, observer) ?: throw IllegalStateException("Couldn't create the connection") + pc = p + p.addTransceiver(MediaStreamTrack.MediaType.MEDIA_TYPE_VIDEO, RtpTransceiver.RtpTransceiverInit(RtpTransceiver.RtpTransceiverDirection.RECV_ONLY)) + p.addTransceiver(MediaStreamTrack.MediaType.MEDIA_TYPE_AUDIO, RtpTransceiver.RtpTransceiverInit(RtpTransceiver.RtpTransceiverDirection.SEND_RECV)) + // Same channels and protocols as XStreaming's player + open(p, "input", "1.0") + open(p, "chat", "chatV1") + open(p, "control", "controlV1") + open(p, "message", "messageV1") + } + + private fun open(p: PeerConnection, name: String, protocol: String) { + val ch = p.createDataChannel(name, DataChannel.Init().apply { ordered = true; this.protocol = protocol }) ?: return + channels[name] = ch + ch.registerObserver(object : DataChannel.Observer { + override fun onBufferedAmountChange(previous: Long) {} + override fun onStateChange() { + if (ch.state() == DataChannel.State.OPEN) work.post { onChannelOpen(name) } + } + override fun onMessage(buffer: DataChannel.Buffer) { + val bytes = ByteArray(buffer.data.remaining()).also { buffer.data.get(it) } + work.post { onChannelMessage(name, bytes, buffer.binary) } + } + }) + } + + private fun createOffer(): SessionDescription { + val p = pc!! + var made: SessionDescription? = null + var error: String? = null + val l1 = CountDownLatch(1) + p.createOffer(object : SdpAdapter() { + override fun onCreateSuccess(d: SessionDescription) { made = d; l1.countDown() } + override fun onCreateFailure(e: String?) { error = e; l1.countDown() } + }, MediaConstraints()) + l1.await(10, TimeUnit.SECONDS) + val offer = made ?: throw IllegalStateException("Offer failed: $error") + // The Xbox answers H.264 at profile-level-id 42e02a; libwebrtc treats 42e01f and 42e02a as one codec, so an extra + // entry in the codec list never reaches the offer (test151). Offer the H.264 at the level the Xbox answers. + val level = offer.description.replace("\r", "").replace("profile-level-id=42e01f", "profile-level-id=42e02a") + val munged = SessionDescription(offer.type, capBitrate(stereo(level))) + sectionLines(munged.description, "video").forEach { Log.i(TAG, "offer $it") } + val l2 = CountDownLatch(1) + p.setLocalDescription(object : SdpAdapter() { + override fun onSetSuccess() { l2.countDown() } + override fun onSetFailure(e: String?) { error = e; l2.countDown() } + }, munged) + l2.await(10, TimeUnit.SECONDS) + return munged + } + + /** The lines of one media section (video, audio...) of an SDP, without the \r. */ + private fun sectionLines(sdp: String, kind: String): List { + val out = ArrayList() + var inside = false + for (l in sdp.replace("\r", "").split("\n")) { + if (l.startsWith("m=")) inside = l.startsWith("m=$kind ") + if (inside && l.isNotBlank()) out.add(l) + } + return out + } + + private fun setRemote(sdp: String) { + val l = CountDownLatch(1) + var error: String? = null + pc!!.setRemoteDescription(object : SdpAdapter() { + override fun onSetSuccess() { l.countDown() } + override fun onSetFailure(e: String?) { error = e; l.countDown() } + }, SessionDescription(SessionDescription.Type.ANSWER, sdp)) + l.await(10, TimeUnit.SECONDS) + error?.let { throw IllegalStateException("Answer rejected: $it") } + } + + /** XStreaming's _setBitrate: a b=AS line (kbps, Mbps x 1024) in the video section, after its i= / c= lines. */ + private fun capBitrate(sdp: String): String { + if (maxMbps <= 0) return sdp + val lines = sdp.replace("\r", "").split("\n").toMutableList() + val m = lines.indexOfFirst { it.startsWith("m=video") } + if (m < 0) return sdp + var i = m + 1 + while (i < lines.size && (lines[i].startsWith("i=") || lines[i].startsWith("c="))) i++ + val line = "b=AS:${maxMbps * 1024}" + if (i < lines.size && lines[i].startsWith("b=AS:")) lines[i] = line else lines.add(i, line) + Log.i(TAG, "video capped at $maxMbps Mbps") + return lines.joinToString("\r\n") + } + + /** Opus in stereo with in-band FEC, as XStreaming does (forceStereoAudio). */ + private fun stereo(sdp: String): String { + val lines = sdp.replace("\r", "").split("\n").toMutableList() + val opus = lines.firstOrNull { it.startsWith("a=rtpmap:") && it.lowercase().contains(" opus/") } + ?.removePrefix("a=rtpmap:")?.substringBefore(' ') ?: return sdp + val i = lines.indexOfFirst { it.startsWith("a=fmtp:$opus ") } + if (i < 0) return sdp + val keep = lines[i].removePrefix("a=fmtp:$opus ").split(';').map { it.trim() } + .filter { it.isNotEmpty() && !it.startsWith("stereo=") && !it.startsWith("minptime=") && !it.startsWith("useinbandfec=") } + lines[i] = "a=fmtp:$opus " + (keep + listOf("stereo=1", "minptime=10", "useinbandfec=1")).joinToString(";") + return lines.joinToString("\r\n") + } + + private val observer = object : PeerConnection.Observer { + override fun onSignalingChange(s: PeerConnection.SignalingState) {} + override fun onIceConnectionChange(s: PeerConnection.IceConnectionState) { Log.i(TAG, "ice $s") } + override fun onIceConnectionReceivingChange(b: Boolean) {} + override fun onIceGatheringChange(s: PeerConnection.IceGatheringState) { + if (s == PeerConnection.IceGatheringState.COMPLETE) gatheringDone.countDown() + } + override fun onIceCandidate(c: IceCandidate) { synchronized(localCandidates) { localCandidates.add(c) } } + override fun onIceCandidatesRemoved(c: Array) {} + override fun onAddStream(s: MediaStream) {} + override fun onRemoveStream(s: MediaStream) {} + override fun onDataChannel(d: DataChannel) {} + override fun onRenegotiationNeeded() {} + override fun onAddTrack(r: RtpReceiver, s: Array) { + val t = r.track() ?: return + Log.i(TAG, "track ${t.kind()} arrived") + work.post { + if (t is VideoTrack) { t.addSink(videoSink); videoTrackSeen = true } else audioTrackSeen = true + if (videoTrackSeen && audioTrackSeen && !keyframeAsked) { keyframeAsked = true; askKeyframe() } + } + } + override fun onConnectionChange(s: PeerConnection.PeerConnectionState) { + Log.i(TAG, "connection $s") + when (s) { + PeerConnection.PeerConnectionState.CONNECTED -> { connected = true; listener.onConnected() } + PeerConnection.PeerConnectionState.FAILED -> if (!closed) listener.onFailed("The connection to the Xbox dropped") + else -> {} + } + } + } + + // ------------------------------------------------------------------ channels + + private fun sendText(name: String, text: String) { + channels[name]?.send(DataChannel.Buffer(ByteBuffer.wrap(text.toByteArray()), false)) + } + + private fun onChannelOpen(name: String) { + Log.i(TAG, "channel $name open") + if (name == "message") sendText("message", JSONObject().put("type", "Handshake").put("version", "messageV1") + .put("id", UUID.randomUUID().toString()).put("cv", messageCv.getAndIncrement().toString()).toString()) + } + + private fun onChannelMessage(name: String, data: ByteArray, binary: Boolean) { + when (name) { + "message" -> { + val j = try { JSONObject(String(data)) } catch (e: Exception) { return } + if (j.optString("type") == "HandshakeAck" && !inputStarted) onHandshake() + } + "input" -> parseInput(data) + } + } + + /** The Xbox accepted us: authorise, announce the pads, start input and send the client description. */ + private fun onHandshake() { + Log.i(TAG, "message handshake acked") + sendText("control", JSONObject().put("message", "authorizationRequest").put("accessKey", ACCESS_KEY).toString()) + val slots = if (coop) listOf(0, 1) else listOf(0) + slots.forEach { sendText("control", gamepadChanged(it, false)) } + work.postDelayed({ slots.forEach { sendText("control", gamepadChanged(it, true)) } }, 500) + + // Input: the client metadata packet first, then the pad state on a fixed beat + channels["input"]?.send(DataChannel.Buffer(packet(clientMetadata = true), true)) + inputStarted = true + work.post(inputBeat) + work.postDelayed(frameFeedback, 2000) + // Ask for the first picture now that the control channel is open: the request made when the tracks arrived + // can come before the channel opens and get lost, and then the Xbox never starts the video (test129). + askKeyframe() + work.postDelayed(keyframeWatch, 3000) + + listOf( + "/streaming/systemUi/configuration" to JSONObject().put("version", JSONArray(listOf(0, 2, 0))) + .put("systemUis", JSONArray(listOf(10, 19, 31, 27, 32, -44, 40, 41, -43))), + "/streaming/properties/clientappinstallidchanged" to JSONObject().put("clientAppInstallId", UUID.randomUUID().toString()), + "/streaming/characteristics/orientationchanged" to JSONObject().put("orientation", 0), + "/streaming/characteristics/touchinputenabledchanged" to JSONObject().put("touchInputEnabled", false), + "/streaming/characteristics/clientdevicecapabilities" to JSONObject(), + "/streaming/characteristics/dimensionschanged" to JSONObject().put("horizontal", 1920).put("vertical", 1080) + .put("preferredWidth", 1920).put("preferredHeight", 1080).put("safeAreaLeft", 0).put("safeAreaTop", 0) + .put("safeAreaRight", 1920).put("safeAreaBottom", 1080).put("supportsCustomResolution", true), + ).forEach { (target, content) -> + sendText("message", JSONObject().put("type", "Message").put("content", content.toString()) + .put("id", UUID.randomUUID().toString()).put("target", target).put("cv", messageCv.getAndIncrement().toString()).toString()) + } + } + + private fun gamepadChanged(index: Int, added: Boolean) = + JSONObject().put("message", "gamepadChanged").put("gamepadIndex", index).put("wasAdded", added).toString() + + /** Until video flows, ask for a keyframe again every 3 s (up to 5 times). */ + private var keyframeTries = 0 + private val keyframeWatch = object : Runnable { + override fun run() { + if (closed || videoFlowing) return + if (keyframeTries++ >= 5) { + // ~18 s with no video bytes from the Xbox: end this session (the console keeps it otherwise) and say so + Log.w(TAG, "no video after ${keyframeTries * 3} s: the Xbox accepted the session but sent no picture") + listener.onFailed("The Xbox connected but sent no picture.") + return + } + Log.i(TAG, "no video yet, asking for a keyframe again") + askKeyframe() + work.postDelayed(this, 3000) + } + } + @Volatile private var videoFlowing = false + private var statsLogs = 0 + + private fun askKeyframe() = sendText("control", JSONObject().put("message", "videoKeyframeRequested").put("ifrRequested", false).toString()) + + private val inputBeat = object : Runnable { + override fun run() { + if (closed) return + channels["input"]?.let { if (it.state() == DataChannel.State.OPEN) it.send(DataChannel.Buffer(packet(), true)) } + work.postDelayed(this, 16) + } + } + + // A "frame processed" report every 10 s, as XStreaming sends it (the Xbox expects some feedback) + private var pendingFeedback = false + private val frameFeedback = object : Runnable { + override fun run() { + if (closed) return + pendingFeedback = true + work.postDelayed(this, 10_000) + } + } + + private fun nowMs() = android.os.SystemClock.elapsedRealtimeNanos() / 1_000_000.0 + + /** XStreaming's input packet (Packet/index.ts): report type, sequence, timestamp, then metadata / gamepad frames. */ + private fun packet(clientMetadata: Boolean = false): ByteBuffer { + val now = nowMs() + val frames = if (clientMetadata) emptyList() else pads.ifEmpty { listOf(PadFrame(0)) } + val feedback = !clientMetadata && pendingFeedback + var size = 14 + if (feedback) size += 1 + 28 + if (frames.isNotEmpty()) size += 1 + 23 * frames.size + if (clientMetadata) size += 1 + val b = ByteBuffer.allocateDirect(size).order(ByteOrder.LITTLE_ENDIAN) + var type = 0 + if (feedback) type = type or 1 + if (frames.isNotEmpty()) type = type or 2 + if (clientMetadata) type = 8 + b.putShort(type.toShort()) + b.putInt(inputSequence.getAndIncrement()) + b.putDouble(now) + if (feedback) { + pendingFeedback = false + val n = now.toLong().toInt(); val sub = n - 10 + b.put(1.toByte()) + b.putInt(System.currentTimeMillis().toInt()); b.putInt(sub); b.putInt(sub); b.putInt(n); b.putInt(n); b.putInt(n); b.putInt(n) + } + if (frames.isNotEmpty()) { + b.put(frames.size.toByte()) + frames.forEach { f -> + b.put(f.index.toByte()) + var m = 0 + if (f.nexus) m = m or 2; if (f.menu) m = m or 4; if (f.view) m = m or 8 + if (f.a) m = m or 16; if (f.b) m = m or 32; if (f.x) m = m or 64; if (f.y) m = m or 128 + if (f.up) m = m or 256; if (f.down) m = m or 512; if (f.left) m = m or 1024; if (f.right) m = m or 2048 + if (f.lb) m = m or 4096; if (f.rb) m = m or 8192; if (f.ls) m = m or 16384; if (f.rs) m = m or 32768 + b.putShort(m.toShort()) + fun ax(v: Float) = (v * 32767f).coerceIn(-32767f, 32767f).toInt().toShort() + fun tr(v: Float) = (v.coerceIn(0f, 1f) * 65535f).toInt().toShort() + b.putShort(ax(f.lx)); b.putShort(ax(-f.ly)); b.putShort(ax(f.rx)); b.putShort(ax(-f.ry)) + b.putShort(tr(f.lt)); b.putShort(tr(f.rt)) + var ph = 0 + if (f.up) ph = ph or 0x1; if (f.down) ph = ph or 0x2; if (f.left) ph = ph or 0x4; if (f.right) ph = ph or 0x8 + if (f.menu) ph = ph or 0x10; if (f.view) ph = ph or 0x20; if (f.ls) ph = ph or 0x40; if (f.rs) ph = ph or 0x80 + if (f.lb) ph = ph or 0x100; if (f.rb) ph = ph or 0x200; if (f.nexus) ph = ph or 0x400 + if (f.a) ph = ph or 0x1000; if (f.b) ph = ph or 0x2000; if (f.x) ph = ph or 0x4000; if (f.y) ph = ph or 0x8000 + if (f.lt > 0f) ph = ph or 0x10000; if (f.rt > 0f) ph = ph or 0x20000 + if (f.lx != 0f) ph = ph or 0x40000; if (f.ly != 0f) ph = ph or 0x80000 + if (f.rx != 0f) ph = ph or 0x100000; if (f.ry != 0f) ph = ph or 0x200000 + b.putInt(ph); b.putInt(0) + } + } + if (clientMetadata) b.put(0.toByte()) // max touch points + b.flip() + return b + } + + /** Server messages on the input channel: picture size and rumble. */ + private fun parseInput(d: ByteArray) { + if (d.size < 2) return + val b = ByteBuffer.wrap(d).order(ByteOrder.LITTLE_ENDIAN) + val type = b.getShort(0).toInt() and 0xffff + var off = 2 + if (type and 16 != 0) off += 8 + if (type and 128 != 0 && d.size >= off + 11) { + val pad = d[off + 1].toInt() and 0xff + val left = (d[off + 2].toInt() and 0xff) / 100.0 + val right = (d[off + 3].toInt() and 0xff) / 100.0 + val dur = b.getShort(off + 6).toInt() and 0xffff + listener.onRumble(pad, right, left, dur) + } + } + + // ------------------------------------------------------------------ stats + + data class Stats(val fps: Double, val rttMs: Double, val mbps: Double, val lossPct: Double, val width: Int, val height: Int) + private var lastBytes = -1L + private var lastAt = 0L + + /** Live numbers for the Quick menu. Calls back on WebRTC's thread. */ + fun stats(cb: (Stats) -> Unit) { + val p = pc ?: return + p.getStats { report -> + var fps = -1.0; var rtt = -1.0; var mbps = -1.0; var loss = -1.0; var w = 0; var h = 0 + report.statsMap.values.forEach { s -> + val m = s.members + if (s.type == "inbound-rtp" && m["kind"] == "video") { + fps = (m["framesPerSecond"] as? Number)?.toDouble() ?: fps + w = (m["frameWidth"] as? Number)?.toInt() ?: w; h = (m["frameHeight"] as? Number)?.toInt() ?: h + val lost = (m["packetsLost"] as? Number)?.toDouble() ?: 0.0 + val got = (m["packetsReceived"] as? Number)?.toDouble() ?: 0.0 + if (lost + got > 0) loss = 100.0 * lost / (lost + got) + val bytes = (m["bytesReceived"] as? Number)?.toLong() ?: -1L + val now = System.currentTimeMillis() + if (bytes >= 0 && lastBytes >= 0 && now > lastAt) mbps = (bytes - lastBytes) * 8.0 / ((now - lastAt) * 1000.0) + if (bytes >= 0) { lastBytes = bytes; lastAt = now } + } + if (s.type == "candidate-pair" && m["state"] == "succeeded" && m["nominated"] == true) + rtt = ((m["currentRoundTripTime"] as? Number)?.toDouble() ?: -0.001) * 1000.0 + } + if (fps > 0 || mbps > 0) videoFlowing = true + else if (!videoFlowing && statsLogs == 0) { + // once: what libwebrtc actually negotiated on the video receiver, and which stats it reports at all + statsLogs++ + report.statsMap.values.forEach { st -> + Log.i(TAG, "stats type=${st.type} id=${st.id} keys=${st.members.keys.joinToString()}") + } + p.transceivers.filter { it.mediaType == MediaStreamTrack.MediaType.MEDIA_TYPE_VIDEO }.forEach { t -> + val rp = t.receiver.parameters + Log.i(TAG, "recv codecs: " + rp.codecs.joinToString { "${it.name}/pt=${it.payloadType} ${it.parameters}" }) + Log.i(TAG, "recv encodings ssrc: " + rp.encodings.joinToString { "${it.ssrc}" }) + } + } + if (!videoFlowing && statsLogs in 1..12 && fps <= 0 && mbps <= 0) { + statsLogs++ + // diagnostics while the picture is missing: is any video RTP arriving at all? + val vid = report.statsMap.values.filter { it.type == "inbound-rtp" && it.members["kind"] == "video" } + val v = vid.firstOrNull()?.members + val codec = v?.get("codecId")?.let { report.statsMap[it as String]?.members?.get("mimeType") } + // padding-only packets (bandwidth probes) count as packets but carry no payload: bytes stay 0 + Log.i(TAG, "video in: ${vid.size} inbound-rtp, bytes=${v?.get("bytesReceived")} " + + "packets=${v?.get("packetsReceived")} frames=${v?.get("framesDecoded")} " + + "codec=$codec ssrc=${v?.get("ssrc")} pt=${v?.get("payloadType")} " + + "discarded=${v?.get("packetsDiscarded")} plis=${v?.get("pliCount")} nacks=${v?.get("nackCount")} " + + "firs=${v?.get("firCount")} keyFrames=${v?.get("keyFramesDecoded")}") + } + cb(Stats(fps, rtt, mbps, loss, w, h)) + } + } + + private open class SdpAdapter : SdpObserver { + override fun onCreateSuccess(d: SessionDescription) {} + override fun onSetSuccess() {} + override fun onCreateFailure(e: String?) {} + override fun onSetFailure(e: String?) {} + } +} diff --git a/app/src/main/java/com/metallic/chiaki/lib/Chiaki.kt b/app/src/main/java/com/metallic/chiaki/lib/Chiaki.kt new file mode 100644 index 0000000000..ee6b916a52 --- /dev/null +++ b/app/src/main/java/com/metallic/chiaki/lib/Chiaki.kt @@ -0,0 +1,566 @@ +package com.metallic.chiaki.lib + +import android.util.Log +import android.view.Surface +import java.lang.Exception +import java.net.InetSocketAddress +import kotlin.math.abs + +enum class Target(val value: Int) +{ + PS4_UNKNOWN(0), + PS4_8(800), + PS4_9(900), + PS4_10(1000), + PS5_UNKNOWN(1000000), + PS5_1(1000100); + + companion object + { + @JvmStatic + fun fromValue(value: Int) = values().firstOrNull { it.value == value } ?: PS4_10 + } + + val isPS5 get() = value >= PS5_UNKNOWN.value +} + +enum class VideoResolutionPreset(val value: Int) +{ + RES_360P(1), + RES_540P(2), + RES_720P(3), + RES_1080P(4) +} + +enum class VideoFPSPreset(val value: Int) +{ + FPS_30(30), + FPS_60(60) +} + +enum class Codec(val value: Int) +{ + CODEC_H264(0), + CODEC_H265(1), + CODEC_H265_HDR(2) +} + +data class ConnectVideoProfile( + val width: Int, + val height: Int, + val maxFPS: Int, + val bitrate: Int, + val codec: Codec +) +{ + companion object + { + fun preset(resolutionPreset: VideoResolutionPreset, fpsPreset: VideoFPSPreset, codec: Codec) + = ChiakiNative.videoProfilePreset(resolutionPreset.value, fpsPreset.value, codec) + } +} + +data class ConnectInfo( + val ps5: Boolean, + val host: String, + val registKey: ByteArray, + val morning: ByteArray, + val videoProfile: ConnectVideoProfile, + // MoonCast: Remote Play over the internet (PSN hole-punch). Read by the JNI only when both + // psnToken and duid are set; null = LAN direct connection, as before. + val psnToken: String? = null, + val duid: String? = null, + val psnAccountId: ByteArray? = null +) + +/** MoonCast: stop the PSN connect in flight (see ChiakiNative.psnCancel). */ +fun psnCancel() = ChiakiNative.psnCancel() + +/** MoonCast: set the PS5 decoder's latency mode before a session (see ChiakiNative.decoderLatencyMode). */ +fun decoderLatencyMode(mode: Int) = ChiakiNative.decoderLatencyMode(mode) + +/** MoonCast: set the PS5 player's frame pacing before a session (see ChiakiNative.decoderPacing). */ +fun decoderPacing(mode: Int, fps: Int) = ChiakiNative.decoderPacing(mode, fps) + +private class ChiakiNative +{ + data class CreateResult(var errorCode: Int, var ptr: Long) + companion object + { + init + { + System.loadLibrary("chiaki-jni") + } + @JvmStatic external fun errorCodeToString(value: Int): String + @JvmStatic external fun quitReasonToString(value: Int): String + @JvmStatic external fun quitReasonIsError(value: Int): Boolean + @JvmStatic external fun videoProfilePreset(resolutionPreset: Int, fpsPreset: Int, codec: Codec): ConnectVideoProfile + @JvmStatic external fun sessionCreate(result: CreateResult, connectInfo: ConnectInfo, logFile: String?, logVerbose: Boolean, javaSession: Session) + @JvmStatic external fun sessionFree(ptr: Long) + @JvmStatic external fun sessionStart(ptr: Long): Int + @JvmStatic external fun sessionStop(ptr: Long): Int + @JvmStatic external fun sessionJoin(ptr: Long): Int + @JvmStatic external fun sessionSetSurface(ptr: Long, surface: Surface?) + @JvmStatic external fun sessionSetControllerState(ptr: Long, controllerState: ControllerState) + @JvmStatic external fun sessionSetLoginPin(ptr: Long, pin: String) + @JvmStatic external fun sessionGotoBed(ptr: Long): Int + /** MoonCast: stop the PSN connect in flight (a sessionCreate stuck in its hole-punch). Needs a libchiaki-jni.so with psnCancel. */ + @JvmStatic external fun psnCancel() + /** MoonCast: the PS5 decoder's latency mode for the next session: 0 standard, 1 low latency, 2 ultra low latency. */ + @JvmStatic external fun decoderLatencyMode(mode: Int) + /** MoonCast: frame pacing for the next session: 0 lowest delay, 1 balanced, 2 smoothest, at [fps]. */ + @JvmStatic external fun decoderPacing(mode: Int, fps: Int) + @JvmStatic external fun sessionStats(ptr: Long): DoubleArray? + @JvmStatic external fun discoveryServiceCreate(result: CreateResult, options: DiscoveryServiceOptions, javaService: DiscoveryService) + @JvmStatic external fun discoveryServiceFree(ptr: Long) + @JvmStatic external fun discoveryServiceWakeup(ptr: Long, host: String, userCredential: Long, ps5: Boolean) + @JvmStatic external fun registStart(result: CreateResult, registInfo: RegistInfo, javaLog: ChiakiLog, javaRegist: Regist) + @JvmStatic external fun registStop(ptr: Long) + @JvmStatic external fun registFree(ptr: Long) + } +} + +class ErrorCode(val value: Int) +{ + override fun toString() = ChiakiNative.errorCodeToString(value) + var isSuccess = value == 0 +} + +class ChiakiLog(val levelMask: Int, val callback: (level: Int, text: String) -> Unit) +{ + companion object + { + fun formatLog(level: Int, text: String) = + "[${when(level) + { + Level.DEBUG.value -> "D" + Level.VERBOSE.value -> "V" + Level.INFO.value -> "I" + Level.WARNING.value -> "W" + Level.ERROR.value -> "E" + else -> "?" + } + }] $text" + } + + enum class Level(val value: Int) + { + DEBUG(1 shl 4), + VERBOSE(1 shl 3), + INFO(1 shl 2), + WARNING(1 shl 1), + ERROR(1 shl 0), + ALL(0.inv()) + } + + private fun log(level: Int, text: String) + { + callback(level, text) + } + + fun d(text: String) = log(Level.DEBUG.value, text) + fun v(text: String) = log(Level.VERBOSE.value, text) + fun i(text: String) = log(Level.INFO.value, text) + fun w(text: String) = log(Level.WARNING.value, text) + fun e(text: String) = log(Level.ERROR.value, text) +} + +private fun maxAbs(a: Short, b: Short) = if(abs(a.toInt()) > abs(b.toInt())) a else b + +private val CONTROLLER_TOUCHES_MAX = 2 // must be the same as CHIAKI_CONTROLLER_TOUCHES_MAX + +data class ControllerTouch( + var x: UShort = 0U, + var y: UShort = 0U, + var id: Byte = -1 // -1 = up +) + +data class ControllerState constructor( + var buttons: UInt = 0U, + var l2State: UByte = 0U, + var r2State: UByte = 0U, + var leftX: Short = 0, + var leftY: Short = 0, + var rightX: Short = 0, + var rightY: Short = 0, + private var touchIdNext: UByte = 0U, + var touches: Array = arrayOf(ControllerTouch(), ControllerTouch()), + var gyroX: Float = 0.0f, + var gyroY: Float = 0.0f, + var gyroZ: Float = 0.0f, + var accelX: Float = 0.0f, + var accelY: Float = 1.0f, + var accelZ: Float = 0.0f, + var orientX: Float = 0.0f, + var orientY: Float = 0.0f, + var orientZ: Float = 0.0f, + var orientW: Float = 1.0f +){ + companion object + { + val BUTTON_CROSS = (1 shl 0).toUInt() + val BUTTON_MOON = (1 shl 1).toUInt() + val BUTTON_BOX = (1 shl 2).toUInt() + val BUTTON_PYRAMID = (1 shl 3).toUInt() + val BUTTON_DPAD_LEFT = (1 shl 4).toUInt() + val BUTTON_DPAD_RIGHT = (1 shl 5).toUInt() + val BUTTON_DPAD_UP = (1 shl 6).toUInt() + val BUTTON_DPAD_DOWN = (1 shl 7).toUInt() + val BUTTON_L1 = (1 shl 8).toUInt() + val BUTTON_R1 = (1 shl 9).toUInt() + val BUTTON_L3 = (1 shl 10).toUInt() + val BUTTON_R3 = (1 shl 11).toUInt() + val BUTTON_OPTIONS = (1 shl 12).toUInt() + val BUTTON_SHARE = (1 shl 13).toUInt() + val BUTTON_TOUCHPAD = (1 shl 14).toUInt() + val BUTTON_PS = (1 shl 15).toUInt() + val TOUCHPAD_WIDTH: UShort = 1920U + val TOUCHPAD_HEIGHT: UShort = 942U + } + + infix fun or(o: ControllerState) = ControllerState( + buttons = buttons or o.buttons, + l2State = maxOf(l2State, o.l2State), + r2State = maxOf(r2State, o.r2State), + leftX = maxAbs(leftX, o.leftX), + leftY = maxAbs(leftY, o.leftY), + rightX = maxAbs(rightX, o.rightX), + rightY = maxAbs(rightY, o.rightY), + touches = touches.zip(o.touches) { a, b -> if(a.id >= 0) a else b }.toTypedArray(), + gyroX = gyroX, + gyroY = gyroY, + gyroZ = gyroZ, + accelX = accelX, + accelY = accelY, + accelZ = accelZ, + orientX = orientX, + orientY = orientY, + orientZ = orientZ, + orientW = orientW + ) + + override fun equals(other: Any?): Boolean + { + if(this === other) return true + if(javaClass != other?.javaClass) return false + + other as ControllerState + + if(buttons != other.buttons) return false + if(l2State != other.l2State) return false + if(r2State != other.r2State) return false + if(leftX != other.leftX) return false + if(leftY != other.leftY) return false + if(rightX != other.rightX) return false + if(rightY != other.rightY) return false + if(touchIdNext != other.touchIdNext) return false + if(!touches.contentEquals(other.touches)) return false + if(gyroX != other.gyroX) return false + if(gyroY != other.gyroY) return false + if(gyroZ != other.gyroZ) return false + if(accelX != other.accelX) return false + if(accelY != other.accelY) return false + if(accelZ != other.accelZ) return false + if(orientX != other.orientX) return false + if(orientY != other.orientY) return false + if(orientZ != other.orientZ) return false + if(orientW != other.orientW) return false + + return true + } + + override fun hashCode(): Int + { + var result = buttons.hashCode() + result = 31 * result + l2State.hashCode() + result = 31 * result + r2State.hashCode() + result = 31 * result + leftX + result = 31 * result + leftY + result = 31 * result + rightX + result = 31 * result + rightY + result = 31 * result + touchIdNext.hashCode() + result = 31 * result + touches.contentHashCode() + result = 31 * result + gyroX.hashCode() + result = 31 * result + gyroY.hashCode() + result = 31 * result + gyroZ.hashCode() + result = 31 * result + accelX.hashCode() + result = 31 * result + accelY.hashCode() + result = 31 * result + accelZ.hashCode() + result = 31 * result + orientX.hashCode() + result = 31 * result + orientY.hashCode() + result = 31 * result + orientZ.hashCode() + result = 31 * result + orientW.hashCode() + return result + } + + fun startTouch(x: UShort, y: UShort): UByte? = + touches + .find { it.id < 0 } + ?.also { + it.id = touchIdNext.toByte() + it.x = x + it.y = y + touchIdNext = ((touchIdNext + 1U) and 0x7fU).toUByte() + }?.id?.toUByte() + + fun stopTouch(id: UByte) + { + touches.find { + it.id >= 0 && it.id == id.toByte() + }?.let { + it.id = -1 + } + } + + fun setTouchPos(id: UByte, x: UShort, y: UShort): Boolean + = touches.find { + it.id >= 0 && it.id == id.toByte() + }?.let { + val r = it.x != x || it.y != y + it.x = x + it.y = y + r + } ?: false +} + +class QuitReason(val value: Int) +{ + override fun toString() = ChiakiNative.quitReasonToString(value) + + val isError = ChiakiNative.quitReasonIsError(value) +} + +sealed class Event +object ConnectedEvent: Event() +data class LoginPinRequestEvent(val pinIncorrect: Boolean): Event() +data class QuitEvent(val reason: QuitReason, val reasonString: String?): Event() +data class RumbleEvent(val left: UByte, val right: UByte): Event() + +class CreateError(val errorCode: ErrorCode): Exception("Failed to create a native object: $errorCode") + +class Session(connectInfo: ConnectInfo, logFile: String?, logVerbose: Boolean) +{ + interface EventCallback + { + fun sessionEvent(event: Event) + } + + private var nativePtr: Long + var eventCallback: ((event: Event) -> Unit)? = null + + init + { + val result = ChiakiNative.CreateResult(0, 0) + ChiakiNative.sessionCreate(result, connectInfo, logFile, logVerbose, this) + val errorCode = ErrorCode(result.errorCode) + if(!errorCode.isSuccess) + throw CreateError(errorCode) + nativePtr = result.ptr + } + + fun start() = ErrorCode(ChiakiNative.sessionStart(nativePtr)) + fun stop() = ErrorCode(ChiakiNative.sessionStop(nativePtr)) + /** Put the console into rest mode (it ends the session). Needs a libchiaki-jni.so with sessionGotoBed. */ + fun gotoBed() = ErrorCode(ChiakiNative.sessionGotoBed(nativePtr)) + + private val statsLock = Any() + + /** + * Live numbers for the Quick menu: [measured Mbps, packet loss 0..1, PS5-reported rtt, rtt at connect (ms), + * frames shown, frames lost], or null once the session is gone. Needs a libchiaki-jni.so with sessionStats. + */ + fun stats(): DoubleArray? = synchronized(statsLock) { + if(nativePtr == 0L) null else try { ChiakiNative.sessionStats(nativePtr) } catch(e: UnsatisfiedLinkError) { null } + } + + fun dispose() + { + if(nativePtr == 0L) + return + ChiakiNative.sessionJoin(nativePtr) + // stats() may be reading the session from the UI thread; free it only between reads + synchronized(statsLock) { + ChiakiNative.sessionFree(nativePtr) + nativePtr = 0L + } + } + + private fun event(event: Event) + { + eventCallback?.let { it(event) } + } + + private fun eventConnected() + { + event(ConnectedEvent) + } + + private fun eventLoginPinRequest(pinIncorrect: Boolean) + { + event(LoginPinRequestEvent(pinIncorrect)) + } + + private fun eventQuit(reasonValue: Int, reasonString: String?) + { + event(QuitEvent(QuitReason(reasonValue), reasonString)) + } + + private fun eventRumble(left: Int, right: Int) + { + event(RumbleEvent(left.toUByte(), right.toUByte())) + } + + fun setSurface(surface: Surface?) + { + ChiakiNative.sessionSetSurface(nativePtr, surface) + } + + fun setControllerState(controllerState: ControllerState) + { + ChiakiNative.sessionSetControllerState(nativePtr, controllerState) + } + + fun setLoginPin(pin: String) + { + ChiakiNative.sessionSetLoginPin(nativePtr, pin) + } +} + +data class DiscoveryHost( + val state: State, + val hostRequestPort: UShort, + val hostAddr: String?, + val systemVersion: String?, + val deviceDiscoveryProtocolVersion: String?, + val hostName: String?, + val hostType: String?, + val hostId: String?, + val runningAppTitleid: String?, + val runningAppName: String?) +{ + enum class State + { + UNKNOWN, + READY, + STANDBY + } + + val isPS5 get() = deviceDiscoveryProtocolVersion == "00030010" +} + + +data class DiscoveryServiceOptions( + val hostsMax: ULong, + val hostDropPings: ULong, + val pingMs: ULong, + val sendAddr: InetSocketAddress +) + +class DiscoveryService( + options: DiscoveryServiceOptions, + val callback: ((hosts: List) -> Unit)?) +{ + companion object + { + fun wakeup(service: DiscoveryService?, host: String, userCredential: ULong, ps5: Boolean) = + ChiakiNative.discoveryServiceWakeup(service?.nativePtr ?: 0, host, userCredential.toLong(), ps5) + } + + private var nativePtr: Long + + init + { + val result = ChiakiNative.CreateResult(0, 0) + ChiakiNative.discoveryServiceCreate(result, options, this) + val errorCode = ErrorCode(result.errorCode) + if(!errorCode.isSuccess) + throw CreateError(errorCode) + nativePtr = result.ptr + } + + fun dispose() + { + if(nativePtr == 0L) + return + ChiakiNative.discoveryServiceFree(nativePtr) + nativePtr = 0L + } + + private fun hostsUpdated(hosts: Array) + { + val hostsList = hosts.toList() + Log.i("Chiaki", "got hosts from native: $hostsList") + callback?.let { it(hostsList) } + } + +} + +data class RegistInfo( + val target: Target, + val host: String, + val broadcast: Boolean, + val psnOnlineId: String?, + val psnAccountId: ByteArray?, + val pin: Int +) +{ + companion object + { + const val ACCOUNT_ID_SIZE = 8 + } +} + +data class RegistHost( + val target: Target, + val apSsid: String, + val apBssid: String, + val apKey: String, + val apName: String, + val serverMac: ByteArray, + val serverNickname: String, + val rpRegistKey: ByteArray, + val rpKeyType: UInt, + val rpKey: ByteArray +) + +sealed class RegistEvent +object RegistEventCanceled: RegistEvent() +object RegistEventFailed: RegistEvent() +class RegistEventSuccess(val host: RegistHost): RegistEvent() + +class Regist( + info: RegistInfo, + log: ChiakiLog, + val callback: (RegistEvent) -> Unit +) +{ + private var nativePtr: Long + + init + { + val result = ChiakiNative.CreateResult(0, 0) + ChiakiNative.registStart(result, info, log, this) + val errorCode = ErrorCode(result.errorCode) + if(!errorCode.isSuccess) + throw CreateError(errorCode) + nativePtr = result.ptr + } + + fun stop() + { + ChiakiNative.registStop(nativePtr) + } + + fun dispose() + { + if(nativePtr == 0L) + return + ChiakiNative.registFree(nativePtr) + nativePtr = 0L + } + + private fun event(event: RegistEvent) + { + callback(event) + } +} diff --git a/app/src/main/res/drawable-nodpi/mc_channel_mark.png b/app/src/main/res/drawable-nodpi/mc_channel_mark.png new file mode 100644 index 0000000000..72d71fe8e5 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/mc_channel_mark.png differ diff --git a/app/src/main/res/drawable-nodpi/mc_logo.png b/app/src/main/res/drawable-nodpi/mc_logo.png new file mode 100644 index 0000000000..096a7bbef7 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/mc_logo.png differ diff --git a/app/src/main/res/drawable-xhdpi/atv_banner.png b/app/src/main/res/drawable-xhdpi/atv_banner.png index 2e15225df9..893e3c7b66 100644 Binary files a/app/src/main/res/drawable-xhdpi/atv_banner.png and b/app/src/main/res/drawable-xhdpi/atv_banner.png differ diff --git a/app/src/main/res/drawable-xhdpi/ouya_icon.png b/app/src/main/res/drawable-xhdpi/ouya_icon.png deleted file mode 100644 index cda722c6f2..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ouya_icon.png and /dev/null differ diff --git a/app/src/main/res/drawable/app_icon.png b/app/src/main/res/drawable/app_icon.png deleted file mode 100644 index b8021d2f96..0000000000 Binary files a/app/src/main/res/drawable/app_icon.png and /dev/null differ diff --git a/app/src/main/res/drawable/ic_channel.xml b/app/src/main/res/drawable/ic_channel.xml index 4dada9a7f4..258965f198 100644 --- a/app/src/main/res/drawable/ic_channel.xml +++ b/app/src/main/res/drawable/ic_channel.xml @@ -1,7 +1,7 @@ - - - - - - - - - - diff --git a/app/src/main/res/drawable/mc_bg.xml b/app/src/main/res/drawable/mc_bg.xml new file mode 100644 index 0000000000..dcc9102539 --- /dev/null +++ b/app/src/main/res/drawable/mc_bg.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/mc_tile_focus.xml b/app/src/main/res/drawable/mc_tile_focus.xml new file mode 100644 index 0000000000..76a342cbc1 --- /dev/null +++ b/app/src/main/res/drawable/mc_tile_focus.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/app_grid_item.xml b/app/src/main/res/layout/app_grid_item.xml index 3e40450852..e46cabc69e 100644 --- a/app/src/main/res/layout/app_grid_item.xml +++ b/app/src/main/res/layout/app_grid_item.xml @@ -24,6 +24,14 @@ android:layout_below="@id/grid_overlay" android:layout_margin="5dp" android:gravity="center" - android:textSize="18sp"> + android:textSize="17sp" + android:textColor="@color/mc_silver" + android:fontFamily="sans-serif-medium"> + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 65291b96e8..4df90c3c1e 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -2,5 +2,5 @@ - + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png index ee146e882e..aaf2d03397 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png index bccd189b2d..2d1d049acb 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png b/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000000..12e9e7442b Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png index 4b93e1a562..3b7f7b4f7b 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png index ee3c095880..7e3524350b 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png b/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000000..f7765b051e Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png index b8021d2f96..bf793d0236 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png index 5139654603..47843c8b13 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000000..2fd901b190 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index 08082e3a16..47c25b31fa 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png index 678ae036c8..1715b38de7 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000000..79a3d91379 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index dd60674ae5..ad0a35b427 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png index eb6180ef49..22403f51a3 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000000..6b8b4e0279 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png differ diff --git a/app/src/main/res/raw/mc_back_01.wav b/app/src/main/res/raw/mc_back_01.wav new file mode 100755 index 0000000000..7e270a6882 Binary files /dev/null and b/app/src/main/res/raw/mc_back_01.wav differ diff --git a/app/src/main/res/raw/mc_error_01.wav b/app/src/main/res/raw/mc_error_01.wav new file mode 100755 index 0000000000..b5034496ff Binary files /dev/null and b/app/src/main/res/raw/mc_error_01.wav differ diff --git a/app/src/main/res/raw/mc_focus_01.wav b/app/src/main/res/raw/mc_focus_01.wav new file mode 100755 index 0000000000..145a4a0ec7 Binary files /dev/null and b/app/src/main/res/raw/mc_focus_01.wav differ diff --git a/app/src/main/res/raw/mc_focus_02.wav b/app/src/main/res/raw/mc_focus_02.wav new file mode 100755 index 0000000000..ba38edab9f Binary files /dev/null and b/app/src/main/res/raw/mc_focus_02.wav differ diff --git a/app/src/main/res/raw/mc_launch_01.wav b/app/src/main/res/raw/mc_launch_01.wav new file mode 100755 index 0000000000..798a180d06 Binary files /dev/null and b/app/src/main/res/raw/mc_launch_01.wav differ diff --git a/app/src/main/res/raw/mc_modal_close_01.wav b/app/src/main/res/raw/mc_modal_close_01.wav new file mode 100755 index 0000000000..31abba3148 Binary files /dev/null and b/app/src/main/res/raw/mc_modal_close_01.wav differ diff --git a/app/src/main/res/raw/mc_modal_open_01.wav b/app/src/main/res/raw/mc_modal_open_01.wav new file mode 100755 index 0000000000..a0264a2d32 Binary files /dev/null and b/app/src/main/res/raw/mc_modal_open_01.wav differ diff --git a/app/src/main/res/raw/mc_notify_01.wav b/app/src/main/res/raw/mc_notify_01.wav new file mode 100755 index 0000000000..d76d26b995 Binary files /dev/null and b/app/src/main/res/raw/mc_notify_01.wav differ diff --git a/app/src/main/res/raw/mc_open_01.wav b/app/src/main/res/raw/mc_open_01.wav new file mode 100755 index 0000000000..ecf4ffdc03 Binary files /dev/null and b/app/src/main/res/raw/mc_open_01.wav differ diff --git a/app/src/main/res/raw/mc_resume_01.wav b/app/src/main/res/raw/mc_resume_01.wav new file mode 100755 index 0000000000..b05cb5973f Binary files /dev/null and b/app/src/main/res/raw/mc_resume_01.wav differ diff --git a/app/src/main/res/raw/mc_saved_01.wav b/app/src/main/res/raw/mc_saved_01.wav new file mode 100755 index 0000000000..facb8121ff Binary files /dev/null and b/app/src/main/res/raw/mc_saved_01.wav differ diff --git a/app/src/main/res/raw/mc_select_01.wav b/app/src/main/res/raw/mc_select_01.wav new file mode 100755 index 0000000000..d73ac500ad Binary files /dev/null and b/app/src/main/res/raw/mc_select_01.wav differ diff --git a/app/src/main/res/raw/mc_slider_01.wav b/app/src/main/res/raw/mc_slider_01.wav new file mode 100755 index 0000000000..90d9bf46f7 Binary files /dev/null and b/app/src/main/res/raw/mc_slider_01.wav differ diff --git a/app/src/main/res/raw/mc_slider_02.wav b/app/src/main/res/raw/mc_slider_02.wav new file mode 100755 index 0000000000..975c964b8f Binary files /dev/null and b/app/src/main/res/raw/mc_slider_02.wav differ diff --git a/app/src/main/res/raw/mc_slider_03.wav b/app/src/main/res/raw/mc_slider_03.wav new file mode 100755 index 0000000000..0ae3c8e1b0 Binary files /dev/null and b/app/src/main/res/raw/mc_slider_03.wav differ diff --git a/app/src/main/res/raw/mc_tab_01.wav b/app/src/main/res/raw/mc_tab_01.wav new file mode 100755 index 0000000000..fb63ab1c7e Binary files /dev/null and b/app/src/main/res/raw/mc_tab_01.wav differ diff --git a/app/src/main/res/raw/mc_toggle_01.wav b/app/src/main/res/raw/mc_toggle_01.wav new file mode 100755 index 0000000000..4b544ca111 Binary files /dev/null and b/app/src/main/res/raw/mc_toggle_01.wav differ diff --git a/app/src/main/res/raw/mc_toggle_02.wav b/app/src/main/res/raw/mc_toggle_02.wav new file mode 100755 index 0000000000..a5a7f28c7e Binary files /dev/null and b/app/src/main/res/raw/mc_toggle_02.wav differ diff --git a/app/src/main/res/raw/mc_toggle_03.wav b/app/src/main/res/raw/mc_toggle_03.wav new file mode 100755 index 0000000000..d57a6ea7b5 Binary files /dev/null and b/app/src/main/res/raw/mc_toggle_03.wav differ diff --git a/app/src/main/res/raw/mc_welcome_01.wav b/app/src/main/res/raw/mc_welcome_01.wav new file mode 100755 index 0000000000..63c5875e82 Binary files /dev/null and b/app/src/main/res/raw/mc_welcome_01.wav differ diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index ba8d037136..e758ca33c9 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -46,11 +46,6 @@ Извън линия Преглед на всички приложения Раздвояване - Moonlight тества мрежовата връзка, за да определи дали някой от необходимите портове не е блокиран.\n\nТова може да отнеме няколко секунди… - Вашата мрежа изглежда не блокира Moonlight. Ако все още имате проблеми със свързването, проверете настройките на защитната стена на компютъра си.\n\nАко се опитвате да осъществите достъп през Интернет, инсталирайте „Moonlight Internet Hosting Tool“ на компютъра си и стартирайте включения тестер за достъп през Интернет, за да проверите връзката на компютъра си с Интернет. - Мрежовият тест не може да бъде извършен, защото нито един от сървърите за проверка на връзката на Moonlight не е достъпен. Проверете връзката си с Интернет или опитайте отново по-късно. - Текущата мрежова връзка на устройството изглежда блокира Moonlight. Достъпът през Интернет може да не работи, докато компютърът е свързан към тази мрежа.\n\nСледните мрежови портове са блокирани:\n - Текущата мрежова връзка на устройството блокира Moonlight. Достъпът през Интернет може да не работи, докато компютърът е свързан към тази мрежа. Моля, въведете следния ПИН на избрания компютър: Неуспешно сдвояване Сдвояването вече е в ход @@ -63,7 +58,6 @@ Видео декодерът не успя да се инициализира. Вашето устройство може да не поддържа избраната резолюция или честота на кадрите. Услугата ComputerManager не работи. Моля, изчакайте няколко секунди или рестартирайте приложението. GFE върна грешка HTTP 404. Уверете се, че видео картата на компютъра се поддържа. Използването на софтуер за отдалечен работен плот също може да причини тази грешка. Опитайте да рестартирате машината си или да преинсталирате GFE. - Moonlight претърпя срив поради несъвместимост с декодера за видео на това устройство. Опитайте да коригирате настройките за поточно предаване, ако сривовете продължат. Нулиране на видео настройките Видео декодерът на устройството продължава да претърпява сривове при избраните от Вас настройки за поточно предаване. Настройките бяха нулирани до стандартните. Стерео @@ -146,7 +140,6 @@ Настройки на контролера % Замяна на собствената поддръжка на контролери за Xbox - Използване на USB драйвера на Moonlight за всички поддържани контролери, дори ако е налична собствена поддръжка на контролери за Xbox Разменя бутоните в дясната част на контролера – A/B и X/Y – това важи както за контролерите, така и за екранните елементи за управление Отдалеченият компютър не може да бъде намерен Текущата пускова програма не разрешава създаването на преки пътища. @@ -157,7 +150,6 @@ Включва вградения USB драйвер за устройства без собствена поддръжка на контролери за Xbox Настройване на мъртвата зона на аналоговите стикове USB драйвер за контролери за Xbox 360/One - Забележка: някои игри може да налагат по-голяма мъртва зона от тази, която е настроена в Moonlight. Симулиране на мишка чрез контролер Ако на отдалечения компютър работи Sunshine, отворете уеб интерфейса на Sunshine и въведете ПИН кода. Отдалеченият компютър докладва критична грешка при кодиране на видеото.\n\nОпитайте да изключите режима HDR, да промените резолюцията на потока или резолюцията на екрана на отдалечения компютър. @@ -184,7 +176,6 @@ Включване на режима за наблюдаване „картина в картината“ Позволява поточното предаване да бъде наблюдавано (но не и управлявано) докато извършвате други задачи на устройството Език - Езикът, който да ползва Moonlight Използване на малки обложки По-малките обложки в списъка с приложения позволяват да се виждат повече приложения едновременно на екрана Настройки на отдалечения компютър @@ -227,7 +218,6 @@ Вижте инструкции как да настроите компютъра си за поточно предаване на игри Ръководство за отстраняване на проблеми Политика за поверителност - Преглед на политиката за поверителност на Moonlight 360p 480p 720p diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 1947ff11a0..6ddc5c0155 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -13,13 +13,7 @@ \n \nPokud problém přetrvává, zkuste přeinstalovat ovladače GPU a GeForce Experience. Probouzení PC… - Vypadá to, že vaše síť Moonlight neblokuje. Pokud máte i tak potíže s připojením, zkontrolujte nastavení firewallu vašeho PC. -\n -\nPokud se pokoušíte streamovat po internetu, nainstalujte si na svůj počítač Moonlight Internet Hosting Tool a spusťte přiložený program Internet Streaming Tester pro kontrolu vašeho připojení k Internetu. Nezdařilo se zjistit hostitele - Moonlight testuje vaše síťové připojení, aby zjistil, jestli je NVIDIA GameStream zablokován. -\n -\nTo může chvilku zabrat… Testování připojení k síti Párování… Počítač je offline @@ -32,7 +26,6 @@ GFE vrátilo HTTP chybu 404. Ujistěte se, že vaše PC má podporované GPU. Tato chyba může být způsobena i použitím softwaru pro vzdálenou plochu. Zkuste restartovat počítač, nebo přeinstalujte GFE. Dekodér videa spadnul Správce zařízení neumožňuje přístup k USB. Zkontrolujte nastavení Knoxu nebo MDM. - Moonlight spadnul kvůli nekompatibilnímu video dekodéru na tomto zařízení. Ujistěte se, že je GeForce Experience na vašem PC aktualizováno na nejnovější verzi. Zkuste upravit nastavení streamování, pokud aplikace padá opakovaně. Video dekodér vašeho zařízení při současných nastaveních streamování padá. Vaše nastavení streamování byla resetována na výchozí. Nezdařilo se spustit video dekodér. Vaše zařízení nemusí podporovat zvolené rozlišení nebo snímkovou frekvenci. Od hostitele nebylo přijato žádné video. @@ -72,11 +65,9 @@ Povolí zabudovaný USB ovladač pro zařízení bez nativní podpory pro Xbox ovladač Rozvibruje vaše zařízení pro emulaci vibrací ovladače, pokud je váš herní ovladač nepodporuje % - Poznámka: Některé hry mohou vynutit větší deadzone, než jaká je nastavena v Moonlightu. Xbox 360/One USB gamepad ovladač Vynutit nativní podporu herního ovladače Xbox Dlouhým podržením tlačítka Start přepnete herní ovladač do režimu myši - Použít USB ovladač Moonlightu pro všechny podporované gamepady, i když existuje nativní podpora pro Xbox ovladač Emulace myši pomocí herního ovladače Přehodit tlačítka A/B, X/Y Režim myši pro vzdálenou plochu @@ -127,12 +118,6 @@ Otestovat připojení k síti Zobrazit detaily Test sítě dokončen - Test sítě nemohl být proveden, protože se nezdařilo připoojení k žádným z testovacíh serverů Moonlight. Zkontrolujte své připojení k Internetu, nebo to zkuste znovu později. - Současná síť vašeho počítače nejspíš blokuje Moonlight. Streamování přes Internet nemusí během připojení k této síti fungovat. -\n -\nNásledující porty byly zablokovány: -\n - Vaše současné připojení k síti blokuje Moonlight. Streamování přes Internet nemusí fungovat, dokud jste připojeni k této síti. Na počítači momentálně běží hra. Před párováním musíte hru zavřít. Párování Vložte prosím následující PIN na cílovém PC: @@ -191,7 +176,6 @@ Zobrazit informace o zpoždění po streamování Zobrazit informace o zpoždění po skončení streamování Zásady ochrany osobních údajů - Zobrazit zásady ochrany osobních údajů Moonlightu 360p 480p Vyvážené s limitem FPS @@ -233,7 +217,6 @@ Zobrazit pouze L3 a R3 Umožní, aby byl stream sledován (ale ne ovládá) během multitaskingu Jazyk - Jazyk, který bude Moonlight používat Použít malý box art Díky menšímu box artu v seznamu aplikací uvidíte na obrazovce více aplikací naráz Nastavení hostitele diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 8094135082..149e3b8ffc 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -19,19 +19,7 @@ Details anzeigen Netzwerkverbindung wird getestet - Moonlight testet Ihre Netzwerkverbindung, um festzustellen, ob benötigte Ports blockiert sind. -\n -\nDies kann einige Sekunden dauern… Netzwerktest abgeschlossen - Dein Netzwerk scheint Moonlight nicht zu blockieren. Wenn du weiterhin Probleme mit der Verbindung hast, überprüfe die Firewall-Einstellungen deines Hosts -\n -\nWenn du versuchst, über das Internet zu streamen, installiere das Moonlight Internet Hosting Tool auf deinem Host und führe den mitgelieferten Internet-Streaming-Tester aus, um die Internetverbindung des Hosts zu überprüfen. - Der Netzwerktest konnte nicht durchgeführt werden, da keiner der Verbindungstestserver von Moonlight erreichbar war. Überprüfe deine Internetverbindung oder versuche es später noch einmal. - Die aktuelle Netzwerkverbindung deines Geräts scheint Moonlight zu blockieren. Das Streaming über das Internet funktioniert möglicherweise nicht, während du mit diesem Netzwerk verbunden bist. -\n -\nFolgende Netzwerkports wurden blockiert: -\n - Die aktuelle Netzwerkverbindung deines Geräts blockiert Moonlight. Das Streaming über das Internet funktioniert möglicherweise nicht, während du mit diesem Netzwerk verbunden bist. Koppeln… Host ist offline @@ -58,7 +46,6 @@ Hostname konnte nicht aufgelöst werden GFE ist auf einen HTTP 404 Fehler gestoßen. Stelle sicher, dass die GPU deines Hosts unterstützt wird. Die Verwendung von Remote-Desktop Software kann ebenso diesen Fehler verursachen. Starte deinen Host neu oder installiere GFE neu. Video Dekodierer ist abgestürzt - Moonlight ist wegen einer Inkompatibilität zu dem Video Dekodierer deines Gerätes abgestürzt. Sollten weiterhin Abstürze auftreten, ändere deine Streaming Einstellungen. Video Einstellungen zurücksetzen Der Video Dekodierer deines Geräts ist wiederholt mit den ausgewählten Einstellungen abgestürzt. Deine Streamingeinstellungen wurden zurückgesetzt. USB Zugriff ist administrativ unterbunden. Bitte überprüfen sie ihre Knox oder MDM Einstellungen. @@ -149,7 +136,6 @@ Xbox 360/One Controller Treiber Aktiviert den eingebauten USB Treiber für Geräte die keine Xbox-Controller Unterstützung haben Android Controller Unterstützung überschreiben - Erzwingt die Verwendung von Moonlights USB Treiber für alle Xbox kompatiblen Controller Maus Emulation via Controller Langes gedrückt halten der Start-Taste wechselt in den Maus Modus Vor- und Zurück-Tasten aktivieren @@ -176,7 +162,6 @@ Aktivieren des Bild-in-Bild Modus Ermöglicht die Anzeige (aber nicht die Steuerung) des Streams während des Multitasking Sprache - Sprache die Moonlight verwenden soll Verwende kleine Cover Verwende kleine Cover in der Rasteransicht, um mehr gleichzeitig anzeigen zu können Host Einstellungen @@ -249,8 +234,6 @@ Modus für ferngesteuerte Maus Hilfe Einstellungsanleitung - Moonlight\'s Datenschutzbestimmungen anzeigen - Hinweis: Einige Spiele können eine größere Controller-Totzone erzwingen als die, die in Moonlight konfiguriert ist. Ermöglicht das Funktionieren von Audioeffekten beim Streaming, kann aber die Audio-Latenz erhöhen (Portrait) Aktualisierungsrate verringern erlauben diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index e9468b5613..d4f8168996 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -2,11 +2,7 @@ Η τρέχουσα εφαρμογή εκκίνησης δεν επιτρέπει τη δημιουργία καρφιτσωμένων συντομεύσεων. Διαγραφή υπολογιστή - Το δίκτυό σας δεν φαίνεται να μπλοκάρει το Moonlight. Αν εξακολουθείτε να έχετε πρόβλημα σύνδεσης, ελέγξτε τις ρυθμίσεις του τείχους προστασίας του υπολογιστή σας. -\n -\nΕάν προσπαθείτε να κάνετε streaming μέσω του Διαδικτύου, εγκαταστήστε το Moonlight Internet Hosting Tool στον υπολογιστή σας και εκτελέστε το συμπεριλαμβανόμενο Internet Streaming Tester για να ελέγξετε τη σύνδεση του υπολογιστή σας στο Διαδίκτυο. Η υπηρεσία ComputerManager δεν εκτελείται. Περιμένετε μερικά δευτερόλεπτα ή επανεκκινήστε την εφαρμογή. - Το Moonlight κατέρρευσε λόγω ασυμβατότητας με τον αποκωδικοποιητή βίντεο αυτής της συσκευής. Βεβαιωθείτε ότι το GeForce Experience έχει ενημερωθεί στην τελευταία έκδοση στον υπολογιστή σας. Δοκιμάστε να προσαρμόσετε τις ρυθμίσεις ροής, εάν οι καταρρεύσεις συνεχίζονται. Πρόγραμμα προβολής βοήθειας Φόρτωση σελίδας βοήθειας… Σε σύνδεση @@ -17,8 +13,6 @@ Αποστολή αιτήματος Wake-On-LAN Δοκιμή σύνδεσης δικτύου Ολοκληρώθηκε η δοκιμή δικτύου - Η δοκιμή δικτύου δεν μπορούσε να πραγματοποιηθεί επειδή κανένας από τους διακομιστές δοκιμής σύνδεσης της Moonlight δεν ήταν προσβάσιμος. Ελέγξτε τη σύνδεσή σας στο Internet ή δοκιμάστε ξανά αργότερα. - Η τρέχουσα σύνδεση δικτύου της συσκευής σας εμποδίζει το Moonlight. Η ροή μέσω του Internet ενδέχεται να μην λειτουργεί ενώ είστε συνδεδεμένοι σε αυτό το δίκτυο. Σύζευξη… Ο υπολογιστής είναι εκτός σύνδεσης Ο υπολογιστής βρίσκεται αυτή τη στιγμή σε παιχνίδι. Πρέπει να κλείσετε το παιχνίδι πριν κάνετε σύζευξη. @@ -32,10 +26,6 @@ Η παρεχόμενη εφαρμογή δεν είναι έγκυρη Ο παρεχόμενος υπολογιστής δεν είναι έγκυρος Προβολή λεπτομερειών - Η τρέχουσα σύνδεση δικτύου της συσκευής σας φαίνεται να εμποδίζει το Moonlight. Η ροή μέσω του Internet ενδέχεται να μην λειτουργεί ενώ είστε συνδεδεμένοι σε αυτό το δίκτυο. -\n -\nΟι ακόλουθες θύρες δικτύου αποκλείστηκαν: -\n Μέσος λανθάνων χρόνος δικτύου: %1$d MS (διακύμανση: %2$d ms) Απόκρυψη εφαρμογής Λίστα εφαρμογών @@ -88,7 +78,6 @@ Πρόγραμμα οδήγησης για Xbox 360/One USB gamepad Ενεργοποιεί ένα ενσωματωμένο πρόγραμμα οδήγησης USB για συσκευές χωρίς εγγενή υποστήριξη χειριστηρίου Xbox Παράκαμψη εγγενούς υποστήριξης του Xbox gamepad - Χρησιμοποιήστε το πρόγραμμα οδήγησης USB του Moonlight για όλα τα υποστηριζόμενα gamepad, ακόμα και αν υπάρχει υποστήριξη εγγενούς ελεγκτή Xbox Εξομοίωση ποντικιού μέσω gamepad Ενεργοποίηση κουμπιών ποντικιού πίσω και εμπρός Η ενεργοποίηση αυτής της επιλογής μπορεί να διακόψει το δεξί κλικ σε ορισμένες συσκευές με σφάλματα @@ -107,15 +96,10 @@ Ενεργοποίηση της λειτουργίας παρατηρητή εικόνας μέσα σε εικόνα Επιτρέπει την προβολή της ροής (αλλά όχι τον έλεγχο) κατά την εκτέλεση πολλαπλών εργασιών Γλώσσα - Γλώσσα που χρησιμοποιείται για το Moonlight Χρησιμοποιήστε μικρά κουτιά Ο υπολογιστής δεν έχει συζευχθεί Εκτός σύνδεσης Δοκιμή σύνδεσης δικτύου - Το Moonlight δοκιμάζει τη σύνδεση δικτύου σας για να διαπιστώσει εάν κάποια θύρα είναι αποκλεισμένη. -\n -\n -\nΑυτό μπορεί να πάρει μερικά δευτερόλεπτα… Η σύζευξη απέτυχε Δεν είναι δυνατή η αφύπνιση του υπολογιστή επειδή το GFE δεν έστειλε διεύθυνση MAC Απέτυχε η αποστολή πακέτων Wake-On-LAN diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 348513519c..3107f8ff45 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -95,7 +95,6 @@ Ocultar todo excepto L3 y R3 Configuración de la interfaz Idioma - Idioma a usar por Moonlight Usar iconos pequeños Usar iconos pequeños en las entradas de cuadrículas para permitir más entradas en la pantalla Configuración de servidor @@ -119,23 +118,11 @@ Probar Conexión de Red Ver Detalles Probando Conexión de Red - Moonlight está probando tu conexión a la red para determinar si algún puerto necesario está bloqueado. -\n -\nEsto puede tardar unos segundos… - La prueba de red no pudo ejecutarse debido a que no se pudo alcanzar ningún servidor de prueba de conexión de Moonlight. Revisa tu conexión a internet o prueba nuevamente mas tarde. - La actual conexión de red de tu dispositivo parece estar bloqueando Moonlight. La Transmisión mediante Internet puede no funcionar mientras estes conectado a esta red. -\n -\nSe han bloqueado los siguientes puertos de red: -\n Falló el Decodificador de Video PC borrado PC no encontrado Cargando página de ayuda… Fuera de línea - Tu red parece no estar bloqueando Moonlight. Si aun tienes problemas de conexión, comprueba la configuración del firewall de tu PC. -\n -\nSi estas intentado transmitir mediante Internet, instala en tu pc la Moonlight Internet Hosting Tool y ejecuta el Internet Streaming Tester incluido para comprobar la conexión de tu PC a internet. - La conexión de red actual de tu dispositivo esta bloqueando Moonlight. La Transmisión mediante Internet puede no funcionar mientras estés conectado a esta red. Emparejamiento ya en progreso Pantalla Completa Nativa Configuración de Sonido Envolvente @@ -174,7 +161,6 @@ Estéreo Revisa la configuración de tu firewall, así como, las reglas de redireccionamiento para él(los) puerto(s): El acceso a USB está restringido en tu dispositivo por el administrador. Revisar tus ajustes en MDM o Knox. - Moonlight se ha bloqueado debido a una incompatibilidad con el descodificador de vídeo de este dispositivo. Intenta ajustar la configuración de streaming si los bloqueos continúan. La decodificación de video de tu equipo sigue fallando con las opciones elegidas. Las opciones de la transmisión han sido revertidas a las predeterminadas. El descodificador de video no pudo ser iniciado. Tu dispositivo puede no soportar la resolución elegida o la tasa de cuadros por segundo. La conexión de red no está teniendo un buen desempeño. Reduce la tasa de bits de video o prueba con una conexión de mayor velocidad. @@ -201,7 +187,6 @@ Activar sonido envolvente de 5.1 o 7.1 canales para sistemas de teatro en casa Emular la vibración del mando con vibración del equipo Permitir funcionar efectos de sonido durante la transmisión, pero podría incrementar la latencia del sonido - Usar el controlador de USB de Moonlight para todos los mandos soportados, incluso si el control de Xbox se encuentra presente Presionar por un periodo largo el botón de inicio hará cambiar el mando a modo de ratón Activar los botones atrás y adelante del ratón Desbloquear todas las opciones de tasa de cuadros disponibles @@ -218,7 +203,6 @@ Opciones de Entrada Usa la pantalla táctil como panel táctil Si está activo, la pantalla táctil funcionará como panel táctil. Si está desactivado, la pantalla táctil directamente controlará el cursor del ratón. - Nota: Algunos juegos pueden forzar una zona muerta mayor a la que Moonlight esta configurada a usar. Invertir botones frontales Esto puede hacer que la aceleración de mouse se comporte mas natural para uso de escritorio remoto, pero es incompatible con muchos juegos. Activar el soporte para ecualizador de sistema @@ -245,7 +229,6 @@ Ayuda Guía para la Configuración Mostrar instrucciones para como configurar tu PC para juegos para la transmisión - Mostrar la política de privacidad de Moonlight 480p 30 FPS 60 FPS diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index f8a3ad7ed7..e85694d1e1 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -18,19 +18,7 @@ Voir les détails Test de la connexion réseau - Moonlight teste votre connexion réseau pour déterminer si les ports requis sont bloqués. -\n -\nCela peut prendre quelques secondes… Test du réseau terminé - Votre réseau ne semble pas bloquer Moonlight. Si vous avez encore des difficultés à vous connecter, vérifiez les paramètres du pare-feu de votre PC. -\n -\nSi vous essayez de streamer depuis Internet, installez l\'outil Moonlight Internet Hosting Tool sur votre PC et exécutez le testeur de streaming Internet inclus pour vérifier la connexion Internet de votre PC. - Le test réseau n\'a pas pu être effectué car aucun des serveurs de test de connexion de Moonlight n\'était accessible. Vérifiez votre connexion Internet ou réessayez plus tard. - La connexion réseau actuelle de votre appareil semble bloquer Moonlight. Le streaming sur Internet peut ne pas fonctionner lorsque vous êtes connecté à ce réseau. -\n -\nLes ports réseau suivants ont été bloqués : -\n - La connexion réseau actuelle de votre appareil bloque Moonlight. Le streaming sur Internet peut ne pas fonctionner lorsque vous êtes connecté à ce réseau. Appariement… L\'ordinateur est hors ligne @@ -57,7 +45,6 @@ Échec de la résolution de l\'hôte GFE renvoie une erreur HTTP 404. Assurez-vous que votre PC possède un GPU pris en charge. L\'utilisation d\'un logiciel de bureau à distance peut également provoquer cette erreur. Essayez de redémarrer votre machine ou de réinstaller GFE. Le décodeur vidéo a planté - Moonlight a planté en raison d\'une incompatibilité avec le décodeur vidéo de cet appareil. Essayez d\'ajuster les paramètres de streaming si les plantages continuent. Réinitialiser les paramètres vidéo Le décodeur vidéo de votre appareil continue de planter avec les paramètres de streaming sélectionnés. Vos paramètres de streaming ont été réinitialisés à ceux par défaut. L\'accès USB est interdit par l\'administrateur de votre appareil. Vérifiez vos paramètres Knox ou MDM. @@ -145,7 +132,6 @@ Pilote USB pour manette Xbox 360/One Active un pilote USB intégré pour les appareils qui ne prennent pas nativement en charge les manettes Xbox Remplacer le support natif des manettes Xbox - Utiliser le pilote USB de Moonlight pour toutes les manettes prises en charge, même si l\'appareil supporte nativement celles-ci Émuler la souris avec la manette Appuyez longuement sur le bouton Start pour faire basculer la manette en mode souris Activer les boutons de la souris arrière et avant @@ -170,7 +156,6 @@ % Paramètres de l\'interface utilisateur Langue - Langue à utiliser pour Moonlight Utiliser des petites icônes Des icônes plus petites permettent d\'afficher plus d\'applications en même temps Paramètres de l\'hôte @@ -242,12 +227,10 @@ Guide de configuration Guide de dépannage Politique de confidentialité - Voir la politique de confidentialité de Moonlight Voir les instructions sur la façon de configurer votre PC de jeu pour le streaming Voir des conseils pour diagnostiquer et résoudre les problèmes de streaming courants Équilibré avec limite FPS Mode souris pour bureau à distance - Remarque : Certains jeux peuvent imposer une zone morte plus grande que celle que Moonlight est configuré pour utiliser. Cela peut rendre l\'accélération de la souris plus naturelle pour l\'utilisation du bureau à distance, mais il est incompatible avec de nombreux jeux. (Paysage) (Portrait) diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 07a8758d4a..574a87dba1 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -22,7 +22,6 @@ Gazdagép beállítások Az alkalmazásrács kisebbik rajza lehetővé teszi, hogy több alkalmazás is látható legyen a képernyőn Kis dobozos rajzok használata - A Moonlight-hoz használt nyelv Nyelv Lehetővé teszi a stream megtekintését (de nem vezérlését) miközben mást csinálsz Kép a képben megfigyelő mód engedélyezése @@ -49,7 +48,6 @@ Az előre és hátra gombok engedélyezése A Start gomb hosszan tartó megnyomásával egér üzemmódba kapcsol a gamepad Az egér emulációja a gamepadon keresztül - A Moonlight USB-illesztőprogramjának használata az összes támogatott játékvezérlőhöz, még akkor is, ha natív Xbox-vezérlő támogatás van jelen Natív Xbox játéktámogatás felülbírálása Beépített USB-illesztőprogramot engedélyez natív Xbox-vezérlő támogatás nélküli eszközökhöz Xbox 360/One USB gamepad illesztőprogram @@ -136,7 +134,6 @@ Az USB-hozzáférést az eszköz rendszergazdája tiltja. Ellenőrizze a Knox vagy az MDM beállításait. Az eszköz videodekóderje továbbra is összeomlik a kiválasztott adatfolyam-beállításoknál. A streamelési beállítások vissza lettek állítva az alapértelmezettre. Videóbeállítások visszaállítása - A Moonlight összeomlott az eszköz videódekóderével való inkompatibilitás miatt. Próbáld meg módosítani a streamelési beállításokat, ha az összeomlások továbbra is fennállnak. A videó dekóder összeomlott A GFE HTTP 404 hibát adott vissza. Győződj meg arról, hogy a számítógépen támogatott GPU fut. A távoli asztali szoftver használata szintén ezt a hibát okozhatja. Próbáld újraindítani a gépet, vagy telepítsd újra a GFE-t. Csatlakozási hiba @@ -162,12 +159,7 @@ Kérjük, adja meg a következő PIN-kódot a cél számítógépen: A számítógép jelenleg játékban van. A párosítás előtt be kell zárni a játékot. A számítógép nem elérhető - Az eszköz jelenlegi hálózati kapcsolata blokkolja a Moonlight-ot. Előfordulhat, hogy az interneten keresztüli streamelés nem működik, ha csatlakozik ehhez a hálózathoz. - Úgy tűnik, hogy az eszköz jelenlegi hálózati kapcsolata blokkolja a Moonlight-ot. Előfordulhat, hogy az interneten keresztüli streamelés nem működik, ha csatlakozik ehhez a hálózathoz.\n\nA következő hálózati portokat blokkolták:\n - A hálózati tesztet nem sikerült elvégezni, mert a Moonlight egyik kapcsolat tesztelő szervere sem volt elérhető. Ellenőrizd az internetkapcsolatot, vagy próbáld újra később. - Úgy tűnik, a hálózatod nem blokkolja a Moonlightot. Ha továbbra is problémáid vannak a csatlakozással, ellenőrizd a számítógép tűzfalbeállításait.\n\nHa az interneten keresztül szeretnél streamelni, telepítsd a Moonlight Internet Hosting Toolt a számítógépedre, és futtassd a mellékelt Internet Streaming Testert a számítógép internetkapcsolatának ellenőrzéséhez. Hálózati kapcsolat tesztelése befejeződött - A Moonlight teszteli a hálózati kapcsolatot, hogy megállapítsa, nem blokkoltak-e szükséges portokat.\n\nEz eltarthat néhány másodpercig… Hálózati kapcsolat tesztelése Hálózati kapcsolat tesztelése Részletek megtekintése @@ -196,7 +188,6 @@ Natív FPS figyelmeztetés Kiegyensúlyozott Ez részletvesztést okoz a világos és sötét területeken, ha a készülék nem jeleníti meg megfelelően a teljes színtartományú videotartalmat. - A Moonlight adatvédelmi szabályzatának megtekintése 4K Simább videó előnyben részesítése (jelentősen növelheti a késleltetést) 120 FPS @@ -220,7 +211,6 @@ 90 FPS Lehetővé teszi a támogatott gazdagépek számára, hogy mozgásérzékelős adatokat kérjenek, amikor mozgásérzékelőkkel ellátott gamepadot emulálnak. A letiltás némileg csökkentheti az energia- és hálózathasználatot, ha a játékban nem használnak mozgásérzékelőket. 1080p - Megjegyzés: Egyes játékok nagyobb holtteret kényszeríthetnek ki, mint amire a Moonlight be van állítva. Sztereó H.264 előnyben részesítése Adatvédelmi irányelvek diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 09a4235ee3..c7b8620d88 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -14,15 +14,6 @@ Kirim permintaan Wake-On-LAN Hapus komputer Menguji koneksi jaringan - Moonlight sedang menguji koneksi jaringan untuk mencari tahu apakah jalur diblokir. -\n -\nIni mungkin membutuhkan beberapa saat… - Uji koneksi jaringan tidak dapat dilakukan karena tidak ada server pengujian Moonlight yang bisa dijangkau. Cek koneksi Internet Anda atau coba lagi nanti. - Koneksi jaringan perangkat anda sepertinya memblokir Moonlight. Streaming lewat Internet mungkin tidak bekerja ketika terhubung dengan jaringan ini. -\n -\nPort-port ini telah diblokir: -\n - Koneksi jaringan perangkat anda memblokir Moonlight. Streaming lewat Internet mungkin tidak bekerja ketika terhubung dengan jaringan ini. Komputer tidak tersedia Memasang Mohon isi PIN ini di komputer target: @@ -38,7 +29,6 @@ Gagal memutuskan pemasangan Komputer tidak tersedia Dekoder video berhenti bekerja - Moonlight telah berhenti bekerja dikarenakan inkompatibilitas dengan dekoder video perangkat ini. Coba ganti pengaturan stream jika masih berlanjut. Pengaturan video dipulihkan Dekoder video perangkat Anda terus-menerus berhenti pada pengaturan stream yang dipilih. Pengaturan stream Anda telah dipulihkan ke bawaan. Akses USB dilarang oleh Administrator perangkat Anda. Cek pengaturan Knox atau MDM Anda. @@ -75,9 +65,6 @@ Lihat rincian Uji jaringan selesai Memasang… - Jaringan Anda sepertinya tidak memblokir Moonlight. Jika Anda masih mengalami masalah menghubungkan, cek pengaturan firewall komputer Anda. -\n -\nJika anda mencoba stream lewat internet, install Moonlight Internet Hosting Tool pada komputer anda dan jalankan Internet Streaming Tester untuk mengecek koneksi Internet komputer Anda. Komputer sedang ada di game. Anda harus menutupnya terlebih dahulu. Gagal mengirim paket Wake-On-LAN Perangkat tidak terpasang @@ -157,7 +144,6 @@ Xbox 360/Satu driver gamepad USB Mengaktifkan driver USB bawaan untuk perangkat tanpa dukungan pengontrol Xbox asli Ganti dukungan gamepad Xbox asli - Gunakan driver USB Moonlight untuk semua gamepad yang didukung, meskipun ada dukungan pengontrol Xbox asli Aktifkan tombol mouse mundur dan maju Mode mouse desktop jarak jauh Tampilkan hamparan pengontrol virtual di layar sentuh @@ -193,13 +179,11 @@ Peringatan Resolusi Asli Mode resolusi asli dan/atau FPS mungkin tidak didukung oleh server streaming, Anda mungkin perlu untuk mengatur custom display mode yang tempat untuk host PC secara manual.\n\nJika Anda memilih untuk membuat resolusi khusus di Panel Kontrol NVIDIA agar sesuai dengan resolusi perangkat Anda, pastikan Anda telah membaca dan memahami peringatan NVIDIA terkait kemungkinan kerusakan monitor, ketidakstabilan PC, dan potensi masalah lainnya.\n\nKami tidak bertanggung jawab atas masalah apa pun yang diakibatkan oleh pembuatan resolusi khusus pada PC Anda.\n\nTerakhir, perangkat atau PC host Anda mungkin tidak mendukung streaming pada resolusi asli. Jika tidak berfungsi pada perangkat Anda, sayangnya Anda kurang beruntung. Aktifkan suara surround 5.1 atau 7.1 untuk sistem home theater - Catatan: Beberapa game dapat menerapkan zona mati yang lebih besar daripada yang dikonfigurasi untuk digunakan oleh Moonlight. Emulasi mouse melalui gamepad Menekan lama tombol Mulai akan mengalihkan gamepad ke mode mouse Hapus tata letak kontrol di layar yang disimpan Mengaktifkan opsi ini dapat merusak klik kanan pada beberapa perangkat bermasalah Aktifkan mode pengamat Gambar-dalam-Gambar - Bahasa yang digunakan untuk Moonlight Izinkan GFE mengubah setelan game untuk streaming yang optimal Kode galat: Setelan Gamepad diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 467d4ceff4..fe72b5d28e 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -42,7 +42,6 @@ Usare un software di desktop remoto può causare questo errore. Prova a riavviare il PC o a reinstallare GFE. Il decodificatore video ha smesso di funzionare - Moonlight ha smesso di funzionare per colpa di un problema al decodificatore video di questo dispositivo. Prova a modificare le impostazioni di stream se il problema persiste. Ripristino impostazioni video Il decodificatore video del dispositivo continua a funzionare in modo anomalo con le impostazioni di streaming selezionate. Le impostazioni di streaming sono state ripristinate ai valori predefiniti. L\'accesso USB è vietato dall\'amministratore del dispositivo. Verifica le impostazioni Knox o MDM. @@ -108,7 +107,6 @@ Driver del controller Xbox 360/One Abilita un driver USB integrato per dispositivi senza supporto al controller Xbox nativo Sovrascrivi il supporto ai controller su Android - Forza i driver USB di Moonlight di assumere il controllo su tutti i controller Xbox supportati Emulazione del mouse tramite controller Tenendo premuto il pulsante Start, il controller passerà alla modalità mouse Impostazioni dei controlli a schermo @@ -123,7 +121,6 @@ I controlli a schermo sono stati ripristinati Impostazioni dell\'interfaccia Lingua - Lingua da usare in Moonlight Usa icone piccole Usa icone piccole nella griglia per avere più oggetti a schermo Impostazioni del PC host @@ -162,14 +159,7 @@ Tempo medio di decodifica: %1$.2f ms Flusso video: %1$s %2$.2f FPS Prova della Connessione di Rete - Moonlight sta testando la tua connessione di rete per controllare se ogni porta richiesta sia bloccata. -\n -\nPotrebbero volerci alcuni secondi… Test della Rete Completato - Il test della rete non è stato effettuato perché nessuno dei server per il test della connessione di Moonlight era raggiungibile. Controlla la tua connessione ad Internet o riprova più tardi. - La tua rete non sembra bloccare Moonlight. Se hai ancora problemi di connessione, controlla le impostazioni del firewall del PC. -\n -\nSe stai cercando di trasmettere via Internet, installa il Moonlight Internet Hosting Tool sul tuo PC ed avvia il Test per la trasmissione via Internet incluso per controllare la connessione ad Internet del tuo PC. Abilita vibrazione Configurazione suono surround Usa lo schermo touch come un trackpad @@ -214,11 +204,6 @@ Bilanciamento frame video Specifica come bilanciare il ritardo video e la fluidità Mostra informazioni real time sulla trasmissione - Sembra che l\'attuale connessione di rete del tuo dispositivo stia bloccando Moonlight. Trasmettere via Internet potrebbe non funzionare mentre sei connesso a questa rete. -\n -\nQueste porte di rete sono bloccate: -\n - L\'attuale connessione di rete del tuo dispositivo sta bloccando Moonlight. Trasmettere via Internet potrebbe non funzionare mentre sei connesso a questa rete. Il tuo launcher non permette la creazione di scorciatoie appuntate. Il decoder video non è riuscito ad inizializzarsi. Il tuo dispositivo potrebbe non supportare la risoluzione o il frame rate selezionati. Controlla il tuo firewall e le regole di inoltro delle seguenti porte: @@ -244,14 +229,12 @@ Aiuto Guida configurazione Bilanciato con il limite FPS - Nota: Alcuni giochi possono imporre una deadzone più grande di quella che Moonlight è configurato per utilizzare. Modalità mouse desktop remoto Questo può rendere l\'accelerazione del mouse più naturale per l\'utilizzo del desktop remoto, ma è incompatibile con molti giochi. Visualizza le istruzioni su come configurare il tuo PC da gaming per lo streaming Guida alla risoluzione dei problemi Informativa sulla privacy Visualizza i suggerimenti per la diagnosi e la risoluzione dei problemi di streaming più comuni - Visualizza l\'informativa sulla privacy di Moonlight Il PC host ha segnalato un errore fatale di codifica video. \n \nProva a disabilitare la modalità HDR, modificare la risoluzione dello streaming o la risoluzione del display del tuo PC host. diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index ec17472f1f..9b1469054b 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -6,9 +6,6 @@ פעיל הסר אימות הראה פרטים - מונלייט בודק את הרשת שלך ובודק אם ישנם פורטים הכרחיים חסומים -\n -\nהמתן כמה שניות… לא מצליח להעיר את המחשב, אין כתובת MAC מחשב לא נמצא עזרה @@ -24,12 +21,6 @@ סוף פעילות שירות NVIDIA GameStream בודק חיבור רשת בדיקת רשת הושלמה - בדיקת הרשת לא התבצעה בגלל שאין קישוריות לשרתי הבדיקה של מונלייט. בבקשה בדוק את החיבור לאינטרנט או נסה שוב מאוחר יותר. - גישה של מונלייט לרשת חסומה במכשיר הנוכחי. הזרמה על גבי האינטרנט לא יכוה לעבוד כל עוד מונלייט מחובר לרשת הנוכחית. -\n -\nהפורטים הבאים חסומים: -\n - חיבור הרשת הנוכחי חוסם את מונלייט. הזרמה על גבי האינטרנט אינה אפשרית כל עוד מונלייט מחובר לרשת הנוכחית. מחשב לא פעיל מחבר אנא הזן את הקוד PIN הבא במחשב המארח: @@ -72,9 +63,6 @@ אזהרת רזולוצייה טבעית פריימים לשניה ייקחו כמה שניות להפעיל את המחשב. אם הוא לא, אנא וודא שהוא מוגדר נכון לפונקציית Wake-On-LAN. - הרשת שלך איננה חוסמת את מונלייט, בבקשה בדוק את הגדרות חומת האש במחשב המארח. -\n -\nאם אתה מנסה להזרים דרך האינטרנט, אנא התקן את הכל Moonlight Internet Hosting Tool על המחשב והפעל את כלי הבדיקה המצורף בכדי לבדוק את חיבור האינטרנט. מאמת… מחשב באמצע משחק. נא לצאת מהמשחק לפני ביצוע חיבור. פעולת החיבור מתבצעת @@ -83,7 +71,6 @@ הגדרות וידיאו אופסו GFE החזיר שגיאת 404. אנא וודא שהמחשב עם כרטיס מסך נתמך. שימוש בתוכנת חיבור מרוחק יכולה לגרום לשגיאה זו. נסה להפעיל מחדש את המחשב או להתקין GFE. גישה למכשירי USB נעולה על ידי מנהל המחשב. בדוק הגדרות knox או MDM. - מונלייט קרס עקב אי-תאימות עם המפענח. נסה לשנות את הגדרות ההזרמה אם הקריסות נמשכות. המפענח ממשיך לקרוס בהגדרות הנוכחיות. הגדרות ההזרמה אופסו לברירת המחדל. נכשלה טעינת מפענח הוידיאו. המכשיר שלך כנראה אינו תומך ברזולוציה או פריימים לשניה שנבחרו. חיבור הרשת של אינו מתפקד כראוי. הורד את הגדרות ה-Bitrate או נסה חיבור מהיר יותר. diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index c5efc5fc04..bb8405dcf4 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -27,7 +27,7 @@ このデバイスはペアリングされませんでした コンピュータはオフラインです - ComputerManagerサービスが動作していません。しばらく待つか、Moonlightを再起動してください + ComputerManagerサービスが動作していません。しばらく待つか、MoonCastを再起動してください ホストが解決できません GFEがHTTP 404を返しました。コンピュータでサポートされているGPUが動作しているか確認してください。リモートデスクトップソフトウェアでもこのエラーが発生する可能性があります。コンピュータを再起動するか、GFEを再インストールしてください @@ -91,7 +91,6 @@ タッチスクリーン上にオンスクリーンコントローラを表示します インターフェース 言語 - Moonlightで使用する言語 小さなアイコン グリッドで小さなアイコンを使用します ホスト @@ -121,30 +120,17 @@ 指定したコンピュータは有効ではないです 指定したアプリは無効です ヘルプページを読み込み中… - ネットワークは Moonlight をブロックしていません。接続に問題があるようでしたらコンピュータのファイアーウォールの設定を確認してください。 -\n -\nインターネット上でゲームをストリームする場合は Moonlight Internet Hosting Tool をインストールして付属のInternet Streaming Tester をはしらせコンピュータの接続を確認してください。 - Moonlight のコネクションテスト用のサーバーがアクセスできなかったためネットワークテストができませんでした。インターネットの接続を確認し、再度お試しください。 ヘルプ表示 リロード中 コンピュータを検知しました コンピュータが見つかりませんでした オフライン NVIDIA GameStream はサービスが終了しています - Moonlight はポートがブロックされていないか確かめるためネットワーク接続をてすとしています。 -\n -\n少々お待ちください… オンライン ネットワーク接続テスト ネットワークテスト終了 詳細を表示 ネットワーク接続テスト中 - デバイスの設定が Moonlight をブロックしているようです。このネットワークに接続している間ストリーミングが使えないかもしれません。 - このデバイスのビデオデコーダーが Moonlight と非対応なため Moonlight がクラッシュしました。クラッシュが継続するようでしたらストリームの設定を変更してください。 - デバイスの設定が Moonlight をブロックしているようです。このネットワークに接続している間ストリーミングが使えないかもしれません。 -\n -\n次のネットワークポートがブロックされています: -\n ビデオデコーダーがクラッシュしました ビデオ設定リセット ホーストのコンピューターが Sunshine を使用している場合、Sunshineにピンを入れてください。 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 54ba7d0929..a2df5e0371 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -102,7 +102,6 @@ 터치스크린에 가상 컨트롤러 오버레이를 표시합니다 UI 설정 언어 - Moonlight에서 사용할 언어를 선택합니다 작은 아이콘 사용 한 화면에서 더 많은 게임을 볼 수 있게 게임을 더 작게 표시합니다 호스트 설정 @@ -129,7 +128,7 @@ \n \n화면 설정과 일치하도록 NVIDIA 제어판에서 사용자 정의 해상도를 생성 할 경우 모니터 손상, PC 불안정 및 기타 잠재적인 문제에 대한 NVIDIA의 경고를 읽고 이해했는지 확인하십시오. \n -\nMoonlight는 PC에서 사용자 지정 해상도를 생성하여 발생하는 문제에 대해 책임을 지지 않습니다. +\nMoonCast는 PC에서 사용자 지정 해상도를 생성하여 발생하는 문제에 대해 책임을 지지 않습니다. \n \n모니터가 필요한 디스플레이 구성을 지원하지 않을 경우 가상 모니터 설정을 시도할 수 있습니다. 장치나 호스트 PC가 특정 해상도나 새로 고침 빈도로 스트리밍을 지원하지 않을 수 있습니다. 네이티브 해상도 주의사항 @@ -159,21 +158,8 @@ 기기 관리자에 의해 USB 액세스가 차단되었습니다. Knox 또는 MDM 설정을 확인하십시오. 선택한 스트리밍 설정에서 장치의 비디오 디코더가 계속 문제가 발생합니다. 스트리밍 설정이 기본값으로 재설정되었습니다. 비디오 설정 초기화 - 이 장치의 비디오 디코더와 호환되지 않아 Moonlight에서 오류가 발생했습니다. 충돌이 계속되면 스트리밍 설정을 조정 해보십시오. 비디오 디코더 오류 - 기기의 현재 네트워크가 Moonlight를 차단하고 있습니다. 이 네트워크에 연결되어 있으면 인터넷을 통한 스트리밍이 작동하지 않을 수 있습니다. - 기기의 현재 네트워크가 Moonlight를 차단하는 것 같습니다. 이 네트워크에 연결되어 있으면 인터넷을 통한 스트리밍이 작동하지 않을 수 있습니다. -\n -\n차단된 네트워크 포트: -\n - Moonlight의 테스트 서버에 연결할 수 없기 때문에 네트워크 테스트를 할 수 없습니다. 인터넷 연결을 확인하거나 나중에 다시 시도하십시오. - 네트워크가 Moonlight를 차단하지 않는 것 같습니다. 여전히 연결에 문제가 있으면 PC의 방화벽 설정을 확인하십시오 -\n -\n인터넷을 통해 연결하려는 경우 PC에 Moonlight Internet Hosting Tool을 설치하고 Internet Streaming Tester를 실행하여 PC의 인터넷 연결을 확인하십시오. 네트워크 연결 테스트 완료 - Moonlight는 필요한 포트가 차단되었는지 확인하기 위해 네트워크 연결을 테스트하고 있습니다. -\n -\n잠시 기다려 주세요… 상태 확인중 네트워크 연결 테스트 오프라인 @@ -213,7 +199,6 @@ 이 옵션을 활성화하면 일부 장치에서 오른쪽 클릭에 문제가 있을 수 있습니다 마우스 사이드버튼 활성화 패드의 Start 버튼을 길게 누르면 컨트롤러가 마우스 모드로 전환됩니다 - Xbox 컨트롤러를 기본으로 지원하는 경우에도 지원되는 모든 컨트롤러에 Moonlight의 USB 드라이버를 사용합니다 기본 Xbox 컨트롤러 지원 무시 컨트롤러를 마우스처럼 사용하기 비디오 스트림 : %1$s %2$.2f FPS @@ -222,7 +207,6 @@ 자동 (권장) HEVC 선호 - 참고: 일부 게임은 이 설정값보다 더 큰 데드존이 적용되있을 수 있습니다. 1080p 360p 720p @@ -238,7 +222,6 @@ 문제 해결 가이드 일반적인 스트리밍 문제 진단 및 해결을 위한 팁 보기 개인 정보 정책 - Moonlight의 개인 정보 보호 정책 보기 비디오 지연 시간과 부드러움의 균형을 맞추는 방법 설정 최저 지연 시간 선호 균형 diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index a2ea87fa52..9f6a692155 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -55,7 +55,6 @@ Spill lyd på PC Optimaliser spillinnstillinger Vertsinnstillinger - Språk brukt i Moonlight Språk Grensesnittsinnstillinger % @@ -134,15 +133,8 @@ Vibrasjonsemulering Får pekeskjermen til å oppføre seg som en pekeflate. Ellers kontrolleres pekeskjermen musen direkte. Tilbakestilling av videoinnstillinger - Moonlight har krasjet som følge av inkompatibilitet med denne enhetens videodekoder. Prøv å justere strømmingsinnstillingene hvis problemet vedvarer. GFE returnerte en HTTP-404-feil. Forsikre deg om at PC-en kjører et støttet skjermkort. Bruk av fjernskrivebordsprogramvare kan også forårsake denne feilen. Prøv å starte maskinen på ny, eller å reinstallere GFE. ComputerManager-tjenesten kjører ikke. Vent noen sekunder eller start programmet på ny. - Nettverkstilkoblingen på din enhet ser ut til å blokkere Moonlight. Strømming over Internett vil muligens ikke fungere tilkoblet gjennom dette nettverket. - Nettverkstilkoblingen på din enhet ser ut til å blokkere Moonlight. Strømming over Internett vil muligens ikke fungere når tilkoblet gjennom dette nettverket. -\n -\nFølgende nettverksporter ble blokkert: -\n - Nettverkstesten kunne ikke utføres fordi ingen av Moonlight sine tilkoblingstesttjenere kunne nås. Sjekk din tilkobling til Internett og prøv igjen senere. Viser ytelsesinfo i sanntid under strømming Vis ytelsesstatistikk under strømming Strøm HDR når spillet og PC-ens skjermkort støtter det. HDR krever GTX 1000-eller nyere. @@ -153,7 +145,6 @@ Vis virtuelle innstillingskontroller for innstillinger i overleggslag på pekeskjermen Vis innstillingskontroller på skjermen Innstillinger for innstillingskontroller som vises på skjermen - Bruk Moonlight sin USB-driver for alle støttede spillkontrollere, selv når systemspesifikk kontrollerstøtte er tilgjengelig Skru på for 5,1- eller 7,1-kringlyd for hjemmekinosystemer Øk for bedret bildekvalitet. Senk for bedre ytelse på trege tilkoblinger. Øk for jevnere videostrøm. Senk for bedre ytelse på trege enheter. @@ -183,12 +174,6 @@ Å skru på dette ødelegger for høyreklikking på noen enheter Skru på museknapper for forover og tilbake Mbps - Det ser ikke ut til at nettverket ditt blokkerer Moonlight. Hvis du fremdeles har problemer bør du sjekke PC-ens brannmursinnstillinger. -\n -\nHvis du prøver å strømme over Internett, kan du installere Moonlight Internett-vertstjeningsverktøyet på din PC og kjøre den inkluderte Internett-strømmingstesteren for å sjekke PC-ens tilkobling til Internett. - Moonlight tester din nettverksforbindelse for å fastsette hvorvidt NVIDIA GameStream er blokkert. -\n -\nDette kan ta noen sekunder … Opphev parring Parr med PC PC ikke parret diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index a1077ffcf0..d47f1b62d6 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -93,7 +93,6 @@ Geeft een virtuele controller weer op het touchscreen Interface­instellingen Taal - Taal te gebruiken in Moonlight Gebruik kleine iconen Gebruik kleine iconen in kolom onderdelen zodat meer items tegelijk zichtbaar worden Host-instellingen @@ -105,7 +104,6 @@ Codec-instellingen wijzigen Nieuwere codecs kunnen de vereiste bandbreedte voor video verlagen als uw apparaat deze ondersteunt. Codec-selecties kunnen worden genegeerd als ze niet worden ondersteund door de host-software of GPU. % - Moonlight is gecrasht vanwege een incompatibiliteit met de videodecoder van dit apparaat. Probeer de streaminginstellingen aan te passen als de crashes aanhouden. PC verwijderd PC niet gekoppeld PC niet gevonden @@ -118,17 +116,7 @@ Test netwerkverbinding Details bekijken Netwerk­verbinding testen - Moonlight test uw netwerkverbinding om te bepalen of er vereiste poorten geblokkeerd zijn.\n\nDit kan enkele seconden duren… Netwerktest voltooid - Uw netwerk lijkt Moonlight niet te blokkeren. Als u nog steeds problemen ondervindt met verbinden, controleer de firewall instellingen van uw PC. -\n -\nAls u probeert te streamen over het internet, installeer de Moonlight Internet Hosting Tool op uw PC en voer de inbegrepen Internet Streaming Tester uit om uw PC\'s internetverbinding te controleren. - De netwerktest kon niet worden uitgevoerd omdat geen Moonlight netwerktesting servers bereikbaar zijn. Controleer uw internetverbinding of probeer later opnieuw. - De internetverbinding van dit toestel lijkt Moonlight te blokkeren. Streamen over het internet werkt mogelijks niet wanneer u verbonden bent met dit netwerk. -\n -\nDe volgende netwerkpoorten zijn geblokkeerd: -\n - De internetverbinding van dit toestel blokkeert Moonlight. Streamen over het internet werkt mogelijks niet wanneer u verbonden bent met dit netwerk. Koppelen al bezig Video Decoder Gecrasht Video Instellingen Resetten @@ -185,7 +173,6 @@ Verhogen voor een vloeiendere video stream. Verlagen voor betere prestaties op minder krachtige apparaten. 5.1 of 7.1 surround sound inschakelen voor home-cinema systemen Wanneer ingeschakeld functioneert het touchscreen als een trackpad. Wanneer uitgeschakeld bedient het touchscreen rechtstreeks de muiscursor. - Gebruik Moonlight\'s USB driver voor alle ondersteunde gamepads, zelfs als een standaard Xbox controller aanwezig is Bent u zeker dat u uw opgeslagen on-screen controle-elementen wilt verwijderen\? Real-time prestatie-informatie tonen tijdens het streamen Simuleer rumble-ondersteuning met trillingen @@ -245,14 +232,12 @@ NVIDIA GameStream - Beëindiging van service Oorspronkelijke FPS-waarschuwing Video­frame­tempo - Bekijk het privacybeleid van Moonlight De host-PC meldde een fatale videocoderingsfout.\n\nProbeer de HDR-modus uit te schakelen, de streamingresolutie te wijzigen of de schermresolutie van uw host-PC te wijzigen. Hostverwerkingsvertraging min/max/gemiddeld: %1$.1f/%2$.1f/%3$.1f ms Systeem-equalizerondersteuning inschakelen Zorgt ervoor dat audio-effecten werken tijdens het streamen, maar kan de audiovertraging verhogen Intensiteit van geëmuleerde rumble aanpassen Versterk of verminder de trillingsintensiteit op uw apparaat - Let op: Sommige spellen kunnen een grotere dode zone afdwingen dan waar Moonlight voor is geconfigureerd. Bedien de muis altijd met het touchpad Dwingt de touchpadinvoer van de gamepad om de muis van de host te besturen, zelfs bij het emuleren van een gamepad met een touchpad. Sta gebruik van gamepad-bewegingssensoren toe diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 7d99219bf6..e9542172e1 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -59,14 +59,12 @@ Wibruje urządzenie, aby naśladować dudnienie, jeśli gamepad go nie obsługuje % Regulacja strefy martwej drążka analogowego - Uwaga: Niektóre gry mogą wymuszać większą strefę martwą niż ta, do której skonfigurowany jest Moonlight. % Sterownik USB gamepada Xbox 360/One Zastąpienie natywnej obsługi gamepada Xbox Emulacja myszy za pomocą gamepada Zamiana przycisków akcji A/B/X/Y Włącza wbudowany sterownik USB dla urządzeń bez natywnej obsługi kontrolera Xbox - Użyj sterownika USB Moonlight dla wszystkich obsługiwanych gamepadów, nawet jeśli natywna obsługa kontrolera Xbox jest obecna Zawsze kontroluj mysz za pomocą touchpada Wymusza sterowanie myszą za pomocą touchpada gamepada, nawet w przypadku emulacji gamepada z touchpadem. Zezwalaj na korzystanie z czujników ruchu gamepada @@ -95,7 +93,6 @@ Włącz tryb obserwatora obrazu w obrazie Przywraca domyślny rozmiar i położenie wszystkich elementów sterujących na ekranie Język - Język używany w Moonlight Ustawienia hosta Mniejsze ramki w siatce aplikacji pozwalają na wyświetlanie większej liczby aplikacji na ekranie Optymalizacja ustawień gry @@ -122,7 +119,6 @@ Wyświetlanie komunikatu informującego o opóźnieniu po zakończeniu strumienia Instrukcja konfiguracji Polityka prywatności - Zobacz politykę prywatności Moonlight 360p 720p Typ gamepada może zostać zmieniony z powodu emulacji czujnika ruchu @@ -145,11 +141,6 @@ Test połączenia sieciowego Wyświetl szczegóły Test sieci zakończony - Bieżące połączenie sieciowe urządzenia wydaje się blokować Moonlight. Przesyłanie strumieniowe przez Internet może nie działać podczas połączenia z tą siecią. -\n -\nNastępujące porty sieciowe zostały zablokowane: -\n - Bieżące połączenie sieciowe urządzenia blokuje Moonlight. Przesyłanie strumieniowe przez Internet może nie działać podczas połączenia z tą siecią. Parowanie Komputer jest offline Nieprawidłowy kod PIN @@ -163,7 +154,6 @@ Urządzenie nie zostało sparowane Komputer jest offline Nie udało się zlokalizować hosta - Moonlight uległ awarii z powodu niezgodności z dekoderem wideo tego urządzenia. Spróbuj dostosować ustawienia przesyłania strumieniowego jeśli awarie będą się powtarzać. GFE zwrócił błąd HTTP 404. Upewnij się, że w Twoim komputerze działa obsługiwany procesor graficzny. Używanie oprogramowania pulpitu zdalnego może również powodować ten błąd. Spróbuj ponownie uruchomić komputer lub ponownie zainstalować GFE. Reset ustawień wideo Dostęp USB jest zabroniony przez administratora urządzenia. Sprawdź ustawienia Knox lub MDM. @@ -228,16 +218,9 @@ Sparuj z PC Rozparuj Wyślij żądanie Wake-On-LAN - Moonlight testuje połączenie sieciowe w celu ustalenia, czy wymagane porty są zablokowane. -\n -\nMoże to potrwać kilka sekund… Parowanie… Komputer jest obecnie w trakcie gry. Przed sparowaniem należy zamknąć grę. - Wygląda na to, że sieć nie blokuje Moonlight. Jeśli nadal masz problemy z połączeniem, sprawdź ustawienia zapory sieciowej na swoim komputerze. -\n -\nJeśli próbujesz przesyłać strumieniowo przez Internet, zainstaluj narzędzie Moonlight Internet Hosting Tool na swoim komputerze i uruchom dołączony tester przesyłania strumieniowego przez Internet, aby sprawdzić połączenie internetowe komputera. Zakończenie usług NVIDIA GameStream - Nie można było wykonać testu sieci ponieważ żaden z serwerów testujących połączenie Moonlight nie był osiągalny. Sprawdź połączenie internetowe lub spróbuj ponownie później. Testowanie połączenia sieciowego Nie udało się wysłać pakietów Wake-On-LAN Usługa ComputerManager nie jest uruchomiona. Poczekaj kilka sekund lub uruchom ponownie aplikację. diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index db2d879f7d..8fb29eaaed 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -24,14 +24,10 @@ Procurando por PCs rodando o GameStream... \n \nCertifique-se de que o GameStream esteja ativado nas configurações do SHIELD do GeForce Experience. - Sua rede não parece estar bloqueando o Moonlight. Se você ainda tiver problemas para se conectar, verifique as configurações de firewall do seu PC. -\n -\nSe você estiver tentando transmitir pela Internet, instale o Moonlight Internet Hosting Tool em seu PC e execute o Internet Streaming Tester para verificar a conexão com a Internet do seu PC. Verifique o firewall e permita a(s) seguinte(s) para porta(s): Não foi possível acordar o PC porque não tem um endereço MAC armazenado Pode levar alguns segundos para o seu PC acordar. Se isso não acontecer, verifique se ele está configurado corretamente para Wake-On-LAN. GFE retornou um erro HTTP 404. Verifique se o seu PC está executando uma GPU compatível. O uso do software de área de trabalho remota também pode causar esse erro. Tente reiniciar sua máquina ou reinstalar o GFE. - Moonlight crashou devido a uma incompatibilidade com o decodificador de vídeo deste dispositivo. Tente ajustar as configurações de transmissão se as falhas continuarem. O decodificador de vídeo do seu dispositivo continua crashando nas configurações de transmissão selecionadas. Suas configurações de transmissão foram redefinidas para o padrão. O acesso USB é proibido pelo administrador do seu dispositivo. Verifique suas configurações de Knox ou MDM. Seu launcher atual não permite a criação de atalhos fixados. @@ -73,13 +69,6 @@ Teste de Internet Completo Parear com o PC Enviar Wake-On-LAN - O Moonlight está testando a sua conexão de internet para determinar se alguma das portas requeridas estão bloqueadas.\n\nIsso pode levar alguns segundos… - O teste de rede não pôde ser executado porque nenhum dos servidores de teste de conexão do Moonlight estava acessível. Verifique sua conexão com a Internet ou tente novamente mais tarde. - A conexão de rede atual do seu dispositivo parece estar bloqueando o Moonlight. A transmissão pela Internet pode não funcionar enquanto estiver conectado a esta rede. -\n -\nAs seguintes portas de rede estão bloqueadas: -\n - A conexão de rede atual do seu dispositivo está bloqueando o Moonlight. A transmissão pela Internet pode não funcionar enquanto estiver conectado a esta rede. Pareando… Esse computador está indisponível Esse computador está atualmente em um jogo. Você deve fechar o jogo antes de parear. @@ -185,7 +174,6 @@ % Driver de gamepad USB Xbox 360/One Substituir o suporte nativo ao gamepad do Xbox - Use o driver USB do Moonlight para todos os gamepads suportados, mesmo se houver suporte nativo ao controle Xbox Emulação de mouse via gamepad Segure o botão Start para mudar o gamepad para o modo de mouse Ativar esta opção pode bugar o clique com o botão direito em alguns dispositivos com bugs @@ -199,7 +187,6 @@ Otimizar configurações de jogo Permitir que o GFE modifique as configurações do jogo para otimizar a transmissão Idioma - Idioma para ser usado no Moonlight Permite que a transmissão seja visualizada (mas não controlada) enquanto usa outros apps Configurações de Interface % @@ -228,9 +215,7 @@ Veja dicas para diagnosticar e corrigir problemas comuns de streaming Política de privacidade Isso pode alterar a aceleração do mouse para se comportar mais naturalmente para uso em desktop remoto, mas é incompatível com muitos jogos. - Veja a política de privacidade do Moonlight Equilibrado com limite de FPS - Nota: Alguns jogos podem impor uma zona morta maior do que o Moonlight está configurado para usar. Modo de mouse para área de trabalho remota (Paisagem) Permitir redução de taxa de atualização diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 88a4412277..1b07a8ae46 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -20,25 +20,13 @@ Enviar pedido Wake-On-LAN Apagar PC Testar conexão com a Internet - Moonlight está testando sua conexão com a rede para determinar se o NVIDIA GameStream está bloqueado. -\n -\nEste processo pode demorar alguns segundos… Balanceado - A sua rede não parece estar a bloquear o Moonlight. Se ainda tiver problemas a se conectar, verifique as configurações de firewall do seu PC. -\n -\nSe estiver a tentar transmitir pela Internet, instale o Moonlight Internet Hosting Tool no seu PC e execute o Internet Streaming Tester para verificar a conexão com a Internet do seu PC. - A conexão de rede atual do seu aparelho parece estar a bloquear o Moonlight. A transmissão pela Internet pode não funcionar enquanto estiver conectado a esta rede. -\n -\nAs seguintes portas de rede estão bloqueadas: -\n - A conexão de rede atual do seu aparelho está a bloquear o Moonlight. A transmissão pela Internet pode não funcionar enquanto estiver conectado a esta rede. Pode levar alguns segundos até o seu PC acordar. Se isso não acontecer, verifique se ele está configurado corretamente para Wake-On-LAN. Falha ao enviar pacotes Wake-On-LAN Despareado com sucesso Falha ao desparear Decodificador de Vídeo Crashou Resetar Configurações de Vídeo - Moonlight crashou devido a uma incompatibilidade com o decodificador de vídeo deste aparelho. Certifique-se de que o GeForce Experience esteja atualizado à versão mais recente no seu PC. Tente ajustar as configurações de transmissão se as falhas continuarem. O seu launcher atual não permite a criação de atalhos fixados. Falha ao iniciar o decodificador de vídeo. O seu aparelho pode não suportar a resolução ou taxa de quadros selecionada. O PC host relatou um erro fatal de codificação de vídeo. @@ -86,7 +74,6 @@ Permitir que efeitos de audio funcionem durante o streaming, pode acrescentar latência de audio Ativa um driver USB integrado para aparelhos sem suporte nativo aos controles Xbox Substituir o suporte nativo ao gamepad do Xbox - Use o driver USB do Moonlight para todos os gamepads suportados, mesmo se houver suporte nativo ao controle Xbox Emulação de rato pelo gamepad Segure o botão Start para mudar o gamepad ao modo de rato Inverte os botões A/B e X/Y dos gamepads e controles de ecrã @@ -131,7 +118,6 @@ Automático (recomendado) Ritmo de quadros Preferir vídeo mais suave (pode aumentar significativamente a latência) - Nota: Alguns jogos podem impor uma zona morta maior do que o Moonlight está configurado para usar. Tem certeza de que deseja apagar o layout salvo dos controles de ecrã\? Configurações de Host Equilibrado com limite de FPS @@ -201,7 +187,6 @@ Redefine todos os controles no ecrã para tamanho e posição padrão dele Deixe os controles de ecrã mais/menos transparentes Idioma - Idioma para ser usado no Moonlight Mostrar ícones menores Permitir que o GFE modifique as configurações do jogo para otimizar a transmissão Reproduzir áudio no PC @@ -210,7 +195,6 @@ Transmita HDR quando o jogo e a GPU do PC suportarem. O HDR requer uma GPU da série GTX 1000 ou posterior. Mostrar status de performance durante a transmissão Teste completo da rede - O teste de rede não pôde ser executado porque nenhum dos servidores de teste de conexão do Moonlight estava acessível. Verifique a sua conexão com a Internet ou tente novamente mais tarde. A parear… Esse computador está offline Esse computador está atualmente num jogo. Deve fechar o jogo antes de parear. @@ -245,7 +229,6 @@ Ativar esta opção pode bugar o clique com o botão direito em alguns aparelhos com bugs Inverter botões Política de privacidade - Veja a política de privacidade do Moonlight 480p 720p Especifique o equilíbrio entre latência e suavidade do vídeo diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index b145f4d712..ae006ac2f3 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -44,7 +44,6 @@ Este posibil sa apară această eroare daca folosești o alta aplicație de remote desktop. Încearcă să repornești PC-ul sau să reinstalezi GFE. Decodorul video s-a închis în mod neașteptat - Moonlight s-a închis în mod neașteptat datorită unei incompatibilități cu decodorul video al acestui dispozitiv. Asigură-te ca folosești ultima versiune de GFE. Dacă problema persistă, ajustează setările de streaming. Resetează setările video Decodorul video al acestui dispozitiv continuă să se blocheze folosind setările video curente. Au fost resetate cele implicite. Accesul USB este interzis de către administratorul dispozitivului. Verifică setarile Knox sau MDM. @@ -133,7 +132,6 @@ Driver pentru controllerele de Xbox 360/One Activează un driver USB pentru dispozitivele fără suport nativ pentru controllere Xbox Inlocuiește driverul implicit pentru controllere - Forțează driverul USB Moonlight să preia toate controllerele Xbox suportate Simulează mouse cu controllerul Apăsarea lungă pe butonul Start schimba modul de operare a controllerului în modul mouse. Activează butoanele de înainte și înapoi ale mousului @@ -156,7 +154,6 @@ % Setari UI Limba (Language) - Limba folosită de către Moonlight Folosește iconițe mici Iconițele folosite în grile vor fi mici pentru a încăpea mai multe odata Setările PC-ului gazdă diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 7c9e8dae4b..7e4b842d43 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -8,13 +8,7 @@ Удалить ПК Тестирование сетевого подключения - Moonlight тестирует ваше сетевое подключение, чтобы определить, заблокированы ли нужные порты. -\n -\nЭто может занять некоторое время… Тестирование подключения завершено - Ваша сеть не блокирует Moonlight. Если у вас всё ещё есть проблемы с соединением, проверьте настройки брандмауэра на компьютере.\n\nЕсли вы пытаетесь транслировать через интернет, установите Moonlight Internet Hosting Tool на ваш компьютер и запустите установленный Internet Streaming Tester, чтобы проверить Интернет-соединение на вашем компьютере. - Тестирование подключения не может быть выполнено, потому что сервера тестирования подключения Moonlight не доступны. Проверьте ваше Интернет-соединение или повторите попытку позже. - Похоже, что текущее сетевое подключение вашего устройства блокирует Moonlight. Трансляция через Интернет может не работать при подключении к этой сети.\n\nСледующие сетевые порты были заблокированы:\n Создание пары… Компьютер выключен или находится не в сети @@ -102,7 +96,6 @@ Меняет местами кнопки A/B и X/Y на геймпадах и экранных кнопках Настройки интерфейса Язык - Язык, который будет использоваться в Moonlight Использовать маленькие иконки Использовать маленькие иконки в сетке для отображения большего числа элементов на экране Настройки хоста @@ -128,7 +121,6 @@ Помощь Подключение к ПК… Сбой декодирования видео - Moonlight произошел сбой из-за несовместимости с видеодекодером этого устройства. Попробуйте изменить настройки потоковой передачи, если сбои продолжаются. Настройки видео были сброшены Видео декодер Вашего устройства давал сбои с выбранными настройками. Настройки трансляции были сброшены до значений по умолчанию. USB доступ запрещён администратором устройства. Проверьте настройки Knox или MDM. @@ -136,7 +128,6 @@ Включить просмотр в режиме \"Картинка в картинке\" Позволяет просматривать трансляцию (но не управлять ей) во время работы в других приложениях Переопределить поддержку контроллеров Android - Заставляет USB драйвер Moonlight взять на себя работу со всеми поддерживаемыми Xbox геймпадами Эмуляция мыши на геймпаде Долгое нажатие кнопки Start переключит геймпад в режим мыши Сбросить схему расположения экранных кнопок @@ -197,7 +188,6 @@ \nУбедитесь, что на вашем хост-ПК не открыт контент, защищенный DRM. Вы также можете попробовать перезагрузить хост-компьютер. Ваше сетевое подключение не справляется с заданными настройками. Попробуйте изменить битрейт или использовать более быстрое подключение. Видео от хоста не получено. - Текущее сетевое соединение вашего устройства блокирует Moonlight. Потоковая передача через Интернет может не работать при подключении к этой сети. Родное Режимы родного разрешения официально не поддерживаются GeForce Experience, поэтому он не будет устанавливать разрешение экрана вашего хоста самостоятельно. Вам нужно будет установить его вручную. \n @@ -235,7 +225,6 @@ Посмотреть инструкцию о том как настроить ваш ПК для стриминга Руководство по устранению неполадок Укажите, как сбалансировать задержку и плавность видеосигнала - Посмотреть политику конфиденциальности Moonlight Политика конфиденциальности 360p 1080p @@ -244,7 +233,6 @@ Помощь 720p (Портретный) - Некоторые игры могут принудительно увеличить мёртвую зону вместо указанной в Moonlight. Режим удалённого рабочего стола для мыши Разрешить снижение частоты обновления 4K diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 175af18c89..4914523858 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -19,9 +19,6 @@ Visa detaljer NVIDIA GameStream upphör att fungera Testning av nätverksanslutning - Moonlight testar din nätverksanslutning för att avgöra om några nödvändiga portar är blockerade. -\n -\nDetta kan ta några sekunder… Datorn är för närvarande i ett spel. Du måste stänga spelet innan fogning. Fogar… Fogning @@ -42,7 +39,6 @@ Dator är offline Misslyckades med att lösa värd Videoavkodare brakade - Moonlight har brakat på grund av en inkompatibilitet med den här enhetens videodekoder. Försök att jämka streaminginställningarna om braket fortsätter. Återställning av videoinställningar USB-åtkomst är förbjuden av enhetens administratör. Kontrollera dina Knox- eller MDM-inställningar. Din nuvarande lanseringsprogramvara tillåter inte att du skapar genvägar som är fastlåsta. @@ -119,7 +115,6 @@ Automatiskt upptäckande av närvarande spelkontroll Efterlikna stöd för rumble med vibrationer Vibrerar enheten för att efterlikna rumble om din spelplatta inte har stöd för det - Observera: Vissa spel kan kräva en större dödzon än vad Moonlight är konfigurerat för att använda. Jämka analogstickans dödzon % Drivrutin för Xbox 360/One USB-spelplatta @@ -145,7 +140,6 @@ % Sätt på bild-i-bild-observatörsläge Språk - Språk att använda för Moonlight Använd liten omslagskonst Mindre lådor i appgallret gör att fler appar syns på skärmen Värdinställningar @@ -173,7 +167,6 @@ Vägledning för felsökning Visa gissningar för att utvärdera och åtgärda vanliga strömningsbeskymmer Integritetspolicy - Visa Moonlights integritetspolicy 360p 480p 720p @@ -190,15 +183,6 @@ Balanserad med FPS-gräns Föredrar smidigast video (kan öka latensen avsevärt) Nätverkstestet fullföljt - Din enhets aktuella nätverksanslutning verkar blockera Moonlight. Streaming via Internet kanske inte fungerar när du är ansluten till det här nätverket. -\n -\nFöljande nätverksportar blockerades: -\n - Ditt nätverk verkar inte blockera Moonlight. Om du fortfarande har problem med anslutningen kan du kontrollera datorns brandväggsinställningar. -\n -\nOm du försöker streama via Internet installerar du Moonlight Internet Hosting Tool på din dator och kör den medföljande Internet Streaming Tester för att kontrollera datorns Internetanslutning. - Nätverkstestet kunde inte utföras eftersom ingen av Moonlights servrar för anslutningstestning kunde nås. Kontrollera din internetanslutning eller försök igen senare. - Enhetens aktuella nätverksanslutning blockerar Moonlight. Streaming via Internet kanske inte fungerar när du är ansluten till det här nätverket. Dator är offline Tjänsten ComputerManager körs inte. Vänta några sekunder eller starta om programmet. GFE returnerade ett HTTP 404-fel. Kontrollera att din dator har en GPU som stöds. Användning av fjärrskrivbordsprogram kan också orsaka det här felet. Försök att starta om maskinen eller installera om GFE. @@ -229,7 +213,6 @@ Aktivera 5.1 eller 7.1 surroundljud för hemmabiosystem Ljudeffekter kan fungera under streaming, men kan öka ljudfördröjningen Om du avmarkerar det här alternativet tvingas en spelplatta att alltid finnas med - Använd Moonlights USB-drivrutin för alla gamepads som stöds, även om det finns stöd för Xbox-kontroller i original Om du lägger till det här alternativet kan högerklickandet gå sönder på vissa felaktiga enheter Visa kontrollerna på skärmen Visa en virtuell styrenhet på pekskärmen diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index 63b7d7c1f8..0e9a97a326 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -29,11 +29,6 @@ பிணைய இணைப்பை சோதிக்கவும் என்விடியா கேம்ச்ட்ரீம் சேவையின் இறுதி பிணைய இணைப்பை சோதித்தல் - தேவையான துறைமுகங்கள் தடுக்கப்பட்டுள்ளதா என்பதை தீர்மானிக்க மூன்லைட் உங்கள் பிணைய இணைப்பை சோதிக்கிறது.\n\n இதற்கு சில வினாடிகள் ஆகலாம்… - உங்கள் பிணையம் நிலவொளியைத் தடுப்பதாகத் தெரியவில்லை. இணைப்பதில் உங்களுக்கு இன்னும் சிக்கல் இருந்தால், உங்கள் கணினியின் ஃபயர்வால் அமைப்புகளை சரிபார்க்கவும்.\n\n நீங்கள் இணையத்தில் ச்ட்ரீம் செய்ய முயற்சிக்கிறீர்கள் என்றால், உங்கள் கணினியில் மூன்லைட் இணைய ஓச்டிங் கருவியை நிறுவி, உங்கள் கணினியின் இணைய இணைப்பை சரிபார்க்க சேர்க்கப்பட்ட இணைய ச்ட்ரீமிங் சோதனையாளரை இயக்கவும். - பிணையம் சோதனையை செய்ய முடியவில்லை, ஏனெனில் மூன்லைட்டின் இணைப்பு சோதனை சேவையகங்கள் எதுவும் அடையப்படவில்லை. உங்கள் இணைய இணைப்பைச் சரிபார்க்கவும் அல்லது பின்னர் மீண்டும் முயற்சிக்கவும். - உங்கள் சாதனத்தின் தற்போதைய பிணைய இணைப்பு நிலவொளியைத் தடுப்பதாகத் தெரிகிறது. இந்த பிணையத்துடன் இணைக்கப்படும்போது இணையத்தில் ச்ட்ரீமிங் வேலை செய்யாது.\n\n பின்வரும் பிணைய துறைமுகங்கள் தடுக்கப்பட்டன:\n - உங்கள் சாதனத்தின் தற்போதைய பிணைய இணைப்பு நிலவொளியைத் தடுக்கிறது. இந்த பிணையத்துடன் இணைக்கப்படும்போது இணையத்தில் ச்ட்ரீமிங் வேலை செய்யாது. இணைத்தல்… கணினி ஆஃப்லைனில் உள்ளது கணினி தற்போது ஒரு விளையாட்டில் உள்ளது. இணைப்பதற்கு முன் நீங்கள் விளையாட்டை மூட வேண்டும். @@ -57,7 +52,6 @@ அவிழ்க்கத் தவறிவிட்டது சாதனம் இணைக்கப்படவில்லை கணினி ஆஃப்லைனில் உள்ளது - இந்த சாதனத்தின் வீடியோ டிகோடருடன் பொருந்தாத தன்மை காரணமாக மூன்லைட் செயலிழந்துள்ளது. செயலிழப்புகள் தொடர்ந்தால் ச்ட்ரீமிங் அமைப்புகளை சரிசெய்ய முயற்சிக்கவும். வீடியோ அமைப்புகள் மீட்டமைக்கப்படுகின்றன உங்கள் சாதனத்தின் வீடியோ டிகோடர் நீங்கள் தேர்ந்தெடுத்த ச்ட்ரீமிங் அமைப்புகளில் தொடர்ந்து செயலிழந்து வருகிறது. உங்கள் ச்ட்ரீமிங் அமைப்புகள் இயல்புநிலைக்கு மீட்டமைக்கப்பட்டுள்ளன. உங்கள் சாதன நிர்வாகியால் யூ.எச்.பி அணுகல் தடைசெய்யப்பட்டுள்ளது. உங்கள் நாக்ச் அல்லது எம்.டி.எம் அமைப்புகளை சரிபார்க்கவும். @@ -150,12 +144,10 @@ உங்கள் சாதனத்தில் அதிர்வு தீவிரத்தை பெருக்கவும் அல்லது குறைக்கவும் % அனலாக் ச்டிக் டெட்சோனை சரிசெய்யவும் - குறிப்பு: சில விளையாட்டுகள் மூன்லைட் பயன்படுத்த கட்டமைக்கப்பட்டதை விட பெரிய டெட் சோனை செயல்படுத்த முடியும். % எக்ச்பாக்ச் 360/ஒரு யூ.எச்.பி கேம்பேட் இயக்கி சொந்த எக்ச்பாக்ச் கட்டுப்படுத்தி உதவி இல்லாத சாதனங்களுக்கான உள்ளமைக்கப்பட்ட யூ.எச்.பி டிரைவரை இயக்குகிறது சொந்த எக்ச்பாக்ச் கேம்பேட் ஆதரவை மேலெழுதவும் - சொந்த எக்ச்பாக்ச் கட்டுப்படுத்தி உதவி இருந்தாலும், அனைத்து ஆதரிக்கப்பட்ட கேம்பேட்களுக்கும் மூன்லைட்டின் யூ.எச்.பி டிரைவரைப் பயன்படுத்தவும் கேம்பேட் வழியாக சுட்டி எமுலேசன் தொடக்க பொத்தானை நீண்ட நேரம் அழுத்துவது கேம்பேட்டை சுட்டி பயன்முறையில் மாற்றும் முகம் பொத்தான்களை புரட்டவும் @@ -193,7 +185,6 @@ படம்-இன்-பட பார்வையாளர் பயன்முறையை இயக்கவும் பல்பணி செய்யும் போது ச்ட்ரீமை பார்க்க அனுமதிக்கிறது (ஆனால் கட்டுப்படுத்தப்படவில்லை) மொழி - நிலவொளிக்கு பயன்படுத்த மொழி சிறிய பெட்டி கலையைப் பயன்படுத்தவும் பயன்பாட்டு கட்டத்தில் உள்ள சிறிய பெட்டி கலை மேலும் பயன்பாடுகள் திரையில் காண அனுமதிக்கிறது புரவலன் அமைப்புகள் @@ -221,7 +212,6 @@ சரிசெய்தல் வழிகாட்டி பொதுவான ச்ட்ரீமிங் சிக்கல்களைக் கண்டறிந்து சரிசெய்வதற்கான உதவிக்குறிப்புகளைக் காண்க தனியுரிமைக் கொள்கை - மூன்லைட்டின் தனியுரிமைக் கொள்கையைக் காண்க இயக்க சென்சார் எமுலேசன் காரணமாக கேம்பேட் வகை மாற்றப்படலாம் 360 ப 480 ப diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index b5ea418da3..fe07e9fb25 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -3,7 +3,6 @@ Tüm ekran kontrollerini varsayılan boyutu ve pozisyonuna sıfırlar Dokunmatik ekranı trackpad olarak kullan Girdi Ayarları - Moonlight için kullanılacak olan dil Ana bilgisayarın IP adresi Detaylar Saydamlığı değiştir @@ -38,16 +37,7 @@ Yardım Tanımlanmış uygulama geçerli değil Tanımlanmış bilgisayar geçerli değil - Moonlight, gerekli bağlantı noktalarının engellenmediğini belirlemek için ağ bağlantınızı test ediyor. -\n -\nBu bir kaç saniye sürebilir… Ağ Testi Tamamlandı - Moonlight\'ın bağlantı testi sunucularından hiçbirine erişilemediği için ağ testi gerçekleştirilemedi. İnternet bağlantınızı kontrol edin veya daha sonra tekrar deneyin. - Cihazınızın mevcut ağ bağlantısı Moonlight\'ı engelliyor gibi görünüyor. Bu ağa bağlıyken İnternet üzerinden yayın akışı çalışmayabilir. -\n -\nAşağıdaki ağ bağlantı noktaları engellenmiş: -\n - Cihazınızın mevcut ağ bağlantısı Moonlight\'ı engelliyor. Bu ağa bağlıyken internet üzerinden yayın akışı çalışmayabilir. Bilgisayar şu anda bir oyunda. Eşleştirmeden önce oyunu kapatmalısınız. Lütfen hedef bilgisayarda aşağıdaki PIN kodunu girin: Eşleştirme zaten devam ediyor @@ -58,9 +48,6 @@ Bilgisayarınızın uyanması birkaç saniye sürebilir. Eğer uyanmazsa, Yerel Ağda Uyandırma için doğru şekilde yapılandırıldığından emin olun. Eşleşme kaldırılamadı Cihaz eşleştirilmedi - Ağınız Moonlight\'ı engelliyor gibi görünmüyor. Bağlanmakta hala sorun yaşıyorsanız, bilgisayarınızın güvenlik duvarı ayarlarını kontrol edin. -\n -\nİnternet üzerinden yayın yapmaya çalışıyorsanız, Moonlight İnternet Barındırma Aracını bilgisayarınıza yükleyin ve bilgisayarınızın internet bağlantısını kontrol etmek için birlikte verilen İnternet Akış Test Cihazını çalıştırın. Oyun kumandası varlığını otomatik algıla Bu seçeneğin işaretini kaldırmak, oyun kumandasının her zaman mevcut olmasını sağlar Oyun kumandanız desteklemiyorsa, gürültüyü taklit etmek için cihazınızı titreştirir @@ -139,7 +126,6 @@ Yerel tam ekran Ses Ayarları Çevresel ses yapılandırması - Not: Bazı oyunlar Moonlight\'ın kullanmak üzere yapılandırıldığından daha büyük bir ölü bölge uygulayabilir. % Xbox 360/One USB oyun kumandası sürücüsü Xbox doğal oyun kumanda desteğini geçersiz kıl @@ -148,7 +134,6 @@ Yüzey düğmelerini çevir Uzak masaüstü fare modu GFE bir HTTP 404 hatası bildirdi. Bilgisayarınızın desteklenen bir GPU çalıştırdığından emin olun. Uzak masaüstü yazılımı kullanmak da bu hataya neden olabilir. Cihazınızı yeniden başlatmayı veya GFE\'yi yeniden yüklemeyi deneyin. - Moonlight, bu cihazın video kod çözücüsüyle uyumsuzluk nedeniyle çöktü. Kilitlenmeler devam ederse akış ayarlarını değiştirmeyi deneyin. Ortalama donanım kod çözme gecikmesi: Video Ayarlarını Sıfırla Yerel ağınızdaki ana bilgisayarlar aranıyor… @@ -168,7 +153,6 @@ Daha iyi görüntü kalitesi için artırın. Daha yavaş bağlantılarda performansı artırmak için azaltın. Videoyu tam ekrana genişlet Titreşim ile gürültü desteğini taklit et - Yerel Xbox oyun kumandası desteği mevcut olsa bile desteklenen tüm oyun kumandaları için Moonlight\'ın USB sürücüsünü kullan Oyun kumandaları ve ekran kontrolleri için A/B ve X/Y yüzey düğmelerini değiştirir Bu fare hızlandırmanın uzak masaüstü kullanımı için daha doğal davranmasını sağlayabilir, ancak birçok oyunla uyumsuzdur. Dokunmatik ekranda sanal denetleyici katmanını göster @@ -231,7 +215,6 @@ 480p 1080p Yaygın akış sorunlarını teşhis etmeye ve düzeltmeye yönelik ipuçlarını görüntüle - Moonlight gizlilik politikasını görüntüleyin Hareket sensörü taklidi nedeniyle oyun kumandası türü değiştirilebilir 5.1 Surround Ses 7.1 Surround Ses diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 48dee59c89..5c1bdf8108 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -91,7 +91,6 @@ Вмикає вбудований USB драйвер для пристроїв без власної підтримки контролерів Xbox Налаштування інтерфейсу Мова - Мова, яка буде використовуватися в Moonlight Використовувати маленькі іконки Використовувати маленькі іконки в сітці застосунків, для відображення більшої кількості елементів на екрані Налаштування хоста @@ -115,7 +114,6 @@ Допомога Підключення до пристрою… Збій відео розцифрувача - Moonlight аварійно завершив роботу через несумісність із відеодекодером цього пристрою. Спробуйте налаштувати параметри потокового передавання, якщо збої триватимуть. Налаштування відео скинуті Відео розцифрувач вашого пристрою продовжує давати збої з поточними налаштуваннями трансляції. Налаштування трансляції були скинуті до значень за замовчуванням. USB доступ заборонений адміністратором пристрою. Перевірте налаштування Knox або MDM. @@ -123,7 +121,6 @@ Увімкнути режим перегляду \"Картинка в картинці\" Дозволяє переглядати стрім (але не контролювати) під час багатозадачності Вимкнути рідну підтримку Xbox контролерів від Андроїда - Використовувати USB драйвер Moonlight для усіх підтриманих контролерів, навіть якщо існує рідна підтримка Емуляція миші через контролер Довге натиснення ґудзика Start, перемкне контролер в режим миші Скинути власне розташування ґудзиків на екрані @@ -179,9 +176,6 @@ Щось пішло не так на вашому головному ПК під час запуску потоку. \n \nПереконайтеся, що на головному ПК немає відкритого вмісту, захищеного DRM. Ви також можете спробувати перезавантажити головний ПК. - Схоже що ваша мережа не блокує Moonlight. Якщо у вас й надалі проблеми з підключенням, перевірте налаштування брандмауера. -\n -\nЯкщо ви намагаєтеся транслювати через Інтернет, встановіть на своєму пристрої Moonlight Internet Hosting Tool, та запустіть випробування мережі щоб перевірити ваше з\'єднання до Інтернету. Рідний повноекранний Рідна Мб/с @@ -200,16 +194,7 @@ Здається, ваше мережеве з\'єднання човпає. Спробуйте зменшити швидкість потоку (бітрейт) чи переключіть пристрій на швидшу мережу. Не отримано відео від хоста. Не вдалося запустити відео розцифрувач. Ваш пристрій може не підтримувати розмір екрану чи частоту кадрів. - Поточне мережеве з’єднання вашого пристрою блокує Moonlight. Трансляції через Інтернет можуть не працювати на цієї мережі. - Поточне мережеве з’єднання вашого пристрою блокує Moonlight. Трансляції через Інтернет можуть не працювати на цієї мережі. -\n -\nНаступні мережеві порти були заблоковані: -\n - Не вдалося перевірити мережу, тому що усі мережо-перевірочні сервери Moonlight недосяжні. Перевірте ваше з\'єднання з інтернетом та спробуйте знову. Перевірка мережі завершена - Moonlight перевіряє ваше мережеве з’єднання, щоб визначити, чи заблоковані необхідні порти. -\n -\nЦе може зайняти кілька секунд… Перевіряємо з\'єднання Перевірити мережеве з\'єднання Оновлення @@ -241,7 +226,6 @@ Політика конфіденційності Переглянути поради щодо діагностики та усунення поширених проблем з потоковою передачею Більш низька частота оновлення дисплея може заощадити енергію за рахунок деякої додаткової затримки відео - Примітка: Деякі ігри можуть встановлювати більшу мертву зону, ніж та, на яку налаштовано Moonlight. Режим миші віддаленого робочого столу Гайд по налаштуванню Переглянути інструкцію, як налаштувати ігровий ПК для стрімінгу @@ -252,7 +236,6 @@ Це може зробити прискорення миші більш природним під час використання віддаленого робочого столу, але воно несумісне з багатьма іграми. Дозволити зниження частоти оновлення Допомога - Переглянути політику конфіденційності Moonlight Головний комп\'ютер повідомив про фатальну помилку кодування відео. \n \nСпробуйте вимкнути режим HDR, змінити роздільну здатність потокового відео або змінити роздільну здатність дисплея головного комп\'ютера. diff --git a/app/src/main/res/values-v21/styles.xml b/app/src/main/res/values-v21/styles.xml index 56d50e0f0d..4191a28c4f 100644 --- a/app/src/main/res/values-v21/styles.xml +++ b/app/src/main/res/values-v21/styles.xml @@ -7,8 +7,8 @@ --> diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index a85727d7fc..b9373870d3 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -19,7 +19,6 @@ Phát âm thanh trên PC Bìa nhỏ trong lưới ứng dụng cho phép hiển thị nhiều ứng dụng hơn trên màn hình Sử dụng bìa nhỏ - Ngôn ngữ để sử dụng cho Moonlight Ngôn ngữ Cho phép stream được xem (nhưng không được điều khiển) trong khi đa nhiệm Bật chế độ quan sát Hình-trong-Hình @@ -46,7 +45,6 @@ Bật các nút quay lại và đi trước của chuột Việc nhấn giữ nút Start sẽ chuyển tay cầm điều khiển vào chế độ chuột Giả lập chuột qua tay cầm điều khiển - Sử dụng driver USB của Moonlight cho tất cả tay cầm điều khiển được hỗ trợ, kể cả nếu có hỗ trợ tay cầm điều khiển Xbox Ghi đè lên sự hỗ trợ tay cầm điều khiển Xbox Bật một driver USB được tích hợp cho các thiết bị không hỗ trợ tay cầm điều khiển Xbox Driver tay cầm điều khiển USB Xbox 360/One @@ -142,7 +140,6 @@ Quyền truy cập USB bị cấm bởi quản trị viên thiết bị. Hãy kiểm tra cài đặt Knox hoặc MDM. Trình giải mã video của thiết bị vẫn tiếp tục dừng đột ngột tại các cài đặt stream đã chọn. Các cài đặt stream đã được đặt lại về mặc định. Đã đặt lại cài đặt video - Moonlight đã bị dừng đột ngột vì một sự không tương thích với trình giải mã video của thiết bị này. Hãy chắc chắn là GeForce Experience được cập nhật lên phiên bản mới nhất trên PC. Hãy thử điều chỉnh cài đặt stream nếu việc dừng đột ngột vẫn tiếp tục. Trình giải mã video bị dừng đột ngột GFE đã trả lời bằng lỗi HTTP 404. Hãy chắc chắn rằng PC của bạn đang chạy một GPU được hỗ trợ. Việc sử dụng phần mềm máy tính từ xa cũng có thể gây ra lỗi này. Hãy thử khởi động lại máy hoặc cài đặt lại GFE. Xử lý máy chủ thất bại @@ -173,19 +170,7 @@ Máy tính hiện đang trong một trò chơi. Bạn phải đóng trò chơi trước khi ghép đôi. Máy tính đang ngoại tuyến Đang ghép đôi… - Kết nối mạng hiện tại của thiết bị đang chặn Moonlight. Việc stream qua Internet có thể sẽ không được khi đang kết nối đến mạng này. - Kết nối mạng hiện tại của thiết bị có vẻ là đang chặn Moonlight. Việc stream qua Internet có thể sẽ không được khi đang kết nối đến mạng này. -\n -\nCác cổng mạng sau bị chặn: -\n - Việc thử nghiệm mạng không thể được thực hiện vì không có máy chủ thử nghiệm kết nối nào của Moonlight có thể được kết nối đến. Hãy kiểm tra kết nối Internet của bạn hoặc thử lại sau. - Mạng của bạn có vẻ là không chặn Moonlight. Nếu bạn vẫn gặp rắc rối trong việc kết nối, hãy kiểm tra cài đặt tường lửa của PC. -\n -\nNếu bạn đang cố stream qua Internet, hãy cài đặt Moonlight Internet Hosting Tool trên PC và chạy Internet Streaming Tester được bao gồm trong đó để kiểm tra kết nối Internet của PC. Thử nghiệm mạng hoàn tất - Moonlight đang thử nghiệm kết nối mạng của bạn để xác định xem NVIDIA GameStream có bị chặn không. -\n -\nViệc này có thể sẽ mất một vài giây… Đang thử nghiệm kết nối mạng Xem chi tiết Thử nghiệm kết nối mạng diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index c03cd575fa..51d0bc40cc 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -42,7 +42,6 @@ 无法解析主机地址 GFE返回了HTTP 404 错误,确保你的电脑显卡支持串流。使用远程桌面软件同样会引起此错误,请尝试重启电脑或重装GFE。 视频解码器崩溃 - 由于与该设备的视频解码器不兼容,Moonlight已崩溃。如果崩溃继续,请尝试调整串流设置。 重置视频设置 由于设备的视频解码器在你选择的串流设置上持续崩溃,已重置你的串流设置。 设备管理员已禁止USB访问。请检查您的Knox或MDM设置。 @@ -133,7 +132,6 @@ Xbox 360/One 手柄驱动 为缺少原生Xbox手柄支持的设备启用内置USB驱动 覆盖安卓手柄支持 - 强制Moonlight的USB驱动接管所有受支持的Xbox手柄 通过手柄模拟鼠标 长按开始键将手柄切换为鼠标模式 启用前进后退鼠标键 @@ -154,7 +152,6 @@ 屏幕按钮布局已经重置 界面设置 语言 - 选择Moonlight显示的语言 使用小图标 使用小图标以在屏幕上显示更多项目 主机设置 @@ -177,18 +174,7 @@ 透明度 % 令屏幕按钮变得更透明/更不透明 - 您设备的当前网络似乎正在阻止Moonlight。连接到该网络时,可能无法通过互联网串流。 -\n -\n以下网络端口被阻止: -\n - 网络测试无法执行,因为无法连接到Moonlight的连接测试服务器。检查您的互联网连接或稍后再试。 - 您的网络似乎没有屏蔽Moonlight。如果您仍然无法连接,请检查您的电脑的防火墙设置。 -\n -\n如果您想通过互联网进行流媒体传输,请在您的电脑上安装Moonlight互联网主机工具,并运行附带的互联网流媒体测试器来检查您的电脑的互联网连接。 网络测试完成 - Moonlight正在测试你的网络连接,请确保所需端口没有被屏蔽。 -\n -\n这可能需要几秒钟…… 测试网络连接中 测试网络连接 刷新中 @@ -212,7 +198,6 @@ 您的网络连接质量不佳。请降低视频码率设置或更换更快的网络连接。 没有接收到来自主机的视频。 视频解码器初始化失败。您的设备可能不支持选定的分辨率或帧数。 - 您设备当前的网络连接拦截了Moonlight。连接到该网络时可能无法通过互联网串流。 平均网络延迟: %1$d ms (抖动: %2$d ms) 视频流: %1$s %2$.2f FPS 原生全屏 @@ -237,7 +222,6 @@ 优先视频流畅度(可能会显著增大延迟) 查看有关如何设置游戏电脑以进行流传输的说明 隐私政策 - 查看 Moonlight 隐私政策 帮助 安装向导 解决方案向导 @@ -245,7 +229,6 @@ 指定如何平衡视频延迟和流畅度 有FPS限制的平衡 优先最低延迟 - 注意:有些游戏可以执行一个比Moonlight摇杆配置的更大的盲区。 适合远程桌面的鼠标模式 这可以使得鼠标加速在远程桌面使用中表现得更自然,但它与许多游戏不兼容。 (横向) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 7f3a3429d8..efbcaaef13 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -42,7 +42,6 @@ 無法解析主機位址 GFE 傳回了 HTTP 404 錯誤,請確保你的電腦 GPU 支援串流。使用遠端桌面軟體同樣會引起此錯誤,請嘗試重啟電腦或重新安裝 GFE。 視訊解碼器已當機 - 由於與這個裝置的視訊解碼器不相容,Moonlight 已當機。若當機持續,請嘗試調整串流設定。 重設視訊設定 由於裝置的視訊解碼器在你選取的串流設定上持續當機,已重設你的串流設定。 裝置管理員已禁止 USB 訪問。請檢查您的 Knox 或 MDM 設定。 @@ -134,7 +133,6 @@ Xbox 360/One USB 手把驅動程式 為缺少原生 Xbox 控制器支援的裝置啟用內建 USB 驅動程式 覆寫原生 Xbox 手把支援 - 強制 Moonlight 的 USB 驅動程式接管所有受支援的原生 Xbox 控制器 透過手把仿真滑鼠 長按開始鍵將手把切換為滑鼠模式 啟用前進後退滑鼠鍵 @@ -155,7 +153,6 @@ 螢幕控制按鈕已重設為預設值 使用者介面設定 語言 - 選擇 Moonlight 顯示的語言 使用小圖示 使用小圖示以在螢幕上顯示更多應用程式 主機設定 @@ -196,19 +193,7 @@ 您的網路連線品質不佳。降低視訊位元速率設定或更換更快的連線。 沒有接收到來自主機的視訊。 無法初始化視訊解碼器,您的裝置可能不支援選取的解析度或影格速率。 - 您裝置目前的網路連線封鎖了 Moonlight。連線到該網路時可能無法透過網際網路串流。 - 您裝置目前的網路連線似乎封鎖了 Moonlight。連線到該網路時可能無法透過網際網路串流。 -\n -\n以下網路通訊埠被封鎖: -\n - 由於沒有 Moonlight 連線測試伺服器可供訪問,因此無法執行網路測試。請檢查您的 Internet 連線或稍後再試。 - 您的網路似乎沒有封鎖 Moonlight。如果仍然無法連線,請檢查您電腦的防火牆設定。 -\n -\n如果您是嘗試透過網際網路串流,請在您的電腦上安裝 Moonlight Internet Hosting Tool,然後執行裡面的 Internet Streaming Tester 來檢查電腦的網際網路連線。 網路檢測完畢 - Moonlight 正在測試您的網路連線以確認必要連接埠是否被封鎖。 -\n -\n可能需要等待一些時間… 正在測試網路連線 測試網路連線 正在重新整理 @@ -245,8 +230,6 @@ 隱私權政策 設定指南 檢視診斷和修正常見串流問題的提示 - 檢視 Moonlight 的隱私權政策 - 注意:有些遊戲可以強制執行一個比 Moonlight 設定大的死區。 遠端桌面滑鼠模式 這可以讓滑鼠在遠端桌面使用中的加速表現更加自然,但與很多遊戲不相容。 啟用系統等化器支援 diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml index edbfd3a922..013386bc88 100644 --- a/app/src/main/res/values/ic_launcher_background.xml +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -1,4 +1,4 @@ - #565C64 + #1A1630 \ No newline at end of file diff --git a/app/src/main/res/values/mc_colors.xml b/app/src/main/res/values/mc_colors.xml new file mode 100644 index 0000000000..29791126ba --- /dev/null +++ b/app/src/main/res/values/mc_colors.xml @@ -0,0 +1,10 @@ + + + #FF06070D + #FF11141F + #FFEEF2F9 + #FF9AA2B4 + #FFD2683A + #FFF5915B + #FF7AA0FF + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b20e8beecc..5419b95578 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -26,12 +26,12 @@ Testing Network Connection - Moonlight is testing your network connection to determine if any required ports are blocked.\n\nThis may take a few seconds… + MoonCast is testing your network connection to see if any ports it needs are blocked.\n\nThis may take a few seconds… Network Test Complete - Your network does not appear to be blocking Moonlight. If you still have trouble connecting, check your PC\'s firewall settings.\n\nIf you are trying to stream over the Internet, install the Moonlight Internet Hosting Tool on your PC and run the included Internet Streaming Tester to check your PC\'s Internet connection. - The network test could not be performed because none of Moonlight\'s connection testing servers were reachable. Check your Internet connection or try again later. - Your device\'s current network connection seems to be blocking Moonlight. Streaming over the Internet may not work while connected to this network.\n\nThe following network ports were blocked:\n - Your device\'s current network connection is blocking Moonlight. Streaming over the Internet may not work while connected to this network. + Your network doesn\'t appear to be blocking MoonCast. If you still have trouble connecting, check your PC\'s firewall settings.\n\nTo play away from home, connect a VPN to your home network, or open Sunshine\'s ports on your router. + The network test couldn\'t run because none of its test servers could be reached. Check your Internet connection or try again later. + Your device\'s current network connection seems to be blocking MoonCast. Streaming over the Internet may not work on this network.\n\nThese network ports were blocked:\n + Your device\'s current network connection is blocking MoonCast. Streaming over the Internet may not work on this network. Pairing… @@ -67,7 +67,7 @@ Using remote desktop software can also cause this error. Try rebooting your machine or reinstalling GFE. Video Decoder Crashed - Moonlight has crashed due to an incompatibility with this device\'s video decoder. Try adjusting the streaming settings if the crashes continue. + MoonCast stopped because of a problem with this device\'s video decoder. Try changing the streaming settings if it keeps happening. Video Settings Reset Your device\'s video decoder continues to crash at your selected streaming settings. Your streaming settings have been reset to default. USB access is prohibited by your device administrator. Check your Knox or MDM settings. @@ -177,12 +177,12 @@ Amplify or reduce the vibration intensity on your device % Adjust analog stick deadzone - Note: Some games can enforce a larger deadzone than what Moonlight is configured to use. + Note: some games use a larger deadzone than the one set here. % Xbox 360/One USB gamepad driver Enables a built-in USB driver for devices without native Xbox controller support Override native Xbox gamepad support - Use Moonlight\'s USB driver for all supported gamepads, even if native Xbox controller support is present + Use MoonCast\'s USB driver for all supported gamepads, even if native Xbox controller support is present Mouse emulation via gamepad Long pressing the Start button will switch the gamepad into mouse mode Flip face buttons @@ -225,7 +225,7 @@ Enable Picture-in-Picture observer mode Allows the stream to be viewed (but not controlled) while multitasking Language - Language to use for Moonlight + Language to use for MoonCast Use small box art Smaller box art in the app grid allows more apps to be visible on screen @@ -261,13 +261,13 @@ Troubleshooting guide View tips for diagnosing and fixing common streaming issues Privacy policy - View Moonlight\'s privacy policy + View the privacy policy Gamepad type may be changed due to motion sensor emulation - Moonlight requires local network permissions to communicate with nearby hosts + MoonCast needs local network permission to find and talk to your PCs and consoles nearby 360p diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index f47bebe739..64b335d745 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -1,37 +1,45 @@ - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index 5b09082b35..1b7dbee17a 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -129,11 +129,7 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/tv/java/com/limelight/PhoneLink.java b/app/src/tv/java/com/limelight/PhoneLink.java new file mode 100644 index 0000000000..dffa8b3ad5 --- /dev/null +++ b/app/src/tv/java/com/limelight/PhoneLink.java @@ -0,0 +1,520 @@ +package com.limelight; + +import android.content.Context; +import android.content.Intent; +import android.os.Handler; +import android.os.Looper; + +import com.limelight.computers.ComputerDatabaseManager; +import com.limelight.home.Ps5Store; +import com.limelight.home.Xbox; +import com.limelight.nvstream.http.ComputerDetails; +import com.limelight.nvstream.http.NvApp; +import com.limelight.nvstream.http.NvHTTP; +import com.limelight.utils.CacheHelper; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.StringReader; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.List; + +/** + * The phone's channel to this TV: a tiny HTTP server on :8099. The phone (or Odin) POSTs a game to /play and this TV + * starts it with native Moonlight or native chiaki; /register links a PlayStation through this TV; /mc-native lets the + * phone find us. Moved out of the old receiver screen so the Big Picture home can keep listening while you browse. + * + * PS5 keys that arrive here are saved to {@link Ps5Store}, so the console also appears on the TV's own shelf. + */ +public class PhoneLink { + public static final int PORT = 8099; + /** What /mc-native reports as "version". 2 = /play accepts xboxId (+ xboxName). 3 = POST /stop. + * 4 = /play "pad":"direct" (the sender's controller goes straight to the PC) and POST /pad (relay after all). + * 5 = with "pad":"direct" the TV's stream registers no gamepad at all, so the sender's session is pad 0. + * 6 = GET /status: how the stream the last /play started is going ({"state","error","seq"}). */ + public static final int PROTOCOL_VERSION = 6; + + // GET /status: "idle", "starting" (after /play), "playing" or "failed" with the player's own words + private static volatile String playState = "idle"; + private static volatile long lastPlayAt; // when the last /play came in + private static volatile String playError = null; + private static volatile int playSeq = 0; + + // The stream screens open right now (PC, PS5, Xbox), so /stop can end them. Tracked once for the whole app. + private static final java.util.Set streams = + java.util.Collections.newSetFromMap(new java.util.WeakHashMap<>()); + private static boolean tracking; + + private static synchronized void track(Context app) { + if (tracking || !(app instanceof android.app.Application)) return; + tracking = true; + com.limelight.home.StreamReport.setListener((state, msg) -> { + playState = state; playError = "failed".equals(state) ? msg : null; + return kotlin.Unit.INSTANCE; + }); + ((android.app.Application) app).registerActivityLifecycleCallbacks(new android.app.Application.ActivityLifecycleCallbacks() { + private boolean isStream(android.app.Activity a) { + return a instanceof Game || a instanceof Ps5StreamActivity || a instanceof XboxStreamActivity; + } + @Override public void onActivityCreated(android.app.Activity a, android.os.Bundle b) { if (isStream(a)) synchronized (streams) { streams.add(a); } } + @Override public void onActivityDestroyed(android.app.Activity a) { + synchronized (streams) { + streams.remove(a); + // #41: the last stream screen closed, so nothing is playing any more. A failure stays reported + // (with its words) until the next /play, so the sender can still read why. + if (isStream(a) && streams.isEmpty() && !"failed".equals(playState)) playState = "idle"; + // #40: forget the sender, unless a new /play just came in (a game switch: the old screen can + // close after the next game's /play has already set the sender) + if (isStream(a) && streams.isEmpty() && System.currentTimeMillis() - lastPlayAt > 10_000) PhoneGamepad.forgetPhone(); + } + } + @Override public void onActivityStarted(android.app.Activity a) { } + @Override public void onActivityResumed(android.app.Activity a) { } + @Override public void onActivityPaused(android.app.Activity a) { } + @Override public void onActivityStopped(android.app.Activity a) { } + @Override public void onActivitySaveInstanceState(android.app.Activity a, android.os.Bundle b) { } + }); + } + + /** What the home screen shows while a sent game starts. Called on the main thread. */ + public interface Listener { + void onIncoming(String title, String from); + void onConsolesChanged(); + /** The Odin started registering a PlayStation through this TV. */ + default void onRegistering(String host) { } + /** Registration finished: the console's name, or null if it failed. */ + default void onRegistered(String host, String name) { } + } + + private final Context ctx; + private final Listener listener; + private final Handler main = new Handler(Looper.getMainLooper()); + private volatile boolean running = true; + private ServerSocket server; + + public PhoneLink(Context ctx, Listener listener) { + this.ctx = ctx.getApplicationContext(); + this.listener = listener; + track(this.ctx); + } + + public void start() { new Thread(this::serve, "mc-phone-link").start(); } + + public void stop() { + running = false; + try { if (server != null) server.close(); } catch (Exception ignored) { } + } + + // Prefer a PAIRED PC (it has a stored server certificate - set only after a successful pairing), so a stray + // auto-discovered PC that was never paired can't win. Fall back to the first PC if none is paired yet. + private ComputerDetails firstComputer() { + ComputerDatabaseManager db = new ComputerDatabaseManager(ctx); + try { + List all = db.getAllComputers(); + if (all.isEmpty()) return null; + for (ComputerDetails c : all) { + if (c.serverCert != null) return c; + } + return all.get(0); + } finally { + db.close(); + } + } + + private void serve() { + try { + server = new ServerSocket(); + server.setReuseAddress(true); + server.bind(new InetSocketAddress(PORT)); + while (running) { + Socket s = server.accept(); + new Thread(() -> handle(s)).start(); + } + } catch (Exception ignored) { } + } + + private void handle(Socket s) { + try { + s.setSoTimeout(4000); + BufferedReader in = new BufferedReader(new InputStreamReader(s.getInputStream(), StandardCharsets.UTF_8)); + String line = in.readLine(); + if (line == null) { s.close(); return; } + String[] p = line.split(" "); + String method = p.length > 0 ? p[0] : "", path = p.length > 1 ? p[1] : ""; + int clen = 0; String l; + while ((l = in.readLine()) != null && !l.isEmpty()) { + int c = l.indexOf(':'); + if (c > 0 && l.substring(0, c).trim().equalsIgnoreCase("Content-Length")) + try { clen = Integer.parseInt(l.substring(c + 1).trim()); } catch (Exception ignored) { } + } + String from = "your phone"; + if ("GET".equals(method) && path.startsWith("/mc-native")) { + ComputerDetails pc = firstComputer(); + // version 2: /play takes xboxId too. "xbox" = this TV is signed in to Xbox (it plays with its own sign-in). + // "id" stays the same when the TV's address changes, so a handheld can remember it. + send(s, 200, "{\"mooncast_native\":1,\"name\":" + quote(android.os.Build.MODEL) + + ",\"pc\":" + quote(pc == null ? "" : pc.name) + + ",\"xbox\":" + Xbox.INSTANCE.signedIn(ctx) + + ",\"version\":" + PROTOCOL_VERSION + + ",\"id\":" + quote(tvId()) + + ",\"identityKey\":" + quote(identityKey()) + + ",\"paired\":" + pairedNow(pc) + "}"); + } else if ("GET".equals(method) && path.startsWith("/status")) { + send(s, 200, "{\"state\":" + quote(playState) + ",\"error\":" + quote(playError == null ? "" : playError) + + ",\"seq\":" + playSeq + "}"); + } else if ("POST".equals(method) && path.startsWith("/play")) { + playSeq++; playState = "starting"; playError = null; + lastPlayAt = System.currentTimeMillis(); + String json = readBody(in, clen, 16384); // read it all: a PSN token makes it bigger than one read + String appId = field(json, "appId"), appName = field(json, "appName"); + String sender = field(json, "deviceName"); + if (sender != null && !sender.isEmpty()) from = sender; + // The phone is the controller: remember where it is (the address it POSTed from, or an explicit phoneIp). + String phoneIp = field(json, "phoneIp"); + if (phoneIp == null || phoneIp.isEmpty()) { + try { phoneIp = s.getInetAddress().getHostAddress(); } catch (Exception ignored) { } + } + // "pad":"direct" (PC games): the sender has its own controller session on the PC, so no relay from here. + // Set before the key, which may restart the relay. + PhoneGamepad.setDirect("direct".equals(field(json, "pad")) && field(json, "ps5host") == null && field(json, "xboxId") == null); + PhoneGamepad.setPhoneHost(phoneIp); + PhoneGamepad.setPadKey(field(json, "padKey")); // key to authorize with the phone's pad server + // A device with no PC pairing sent its identity: use it for this session only. + String identity = field(json, "identity"); + if (identity != null && !identity.isEmpty() && !acceptIdentity(identity)) { + send(s, 400, "{\"error\":\"identity not accepted\"}"); + s.close(); + return; + } + // PS5 Remote Play (native chiaki): the phone sends the console's host + registration keys. + String ps5host = field(json, "ps5host"); + if (ps5host != null && !ps5host.isEmpty()) { + if (playPs5(ps5host, field(json, "registKey"), field(json, "morning"), field(json, "ps5"), + field(json, "psnToken"), field(json, "duid"), field(json, "accountId"), from)) + send(s, 200, "{\"ok\":1}"); + else send(s, 400, "{\"error\":\"ps5 launch failed\"}"); + } else if (field(json, "xboxId") != null) { + // Xbox: this TV plays it with its OWN Xbox sign-in; the sender is only the controller. + String err = playXbox(field(json, "xboxId"), field(json, "xboxName"), from); + if (err == null) send(s, 200, "{\"ok\":1}"); + else send(s, 400, "{\"error\":" + quote(err) + "}"); + } else if ((appId != null || appName != null) && play(appId, appName, from)) send(s, 200, "{\"ok\":1}"); + else send(s, 400, "{\"error\":\"no app id, or no paired PC\"}"); + } else if ("POST".equals(method) && path.startsWith("/stop")) { + endSessionIdentity(ctx); + // The sender takes its game back (Play here instead) or stops: end the stream it started here and go + // home. Disconnect only; {"quit":true} quits the game on the PC too. Only the sender of the last + // /play may do this (it presents the same padKey). + String json = readBody(in, clen, 2048); + String key = field(json, "padKey"), mine = PhoneGamepad.padKey(); + if (mine != null && !mine.isEmpty() && !mine.equals(key)) send(s, 403, "{\"error\":\"not the device that sent this game\"}"); + else { + boolean quit = json.replace(" ", "").contains("\"quit\":true"); + stopStreams(quit); + send(s, 200, "{\"ok\":1}"); + } + } else if ("POST".equals(method) && path.startsWith("/pad")) { + // The sender's direct controller session didn't connect: relay its pad through this TV after all. + String json = readBody(in, clen, 2048); + String key = field(json, "padKey"), mine = PhoneGamepad.padKey(); + if (mine != null && !mine.isEmpty() && !mine.equals(key)) send(s, 403, "{\"error\":\"not the device that sent this game\"}"); + else { + String ip = field(json, "phoneIp"); + if (ip == null || ip.isEmpty()) ip = s.getInetAddress().getHostAddress(); + PhoneGamepad.setPhoneHost(ip); + PhoneGamepad.useRelay(); + send(s, 200, "{\"ok\":1}"); + } + } else if ("POST".equals(method) && path.startsWith("/register")) { + // Register a PlayStation (the PIN "Link Device" flow): host + PSN account id (base64) + 8-digit pin. + String json = readBody(in, clen, 2048); + String host = field(json, "host"); + main.post(() -> listener.onRegistering(host)); + try { + byte[] accountId = android.util.Base64.decode(field(json, "accountId"), android.util.Base64.DEFAULT); + int pin = Integer.parseInt(field(json, "pin").trim()); + boolean isPs5 = !"false".equalsIgnoreCase(field(json, "ps5")); + Ps5Register.Result r = Ps5Register.INSTANCE.register(host, accountId, pin, isPs5, 30000L); + if (r != null) { + Ps5Store.INSTANCE.save(ctx, new Ps5Store.Console(host, r.getNickname() == null ? "PS5" : r.getNickname(), + r.getRegistKeyHex(), r.getMorningHex(), isPs5)); + final String nick = r.getNickname() == null ? "PS5" : r.getNickname(); + main.post(() -> { listener.onConsolesChanged(); listener.onRegistered(host, nick); }); + send(s, 200, "{\"ok\":1,\"registKey\":\"" + r.getRegistKeyHex() + "\",\"morning\":\"" + + r.getMorningHex() + "\",\"nickname\":" + quote(r.getNickname()) + "}"); + } + else { + main.post(() -> listener.onRegistered(host, null)); + send(s, 400, "{\"error\":\"registration failed - check the 8-digit code and that the console is on\"}"); + } + } catch (Exception e) { + main.post(() -> listener.onRegistered(host, null)); + send(s, 400, "{\"error\":\"bad register request\"}"); + } + } else { + send(s, 404, "{\"error\":\"not found\"}"); + } + s.close(); + } catch (Exception ignored) { try { s.close(); } catch (Exception e) { } } + } + + private static String readBody(BufferedReader in, int clen, int max) throws java.io.IOException { + char[] body = new char[Math.max(0, Math.min(clen, max))]; + int n = 0; + while (n < body.length) { int r = in.read(body, n, body.length - n); if (r < 0) break; n += r; } + return new String(body, 0, Math.max(0, n)); + } + + // Show "sent from" on the TV for a moment, then start the stream through the trampoline (it handles the paired + // PC and the connection). + /** The last answer from the PC about this TV's pairing. Null until the first check finishes. */ + private static volatile Boolean pcAccepts; + private static final java.util.concurrent.atomic.AtomicBoolean checking = new java.util.concurrent.atomic.AtomicBoolean(); + + /** + * Whether this TV can really stream from [pc]. A saved certificate alone is not enough: the PC must still accept this + * TV's identity. The PC is asked in the background, so the discovery reply stays quick; it reports the last answer. + */ + private boolean pairedNow(ComputerDetails pc) { + if (pc == null || pc.serverCert == null) return false; + if (checking.compareAndSet(false, true)) { + final ComputerDetails target = pc; + new Thread(() -> { + try { pcAccepts = pcAcceptsUs(target); } finally { checking.set(false); } + }).start(); + } + Boolean last = pcAccepts; + return last == null || last; + } + + private boolean pcAcceptsUs(ComputerDetails pc) { + try { + ComputerDetails.AddressTuple addr = pc.localAddress != null ? pc.localAddress : pc.activeAddress; + if (addr == null) return false; + int httpsPort = pc.httpsPort > 0 ? pc.httpsPort : 47984; + com.limelight.nvstream.http.NvHTTP http = new com.limelight.nvstream.http.NvHTTP(addr, httpsPort, + new com.limelight.computers.IdentityManager(ctx).getUniqueId(), pc.serverCert, + com.limelight.binding.PlatformBinding.getCryptoProvider(ctx)); + return http.getPairState() == com.limelight.nvstream.http.PairingManager.PairState.PAIRED; + } catch (Exception e) { + return false; + } + } + + /** The PC a sent identity came with, while its session is open. Null when this TV uses its own pairing. */ + private static volatile String sessionPcUuid; + + /** Opens an identity sent by another MoonCast device: saved as session files, and its PC added for this session. */ + private boolean acceptIdentity(String blob) { + try { + identityKey(); // makes this run's receiver key if it isn't there yet + org.json.JSONObject j = com.limelight.identity.IdentityTransfer.openIdentity( + identityKeys.getPrivate(), com.limelight.identity.IdentityTransfer.rawPublic(identityKeys.getPublic()), blob); + java.io.File dir = ctx.getFilesDir(); + writeFile(new java.io.File(dir, "session-client.crt"), android.util.Base64.decode(j.getString("cert"), android.util.Base64.DEFAULT)); + writeFile(new java.io.File(dir, "session-client.key"), android.util.Base64.decode(j.getString("key"), android.util.Base64.DEFAULT)); + + ComputerDetails pc = new ComputerDetails(); + pc.uuid = j.getString("uuid"); + pc.name = j.getString("name"); + pc.localAddress = new ComputerDetails.AddressTuple(j.getString("address"), j.getInt("port")); + pc.activeAddress = pc.localAddress; + pc.serverCert = (java.security.cert.X509Certificate) java.security.cert.CertificateFactory.getInstance("X.509") + .generateCertificate(new java.io.ByteArrayInputStream( + android.util.Base64.decode(j.getString("serverCert"), android.util.Base64.DEFAULT))); + pc.pairState = com.limelight.nvstream.http.PairingManager.PairState.PAIRED; + pc.httpsPort = 47984; + pc.state = ComputerDetails.State.ONLINE; + ComputerDatabaseManager db = new ComputerDatabaseManager(ctx); + try { db.updateComputer(pc); } finally { db.close(); } + sessionPcUuid = pc.uuid; + com.limelight.computers.ComputerManagerService.addSessionComputer(pc); + return true; + } catch (Exception e) { + endSessionIdentity(ctx); + return false; + } + } + + private static void writeFile(java.io.File f, byte[] data) throws java.io.IOException { + try (java.io.FileOutputStream out = new java.io.FileOutputStream(f)) { out.write(data); } + } + + /** Tells the home screen a session PC went away, so its card goes at once. */ + public interface SessionListener { void onSessionEnded(String uuid); } + public static volatile SessionListener sessionListener; + + /** Removes the session identity and its PC entry. Called when the stream stops and when the TV app closes. */ + public static void endSessionIdentity(Context c) { + new java.io.File(c.getFilesDir(), "session-client.crt").delete(); + new java.io.File(c.getFilesDir(), "session-client.key").delete(); + String uuid = sessionPcUuid; + sessionPcUuid = null; + if (uuid != null) { + ComputerDatabaseManager db = new ComputerDatabaseManager(c); + try { + ComputerDetails pc = db.getComputerByUUID(uuid); + if (pc != null) { + com.limelight.computers.ComputerManagerService.removeSessionComputer(pc); + db.deleteComputer(pc); + } + } finally { db.close(); } + SessionListener l = sessionListener; + if (l != null) l.onSessionEnded(uuid); + } + } + + private boolean play(String appId, String appName, String from) { + final ComputerDetails pc; + if (sessionPcUuid != null) { + ComputerDatabaseManager db = new ComputerDatabaseManager(ctx); + try { pc = db.getComputerByUUID(sessionPcUuid); } finally { db.close(); } + } else pc = firstComputer(); + if (pc == null) return false; + // Any sender (phone, tablet, Odin) names the game, and the game ID is looked up on THIS TV's PC. A sender's own + // ID is only the fallback: its PC list may number the games differently. + final String localId = appName == null ? null : localAppId(pc.uuid, appName); + if (localId == null && appId == null) return false; + main.post(() -> listener.onIncoming(appName == null ? "Your game" : appName, from)); + main.postDelayed(() -> { + replaceStreams(); + Intent i = new Intent(ctx, ShortcutTrampoline.class); + i.putExtra(AppView.UUID_EXTRA, pc.uuid); + i.putExtra(AppView.NAME_EXTRA, pc.name); + i.putExtra(Game.EXTRA_APP_ID, localId != null ? localId : appId); + if (appName != null) i.putExtra(Game.EXTRA_APP_NAME, appName); + i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + ctx.startActivity(i); + }, 700); + return true; + } + + /** The game's ID in this TV's cached app list for the PC, or null if the list isn't cached or has no such game. */ + private String localAppId(String uuid, String name) { + try { + String raw = CacheHelper.readInputStreamToString(CacheHelper.openCacheFileForInput(ctx.getCacheDir(), "applist", uuid)); + if (raw == null || raw.isEmpty()) return null; + for (NvApp a : NvHTTP.getAppListByReader(new StringReader(raw))) + if (a.getAppName().equals(name)) return String.valueOf(a.getAppId()); + } catch (Exception ignored) { } + return null; + } + + /** A new /play replaces whatever player is open (even one showing an error): close it, the new one starts fresh. */ + private static void replaceStreams() { + java.util.List list; + synchronized (streams) { list = new java.util.ArrayList<>(streams); } + for (android.app.Activity a : list) if (!a.isFinishing()) a.finish(); + } + + /** Finish every stream screen (PC, PS5, Xbox) and bring the home back. */ + private void stopStreams(boolean quit) { + main.post(() -> { + java.util.List list; + synchronized (streams) { list = new java.util.ArrayList<>(streams); } + for (android.app.Activity a : list) { + if (a.isFinishing()) continue; + if (quit && a instanceof Game) ((Game) a).quitOnHostAndFinish(); + else a.finish(); // disconnect: the game keeps running (a PS5 stays on) + } + PhoneGamepad.stop(); + Intent home = new Intent(ctx, com.limelight.home.HomeActivity.class); + home.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + try { ctx.startActivity(home); } catch (Exception ignored) { } + }); + } + + /** Start an Xbox the sender named, if this TV's Xbox account has it. Returns an error for the sender, or null. */ + private String playXbox(String id, String name, String from) { + if (!Xbox.INSTANCE.signedIn(ctx)) return "Sign in to Xbox on this TV first"; + Xbox.Console found = null; + for (Xbox.Console c : Xbox.INSTANCE.consoles(ctx)) if (c.getId().equals(id)) found = c; + if (found == null) { // a console added since the TV last looked: ask Xbox network once + try { for (Xbox.Console c : Xbox.INSTANCE.refreshConsoles(ctx)) if (c.getId().equals(id)) found = c; } + catch (Exception ignored) { } + } + if (found == null) return "This TV's Xbox account doesn't have " + (name == null || name.isEmpty() ? "that Xbox" : name); + final Xbox.Console c = found; + XboxStreamActivity.setRemotePad(XboxPhonePad.INSTANCE); + main.post(() -> listener.onIncoming(c.getName(), from)); + main.postDelayed(() -> { + replaceStreams(); + Intent i = com.limelight.home.Launch.xboxIntent(ctx, c); + i.putExtra(XboxStreamActivity.EXTRA_REMOTE_PAD, true); + i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + ctx.startActivity(i); + }, 700); + return null; + } + + /** Same for the life of this install; lets a handheld recognise this TV after its address changes. */ + /** This TV's public key for identity transfer (raw X25519, base64). Made once per run; the private half never leaves. */ + private static java.security.KeyPair identityKeys; + + private synchronized String identityKey() { + try { + if (identityKeys == null) identityKeys = com.limelight.identity.IdentityTransfer.newReceiverKeys(); + return android.util.Base64.encodeToString( + com.limelight.identity.IdentityTransfer.rawPublic(identityKeys.getPublic()), android.util.Base64.NO_WRAP); + } catch (Exception e) { + return ""; + } + } + + private String tvId() { + String a = android.provider.Settings.Secure.getString(ctx.getContentResolver(), android.provider.Settings.Secure.ANDROID_ID); + return a == null ? "" : Integer.toHexString(a.hashCode()); + } + + private boolean playPs5(String host, String registKey, String morning, String ps5, + String psnToken, String duid, String accountId, String from) { + if (registKey == null || morning == null) return false; + final boolean isPs5 = ps5 == null || !"false".equalsIgnoreCase(ps5); + Ps5Store.INSTANCE.save(ctx, new Ps5Store.Console(host, "PS5", registKey, morning, isPs5)); + main.post(() -> { listener.onConsolesChanged(); listener.onIncoming("PlayStation 5", from); }); + main.postDelayed(() -> { replaceStreams(); ctx.startActivity(ps5Intent(ctx, host, registKey, morning, isPs5, psnToken, duid, accountId)); }, 700); + return true; + } + + /** The intent that starts a native PS5 Remote Play session. The PSN fields are only for play away from home. */ + public static Intent ps5Intent(Context ctx, String host, String registKey, String morning, boolean isPs5, + String psnToken, String duid, String accountId) { + return com.limelight.home.Launch.ps5Intent(ctx, host, registKey, morning, isPs5, psnToken, duid, accountId); + } + + private static String field(String json, String key) { + try { + int i = json.indexOf("\"" + key + "\""); + if (i < 0) return null; + int colon = json.indexOf(':', i); + int a = json.indexOf('"', colon + 1); + if (a < 0) { // numeric value + int st = colon + 1; while (st < json.length() && " \t".indexOf(json.charAt(st)) >= 0) st++; + int e = st; while (e < json.length() && "0123456789".indexOf(json.charAt(e)) >= 0) e++; + return e > st ? json.substring(st, e) : null; + } + int b = json.indexOf('"', a + 1); + // org.json writes "/" as "\/" (base64 account ids and tokens can contain "/") + return b > a ? json.substring(a + 1, b).replace("\\/", "/") : null; + } catch (Exception e) { return null; } + } + + private static String quote(String s) { + if (s == null) s = ""; + StringBuilder b = new StringBuilder("\""); + for (char c : s.toCharArray()) { if (c == '"' || c == '\\') b.append('\\'); b.append(c >= 32 ? c : ' '); } + return b.append('"').toString(); + } + + private static void send(Socket s, int code, String body) throws Exception { + byte[] by = body.getBytes(StandardCharsets.UTF_8); + OutputStream o = s.getOutputStream(); + o.write(("HTTP/1.1 " + code + " OK\r\nContent-Type: application/json\r\nContent-Length: " + by.length + + "\r\nAccess-Control-Allow-Origin: *\r\nConnection: close\r\n\r\n").getBytes(StandardCharsets.UTF_8)); + o.write(by); o.flush(); + } +} diff --git a/app/src/tv/java/com/limelight/XboxPhonePad.kt b/app/src/tv/java/com/limelight/XboxPhonePad.kt new file mode 100644 index 0000000000..59030589ae --- /dev/null +++ b/app/src/tv/java/com/limelight/XboxPhonePad.kt @@ -0,0 +1,137 @@ +package com.limelight + +import android.util.Base64 +import android.util.Log +import org.json.JSONObject +import java.io.InputStream +import java.io.OutputStream +import java.net.InetSocketAddress +import java.net.Socket +import java.nio.charset.StandardCharsets +import java.security.SecureRandom + +/** + * The sender's controller for an Xbox game sent to this TV. Same link as [PhoneGamepad] and [Ps5Pad]: connect to the + * sender's pad server (ws://:8099/pad?k=), read {"pads":[{"b":[17],"a":[4]}]} and hand pad 0 to + * XboxStreamActivity, which drives player 1 with it. Registered with XboxStreamActivity.remotePad by [PhoneLink]. + */ +object XboxPhonePad : XboxStreamActivity.RemotePad { + private const val TAG = "MoonCast-XboxPad" + private const val PAD_PORT = 8099 + @Volatile private var running = false + @Volatile private var sock: Socket? = null + @Volatile private var sink: ((DoubleArray, DoubleArray) -> Unit)? = null + + override fun start(sink: (DoubleArray, DoubleArray) -> Unit) { + stop() + val h = PhoneGamepad.phoneHost() ?: return + this.sink = sink + running = true + Thread({ run(h) }, "xbox-phone-pad").start() + } + + override fun stop() { + running = false + sink = null + try { sock?.close() } catch (e: Exception) { } + sock = null + } + + private fun run(h: String) { + var backoff = 1000L + while (running) { + try { session(h); backoff = 1000L } catch (e: Exception) { Log.w(TAG, "pad link: $e") } + if (!running) break + try { Thread.sleep(backoff) } catch (e: InterruptedException) { break } + if (backoff < 8000L) backoff += 1000L + } + } + + private fun session(h: String) { + val s = Socket() + s.connect(InetSocketAddress(h, PAD_PORT), 2000) + s.tcpNoDelay = true + s.soTimeout = 25000 // the sender sends a heartbeat every few seconds; silence this long = a dead link + sock = s + val out = s.getOutputStream(); val inp = s.getInputStream() + val key = Base64.encodeToString(ByteArray(16).also { SecureRandom().nextBytes(it) }, Base64.NO_WRAP) + val pk = PhoneGamepad.padKey() + val path = if (!pk.isNullOrEmpty()) "/pad?k=$pk" else "/pad" + out.write(("GET $path HTTP/1.1\r\nHost: $h:$PAD_PORT\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" + + "Sec-WebSocket-Key: $key\r\nSec-WebSocket-Version: 13\r\n\r\n").toByteArray(StandardCharsets.UTF_8)) + out.flush() + if (!readHeaders(inp)) throw Exception("handshake refused") + sendText(out, "{\"hello\":{\"id\":\"mctv-xbox\",\"kind\":\"mctv-xbox\"}}") + try { + while (running) { + val msg = readTextFrame(inp) ?: break + if (msg.isEmpty()) continue + apply(msg) + } + } finally { + try { s.close() } catch (e: Exception) { } + sink?.invoke(DoubleArray(17), DoubleArray(4)) // release everything while we reconnect + } + } + + private fun apply(json: String) { + try { + val o = JSONObject(json) + if (!o.has("pads")) return + val pads = o.optJSONArray("pads") ?: return + val bt = DoubleArray(17); val ax = DoubleArray(4) + if (pads.length() > 0 && !pads.isNull(0)) { + val pad = pads.getJSONObject(0) + val b = pad.optJSONArray("b"); val a = pad.optJSONArray("a") + for (i in 0 until 17) if (b != null && i < b.length()) bt[i] = b.optDouble(i, 0.0) + for (i in 0 until 4) if (a != null && i < a.length()) ax[i] = a.optDouble(i, 0.0) + } + sink?.invoke(bt, ax) + } catch (e: Exception) { Log.w(TAG, "bad pad: $e") } + } + + // ---- minimal WebSocket client (as in PhoneGamepad) ---- + private fun readHeaders(inp: InputStream): Boolean { + val sb = StringBuilder(); var nl = 0; var c: Int + while (inp.read().also { c = it } != -1) { + sb.append(c.toChar()) + if (c == '\n'.code) { if (++nl == 2) break } else if (c != '\r'.code) nl = 0 + } + val h = sb.toString() + return h.startsWith("HTTP/1.1 101") || h.contains(" 101 ") + } + + private fun sendText(out: OutputStream, text: String) { + val payload = text.toByteArray(StandardCharsets.UTF_8) + val mask = ByteArray(4).also { SecureRandom().nextBytes(it) } + val f = java.io.ByteArrayOutputStream() + f.write(0x81) + val len = payload.size + if (len < 126) f.write(0x80 or len) else { f.write(0x80 or 126); f.write(len shr 8); f.write(len and 0xff) } + f.write(mask) + for (i in payload.indices) f.write(payload[i].toInt() xor mask[i and 3].toInt()) + synchronized(out) { out.write(f.toByteArray()); out.flush() } + } + + private fun readTextFrame(inp: InputStream): String? { + val b0 = inp.read(); if (b0 == -1) return null + val b1 = inp.read(); if (b1 == -1) return null + val opcode = b0 and 0x0F + val masked = (b1 and 0x80) != 0 + var len = (b1 and 0x7F).toLong() + if (len == 126L) len = ((rd(inp) shl 8) or rd(inp)).toLong() + else if (len == 127L) { len = 0; repeat(8) { len = (len shl 8) or rd(inp).toLong() } } + if (len > 65536) return null + val mask = ByteArray(4) + if (masked) for (i in 0 until 4) mask[i] = rd(inp).toByte() + val data = ByteArray(len.toInt()) + var off = 0 + while (off < data.size) { val r = inp.read(data, off, data.size - off); if (r < 0) return null; off += r } + if (masked) for (i in data.indices) data[i] = (data[i].toInt() xor mask[i and 3].toInt()).toByte() + if (opcode == 0x8) return null + if (opcode == 0x9 || opcode == 0xA || opcode == 0x0) return "" + return String(data, StandardCharsets.UTF_8) + } + + private fun rd(inp: InputStream): Int { val c = inp.read(); if (c == -1) throw Exception("eof"); return c } +} diff --git a/app/src/tv/java/com/limelight/home/HomeActivity.kt b/app/src/tv/java/com/limelight/home/HomeActivity.kt new file mode 100644 index 0000000000..de7c17607f --- /dev/null +++ b/app/src/tv/java/com/limelight/home/HomeActivity.kt @@ -0,0 +1,2097 @@ +package com.limelight.home + +import android.app.Activity +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.graphics.Outline +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.LayerDrawable +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.provider.Settings +import android.text.format.DateFormat +import android.view.Gravity +import android.view.KeyEvent +import android.view.View +import android.view.ViewGroup +import android.view.ViewOutlineProvider +import android.view.WindowManager +import android.widget.EditText +import android.widget.FrameLayout +import android.widget.GridLayout +import android.widget.HorizontalScrollView +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView +import android.widget.Toast +import com.limelight.PhoneLink +import com.limelight.R +import com.limelight.AppView +import com.limelight.binding.PlatformBinding +import com.limelight.binding.crypto.AndroidCryptoProvider +import com.limelight.computers.ComputerManagerListener +import com.limelight.computers.ComputerManagerService +import com.limelight.grid.assets.CachedAppAssetLoader +import com.limelight.grid.assets.DiskAssetLoader +import com.limelight.grid.assets.MemoryAssetLoader +import com.limelight.grid.assets.NetworkAssetLoader +import com.limelight.nvstream.http.ComputerDetails +import com.limelight.nvstream.http.NvApp +import com.limelight.nvstream.http.NvHTTP +import com.limelight.nvstream.http.PairingManager +import com.limelight.nvstream.wol.WakeOnLanSender +import com.limelight.preferences.AddComputerManually +import com.limelight.preferences.StreamSettings +import com.limelight.utils.CacheHelper +import com.limelight.utils.ServerHelper +import com.limelight.utils.ShortcutHelper +import java.io.FileNotFoundException +import java.io.StringReader +import java.net.UnknownHostException +import java.util.Date +import java.util.concurrent.Executors + +/** + * MoonCast TV home, in the style of Steam Big Picture: Home (what's running + your consoles), Library (every game from + * every console), Consoles (pair, wake, remove), Settings. It also keeps listening for the phone, so a game sent from + * the phone or Odin takes over the screen from wherever you are. + * + * Moonlight's own pieces do the real work: ComputerManagerService finds and polls PCs, ServerHelper starts and quits + * games, PairingManager pairs; native chiaki (Ps5StreamActivity) plays the PS5. + */ +class HomeActivity : Activity(), PhoneLink.Listener { + private enum class Tab(val title: String) { HOME("Home"), LIBRARY("Library"), CONSOLES("Consoles"), SETTINGS("Settings") } + + private lateinit var tv: Tv + private lateinit var main: LinearLayout + private lateinit var content: FrameLayout + private lateinit var tabsRow: LinearLayout + private lateinit var hintsBar: LinearLayout + private lateinit var overlay: FrameLayout + private lateinit var clock: TextView + private lateinit var glide: FocusGlide + private val ui = Handler(Looper.getMainLooper()) + + // Moonlight's PC manager + private var binder: ComputerManagerService.ComputerManagerBinder? = null + private var polling = false + private var resumed = false + private val pcs = LinkedHashMap() + private val apps = HashMap>() + private val rawLists = HashMap() + private val loaders = HashMap() + private val heroLabel by lazy { TextView(this) } // unused label for the hero's box art (see loader()) + private val appPollers = HashMap() + + // PlayStation consoles the Odin registered through this TV + private var consoles = listOf() + private val psStatus = HashMap() + private val probes = Executors.newSingleThreadExecutor() + + private var tab = Tab.HOME + private var detail: String? = null // "pc:" or "ps:" + private var filter: String? = null // library filter: a PC uuid, "ps", or null for all + private var lastSig = "" + private var overlayKind: String? = null + private var lastInput = System.currentTimeMillis() + + private val prefs by lazy { getSharedPreferences("mc_tv", Context.MODE_PRIVATE) } + + // ---------------------------------------------------------------- lifecycle + + // The size the screen was laid out for. The manifest keeps the activity across display changes (HDMI mode, + // resolution, density), so without this the TV scale stayed the old one until MoonCast was restarted. + private var laidOut: String? = null + + override fun onConfigurationChanged(c: android.content.res.Configuration) { + super.onConfigurationChanged(c) + val now = "${c.screenWidthDp}x${c.screenHeightDp}@${c.densityDpi}" + if (laidOut != null && laidOut != now) recreate() + laidOut = now + } + + override fun onCreate(b: Bundle?) { + super.onCreate(b) + resources.configuration.let { laidOut = "${it.screenWidthDp}x${it.screenHeightDp}@${it.densityDpi}" } + Profile.syncPcSettings(this) + com.limelight.PhoneLink.sessionListener = com.limelight.PhoneLink.SessionListener { uuid -> + runOnUiThread { pcs.remove(uuid); apps.remove(uuid); rawLists.remove(uuid); render(force = true) } + } + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + tv = Tv(this, prefs.getBoolean("oled", false)) + buildShell() + bindService(Intent(this, ComputerManagerService::class.java), conn, Service_BIND) + PhoneLinkService.start(this) // the phone's channel lives in the service, so it outlasts this screen + if (!prefs.getBoolean("oled_asked", false)) ui.post { askOled() } + } + + override fun onResume() { + super.onResume() + resumed = true + lastInput = System.currentTimeMillis() + // anonymous usage check-in: once a day if the user said yes; the question once, a moment after home is up + com.limelight.home.Usage.checkIn(this) + if (com.limelight.home.Usage.shouldAsk(this)) ui.postDelayed({ + if (resumed && overlayKind == null && com.limelight.home.Usage.shouldAsk(this)) + confirm(com.limelight.home.Usage.QUESTION, com.limelight.home.Usage.EXPLAIN, "Yes, send", no = "No thanks", + noPicked = true, onNo = { com.limelight.home.Usage.answer(this, false) }) { com.limelight.home.Usage.answer(this, true) } + }, 1500) + PhoneLinkService.listener = this + if (overlayKind == "incoming") hideOverlay() + consoles = Ps5Store.all(this) + xboxes = Xbox.consoles(this) + refreshXboxes() + startPolling() + ui.post(tick) + render(force = true) + } + + override fun onPause() { + resumed = false + if (PhoneLinkService.listener === this) PhoneLinkService.listener = null + stopPolling() + ui.removeCallbacks(tick) + super.onPause() + } + + override fun onDestroy() { + com.limelight.PhoneLink.endSessionIdentity(this) + com.limelight.PhoneLink.sessionListener = null + probes.shutdownNow() + glide.detach() + loaders.values.forEach { it.cancelBackgroundLoads(); it.cancelForegroundLoads(); it.freeCacheMemory() } + try { unbindService(conn) } catch (e: Exception) { } + super.onDestroy() + } + + private val conn = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName, service: IBinder) { + val local = service as ComputerManagerService.ComputerManagerBinder + Thread { + local.waitForReady() + // make a keypair early so discovery and pairing don't wait for it later + AndroidCryptoProvider(this@HomeActivity).clientCertificate + runOnUiThread { binder = local; startPolling() } + }.start() + } + override fun onServiceDisconnected(name: ComponentName) { binder = null; polling = false } + } + + private fun startPolling() { + val b = binder ?: return + if (!resumed || polling) return + polling = true + b.startPolling(ComputerManagerListener { d -> runOnUiThread { onComputer(d) } }) + } + + private fun stopPolling() { + appPollers.values.forEach { it.stop() } + appPollers.clear() + if (polling) binder?.stopPolling() + polling = false + } + + /** Clock, PS5 probes and the OLED screen saver, every few seconds while the home is on screen. */ + private val tick = object : Runnable { + override fun run() { + clock.text = DateFormat.getTimeFormat(this@HomeActivity).format(Date()) + probeConsoles() + if (tv.oled && overlayKind == null && System.currentTimeMillis() - lastInput > 120_000) showIdle() + if (overlayKind == "idle") moveIdle() + if (prefs.getBoolean("hide_hints", tv.oled) && System.currentTimeMillis() - lastInput > 10_000) hintsBar.animate().alpha(0f).setDuration(400).start() + if (prefs.getBoolean("pixel_shift", tv.oled) && ++shiftTick % 30 == 0) { // every ~3 minutes + main.translationX = tv.pxf((-4..4).random()); main.translationY = tv.pxf((-4..4).random()) + } + ui.postDelayed(this, 6_000) + } + } + + // ---------------------------------------------------------------- data + + private fun onComputer(d: ComputerDetails) { + val uuid = d.uuid ?: return + pcs[uuid] = d + val raw = d.rawAppList + if (raw != null && raw != rawLists[uuid]) { + rawLists[uuid] = raw + apps[uuid] = try { NvHTTP.getAppListByReader(StringReader(raw)) } catch (e: Exception) { apps[uuid] ?: emptyList() } + } else if (!apps.containsKey(uuid)) { + apps[uuid] = try { + NvHTTP.getAppListByReader(StringReader(CacheHelper.readInputStreamToString( + CacheHelper.openCacheFileForInput(cacheDir, "applist", uuid)))) + } catch (e: Exception) { emptyList() } + } + if (d.state == ComputerDetails.State.ONLINE && d.pairState == PairingManager.PairState.PAIRED && !appPollers.containsKey(uuid)) { + binder?.let { b -> appPollers[uuid] = b.createAppListPoller(b.getComputer(uuid) ?: d).also { it.start() } } + } + render() + } + + private fun probeConsoles() { + val list = consoles + if (list.isEmpty()) return + probes.execute { + for (c in list) { + val st = Ps5Store.probe(c.host, c.ps5) + runOnUiThread { + psStatus[c.host] = st + val hn = st.hostName + if (hn != null && hn != c.name) { Ps5Store.rename(this, c.host, hn); consoles = Ps5Store.all(this) } + render() + } + } + } + } + + private fun paired(d: ComputerDetails) = d.pairState == PairingManager.PairState.PAIRED + private fun online(d: ComputerDetails) = d.state == ComputerDetails.State.ONLINE + private fun runningApp(d: ComputerDetails): NvApp? { + if (d.runningGameId == 0) return null + return apps[d.uuid]?.firstOrNull { it.appId == d.runningGameId } ?: NvApp("Your game", d.runningGameId, false) + } + private fun power(c: Ps5Store.Console) = psStatus[c.host]?.power ?: Ps5Store.Power.CHECKING + + private fun loader(d: ComputerDetails): CachedAppAssetLoader = loaders.getOrPut(d.uuid) { + CachedAppAssetLoader(d, (300.0 / tv.px(222)).coerceAtLeast(1.0), + NetworkAssetLoader(this, binder?.uniqueId ?: ""), MemoryAssetLoader(), DiskAssetLoader(this), + android.graphics.BitmapFactory.decodeResource(resources, R.drawable.no_app_image)) + } + + // ---------------------------------------------------------------- shell + + private fun buildShell() { + val root = FrameLayout(this).apply { setBackgroundColor(tv.p.night) } + main = tv.col().apply { setPadding(tv.px(96), tv.px(56), tv.px(96), tv.px(48)); clipChildren = false } + + val top = tv.row() + val mark = tv.row().apply { + addView(Crescent(this@HomeActivity, tv.p.moon), tv.lp(28, 28).apply { rightMargin = tv.px(14) }) + addView(tv.text("MoonCast", 28, tv.p.moon, tv.display(700))) + } + top.addView(mark) + tabsRow = tv.row().apply { setPadding(tv.px(56), 0, 0, 0) } + Tab.values().forEach { t -> + tabsRow.addView(tv.text(t.title, 24, tv.p.dust, tv.ui(700)).apply { + gravity = Gravity.CENTER + setPadding(tv.px(24), 0, tv.px(24), 0) + tag = "tab:${t.name}" + tv.focusableView(this) + setOnClickListener { go(t) } + }, tv.lp(-2, 56).apply { rightMargin = tv.px(8) }) + } + top.addView(tabsRow) + top.addView(tv.grow()) + clock = tv.text("", 24, tv.p.moon, tv.display(500)) + top.addView(clock) + if (tv.oled) top.alpha = 0.72f + main.addView(top, tv.lp(-1, 64)) + + content = FrameLayout(this).apply { clipChildren = false; clipToPadding = false } // focus rings may overhang + main.addView(content, LinearLayout.LayoutParams(-1, 0, 1f).apply { topMargin = tv.px(40); bottomMargin = tv.px(24) }) + + hintsBar = tv.row() + if (tv.oled) hintsBar.alpha = 0.72f + main.addView(hintsBar, tv.lp(-1, 40)) + + root.addView(main, FrameLayout.LayoutParams(-1, -1)) + overlay = FrameLayout(this).apply { visibility = View.GONE; isClickable = true } + root.addView(overlay, FrameLayout.LayoutParams(-1, -1)) + setContentView(root) + + // the highlight glides from item to item; each item keeps its own ring, the glide fades as it lands on it + glide = FocusGlide(root).apply { + ghost = true + strokeColor = Tv.CORONA + strokeWidth = tv.pxf(3) + cornerRadius = tv.pxf(16) + radiusFor = { v -> if (v.parent === tabsRow) tv.pxf(28) else null } + accept = { v -> v !is EditText } + }.attach() + FeelSounds.warm(this) + } + + private fun go(t: Tab) { + tab = t; detail = null + if (t != Tab.LIBRARY) filter = null + render(force = true, focusContent = false) + } + + private fun styleTabs() { + for (i in 0 until tabsRow.childCount) { + val v = tabsRow.getChildAt(i) as TextView + val on = v.tag == "tab:${tab.name}" + val idle: android.graphics.drawable.Drawable = when { + on && !tv.oled -> tv.round(tv.p.moon, 28) + on -> LayerDrawable(arrayOf(android.graphics.drawable.ColorDrawable(Tv.CORONA))).apply { + setLayerInset(0, tv.px(24), tv.px(53), tv.px(24), 0) } + else -> android.graphics.drawable.ColorDrawable(0) + } + v.background = android.graphics.drawable.StateListDrawable().apply { + addState(intArrayOf(android.R.attr.state_focused), tv.round(if (on && !tv.oled) tv.p.moon else tv.p.raise, 28, Tv.CORONA, 3)) + addState(intArrayOf(), idle) + } + v.setTextColor(if (on) (if (tv.oled) tv.p.moon else tv.p.night) else tv.p.dust) + } + } + + private fun hints(vararg h: Pair) { + hintsBar.removeAllViews() + h.forEach { (k, l) -> if (k == "|") hintsBar.addView(tv.grow()) else hintsBar.addView(tv.hint(k, l)) } + } + + /** Rebuild the current page when something visible changed, keeping focus on the same item. */ + private fun render(force: Boolean = false, focusContent: Boolean = true) { + Profile.syncPcSettings(this) + val sig = signature() + if (!force && sig == lastSig) return + lastSig = sig + val focused = currentFocus + val inTabs = focused != null && focused.parent === tabsRow + val focusTag = focused?.tag as? String + content.removeAllViews() + val pg = page() + content.addView(pg, pg.layoutParams as? FrameLayout.LayoutParams ?: FrameLayout.LayoutParams(-1, -1)) + styleTabs() + if (overlayKind != null) return + when { + inTabs && !force -> {} + !focusContent -> tabsRow.findViewWithTag("tab:${tab.name}")?.requestFocus() + focusTag != null && content.findViewWithTag(focusTag)?.requestFocus() == true -> {} + else -> (content.findViewWithTag("primary") ?: firstFocusable(content))?.requestFocus() + } + } + + private fun firstFocusable(v: View): View? { + if (v.isFocusable && v.isClickable && v.visibility == View.VISIBLE) return v + if (v is ViewGroup) for (i in 0 until v.childCount) firstFocusable(v.getChildAt(i))?.let { return it } + return null + } + + private fun signature(): String = buildString { + append(tab).append(detail).append(filter) + pcs.values.forEach { append(it.uuid).append(it.state).append(it.pairState).append(it.runningGameId).append(apps[it.uuid]?.size) } + consoles.forEach { append(it.host).append(it.name).append(psStatus[it.host]?.power).append(psStatus[it.host]?.runningApp) } + xboxes.forEach { append(it.id).append(it.name).append(it.powerState) } + } + + private fun page(): View { + detail?.let { d -> + if (d.startsWith("pc:")) pcs[d.removePrefix("pc:")]?.let { return pcPage(it) } + if (d.startsWith("ps:")) consoles.firstOrNull { it.host == d.removePrefix("ps:") }?.let { return psPage(it) } + if (d.startsWith("xb:")) xboxes.firstOrNull { it.id == d.removePrefix("xb:") }?.let { return xbPage(it) } + detail = null + } + return when (tab) { + Tab.HOME -> homePage() + Tab.LIBRARY -> libraryPage() + Tab.CONSOLES -> consolesPage() + Tab.SETTINGS -> settingsPage() + } + } + + // ---------------------------------------------------------------- Home + + private fun homePage(): View { + hints("A" to "Select", "LB" to "Previous tab", "RB" to "Next tab", "|" to "", "B" to "Back") + val col = tv.col() + col.addView(hero(), tv.lp(-1, 424)) + col.addView(tv.row(Gravity.BOTTOM).apply { + addView(tv.text("Consoles", 28, tv.p.moon, tv.display(600))) + addView(tv.text("Wake and play at home", 20, tv.p.dust, tv.ui(600)), tv.lp(-2, -2).apply { leftMargin = tv.px(16) }) + }, tv.lp(-1, -2).apply { topMargin = tv.px(40) }) + val shelf = tv.row() + shelfItems().forEach { shelf.addView(it, tv.lp(414, 200).apply { rightMargin = tv.px(24) }) } + col.addView(HorizontalScrollView(this).apply { + isHorizontalScrollBarEnabled = false; isFocusable = false; clipToPadding = false; clipChildren = false + setPadding(tv.px(24), tv.px(16), tv.px(24), tv.px(16)) + addView(shelf) + }, tv.lp(-1, -2).apply { leftMargin = -tv.px(24); rightMargin = -tv.px(24) }) + return col + } + + private fun hero(): View { + val card = FrameLayout(this).apply { background = tv.round(tv.p.deep, 24, tv.p.line, 1); clipToOutline = true; outlineProvider = rounded(24) } + val body = tv.col().apply { setPadding(tv.px(64), tv.px(44), tv.px(64), tv.px(36)) } + + val running = pcs.values.firstOrNull { paired(it) && online(it) && it.runningGameId != 0 } + val psOn = consoles.firstOrNull { power(it) == Ps5Store.Power.ON && psStatus[it.host]?.runningApp != null } + when { + running != null -> { + val app = runningApp(running)!! + card.addView(ImageView(this).apply { + scaleType = ImageView.ScaleType.CENTER_CROP + // keep the label alive: the loader only holds it weakly + loader(running).populateImageView(app, this, heroLabel) + }, tv.flp(760, -1, Gravity.END)) + card.addView(View(this).apply { + background = GradientDrawable(GradientDrawable.Orientation.LEFT_RIGHT, intArrayOf(tv.p.deep, tv.p.deep, 0x00000000)) + }, tv.flp(1300, -1)) + body.addView(tv.row().apply { addView(tv.status("Running now", "live")); addView(tv.status(running.name)) }) + body.addView(tv.text(app.appName, 76, tv.p.moon, tv.display(700), 2), tv.lp(900, -2).apply { topMargin = tv.px(26) }) + body.addView(tv.text("Still running on the PC. Pick up exactly where you left off.", 26, tv.p.soft, tv.ui(500), 2), + tv.lp(760, -2).apply { topMargin = tv.px(20) }) + body.addView(tv.row().apply { + addView(tv.pill("Resume here", "A", true) { start(running, app) }.apply { tag = "primary" }) + addView(tv.pill("Quit game", null, false) { quit(running, app) }.apply { tag = "hero:quit" }) + }, tv.lp(-2, -2).apply { topMargin = tv.px(32) }) + } + psOn != null -> { + body.addView(tv.row().apply { addView(tv.status("On now", "live")); addView(tv.status(psOn.name)) }) + body.addView(tv.text(psStatus[psOn.host]?.runningApp ?: "PlayStation 5", 76, tv.p.moon, tv.display(700), 2), + tv.lp(1200, -2).apply { topMargin = tv.px(26) }) + body.addView(tv.text("Your PS5 is on. Play it here, on this TV.", 26, tv.p.soft, tv.ui(500), 2), + tv.lp(900, -2).apply { topMargin = tv.px(20) }) + body.addView(tv.row().apply { + addView(tv.pill("Play here", "A", true) { playPs(psOn) }.apply { tag = "primary" }) + }, tv.lp(-2, -2).apply { topMargin = tv.px(32) }) + } + else -> { + body.addView(tv.text("Ready to play", 96, tv.p.moon, tv.display(700)), tv.lp(-2, -2).apply { topMargin = tv.px(12) }) + body.addView(tv.text("Send a game from your phone or Odin and it starts here. Or pick one with your controller.", + 28, tv.p.soft, tv.ui(500), 2), tv.lp(1500, -2).apply { topMargin = tv.px(28) }) + body.addView(tv.row().apply { + addView(tv.pill("Browse games", "A", true) { go(Tab.LIBRARY) }.apply { tag = "primary" }) + if (pcs.isEmpty() && consoles.isEmpty()) addView(tv.pill("Add a console", null, false) { addConsole() }) + }, tv.lp(-2, -2).apply { topMargin = tv.px(36) }) + } + } + card.addView(body, FrameLayout.LayoutParams(-1, -1)) + return card + } + + private fun shelfItems(): List = + consoles.map { consoleCard(psCardInfo(it), "ps:${it.host}") { open("ps:${it.host}") } } + + xboxes.map { consoleCard(xbCardInfo(it), "xb:${it.id}") { open("xb:${it.id}") } } + + pcs.values.map { consoleCard(pcCardInfo(it), "pc:${it.uuid}") { open("pc:${it.uuid}") } } + + listOf(addCard()) + + private class CardInfo(val glyph: String, val glyphColor: Int, val name: String, val meta: String, val states: List>) + + private fun pcCardInfo(d: ComputerDetails): CardInfo { + val n = apps[d.uuid]?.size ?: 0 + return when { + !online(d) -> CardInfo("PC", 0, d.name, "Offline. Wake it to play", listOf("Offline" to "rest")) + !paired(d) -> CardInfo("PC", 0, d.name, "Found on your network. Pair it to play", listOf("Not paired" to "rest")) + else -> CardInfo("PC", 0, d.name, if (n > 0) "$n games" else "Paired", + listOf((if (d.runningGameId != 0) "Playing" else "Online") to "live")) + } + } + + private fun psCardInfo(c: Ps5Store.Console): CardInfo { + val st = psStatus[c.host] + return when (st?.power ?: Ps5Store.Power.CHECKING) { + Ps5Store.Power.ON -> CardInfo("PS", 0xFF2F5DF0.toInt(), c.name, st?.runningApp ?: "On", listOf("On" to "live")) + Ps5Store.Power.REST -> CardInfo("PS", 0xFF2F5DF0.toInt(), c.name, "Wakes in about a minute", listOf("Rest mode" to "rest")) + Ps5Store.Power.NOT_FOUND -> CardInfo("PS", 0xFF2F5DF0.toInt(), c.name, "Not on this network", listOf("Away" to "")) + Ps5Store.Power.CHECKING -> CardInfo("PS", 0xFF2F5DF0.toInt(), c.name, "Checking…", emptyList()) + } + } + + private fun consoleCard(i: CardInfo, key: String, onClick: () -> Unit): View { + val card = FrameLayout(this).apply { + background = tv.focusable(tv.p.deep, 24) + tag = key + tv.focusableView(this); tv.lift(this, 1.03f) + setOnClickListener { onClick() } + setPadding(tv.px(28), tv.px(28), tv.px(28), tv.px(26)) + } + card.addView(tv.text(i.glyph, 21, if (i.glyphColor != 0) 0xFFFFFFFF.toInt() else tv.p.moon, tv.display(700)).apply { + gravity = Gravity.CENTER + background = if (i.glyphColor != 0) tv.round(i.glyphColor, 16) else tv.round(tv.p.raise, 16, tv.p.line, 1) + }, tv.flp(60, 60, Gravity.END or Gravity.TOP)) + card.addView(tv.col().apply { + addView(tv.text(i.name, 28, tv.p.moon, tv.display(600)), tv.lp(-2, -2).apply { rightMargin = tv.px(76) }) + addView(tv.text(i.meta, 21, tv.p.dust, tv.ui(500), 2), tv.lp(-1, -2).apply { topMargin = tv.px(10); rightMargin = tv.px(76) }) + }) + card.addView(tv.row().apply { i.states.forEach { (l, k) -> addView(tv.status(l, k)) } }, tv.flp(-2, -2, Gravity.BOTTOM)) + return card + } + + private fun addCard(): View = FrameLayout(this).apply { + background = android.graphics.drawable.StateListDrawable().apply { + addState(intArrayOf(android.R.attr.state_focused), tv.round(tv.p.raise, 24, Tv.CORONA, 3)) + addState(intArrayOf(), GradientDrawable().apply { cornerRadius = tv.pxf(24); setStroke(tv.px(2), tv.p.line, tv.pxf(12), tv.pxf(8)) }) + } + tag = "add" + tv.focusableView(this); tv.lift(this, 1.03f) + setOnClickListener { addConsole() } + setPadding(tv.px(28), tv.px(28), tv.px(28), tv.px(26)) + addView(tv.col().apply { + addView(tv.text("Add a console", 28, tv.p.moon, tv.display(600))) + addView(tv.text("A PC by its address, or register a PS5", 21, tv.p.dust, tv.ui(500), 2), tv.lp(-1, -2).apply { topMargin = tv.px(10) }) + }) + addView(tv.text("+", 34, tv.p.moon, tv.display(600)).apply { gravity = Gravity.CENTER; background = tv.round(tv.p.raise, 16, tv.p.line, 1) }, + tv.flp(60, 60, Gravity.END or Gravity.BOTTOM)) + } + + // ---------------------------------------------------------------- Library + + private fun libraryPage(): View { + hints("A" to "Play on this TV", "X" to "Game menu", "Y" to "Search", "|" to "", "B" to "Back") + val col = tv.col() + val chips = tv.row() + fun chip(label: String, key: String?) = tv.text(label, 20, tv.p.moon, tv.ui(600)).apply { + gravity = Gravity.CENTER + setPadding(tv.px(18), 0, tv.px(18), 0) + val on = filter == key + background = android.graphics.drawable.StateListDrawable().apply { + addState(intArrayOf(android.R.attr.state_focused), tv.round(tv.p.raise, 22, Tv.CORONA, 3)) + addState(intArrayOf(), if (on && !tv.oled) tv.round(tv.p.moon, 22) else tv.round(tv.p.deep, 22, if (on) tv.p.moon else tv.p.line, if (on) 2 else 1)) + } + if (on && !tv.oled) setTextColor(tv.p.night) + tag = "chip:${key ?: "all"}" + tv.focusableView(this) + setOnClickListener { filter = key; render(force = true) } + } + val sources = pcs.values.filter { paired(it) } + val anyHidden = sources.any { hiddenIds(it).isNotEmpty() } + val q = libraryQuery + if (q.isNotEmpty()) chips.addView(tv.text("Search: “$q” ✕", 20, tv.p.night, tv.ui(700)).apply { + gravity = Gravity.CENTER; setPadding(tv.px(18), 0, tv.px(18), 0); tag = "chip:search" + background = android.graphics.drawable.StateListDrawable().apply { + addState(intArrayOf(android.R.attr.state_focused), tv.round(Tv.CORONA, 22, Tv.CORONA, 3)) + addState(intArrayOf(), tv.round(Tv.CORONA, 22)) + } + tv.focusableView(this); setOnClickListener { libraryQuery = ""; render(force = true) } + }, tv.lp(-2, 44).apply { rightMargin = tv.px(20) }) + if (sources.size + (if (consoles.isEmpty()) 0 else 1) > 1 || anyHidden || q.isNotEmpty()) { + chips.addView(chip("All", null), tv.lp(-2, 44).apply { rightMargin = tv.px(12) }) + if (sources.size > 1) sources.forEach { chips.addView(chip(it.name, it.uuid), tv.lp(-2, 44).apply { rightMargin = tv.px(12) }) } + if (consoles.isNotEmpty()) chips.addView(chip("PlayStation", "ps"), tv.lp(-2, 44).apply { rightMargin = tv.px(12) }) + if (anyHidden) chips.addView(chip("Hidden", "hidden"), tv.lp(-2, 44).apply { rightMargin = tv.px(12) }) + col.addView(chips, tv.lp(-1, -2).apply { bottomMargin = tv.px(28) }) + } + + val grid = GridLayout(this).apply { columnCount = 7; clipChildren = false; clipToPadding = false } + var n = 0 + fun add(v: View) { + grid.addView(v, GridLayout.LayoutParams().apply { + width = tv.px(222); height = tv.px(296) + rightMargin = if (n % 7 == 6) 0 else tv.px(24); bottomMargin = tv.px(28) + }); n++ + } + fun matches(name: String) = q.isEmpty() || name.contains(q, ignoreCase = true) + if ((filter == null || filter == "ps") && matches("PlayStation 5")) consoles.forEach { c -> add(psCover(c)) } + sources.filter { filter == null || filter == "hidden" || filter == it.uuid }.forEach { pc -> + val hidden = hiddenIds(pc) + (apps[pc.uuid] ?: emptyList()) + .filter { if (filter == "hidden") it.appId in hidden else it.appId !in hidden } + .filter { matches(it.appName) } + .sortedWith(compareByDescending { it.appId == pc.runningGameId }.thenBy { it.appName.lowercase() }) + .forEach { add(appCover(pc, it)) } + } + if (n == 0 && q.isNotEmpty()) { + col.addView(tv.text("Nothing called “$q”", 40, tv.p.moon, tv.display(600))) + col.addView(tv.row().apply { + addView(tv.pill("Search again", "Y", true) { openSearch() }.apply { tag = "primary" }) + addView(tv.pill("Show all games", null, false) { libraryQuery = ""; filter = null; render(force = true) }) + }, tv.lp(-2, -2).apply { topMargin = tv.px(32) }) + return col + } + if (n == 0) { + col.addView(tv.text(if (sources.isEmpty() && consoles.isEmpty()) "No games yet" else "Loading games…", 40, tv.p.moon, tv.display(600))) + col.addView(tv.text("Pair a PC in Consoles, or register a PS5 from MoonCast on your Odin.", 24, tv.p.dust, tv.ui(500)), + tv.lp(-2, -2).apply { topMargin = tv.px(16) }) + col.addView(tv.row().apply { addView(tv.pill("Go to Consoles", "A", true) { go(Tab.CONSOLES) }.apply { tag = "primary" }) }, + tv.lp(-2, -2).apply { topMargin = tv.px(32) }) + return col + } + col.addView(ScrollView(this).apply { + isVerticalScrollBarEnabled = false; isFocusable = false; clipToPadding = false + setPadding(tv.px(24), tv.px(24), tv.px(24), tv.px(24)) + // the grid gets the padding, not just the scroll view: ScrollView scrolls a focused cover flush to + // its top edge, which cut off the top of the ring on the first row + addView(grid.apply { setPadding(0, tv.px(12), 0, tv.px(12)); clipToPadding = false }) + }, LinearLayout.LayoutParams(-1, 0, 1f).apply { leftMargin = -tv.px(24); rightMargin = -tv.px(24); topMargin = -tv.px(36) }) + return col + } + + // FrameLayout has had setForeground since API 1; lint reads Kotlin's `foreground =` as View's (API 23) + @android.annotation.SuppressLint("NewApi") + private fun coverFrame(key: String, seed: String, onClick: () -> Unit) = FrameLayout(this).apply { + background = tv.twoTone(seed) + foreground = tv.focusRing(16) + clipToOutline = true; outlineProvider = rounded(16) + tag = key + tv.focusableView(this); tv.lift(this, 1.06f) + setOnClickListener { onClick() } + } + + private fun appCover(pc: ComputerDetails, app: NvApp): View { + val f = coverFrame("app:${pc.uuid}:${app.appId}", app.appName) { start(pc, app) } + f.setOnLongClickListener { gameMenu(pc, app); true } + f.setOnKeyListener { _, code, e -> + if (code == KeyEvent.KEYCODE_BUTTON_X || code == KeyEvent.KEYCODE_MENU) { if (e.action == KeyEvent.ACTION_UP) gameMenu(pc, app); true } else false + } + val title = tv.text(app.appName, 23, 0xFFFFFFFF.toInt(), tv.display(700), 3).apply { + gravity = Gravity.BOTTOM; setPadding(tv.px(18), 0, tv.px(18), tv.px(18)) + } + val img = ImageView(this).apply { scaleType = ImageView.ScaleType.CENTER_CROP } + f.addView(img, FrameLayout.LayoutParams(-1, -1)) + f.addView(title, FrameLayout.LayoutParams(-1, -1)) + loader(pc).populateImageView(app, img, title) + if (app.appId == pc.runningGameId) f.addView(tv.status("Running", "live"), tv.flp(-2, 34).apply { leftMargin = tv.px(14); topMargin = tv.px(14) }) + return f + } + + // ---- hidden games (Moonlight's own store, so AppView agrees), search, game menu ---- + private var libraryQuery = "" + private fun hiddenIds(pc: ComputerDetails): Set = + getSharedPreferences(AppView.HIDDEN_APPS_PREF_FILENAME, MODE_PRIVATE).getStringSet(pc.uuid, emptySet())!! + .mapNotNull { it.toIntOrNull() }.toSet() + + private fun setHidden(pc: ComputerDetails, app: NvApp, hide: Boolean) { + val p = getSharedPreferences(AppView.HIDDEN_APPS_PREF_FILENAME, MODE_PRIVATE) + val set = HashSet(p.getStringSet(pc.uuid, emptySet())!!) + if (hide) set.add(app.appId.toString()) else set.remove(app.appId.toString()) + p.edit().putStringSet(pc.uuid, set).apply() + } + + private fun openSearch() { + val card = dialogCard() + card.addView(tv.text("Search your games", 40, tv.p.moon, tv.display(700))) + val field = textField("A few letters of the name", libraryQuery) + card.addView(field, tv.lp(-1, 84).apply { topMargin = tv.px(24) }) + fun search() { libraryQuery = field.text.toString().trim(); hideOverlay(); go(Tab.LIBRARY) } + field.setOnEditorActionListener { _, _, _ -> search(); true } + card.addView(tv.row().apply { + addView(tv.pill("Search", "A", true) { search() }) + if (libraryQuery.isNotEmpty()) addView(tv.pill("Clear", null, false) { field.setText(""); search() }) + }, tv.lp(-2, -2).apply { topMargin = tv.px(28) }) + showOverlay("search", FrameLayout(this).apply { addView(card, tv.flp(1000, -2, Gravity.CENTER)) }) + field.requestFocus() + } + + /** A TV-sized text box (the TV's own keyboard types into it). */ + private fun textField(hint: String, value: String) = android.widget.EditText(this).apply { + setText(value); setSelection(value.length) + this.hint = hint + setTextColor(tv.p.moon); setHintTextColor(tv.p.dust) + setTextSize(android.util.TypedValue.COMPLEX_UNIT_PX, tv.pxf(30)); typeface = tv.ui(600) + setSingleLine(true) + inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS + imeOptions = android.view.inputmethod.EditorInfo.IME_ACTION_DONE + background = tv.focusable(tv.p.night, 16) + setPadding(tv.px(24), 0, tv.px(24), 0) + tag = "primary" + } + + private fun gameMenu(pc: ComputerDetails, app: NvApp) { + val hidden = app.appId in hiddenIds(pc) + val card = dialogCard() + card.addView(tv.text(app.appName, 40, tv.p.moon, tv.display(700), 2)) + card.addView(tv.text("On ${pc.name}" + (if (app.appId == pc.runningGameId) ". Running now." else "."), 22, tv.p.dust, tv.ui(500)), + tv.lp(-1, -2).apply { topMargin = tv.px(10); bottomMargin = tv.px(20) }) + fun item(label: String, sub: String?, primary: Boolean = false, onClick: () -> Unit) { + card.addView(tv.row().apply { + if (primary) tag = "primary" + background = tv.focusable(0, 16, outline = false) + setPadding(tv.px(20), 0, tv.px(20), 0) + addView(tv.text(label, 24, tv.p.moon, tv.ui(700)), LinearLayout.LayoutParams(0, -2, 1f)) + if (sub != null) addView(tv.text(sub, 19, tv.p.dust, tv.ui(600))) + tv.focusableView(this) + setOnClickListener { hideOverlay(); onClick() } + }, tv.lp(-1, 80)) + } + item(if (app.appId == pc.runningGameId) "Resume on this TV" else "Play on this TV", null, true) { start(pc, app) } + if (app.appId == pc.runningGameId) item("Quit game", "Closes it on the PC") { quit(pc, app) } + item(if (hidden) "Show in Library" else "Hide from Library", if (hidden) null else "Still under the Hidden filter") { + setHidden(pc, app, !hidden) + if (filter == "hidden" && pcs.values.none { hiddenIds(it).isNotEmpty() }) filter = null + render(force = true); toast(if (hidden) "${app.appName} is back in your Library" else "${app.appName} hidden") } + item("Add to the TV's home screen", "A row of your games there") { + try { com.limelight.utils.TvChannelHelper(this).addGameToHomeScreen(pc, app); toast("Added. Look for the ${pc.name} row on the TV's home screen.") } + catch (e: Exception) { toast("This TV doesn't allow adding games to its home screen.") } } + card.addView(tv.text("App ${app.appId}" + (if (app.isHdrSupported) ". Supports HDR." else "."), 19, tv.p.dust, tv.ui(600)), + tv.lp(-1, -2).apply { topMargin = tv.px(16) }) + showOverlay("gamemenu", FrameLayout(this).apply { addView(card, tv.flp(900, -2, Gravity.CENTER)) }) + } + + private fun psCover(c: Ps5Store.Console): View { + val f = coverFrame("pscover:${c.host}", "PlayStation 5") { open("ps:${c.host}") } + f.background = tv.twoTone("ps") // the blue pair + f.addView(tv.text(c.name, 15, 0xFFFFFFFF.toInt(), tv.ui(700)).apply { + gravity = Gravity.CENTER; setPadding(tv.px(12), 0, tv.px(12), 0); background = tv.round(0x4D000000, 15) + }, tv.flp(-2, 30).apply { leftMargin = tv.px(16); topMargin = tv.px(16) }) + f.addView(tv.text("PlayStation 5", 23, 0xFFFFFFFF.toInt(), tv.display(700), 3).apply { + gravity = Gravity.BOTTOM; setPadding(tv.px(18), 0, tv.px(18), tv.px(18)) + }, FrameLayout.LayoutParams(-1, -1)) + return f + } + + // ---------------------------------------------------------------- Consoles + + private fun consolesPage(): View { + hints("A" to "Open", "LB" to "Previous tab", "RB" to "Next tab", "|" to "", "B" to "Back") + val grid = GridLayout(this).apply { columnCount = 3; clipChildren = false; clipToPadding = false } + shelfItems().forEachIndexed { i, v -> + grid.addView(v, GridLayout.LayoutParams().apply { + width = tv.px(552); height = tv.px(240) + rightMargin = if (i % 3 == 2) 0 else tv.px(24); bottomMargin = tv.px(24) + }) + } + return ScrollView(this).apply { + isVerticalScrollBarEnabled = false; isFocusable = false; clipToPadding = false + setPadding(tv.px(24), tv.px(24), tv.px(24), tv.px(24)) + addView(grid.apply { setPadding(0, tv.px(8), 0, tv.px(8)); clipToPadding = false }) + layoutParams = FrameLayout.LayoutParams(-1, -1).apply { leftMargin = -tv.px(24); rightMargin = -tv.px(24); topMargin = -tv.px(24) } + } + } + + private fun open(key: String) { detail = key; render(force = true) } + + private fun detailShell(badge: String, badgeColor: Int, name: String, line: String, states: List>, + actions: List, panel: View): View { + hints("A" to "Select", "|" to "", "B" to "Back") + val row = tv.row(Gravity.TOP) + val left = tv.col() + left.addView(tv.text(badge, 40, 0xFFFFFFFF.toInt(), tv.display(700)).apply { + gravity = Gravity.CENTER + background = if (badgeColor != 0) tv.round(badgeColor, 30) else tv.round(tv.p.raise, 30, tv.p.line, 1) + if (badgeColor == 0) setTextColor(tv.p.moon) + }, tv.lp(120, 120)) + left.addView(tv.text(name, 80, tv.p.moon, tv.display(700)), tv.lp(-2, -2).apply { topMargin = tv.px(32) }) + left.addView(tv.text(line, 26, tv.p.soft, tv.ui(500), 3), tv.lp(-1, -2).apply { topMargin = tv.px(14) }) + left.addView(tv.row().apply { states.forEach { (l, k) -> addView(tv.status(l, k)) } }, tv.lp(-2, -2).apply { topMargin = tv.px(24) }) + // up to two buttons on the first row, the rest on a second, so long labels never get cut ("Re…") + left.addView(tv.row().apply { actions.take(2).forEach { addView(it) } }, tv.lp(-2, -2).apply { topMargin = tv.px(36) }) + if (actions.size > 2) left.addView(tv.row().apply { actions.drop(2).forEach { addView(it) } }, tv.lp(-2, -2).apply { topMargin = tv.px(16) }) + row.addView(left, LinearLayout.LayoutParams(0, -2, 1f).apply { rightMargin = tv.px(88) }) + row.addView(panel, tv.lp(820, -2)) + return row + } + + private fun panel(title: String, rows: List>, title2: String? = null, rows2: List> = emptyList()): View { + val p = tv.col().apply { background = tv.round(tv.p.deep, 24, tv.p.line, 1); setPadding(tv.px(40), tv.px(32), tv.px(40), tv.px(24)) } + fun section(t: String, r: List>, first: Boolean) { + p.addView(tv.text(t, 26, tv.p.moon, tv.display(600)), tv.lp(-2, -2).apply { if (!first) topMargin = tv.px(28) }) + r.forEachIndexed { i, (k, v) -> + p.addView(tv.row().apply { + if (i > 0) background = topLine() + addView(tv.text(k, 24, tv.p.moon, tv.ui(700)), LinearLayout.LayoutParams(0, -2, 1f)) + addView(tv.text(v, 23, tv.p.dust, tv.ui(700))) + }, tv.lp(-1, 76)) + } + } + section(title, rows, true) + if (title2 != null) section(title2, rows2, false) + return p + } + + private fun topLine() = tv.topLine() + + private fun pcPage(d: ComputerDetails): View { + val running = runningApp(d) + val actions = mutableListOf() + val line: String + val states: List> + when { + !online(d) -> { + line = "This PC is off or asleep. Wake it, then it shows its games here." + states = listOf("Offline" to "rest", (if (paired(d)) "Paired" else "Not paired") to "") + actions += tv.pill("Wake it", "A", true) { wake(d) }.apply { tag = "primary" } + } + !paired(d) -> { + line = "Found on your network. Pair it once with a PIN to play its games on this TV." + states = listOf("Online" to "live", "Not paired" to "rest") + actions += tv.pill("Pair", "A", true) { pair(d) }.apply { tag = "primary" } + } + else -> { + line = if (running != null) "Playing ${running.appName}." else "Online and paired. Its games are in your Library." + states = listOf((if (running != null) "Playing" else "Online") to "live", "Paired" to "") + if (running != null) actions += tv.pill("Resume here", "A", true) { start(d, running) }.apply { tag = "primary" } + actions += tv.pill("Show games", if (running == null) "A" else null, running == null) { + filter = d.uuid; go(Tab.LIBRARY) + }.apply { if (running == null) tag = "primary" } + } + } + if (online(d) && paired(d)) actions += tv.pill("Unpair", null, false) { confirmUnpair(d) }.apply { tag = "unpair" } + actions += tv.pill("Test network", null, false) { ServerHelper.doNetworkTest(this) }.apply { tag = "nettest" } + actions += tv.pill("Remove", null, false) { confirmRemovePc(d) }.apply { tag = "remove" } + val facts = listOf( + "Address" to (d.activeAddress?.address ?: d.localAddress?.address ?: "Unknown"), + "Games" to "${apps[d.uuid]?.size ?: 0}", + "Wake-on-LAN" to (if (d.macAddress != null && d.macAddress != "00:00:00:00:00:00") "Ready" else "No MAC address yet")) + return detailShell("PC", 0, d.name, line, states, actions, panel("Details", facts)) + } + + private fun psPage(c: Ps5Store.Console): View { + val pw = power(c) + val line = when (pw) { + Ps5Store.Power.ON -> "On" + (psStatus[c.host]?.runningApp?.let { ", playing $it" } ?: "") + ". Play opens Remote Play on this TV." + Ps5Store.Power.REST -> "In rest mode. Play wakes it and opens Remote Play on this TV, in about a minute." + Ps5Store.Power.NOT_FOUND -> if (Psn.signedIn(this)) "Not on this network. Play reaches it through PlayStation Network." + else "Not on this network. Sign in to PlayStation Network in Settings to play it from here." + Ps5Store.Power.CHECKING -> "Checking whether it's on…" + } + val states = when (pw) { + Ps5Store.Power.ON -> listOf("On" to "live") + Ps5Store.Power.REST -> listOf("Rest mode" to "rest") + Ps5Store.Power.NOT_FOUND -> listOf("Away" to "") + Ps5Store.Power.CHECKING -> emptyList() + } + listOf("Registered" to "") + val actions = listOf( + tv.pill(if (pw == Ps5Store.Power.ON) "Play" else "Wake and play", "A", true) { playPs(c) }.apply { tag = "primary" }, + tv.pill("Wake only", null, false) { wakePs(c) }.apply { tag = "wake" }, + tv.pill("Remove", null, false) { confirmRemovePs(c) }.apply { tag = "remove" }) + val route = if (pw != Ps5Store.Power.NOT_FOUND) "Home network" else if (Psn.signedIn(this)) "PlayStation Network" else "Needs PSN sign-in" + return detailShell("PS", 0xFF2F5DF0.toInt(), c.name, line, states, actions, + panel("Stream", listOf("Route" to route, "Picture" to ps5Picture(), "Quality" to "Auto", + "Controller" to "Your phone or a pad on this TV"), + "Buttons your pad doesn't have", listOf("PS button" to "L2 + Start", "Touchpad" to "Select + Start"))) + } + + /** What the PS5 streams with the profile in use (#44): its resolution and frame rate rows. */ + private fun ps5Picture(): String { + val away = Profile.isAway(this) + val res = stream.getString(if (away) "ps5_away_quality" else "ps5_resolution", if (away) "auto" else "1080") ?: "auto" + val fps = stream.getString(if (away) "away_fps" else "ps5_fps_home", "60") ?: "60" + val r = if (res == "auto") "Auto" else "${res}p" + val f = if (fps == "30") "30" else "60" // the PS5 streams at 30 or 60 only + return "$r, $f fps (${if (away) "Away" else "Home"})" + } + + /** What the Xbox streams with the profile in use (#44). Xbox Remote Play is always 60 fps. */ + private fun xboxPicture(): String { + val r = when (val v = stream.getString("xbox_resolution", "auto")) { "auto", null -> "Auto"; "1081" -> "1080p high"; else -> "${v}p" } + return "$r, 60 fps (${if (Profile.isAway(this)) "Away" else "Home"})" + } + + // ---------------------------------------------------------------- Xbox + + private var xboxes: List = emptyList() + + private fun refreshXboxes(loud: Boolean = false) { + if (!Xbox.signedIn(this)) return + Thread { + val r = try { Xbox.refreshConsoles(this) } catch (e: Exception) { null } + runOnUiThread { + if (r != null) { xboxes = r; render(force = true) } + if (loud) toast(when { r == null -> "Couldn't reach Xbox network"; r.isEmpty() -> "No Xbox on this account" + else -> "Found " + r.joinToString { it.name } }) + } + }.start() + } + + private fun xbCardInfo(c: Xbox.Console): CardInfo { + val on = c.powerState.equals("On", true) + return CardInfo("XB", 0xFF107C10.toInt(), c.name, if (on) "On" else "Wakes when you play", + listOf((if (on) "On" else if (c.powerState.isBlank()) "Xbox" else "Asleep") to (if (on) "live" else "rest"))) + } + + private fun xbPage(c: Xbox.Console): View { + val on = c.powerState.equals("On", true) + val actions = listOf( + tv.pill(if (on) "Play" else "Wake and play", "A", true) { playXbox(c) }.apply { tag = "primary" }, + tv.pill("Wake only", null, false) { + Thread { val ok = try { Xbox.wake(this, c.id); true } catch (e: Exception) { false } + runOnUiThread { toast(if (ok) "Waking ${c.name}" else "Couldn't wake ${c.name}") } }.start() }.apply { tag = "wake" }) + return detailShell("XB", 0xFF107C10.toInt(), c.name, + (if (on) "On. " else "") + "Play opens Xbox Remote Play on this TV, at home or away, through Xbox network.", + listOf((if (on) "On" to "live" else "Asleep" to "rest"), "Signed in" to ""), actions, + panel("Stream", listOf("Route" to "Xbox network", "Picture" to xboxPicture(), "Type" to c.type, + "Controller" to "A pad on this TV"), "Buttons", listOf("Xbox button" to "Quick menu, Xbox button"))) + } + + private fun playXbox(c: Xbox.Console) { + FeelSounds.play(this, FeelSounds.Cue.LAUNCH) + startActivity(Launch.xboxIntent(this, c)) + } + + /** Microsoft's device-code sign-in: the TV shows a code, you type it at microsoft.com/link on a phone. */ + private fun xboxSignIn() { + val card = dialogCard() + card.addView(tv.text("Sign in to Xbox", 40, tv.p.moon, tv.display(700))) + val sub = tv.text("Getting a code…", 24, tv.p.dust, tv.ui(500), 3) + card.addView(sub, tv.lp(-1, -2).apply { topMargin = tv.px(12) }) + val code = tv.text("", 88, tv.p.moon, tv.display(700)).apply { letterSpacing = 0.12f } + card.addView(code, tv.lp(-2, -2).apply { topMargin = tv.px(28); bottomMargin = tv.px(28) }) + card.addView(tv.row().apply { addView(tv.pill("Cancel", "B", false) { hideOverlay() }.apply { tag = "primary" }) }) + showOverlay("xbox", FrameLayout(this).apply { addView(card, tv.flp(1000, -2, Gravity.CENTER)) }) + Thread { + try { + val dc = Xbox.startSignIn() + runOnUiThread { + sub.text = "On your phone, open ${dc.url.removePrefix("https://").removePrefix("www.")} and enter this code. " + + "Then sign in with your Xbox account." + code.text = dc.userCode + } + val end = System.currentTimeMillis() + dc.expiresIn * 1000L + while (System.currentTimeMillis() < end && overlayKind == "xbox") { + Thread.sleep(dc.interval * 1000L) + if (overlayKind != "xbox") return@Thread + if (Xbox.pollSignIn(this, dc)) { + val list = try { Xbox.refreshConsoles(this) } catch (e: Exception) { emptyList() } + runOnUiThread { + xboxes = list + if (overlayKind == "xbox") hideOverlay() + toast("Signed in to Xbox" + (Xbox.gamertag(this)?.let { " as $it" } ?: "") + + if (list.isEmpty()) ". No console found yet" else ". Found " + list.joinToString { it.name }) + } + return@Thread + } + } + runOnUiThread { if (overlayKind == "xbox") { sub.text = "The code expired. Press B and try again."; code.text = "" } } + } catch (e: Exception) { + runOnUiThread { if (overlayKind == "xbox") sub.text = "Sign-in didn't work: ${e.message?.take(120) ?: e.javaClass.simpleName}" } + } + }.start() + } + + // ---------------------------------------------------------------- Settings + + private var settingsGroup = "video" + private val stream by lazy { android.preference.PreferenceManager.getDefaultSharedPreferences(this) } + + private fun settingsPage(): View { + hints("A" to "Change", "LB" to "Previous tab", "RB" to "Next tab", "|" to "", "B" to "Back") + val groups = listOf("video" to "Video", "audio" to "Audio", "pads" to "Controllers", "ps" to "PlayStation", + "xbox" to "Xbox", "mk" to "Mouse and keyboard", "screen" to "Screen", "about" to "About and help") + val row = tv.row(Gravity.TOP) + val nav = tv.col() + groups.forEachIndexed { i, (k, label) -> + val on = k == settingsGroup + nav.addView(tv.text(label, 24, if (on) tv.p.moon else tv.p.dust, tv.ui(700)).apply { + gravity = Gravity.CENTER_VERTICAL + setPadding(tv.px(24), 0, tv.px(24), 0) + tag = "group:$k" + background = android.graphics.drawable.StateListDrawable().apply { + addState(intArrayOf(android.R.attr.state_focused), tv.round(tv.p.raise, 16, Tv.CORONA, 3)) + addState(intArrayOf(), if (on) LayerDrawable(arrayOf(tv.round(tv.p.deep, 16, tv.p.line, 1), + android.graphics.drawable.ColorDrawable(Tv.CORONA))).apply { setLayerInset(1, 0, tv.px(16), tv.px(436), tv.px(16)) } + else android.graphics.drawable.ColorDrawable(0)) + } + tv.focusableView(this) + setOnClickListener { settingsGroup = k; licencesOpen = false; render(force = true); firstFocusable(content.findViewWithTag("settings:list") ?: content)?.requestFocus() } + }, tv.lp(-1, 76).apply { if (i == groups.size - 1) topMargin = tv.px(24) }) + } + row.addView(nav, tv.lp(440, -2).apply { rightMargin = tv.px(40) }) + + val list = tv.col().apply { + tag = "settings:list" + background = tv.round(tv.p.deep, 24, tv.p.line, 1); setPadding(tv.px(24), tv.px(12), tv.px(24), tv.px(12)) + } + when (settingsGroup) { + "video" -> { + // Home and Away: one switch for PC, PlayStation and Xbox. Each profile keeps its own settings. + choice(list, Profile.PREF, "Streaming profile", "Home: best quality on your network. Away: lighter settings for PlayStation Network and slower links", + listOf("Home" to "home", "Away" to "away"), "home") + // Native: this screen's real panel size (its display mode), as in Moonlight/Artemis' "Native (WxH)" + val native = nativeResolution() + val presets = listOf("720p" to "1280x720", "1080p" to "1920x1080", "1440p" to "2560x1440", "4K" to "3840x2160") + choice(list, "list_resolution", "Resolution", + "Higher is sharper and needs more bitrate. Native is this screen: ${native.replace("x", " × ")}", + if (presets.any { it.second == native }) presets else presets + ("Native" to native), "1280x720") + val res = stream.getString("list_resolution", "1280x720") ?: "1280x720" + val customRes = res !in listOf("1280x720", "1920x1080", "2560x1440", "3840x2160", nativeResolution()) + action(list, "set:customres", "Custom resolution", "Any size, for example this TV's exact panel. Sunshine scales to it", + if (customRes) res.replace("x", " × ") else "Off") { customResolution(res, customRes) } + choice(list, "list_fps", "Frame rate", "60 is the smoothest. 30 uses less data", listOf("30" to "30", "60" to "60", "90" to "90", "120" to "120"), "60") + val fps = stream.getString("list_fps", "60") ?: "60" + val customFps = fps !in listOf("30", "60", "90", "120") + action(list, "set:customfps", "Custom frame rate", "Any rate between the steps above, for example to match this TV", + if (customFps) "$fps fps" else "Off") { customFrameRate(fps, customFps) } + bitrate(list) + val kbps = stream.getInt("seekbar_bitrate_kbps", 0) + val customRate = kbps != 0 && kbps / 1000 !in listOf(10, 20, 30, 50, 80) + action(list, "set:customrate", "Custom bitrate", "Any value between the steps above, in Mbps", + if (customRate) "${kbps / 1000} Mbps" else "Off") { customBitrate(kbps) } + choice(list, com.limelight.binding.video.FsrRenderer.PREF, "Super resolution", + "Sharper picture when the stream is smaller than this TV (AMD FSR 1.0), on PC, PS5 and Xbox. Not with HDR. Uses a little of the GPU", + listOf("Off" to "off", "FSR 1.0" to "fsr1"), "off") + if (stream.getString(com.limelight.binding.video.FsrRenderer.PREF, "off") == "fsr1") + intChoice(list, com.limelight.binding.video.FsrRenderer.SHARPNESS_PREF, "Sharpening", "How crisp FSR makes edges. Lower it if you see halos", + listOf(0, 20, 40, 60, 80, 100), com.limelight.binding.video.FsrRenderer.DEFAULT_SHARPNESS) { "$it%" } + choice(list, "video_format", "Video codec", "H.265 and AV1 look better at the same bitrate", + listOf("Auto" to "auto", "H.264" to "neverh265", "H.265" to "forceh265", "AV1" to "forceav1"), "auto") + toggle(list, "checkbox_enable_hdr", "HDR", "Only when the PC, the game and this TV support it", false) + toggle(list, "checkbox_full_range", "Full-range colour", "Deeper blacks, if this TV shows them correctly", false) + choice(list, "list_video_scale_mode", "Picture shape", "When the stream's shape doesn't match this TV: fit with bars, fill and crop, or stretch", + listOf("Fit" to "fit", "Fill" to "fill", "Stretch" to "stretch"), + if (stream.getBoolean("checkbox_stretch_video", false)) "stretch" else "fit") + intChoice(list, "seekbar_video_zoom", "Zoom", "Enlarge the picture if this TV cuts off the edges", listOf(100, 102, 104, 106, 108, 110), 100) { + if (it == 100) "Off" else "$it%" } + choice(list, "frame_pacing", "Frame pacing", "PC and PS5: trade a little delay for smoother motion. Xbox paces itself", + listOf("Lowest delay" to "latency", "Balanced" to "balanced", "Smoothest" to "smoothness"), "latency") + toggle(list, "checkbox_ultra_low_latency", "Ultra low latency", + "Experimental. Snapdragon 8 Gen 2 or newer, MediaTek, NVIDIA Shield. This device: ${chipName()}", false) + toggle(list, "checkbox_enable_sops", "Let the PC tune game settings", "Changes a game's graphics settings to suit the stream, on PCs that support it", true) + toggle(list, "checkbox_unlock_fps", "Unlock frame rate", "Allows rates above what this TV shows, like 120 on a 60 Hz screen. May tear", false) + toggle(list, "checkbox_reduce_refresh_rate", "Let the TV lower its refresh rate", "Saves power, adds a little delay", false) + } + "audio" -> { + choice(list, "list_audio_config", "Speakers", "Match what's connected to this TV", + listOf("Stereo" to "2", "5.1" to "51", "7.1" to "71"), "2") + toggle(list, "checkbox_host_audio", "Also play sound on the PC", "Useful when someone's in the room with it", false) + toggle(list, "checkbox_enable_audiofx", "Allow the TV's sound effects", "Lets this TV's equalizer and sound modes work while playing. Adds a little sound delay", false) + tvToggle(list, FeelSounds.PREF, "Menu sounds", "Soft sounds as you move around the menus. Games aren't affected", true) + } + "pads" -> { + action(list, "set:pad", "Pair a controller", "Opens this TV's Bluetooth settings. Put the pad in pairing mode first", null) { pairController() } + action(list, "set:padtest", "Test a controller", "See every button, stick and trigger live, and try the rumble", null) { + startActivity(Intent(this, PadTestActivity::class.java)) } + toggle(list, "checkbox_usb_driver", "USB pad driver", "Makes Xbox pads plugged in by USB work, even where this TV doesn't support them", true) + if (stream.getBoolean("checkbox_usb_driver", true)) + toggle(list, "checkbox_usb_bind_all", "Use it for every USB pad", "Even Xbox pads this TV already supports. Try it if one misbehaves", false) + toggle(list, "checkbox_multi_controller", "More than one player", "On a PC each pad is its own player. On the PS5, every pad is player 1", true) + intChoice(list, "seekbar_deadzone", "Stick deadzone", "Raise it if a character drifts on its own", listOf(3, 5, 7, 10, 15), 7) { "$it%" } + toggle(list, "checkbox_flip_face_buttons", "Swap A and B", "For Nintendo-style layouts", false) + toggle(list, "checkbox_gamepad_motion_sensors", "Motion", "Send the pad's gyro to games that use it", true) + toggle(list, "checkbox_mouse_emulation", "Use the pad as a mouse", "Hold Start to switch", true) + if (stream.getBoolean("checkbox_mouse_emulation", true)) + choice(list, "analog_scrolling", "Scroll with a stick", "While the pad is a mouse", + listOf("Off" to "none", "Right" to "right", "Left" to "left"), "right") + // Only offer the fallbacks when this device has the hardware; most TVs and sticks have neither. + val sensors = getSystemService(Context.SENSOR_SERVICE) as android.hardware.SensorManager + if (sensors.getDefaultSensor(android.hardware.Sensor.TYPE_GYROSCOPE) != null) + toggle(list, "checkbox_gamepad_motion_fallback", "Motion from this device", "For pads without a gyro, use this device's own sensors", false) + if ((getSystemService(Context.VIBRATOR_SERVICE) as? android.os.Vibrator)?.hasVibrator() == true) + toggle(list, "checkbox_vibrate_fallback", "Rumble on this device", "When a pad can't rumble", false) + info(list, "Quick menu", QuickMenu.COMBO_LABEL + ", or Back on the TV remote") + } + "ps" -> { + if (Psn.signedIn(this)) action(list, "set:psn", "PlayStation Network", + "Signed in" + (Psn.onlineId(this)?.takeIf { it.isNotBlank() }?.let { " as $it" } ?: "") + ". This TV can add your PS5 and reach it away from home", + "Sign out") { + confirm("Sign out of PlayStation Network?", "This TV will need the Odin, or a new sign-in, to add a PS5 or reach it away from home.", "Sign out") { + Psn.signOut(this); render(force = true) } } + else action(list, "set:psn", "PlayStation Network", "Sign in once to add a PS5 here and play it away from home", "Sign in") { + startActivityForResult(Intent(this, PsnSignInActivity::class.java), REQ_PSN) } + // Same four rows for each profile; the General profile decides which set they edit. Auto shows both sets. + val mode = Profile.mode(this) + if (mode != "away") { + choice(list, "ps5_resolution", "Resolution", "The PS5 streams up to 1080p", listOf("720p" to "720", "1080p" to "1080"), "1080") + // The PS5 streams at 30 or 60 fps only, so no Custom here (#39) + choice(list, "ps5_fps_home", "Frame rate", "60 fps is smoother; 30 fps lets a weak link keep up", + listOf("60 fps" to "60", "30 fps" to "30"), "60") + choice(list, "ps5_bitrate_home", "Bitrate", "Auto: 30 Mbps on fast Wi-Fi or cable, 20 otherwise", + listOf("Auto" to "auto", "10" to "10", "15" to "15", "20" to "20", "30" to "30", "Custom" to "custom"), "auto", + onPick = { if (it == "custom") customDialog("ps5_bitrate_home", "Custom bitrate", "Mbps", 2, 50, 20, cancelTo = "auto") }) + if (stream.getString("ps5_bitrate_home", "auto") == "custom") + customNumber(list, "ps5_bitrate_home", "Custom bitrate", "2 to 50 Mbps", "Mbps", 2, 50, 20) + choice(list, "ps5_codec", "Video codec", "H.265 looks better at the same bitrate", listOf("H.264" to "h264", "H.265" to "h265"), "h265") + } + if (mode != "home") { + choice(list, "ps5_away_quality", "Resolution", "Auto follows this TV's Wi-Fi: 1080p on a strong link, 720p on a weak one", + listOf("Auto" to "auto", "540p" to "540", "720p" to "720", "1080p" to "1080"), "auto") + choice(list, "away_fps", "Frame rate", "60 fps is smoother; 30 fps lets a weak link keep up", + listOf("60 fps" to "60", "30 fps" to "30"), "60") + choice(list, "away_bitrate", "Bitrate", "Auto: capped by this TV's Wi-Fi link", + listOf("Auto" to "auto", "8" to "8", "10" to "10", "15" to "15", "Custom" to "custom"), "auto", + onPick = { if (it == "custom") customDialog("away_bitrate", "Custom bitrate", "Mbps", 2, 50, 10, cancelTo = "auto") }) + if (stream.getString("away_bitrate", "auto") == "custom") + customNumber(list, "away_bitrate", "Custom bitrate", "2 to 50 Mbps", "Mbps", 2, 50, 10) + choice(list, "away_codec", "Video codec", "H.265 looks better at the same bitrate", listOf("H.264" to "h264", "H.265" to "h265"), "h265") + } + choice(list, "ps5_on_disconnect", "When I disconnect", "What happens to the PS5 after you leave a game", + listOf("Ask" to "ask", "Rest mode" to "rest", "Keep it on" to "keep"), "ask") + toggle(list, "ps5_swap_cross_circle", "Swap Cross and Circle", "For the Japanese layout", false) + choice(list, com.limelight.Ps5StreamActivity.DECODER_PREF, "Decoder", "Low latency shows each frame sooner. Ultra pushes this TV's chip harder. Standard if the picture misbehaves", + listOf("Low latency" to "low", "Ultra low latency" to "ultra", "Standard" to "standard"), "low") + val pin = prefs.getString("ps5_login_pin", "") ?: "" + action(list, "set:pspin", "Console PIN", "Signs in for you when the PS5 asks for its 4-digit login PIN", + if (pin.isEmpty()) "Not set" else "••••") { enterLoginPin() } + toggle(list, "ps5_dpad_touchpad", "D-pad as touchpad", "Hold Select and use the D-pad to swipe on the PS5's touchpad", false) + info(list, "PS button", "L2 + Start") + info(list, "Touchpad", "Select + Start") + } + "xbox" -> { + if (Xbox.signedIn(this)) action(list, "set:xbox", "Xbox network", + "Signed in" + (Xbox.gamertag(this)?.let { " as $it" } ?: "") + ". Your consoles show under Consoles", "Sign out") { + confirm("Sign out of Xbox?", "Your Xbox consoles leave this TV until you sign in again.", "Sign out") { + Xbox.signOut(this); xboxes = emptyList(); render(force = true) } } + else action(list, "set:xbox", "Xbox network", "Sign in once to play your Xbox on this TV, at home or away", "Sign in") { xboxSignIn() } + if (Xbox.signedIn(this)) { + action(list, "set:xbref", "Find my consoles", if (xboxes.isEmpty()) "None found yet" else xboxes.joinToString { it.name }, null) { + refreshXboxes(true) } + info(list, "On the Xbox", "Settings, Devices and connections, Remote features: on. Power mode: Sleep") + } + choice(list, "xbox_resolution", "Picture", "The Xbox sets quality from this. Auto: 720p on weak Wi-Fi, high on a 1440p+ screen, else 1080p", + listOf("Auto" to "auto", "720p" to "720", "1080p" to "1080", "1080p high" to "1081"), "auto") + xboxBitrate(list) + choice(list, "list_video_scale_mode", "Picture shape", "Fit with bars, fill and crop, or stretch. Shared with PC games", + listOf("Fit" to "fit", "Fill" to "fill", "Stretch" to "stretch"), + if (stream.getBoolean("checkbox_stretch_video", false)) "stretch" else "fit") + intChoice(list, "seekbar_video_zoom", "Zoom", "Enlarge the picture if this TV cuts off the edges. Shared with PC games", + listOf(100, 102, 104, 106, 108, 110), 100) { if (it == 100) "Off" else "$it%" } + toggle(list, "checkbox_ultra_low_latency", "Ultra low latency", + "Experimental. Snapdragon 8 Gen 2 or newer, MediaTek, NVIDIA Shield. This device: ${chipName()}", false) + info(list, "Frame rate", "60 fps") + choice(list, "xbox_rumble", "Rumble", "How strongly the controller shakes", listOf("Off" to "off", "Weak" to "weak", + "Medium" to "medium", "Strong" to "strong"), "strong") + intChoice(list, "xbox_deadzone", "Stick deadzone", "Raise it if a character drifts on its own", listOf(3, 5, 8, 10, 15), 8) { "$it%" } + toggle(list, "xbox_edge", "Full stick range", "Rescale after the deadzone so the stick still reaches 100% at its edge", false) + toggle(list, "xbox_swap_ab", "Swap A and B", "For Nintendo-style layouts", false) + toggle(list, "xbox_swap_xy", "Swap X and Y", "For Nintendo-style layouts", false) + toggle(list, "xbox_short_triggers", "Short triggers", "A light pull counts as a full press", false) + toggle(list, "xbox_coop", "Two players", "A second controller plays as player 2", false) + choice(list, "xbox_language", "Game language", "The language games start in", listOf("English" to "en-US", + "Arabic" to "ar-SA", "French" to "fr-FR", "German" to "de-DE", "Spanish" to "es-ES"), "en-US") + info(list, "Quick menu", QuickMenu.COMBO_LABEL + ", or Back on the TV remote") + } + "mk" -> { + toggle(list, "checkbox_absolute_mouse_mode", "Remote-desktop mouse", "The pointer goes exactly where your mouse is, for desktop use", false) + toggle(list, "checkbox_mouse_nav_buttons", "Mouse back and forward buttons", "Send them to the PC instead of using them here", false) + toggle(list, "checkbox_gamepad_touchpad_as_mouse", "DualSense touchpad as a mouse", "Move the PC's pointer with the pad's touchpad", false) + info(list, "Leave the game", "Ctrl + Alt + Shift + Q") + info(list, "Show stats", "Ctrl + Alt + Shift + S") + info(list, "Release the mouse", "Ctrl + Alt + Shift + Z") + } + "screen" -> { + action(list, "set:oled", "OLED screen", "True black, quieter menus, and a dim screen saver that moves", if (tv.oled) "On" else "Off") { + prefs.edit().putBoolean("oled", !tv.oled).apply(); recreate() } + toggle(list, "checkbox_enable_perf_overlay", "Stats while playing", "Frame rate, delay and lost frames in a corner of the game", false) + tvToggle(list, "hide_hints", "Hide button hints", "They fade after 10 seconds and come back when you press a button", tv.oled) + tvToggle(list, "pixel_shift", "Move the menus slightly", "Shifts everything by a few pixels every few minutes, so nothing burns in", tv.oled) + toggle(list, "checkbox_enable_post_stream_toast", "Connection summary after a game", "Shows the average delay and frame rate when you leave", false) + toggle(list, "checkbox_disable_warnings", "Hide connection warnings", "No \"poor connection\" messages while playing", false) + val langs = resources.getStringArray(R.array.language_values) + val names = resources.getStringArray(R.array.language_names) + val cur = langs.indexOf(stream.getString("list_languages", "default")).coerceAtLeast(0) + action(list, "set:lang", "Language while playing", "For messages during a game and errors. These menus stay in English", + if (cur == 0) "Same as the TV" else names[cur]) { pickLanguage(langs, names, cur) } + } + "about" -> if (licencesOpen) licences(list) else { + val ver = try { packageManager.getPackageInfo(packageName, 0).versionName } catch (e: Exception) { "" } + info(list, "MoonCast TV", ver ?: "") + action(list, "set:licences", "Open-source licences", + "MoonCast is free software, built on Moonlight, chiaki-ng and about twenty more open-source projects", "›") { + licencesOpen = true; render(force = true) + firstFocusable(content.findViewWithTag("settings:list") ?: content)?.requestFocus() + } + info(list, "Source code", "github.com/MoonCastDev · ${Licences.SOURCE_LICENCE}") + info(list, "Phones send games here", "Port ${PhoneLink.PORT}") + if (com.limelight.home.Usage.available) + toggle(list, com.limelight.home.Usage.PREF, "Anonymous usage check-in", + "Once a day: app version, device model, and whether you played PC, PS5 or Xbox. Nothing personal", false) + action(list, "set:add", "Add a PC by its address", "For a PC this TV doesn't find on its own", null) { addPcByAddress() } + action(list, "set:all", "All settings", "Every option, including the rare ones", null) { + startActivity(Intent(this, StreamSettings::class.java)) } + } + } + row.addView(ScrollView(this).apply { + isVerticalScrollBarEnabled = false; isFocusable = false; clipToPadding = false + addView(list) + }, LinearLayout.LayoutParams(0, -1, 1f)) + return row + } + + private fun settingRow(list: LinearLayout, key: String, title: String, sub: String?, focusable: Boolean, value: View?, onClick: (() -> Unit)?) { + list.addView(tv.row().apply { + tag = "set:$key" + if (list.childCount > 0) background = topLine() + setPadding(tv.px(24), 0, tv.px(24), 0) + addView(tv.col().apply { + addView(tv.text(title, 24, tv.p.moon, tv.ui(700))) + if (sub != null) addView(tv.text(sub, 19, tv.p.dust, tv.ui(500)), tv.lp(-2, -2).apply { topMargin = tv.px(4) }) + }, LinearLayout.LayoutParams(0, -2, 1f)) + if (value != null) addView(value) + setOnKeyListener { _, code, e -> + if (code == KeyEvent.KEYCODE_DPAD_LEFT && e.action == KeyEvent.ACTION_DOWN) + content.findViewWithTag("group:$settingsGroup")?.requestFocus() ?: false + else false + } + if (focusable) { + val line = background + background = android.graphics.drawable.StateListDrawable().apply { + addState(intArrayOf(android.R.attr.state_focused), tv.round(tv.p.raise, 16, Tv.CORONA, 3)) + addState(intArrayOf(), line ?: android.graphics.drawable.ColorDrawable(0)) + } + tv.focusableView(this) + setOnClickListener { onClick?.invoke() } + } + }, tv.lp(-1, 96)) + } + + /** Options as a segmented control; A moves to the next one. */ + /** A Custom value for a choice row: tapping it asks for a number, and saving a number switches the row to Custom. */ + private fun customNumber(list: LinearLayout, key: String, title: String, sub: String, unit: String, min: Int, max: Int, def: Int) { + val cur = stream.getString(key + "_custom", def.toString()) ?: def.toString() + action(list, "set:$key-custom", title, sub, "$cur $unit") { customDialog(key, title, unit, min, max, def) } + } + + /** + * Asks for a Custom number and saves it; the row switches to Custom. [cancelTo]: picking Custom on a choice row + * already set the row to Custom, so closing the box without saving sets it to this option instead (#43). + */ + private fun customDialog(key: String, title: String, unit: String, min: Int, max: Int, def: Int, cancelTo: String? = null) { + val cur = stream.getString(key + "_custom", def.toString()) ?: def.toString() + entryDialog(title, "$min to $max $unit", unit, cur, true, { v -> + val n = v.trim().toIntOrNull() + if (n == null || n !in min..max) "Enter a number from $min to $max" + else { + stream.edit().putString(key + "_custom", n.toString()).putString(key, "custom").apply() + render(force = true) + null + } + }, onCancel = cancelTo?.let { v -> { stream.edit().putString(key, v).apply() } }) + } + + private fun choice(list: LinearLayout, key: String, title: String, sub: String, options: List>, def: String, + onPick: ((String) -> Unit)? = null) { + val cur = stream.getString(key, def) + val seg = tv.row().apply { background = tv.round(0, 20, tv.p.line, 1) } + options.forEach { (label, v) -> + val on = v == cur + seg.addView(tv.text(label, 19, if (on) (if (tv.oled) tv.p.moon else tv.p.night) else tv.p.dust, tv.ui(700)).apply { + gravity = Gravity.CENTER; setPadding(tv.px(16), 0, tv.px(16), 0) + if (on) background = if (tv.oled) tv.round(tv.p.raise, 20, tv.p.moon, 2) else tv.round(tv.p.moon, 20) + }, tv.lp(-2, 40)) + } + settingRow(list, key, title, sub, true, seg) { + val i = options.indexOfFirst { it.second == cur } + val next = options[(i + 1) % options.size].second + stream.edit().putString(key, next).apply() + render(force = true) + onPick?.invoke(next) + } + } + + private fun intChoice(list: LinearLayout, key: String, title: String, sub: String, options: List, def: Int, label: (Int) -> String) { + val cur = stream.getInt(key, def) + settingRow(list, key, title, sub, true, tv.text(label(cur), 23, tv.p.moon, tv.ui(700))) { + val i = options.indexOf(cur) + stream.edit().putInt(key, options[(i + 1) % options.size]).apply() + render(force = true) + } + } + + /** Moonlight keeps the bitrate in kbps; no value means Moonlight picks one for the resolution. */ + private fun bitrate(list: LinearLayout) { + val steps = listOf(0, 10, 20, 30, 50, 80) + val cur = stream.getInt("seekbar_bitrate_kbps", 0) / 1000 + val shown = if (cur == 0) "Auto" else "$cur Mbps" + settingRow(list, "seekbar_bitrate_kbps", "Bitrate", "Auto picks one for the resolution. Raise it for a sharper picture on a fast network", + true, tv.text(shown, 23, tv.p.moon, tv.ui(700))) { + val next = steps[(steps.indexOf(steps.firstOrNull { it >= cur } ?: 0) + 1) % steps.size] + if (next == 0) stream.edit().remove("seekbar_bitrate_kbps").apply() + else stream.edit().putInt("seekbar_bitrate_kbps", next * 1000).apply() + render(force = true) + } + } + + private fun toggle(list: LinearLayout, key: String, title: String, sub: String, def: Boolean) { + val on = stream.getBoolean(key, def) + val sw = FrameLayout(this).apply { + background = tv.round(if (on) Tv.BEAM else tv.p.line, 19) + addView(View(this@HomeActivity).apply { background = tv.round(if (on) tv.p.night else tv.p.dust, 14) }, + tv.flp(28, 28, Gravity.CENTER_VERTICAL or (if (on) Gravity.END else Gravity.START)).apply { leftMargin = tv.px(5); rightMargin = tv.px(5) }) + layoutParams = tv.lp(68, 38) + } + settingRow(list, key, title, sub, true, sw) { + stream.edit().putBoolean(key, !on).apply() + FeelSounds.play(this, FeelSounds.Cue.TOGGLE, FeelSounds.panFor(sw)) + render(force = true) + } + } + + /** A switch stored with this app's own settings (mc_tv), not Moonlight's stream settings. */ + private fun tvToggle(list: LinearLayout, key: String, title: String, sub: String, def: Boolean) { + val on = prefs.getBoolean(key, def) + val sw = FrameLayout(this).apply { + background = tv.round(if (on) Tv.BEAM else tv.p.line, 19) + addView(View(this@HomeActivity).apply { background = tv.round(if (on) tv.p.night else tv.p.dust, 14) }, + tv.flp(28, 28, Gravity.CENTER_VERTICAL or (if (on) Gravity.END else Gravity.START)).apply { leftMargin = tv.px(5); rightMargin = tv.px(5) }) + layoutParams = tv.lp(68, 38) + } + settingRow(list, key, title, sub, true, sw) { + prefs.edit().putBoolean(key, !on).apply() + FeelSounds.play(this, FeelSounds.Cue.TOGGLE, FeelSounds.panFor(sw)) // turning sounds on answers with the sound + render(force = true) + } + } + + /** The PS5's own 4-digit login PIN, so the player can answer it (PlayStation settings). */ + private fun enterLoginPin() { + val card = dialogCard() + card.addView(tv.text("Console PIN", 40, tv.p.moon, tv.display(700))) + card.addView(tv.text("The 4-digit PIN your PS5 asks for when it starts (if you set one). Leave empty to turn it off.", + 22, tv.p.dust, tv.ui(500), 3), tv.lp(-1, -2).apply { topMargin = tv.px(12) }) + val field = textField("4 digits", prefs.getString("ps5_login_pin", "") ?: "").apply { + inputType = android.text.InputType.TYPE_CLASS_NUMBER or android.text.InputType.TYPE_NUMBER_VARIATION_PASSWORD + filters = arrayOf(android.text.InputFilter.LengthFilter(4)) + } + card.addView(field, tv.lp(480, 84).apply { topMargin = tv.px(24) }) + fun save() { + val v = field.text.toString() + if (v.isNotEmpty() && v.length != 4) { toast("The PIN has 4 digits"); return } + prefs.edit().putString("ps5_login_pin", v).apply(); hideOverlay(); render(force = true) + } + field.setOnEditorActionListener { _, _, _ -> save(); true } + card.addView(tv.row().apply { addView(tv.pill("Save", "A", true) { save() }); addView(tv.pill("Cancel", "B", false) { hideOverlay() }) }, + tv.lp(-2, -2).apply { topMargin = tv.px(24) }) + showOverlay("pspin", FrameLayout(this).apply { addView(card, tv.flp(900, -2, Gravity.CENTER)) }) + field.requestFocus() + } + + private fun action(list: LinearLayout, key: String, title: String, sub: String, value: String?, onClick: () -> Unit) = + settingRow(list, key.removePrefix("set:"), title, sub, true, value?.let { tv.text(it, 23, tv.p.moon, tv.ui(700)) }, onClick) + + private fun info(list: LinearLayout, title: String, value: String) = + settingRow(list, "info:$title", title, null, false, tv.text(value, 22, tv.p.dust, tv.ui(700)), null) + + // ---------------------------------------------------------------- actions + + private fun toast(s: String) = Toast.makeText(this, s, Toast.LENGTH_LONG).show() + + /** About, Open-source licences: each project with its licence; pressing one shows the full text. Back returns to About. */ + private var licencesOpen = false + private fun licences(list: LinearLayout) { + action(list, "lic:back", "‹ About and help", "Back to About", null) { + licencesOpen = false; render(force = true) + firstFocusable(content.findViewWithTag("settings:list") ?: content)?.requestFocus() + } + var group = "" + Licences.parts.forEach { p -> + if (p.group != group) { + group = p.group + list.addView(tv.text(group.uppercase(), 17, tv.p.dust, tv.ui(800)).apply { setPadding(tv.px(24), tv.px(22), 0, tv.px(6)) }) + } + action(list, "lic:${p.name}", p.name, p.what, p.licence) { Licences.open(this, p) } + } + } + + private fun start(pc: ComputerDetails, app: NvApp) { + val b = binder ?: return toast(getString(R.string.error_manager_not_running)) + val other = runningApp(pc) + if (other != null && other.appId != app.appId) { + confirm("Quit ${other.appName}?", "${pc.name} is running ${other.appName}. Quit it and start ${app.appName}?", + "Quit and start", danger = true) { + FeelSounds.play(this, FeelSounds.Cue.LAUNCH) + ServerHelper.doQuit(this, pc, other, b) { runOnUiThread { ServerHelper.doStart(this, app, pc, b) } } + } + return + } + FeelSounds.play(this, FeelSounds.Cue.LAUNCH) + ServerHelper.doStart(this, app, pc, b) + } + + private fun quit(pc: ComputerDetails, app: NvApp) { + val b = binder ?: return + confirm("Quit ${app.appName}?", "The game closes on ${pc.name}. Anything you haven't saved is lost.", "Quit game", danger = true) { + ServerHelper.doQuit(this, pc, app, b) { runOnUiThread { b.invalidateStateForComputer(pc.uuid) } } + } + } + + private fun wake(d: ComputerDetails) { + if (d.macAddress == null || d.macAddress == "00:00:00:00:00:00") return toast(getString(R.string.wol_no_mac)) + Thread { + val msg = try { WakeOnLanSender.sendWolPacket(d); "Waking ${d.name}. It shows as online when it's ready." } + catch (e: Exception) { getString(R.string.wol_fail) } + runOnUiThread { toast(msg) } + }.start() + } + + private fun pair(d: ComputerDetails) { + val b = binder ?: return toast(getString(R.string.error_manager_not_running)) + if (!online(d) || d.activeAddress == null) return toast(getString(R.string.pair_pc_offline)) + val pin = PairingManager.generatePinString() + showPin(d, pin) + stopPolling() + Thread { + var msg: String? = null + var ok = false + try { + b.waitForPollingStopped() + val http = NvHTTP(ServerHelper.getCurrentAddressFromComputer(d), d.httpsPort, b.uniqueId, d.serverCert, + PlatformBinding.getCryptoProvider(this)) + if (http.pairState == PairingManager.PairState.PAIRED) ok = true + else { + val pm = http.pairingManager + when (pm.pair(http.getServerInfo(true), pin)) { + PairingManager.PairState.PIN_WRONG -> msg = getString(R.string.pair_incorrect_pin) + PairingManager.PairState.FAILED -> msg = getString(if (d.runningGameId != 0) R.string.pair_pc_ingame else R.string.pair_fail) + PairingManager.PairState.ALREADY_IN_PROGRESS -> msg = getString(R.string.pair_already_in_progress) + PairingManager.PairState.PAIRED -> { + ok = true + b.getComputer(d.uuid)?.serverCert = pm.pairedCert + b.invalidateStateForComputer(d.uuid) + } + else -> {} + } + } + } catch (e: UnknownHostException) { msg = getString(R.string.error_unknown_host) + } catch (e: FileNotFoundException) { msg = getString(R.string.error_404) + } catch (e: Exception) { msg = e.message ?: getString(R.string.pair_fail) } + runOnUiThread { + if (overlayKind == "pin") hideOverlay() + msg?.let { toast(it) } + if (ok) { toast("Paired with ${d.name}"); filter = d.uuid; tab = Tab.LIBRARY; detail = null } + startPolling() + render(force = true) + } + }.start() + } + + private fun confirmUnpair(d: ComputerDetails) = + confirm("Unpair ${d.name}?", "It stays on this TV, but you'll pair it again with a PIN before you can play its games.", "Unpair", danger = true) { + val b = binder ?: return@confirm + Thread { + val ok = try { + NvHTTP(ServerHelper.getCurrentAddressFromComputer(d), d.httpsPort, b.uniqueId, d.serverCert, + PlatformBinding.getCryptoProvider(this)).unpair(); true + } catch (e: Exception) { false } + runOnUiThread { toast(if (ok) "Unpaired ${d.name}" else "Couldn't reach ${d.name} to unpair it"); b.invalidateStateForComputer(d.uuid) } + }.start() + } + + private fun confirmRemovePc(d: ComputerDetails) = + confirm("Remove ${d.name}?", "It disappears from this TV and is unpaired. To add it back, you'll pair it again with a PIN.", + "Remove and unpair", danger = true) { removePc(d) } + + private fun removePc(d: ComputerDetails) { + val b = binder ?: return + if (online(d) && paired(d)) Thread { + try { + NvHTTP(ServerHelper.getCurrentAddressFromComputer(d), d.httpsPort, b.uniqueId, d.serverCert, + PlatformBinding.getCryptoProvider(this)).unpair() + } catch (e: Exception) { } + }.start() + appPollers.remove(d.uuid)?.stop() + b.removeComputer(d) + DiskAssetLoader(this).deleteAssetsForComputer(d.uuid) + getSharedPreferences(AppView.HIDDEN_APPS_PREF_FILENAME, MODE_PRIVATE).edit().remove(d.uuid).apply() + try { ShortcutHelper(this).disableComputerShortcut(d, getString(R.string.scut_deleted_pc)) } catch (e: Exception) { } + pcs.remove(d.uuid); apps.remove(d.uuid); rawLists.remove(d.uuid); loaders.remove(d.uuid) + detail = null + render(force = true) + } + + private fun playPs(c: Ps5Store.Console) = Launch.playPs(this, c, power(c)) { toast(it) } + + private fun wakePs(c: Ps5Store.Console) { + Thread { + val ok = Ps5Store.wake(c) + runOnUiThread { toast(if (ok) "Waking ${c.name}. It takes about a minute." else "Couldn't wake ${c.name}. Register it again from your Odin.") } + }.start() + } + + private fun confirmRemovePs(c: Ps5Store.Console) = + confirm("Remove ${c.name}?", "It disappears from this TV and its registration here is deleted. To add it back, register it again from your Odin.", + "Remove from this TV", danger = true) { + Ps5Store.remove(this, c.host); consoles = Ps5Store.all(this); psStatus.remove(c.host); detail = null; render(force = true) + } + + private fun addConsole() { + if (pcs.values.any { !paired(it) && online(it) }) { go(Tab.CONSOLES); return } + val card = dialogCard() + card.addView(tv.text("Add a console", 40, tv.p.moon, tv.display(700))) + card.addView(tv.text("PCs running Sunshine on your network appear here by themselves. Register a PlayStation here or from MoonCast on your Odin.", + 22, tv.p.dust, tv.ui(500), 4), tv.lp(-1, -2).apply { topMargin = tv.px(12); bottomMargin = tv.px(28) }) + card.addView(tv.row().apply { + addView(tv.pill("Register a PS5", "A", true) { hideOverlay(); showRegister() }.apply { tag = "primary" }) + addView(tv.pill("Add a PC by address", null, false) { hideOverlay(); addPcByAddress() }) + }) + showOverlay("confirm", FrameLayout(this).apply { addView(card, tv.flp(1000, -2, Gravity.CENTER)) }) + } + + /** The PS5 is registered from the Odin (it has the PSN sign-in); this TV shows the steps and waits for it. */ + private var registerStatus: TextView? = null + private fun showRegister() { + val v = tv.row(Gravity.TOP).apply { setPadding(tv.px(96), tv.px(200), tv.px(96), 0) } + val left = tv.col() + left.addView(tv.row().apply { addView(tv.status("Add a PlayStation", "rest")) }) + left.addView(tv.text("Register it\nfrom your Odin", 72, tv.p.moon, tv.display(700), 2), tv.lp(-2, -2).apply { topMargin = tv.px(28) }) + left.addView(tv.text("The Odin has your PlayStation sign-in. Once it registers the PS5 through this TV, the console appears here.", + 27, tv.p.soft, tv.ui(500), 3), tv.lp(900, -2).apply { topMargin = tv.px(22) }) + registerStatus = tv.text("Waiting for your Odin. B cancels.", 24, tv.p.dust, tv.ui(600)) + left.addView(tv.row().apply { + addView(View(this@HomeActivity).apply { background = tv.round(Tv.CORONA, 7) }, tv.lp(14, 14).apply { rightMargin = tv.px(14) }) + addView(registerStatus) + }, tv.lp(-2, -2).apply { topMargin = tv.px(40) }) + v.addView(left, LinearLayout.LayoutParams(0, -2, 1f)) + val steps = tv.col().apply { background = tv.round(tv.p.deep, 24, tv.p.line, 1); setPadding(tv.px(40), tv.px(36), tv.px(40), tv.px(24)) } + steps.addView(tv.text("Steps", 26, tv.p.moon, tv.display(600))) + listOf("On the PS5, open Settings, System, Remote Play and choose Link device", + "In MoonCast on your Odin, open the PlayStation and choose Register, with this TV as the screen", + "Type the 8-digit code the PS5 shows").forEachIndexed { i, st -> + steps.addView(tv.row(Gravity.TOP).apply { + if (i > 0) background = topLine() + setPadding(0, tv.px(18), 0, tv.px(18)) + addView(tv.text("${i + 1}", 17, tv.p.moon, tv.display(700)).apply { gravity = Gravity.CENTER; background = tv.round(tv.p.raise, 20, tv.p.line, 1) }, + tv.lp(40, 40).apply { rightMargin = tv.px(18) }) + addView(tv.text(st, 23, tv.p.soft, tv.ui(500), 3), LinearLayout.LayoutParams(0, -2, 1f)) + }) + } + v.addView(steps, tv.lp(680, -2)) + left.addView(tv.row().apply { + addView(tv.pill(if (Psn.signedIn(this@HomeActivity)) "Register on this TV" else "Sign in and register on this TV", "A", true) { + registerHere() }.apply { tag = "primary" }) + }, tv.lp(-2, -2).apply { topMargin = tv.px(36) }) + showOverlay("register", v, dim = false) + } + + // ---- registering on the TV itself: PSN sign-in (once) -> pick the PS5 -> 8-digit code ---- + private fun registerHere() { + if (!Psn.signedIn(this)) { startActivityForResult(Intent(this, PsnSignInActivity::class.java), REQ_PSN_REGISTER); return } + pickConsole() + } + + @Deprecated("Deprecated in Java") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (resultCode != RESULT_OK) return + when (requestCode) { + REQ_PSN_REGISTER -> { toast("Signed in to PlayStation Network"); ui.post { pickConsole() } } + REQ_PSN -> { toast("Signed in to PlayStation Network"); render(force = true) } + } + } + + private fun pickConsole() { + val card = dialogCard() + card.addView(tv.text("Which PlayStation?", 40, tv.p.moon, tv.display(700))) + val sub = tv.text("Looking on this network…", 22, tv.p.dust, tv.ui(500), 3) + card.addView(sub, tv.lp(-1, -2).apply { topMargin = tv.px(12); bottomMargin = tv.px(20) }) + val list = tv.col() + card.addView(list, tv.lp(-1, -2)) + showOverlay("pickps", FrameLayout(this).apply { addView(card, tv.flp(1000, -2, Gravity.CENTER)) }) + Thread { + val found = Ps5Store.discover() + runOnUiThread { + if (overlayKind != "pickps") return@runOnUiThread + if (found.isEmpty()) { + sub.text = "No PlayStation answered. Make sure it's on or in rest mode (not fully off) and on this network." + list.addView(tv.row().apply { addView(tv.pill("Look again", "A", true) { pickConsole() }.apply { tag = "primary" }) }) + } else { + sub.text = "Pick yours. Then open Settings, System, Remote Play, Link device on it to see the code." + found.forEachIndexed { i, f -> + list.addView(tv.row().apply { + tag = if (i == 0) "primary" else "ps:${f.host}" + background = tv.focusable(0, 16, outline = false) + setPadding(tv.px(20), 0, tv.px(20), 0) + addView(tv.text("PS", 18, 0xFFFFFFFF.toInt(), tv.display(700)).apply { gravity = Gravity.CENTER; background = tv.round(0xFF2F5DF0.toInt(), 12) }, + tv.lp(52, 52).apply { rightMargin = tv.px(18) }) + addView(tv.text(f.name, 26, tv.p.moon, tv.ui(700)), LinearLayout.LayoutParams(0, -2, 1f)) + addView(tv.status(if (f.on) "On" else "Rest mode", if (f.on) "live" else "rest")) + addView(tv.text(f.host, 19, tv.p.dust, tv.ui(600)), tv.lp(-2, -2).apply { leftMargin = tv.px(8) }) + tv.focusableView(this) + setOnClickListener { enterCode(f) } + }, tv.lp(-1, 88)) + } + } + list.findViewWithTag("primary")?.requestFocus() + } + }.start() + } + + private var codeDigits = StringBuilder() + private var codeBoxes = listOf() + private var codeStatus: TextView? = null + private var codeTarget: Ps5Store.Found? = null + + /** The 8-digit "Link device" code: number pad for a pad or remote; number keys work too. */ + private fun enterCode(f: Ps5Store.Found) { + codeTarget = f; codeDigits = StringBuilder() + val v = tv.row(Gravity.TOP).apply { setPadding(tv.px(96), tv.px(170), tv.px(96), 0) } + val left = tv.col() + left.addView(tv.row().apply { addView(tv.status("Registering ${f.name}", "rest")) }) + left.addView(tv.text("Type the code\nfrom your PS5", 64, tv.p.moon, tv.display(700), 2), tv.lp(-2, -2).apply { topMargin = tv.px(24) }) + left.addView(tv.text("On the PS5: Settings, System, Remote Play, Link device.", 24, tv.p.soft, tv.ui(500), 2), + tv.lp(900, -2).apply { topMargin = tv.px(16) }) + val boxes = tv.row() + codeBoxes = (0 until 8).map { i -> + tv.text("", 56, tv.p.moon, tv.display(700)).apply { gravity = Gravity.CENTER } + .also { b -> boxes.addView(b, tv.lp(92, 116).apply { rightMargin = tv.px(if (i == 3) 28 else 12) }) } + } + left.addView(boxes, tv.lp(-2, -2).apply { topMargin = tv.px(36) }) + codeStatus = tv.text("", 24, tv.p.dust, tv.ui(600), 2) + left.addView(codeStatus, tv.lp(900, -2).apply { topMargin = tv.px(28) }) + v.addView(left, LinearLayout.LayoutParams(0, -2, 1f)) + + val pad = GridLayout(this).apply { columnCount = 3 } + listOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "⌫", "0", "OK").forEach { k -> + pad.addView(tv.text(k, 34, if (k == "OK") tv.p.night else tv.p.moon, tv.display(600)).apply { + gravity = Gravity.CENTER + tag = if (k == "1") "primary" else "key:$k" + val fill = if (k == "OK") Tv.BEAM else tv.p.deep + background = android.graphics.drawable.StateListDrawable().apply { + addState(intArrayOf(android.R.attr.state_focused), tv.round(if (k == "OK") Tv.BEAM else tv.p.raise, 20, Tv.CORONA, 3)) + addState(intArrayOf(), tv.round(fill, 20, if (k == "OK") 0 else tv.p.line, 1)) + } + tv.focusableView(this) + setOnClickListener { codeKey(k) } + }, GridLayout.LayoutParams().apply { width = tv.px(150); height = tv.px(110); rightMargin = tv.px(14); bottomMargin = tv.px(14) }) + } + v.addView(pad, tv.lp(-2, -2)) + showOverlay("code", v, dim = false) + paintCode() + } + + private fun paintCode() { + codeBoxes.forEachIndexed { i, b -> + b.text = codeDigits.getOrNull(i)?.toString() ?: "" + val cur = i == codeDigits.length + b.background = tv.round(tv.p.deep, 16, if (cur) Tv.CORONA else tv.p.line, if (cur) 3 else 1) + } + } + + private fun codeKey(k: String) { + when (k) { + "⌫" -> if (codeDigits.isNotEmpty()) codeDigits.deleteCharAt(codeDigits.length - 1) + "OK" -> { submitCode(); return } + else -> if (codeDigits.length < 8) codeDigits.append(k) + } + paintCode() + if (codeDigits.length == 8) overlay.findViewWithTag("key:OK")?.requestFocus() + } + + private fun submitCode() { + val f = codeTarget ?: return + if (codeDigits.length != 8) { codeStatus?.text = "The code has 8 digits."; return } + val acc = Psn.accountId(this) ?: return toast("Sign in to PlayStation Network first") + val pin = codeDigits.toString().toInt() + codeStatus?.text = "Registering with ${f.name}…" + Thread { + val r = try { com.limelight.Ps5Register.register(f.host, android.util.Base64.decode(acc, android.util.Base64.DEFAULT), pin, true, 30000L) } + catch (e: Exception) { null } + if (r != null) Ps5Store.save(this, Ps5Store.Console(f.host, r.nickname.ifBlank { f.name }, r.registKeyHex, r.morningHex, true)) + runOnUiThread { + if (r == null) codeStatus?.text = "That didn't work. Check the code (it changes every few minutes) and that the PS5 still shows Link device." + else onRegistered(f.host, r.nickname.ifBlank { f.name }) + } + }.start() + } + + override fun onRegistering(host: String) { + if (overlayKind != "register") showRegister() + registerStatus?.text = "Registering with the PS5 at $host…" + } + + override fun onRegistered(host: String, name: String?) { + if (name == null) { + if (overlayKind == "register") registerStatus?.text = "That didn't work. Check the 8-digit code and that the PS5 is on, then try again on the Odin." + return + } + if (overlayKind == "register") hideOverlay() + toast("$name added") + consoles = Ps5Store.all(this) + tab = Tab.CONSOLES + open("ps:$host") + } + + private fun addPcByAddress() { + val card = dialogCard() + card.addView(tv.text("Add a PC by its address", 40, tv.p.moon, tv.display(700))) + card.addView(tv.text("The PC's IP address or name, the one Sunshine runs on. Add :port only if it isn't the usual one.", + 22, tv.p.dust, tv.ui(500), 3), tv.lp(-1, -2).apply { topMargin = tv.px(12) }) + val field = textField("For example 192.168.1.20", "") + card.addView(field, tv.lp(-1, 84).apply { topMargin = tv.px(24) }) + val status = tv.text("", 21, tv.p.dust, tv.ui(600), 2) + card.addView(status, tv.lp(-1, -2).apply { topMargin = tv.px(16) }) + fun submit() { + val raw = field.text.toString().trim() + if (raw.isEmpty()) { status.text = "Type the PC's address first."; return } + val b = binder ?: return + status.text = "Looking for a PC at $raw…" + Thread { + val ok = try { + val uri = try { java.net.URI("moonlight://$raw") } catch (e: Exception) { java.net.URI("moonlight://[$raw]") } + val host = uri.host ?: throw IllegalArgumentException() + val d = ComputerDetails() + d.manualAddress = ComputerDetails.AddressTuple(host, if (uri.port == -1) NvHTTP.DEFAULT_HTTP_PORT else uri.port) + b.addComputerBlocking(d) + } catch (e: Exception) { false } + runOnUiThread { + if (ok) { hideOverlay(); toast("Added. Pair it from its page."); go(Tab.CONSOLES) } + else status.text = "No PC answered at $raw. Check it's on, Sunshine is running, and the address is right." + } + }.start() + } + field.setOnEditorActionListener { _, _, _ -> submit(); true } + card.addView(tv.row().apply { + addView(tv.pill("Add", "A", true) { submit() }) + addView(tv.pill("Cancel", "B", false) { hideOverlay() }) + }, tv.lp(-2, -2).apply { topMargin = tv.px(24) }) + showOverlay("addpc", FrameLayout(this).apply { addView(card, tv.flp(1000, -2, Gravity.CENTER)) }) + field.requestFocus() + } + + /** Settings, Xbox, Bitrate: Auto lets the Xbox decide; a number caps it (XStreaming's "Host stream bitrate"). */ + private fun xboxBitrate(list: LinearLayout) { + val steps = listOf(0, 5, 10, 15, 20, 30) + val cur = stream.getInt("xbox_bitrate", 0) + settingRow(list, "xbox_bitrate", "Bitrate", "Auto lets the Xbox decide. A cap keeps a weak Wi-Fi from stuttering", + true, tv.text(if (cur == 0) "Auto" else "$cur Mbps", 23, tv.p.moon, tv.ui(700))) { + val next = steps[(steps.indexOf(steps.firstOrNull { it >= cur } ?: 0) + 1) % steps.size] + stream.edit().putInt("xbox_bitrate", next).apply() + render(force = true) + } + val custom = cur != 0 && cur !in steps + action(list, "set:xboxrate", "Custom bitrate", "Any cap from 1 to 50 Mbps", if (custom) "$cur Mbps" else "Off") { + entryDialog("Xbox bitrate", "A cap in Mbps, from 1 to 50. The Xbox stays at or below it.", "For example 12", + if (cur > 0) cur.toString() else "", true, onSave = { raw -> + val v = raw.toIntOrNull() + if (v == null || v !in 1..50) "A number from 1 to 50." + else { stream.edit().putInt("xbox_bitrate", v).apply(); render(force = true); null } + }, clear = if (cur > 0) ({ stream.edit().putInt("xbox_bitrate", 0).apply(); render(force = true) }) else null) + } + } + + /** This screen's physical resolution, landscape (the display mode, not the app's possibly scaled window). */ + private fun nativeResolution(): String { + val d = windowManager.defaultDisplay + var w: Int; var h: Int + if (Build.VERSION.SDK_INT >= 23) { + val m = d.mode + w = m.physicalWidth; h = m.physicalHeight + } else { + val p = android.graphics.Point(); d.getRealSize(p); w = p.x; h = p.y + } + if (h > w) { val t = w; w = h; h = t } + return "${w and 1.inv()}x${h and 1.inv()}" + } + + /** The chip, for the Ultra low latency hint: SoC model on Android 12+, otherwise the board name. */ + private fun chipName(): String = com.limelight.home.Chip.name() + + /** A one-line entry dialog; [onSave] returns an error to show, or null when the value is good. */ + /** Runs when the open entry box closes without a save (Cancel, B or Back). */ + private var entryCancel: (() -> Unit)? = null + + private fun entryDialog(title: String, sub: String, hint: String, value: String, numbers: Boolean, onSave: (String) -> String?, + clear: (() -> Unit)? = null, onCancel: (() -> Unit)? = null) { + val card = dialogCard() + card.addView(tv.text(title, 40, tv.p.moon, tv.display(700))) + card.addView(tv.text(sub, 22, tv.p.dust, tv.ui(500), 3), tv.lp(-1, -2).apply { topMargin = tv.px(12) }) + val field = textField(hint, value) + if (numbers) field.inputType = android.text.InputType.TYPE_CLASS_NUMBER + card.addView(field, tv.lp(-1, 84).apply { topMargin = tv.px(24) }) + val status = tv.text("", 21, tv.p.dust, tv.ui(600), 2) + card.addView(status, tv.lp(-1, -2).apply { topMargin = tv.px(16) }) + fun submit() { + val err = onSave(field.text.toString().trim()) + if (err == null) { entryCancel = null; hideOverlay() } else status.text = err + } + field.setOnEditorActionListener { _, _, _ -> submit(); true } + val save = tv.pill("Save", "A", true) { submit() } + // A remote or pad has to reach Save without the keyboard (#43): Down leaves the box for the buttons, and A in + // the box saves, as its glyph says. The key handling above leaves keys in a text box to the box itself. + field.setOnKeyListener { _, code, ev -> + if (ev.action != KeyEvent.ACTION_DOWN) return@setOnKeyListener false + when (code) { + KeyEvent.KEYCODE_DPAD_DOWN -> { + (getSystemService(INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager) + .hideSoftInputFromWindow(field.windowToken, 0) + save.requestFocus(); true + } + KeyEvent.KEYCODE_BUTTON_A -> { submit(); true } + else -> false + } + } + card.addView(tv.row().apply { + addView(save) + if (clear != null) addView(tv.pill("Turn off", null, false) { entryCancel = null; clear(); hideOverlay() }) + addView(tv.pill("Cancel", "B", false) { hideOverlay() }) + }, tv.lp(-2, -2).apply { topMargin = tv.px(24) }) + showOverlay("entry", FrameLayout(this).apply { addView(card, tv.flp(1000, -2, Gravity.CENTER)) }) + entryCancel = onCancel + field.requestFocus() + } + + private fun customResolution(current: String, isCustom: Boolean) { + entryDialog("Custom resolution", "Width x height, for example 1600x900 or 2560x1080. Both even numbers.", + "For example 1600x900", if (isCustom) current else "", false, onSave = { raw -> + val m = Regex("^(\\d{3,4})\\s*[x×*X ]\\s*(\\d{3,4})$").find(raw) + val w = m?.groupValues?.get(1)?.toIntOrNull(); val h = m?.groupValues?.get(2)?.toIntOrNull() + when { + w == null || h == null -> "Type it as width x height, for example 1600x900." + w !in 640..7680 || h !in 360..4320 -> "Between 640x360 and 7680x4320." + w % 2 != 0 || h % 2 != 0 -> "Both numbers need to be even." + else -> { stream.edit().putString("list_resolution", "${w}x$h").apply(); render(force = true); null } + } + }, clear = if (isCustom) ({ stream.edit().putString("list_resolution", "1920x1080").apply(); render(force = true) }) else null) + } + + /** PC frame rate (#42): saved straight into list_fps as a number, like the steps, so the stream reads it as is. */ + private fun customFrameRate(current: String, isCustom: Boolean) { + entryDialog("Custom frame rate", "In fps, from 15 to 120. The PC streams at the rate you pick.", + "For example 45", if (isCustom) current else "", true, onSave = { raw -> + val v = raw.toIntOrNull() + if (v == null || v !in 15..120) "A number from 15 to 120." + else { stream.edit().putString("list_fps", v.toString()).apply(); render(force = true); null } + }, clear = if (isCustom) ({ stream.edit().putString("list_fps", "60").apply(); render(force = true) }) else null) + } + + private fun customBitrate(kbps: Int) { + entryDialog("Custom bitrate", "In Mbps, from 1 to 150. Higher is sharper and needs a faster network.", + "For example 25", if (kbps > 0) (kbps / 1000).toString() else "", true, onSave = { raw -> + val v = raw.toIntOrNull() + if (v == null || v !in 1..150) "A number from 1 to 150." + else { stream.edit().putInt("seekbar_bitrate_kbps", v * 1000).apply(); render(force = true); null } + }, clear = if (kbps > 0) ({ stream.edit().remove("seekbar_bitrate_kbps").apply(); render(force = true) }) else null) + } + + private fun pairController() { + for (a in listOf(Settings.ACTION_BLUETOOTH_SETTINGS, Settings.ACTION_SETTINGS)) { + try { startActivity(Intent(a)); return } catch (e: Exception) { } + } + toast("Open this TV's settings, then Remotes and accessories, to pair a controller.") + } + + // ---------------------------------------------------------------- overlays + + private fun showOverlay(kind: String, v: View, dim: Boolean = true) { + if (kind != "idle" && kind != "incoming") FeelSounds.play(this, FeelSounds.Cue.OPEN) + entryCancel = null // a new overlay replaces an open entry box; entryDialog sets its own afterwards + if (overlayKind == null) overlayReturn = currentFocus?.tag as? String // where focus goes back to (#44) + overlayKind = kind + overlay.removeAllViews() + overlay.setBackgroundColor(if (dim) (if (tv.oled) 0xE6000000.toInt() else 0xB805080F.toInt()) else tv.p.night) + overlay.addView(v) + overlay.visibility = View.VISIBLE + main.descendantFocusability = ViewGroup.FOCUS_BLOCK_DESCENDANTS + (overlay.findViewWithTag("primary") ?: firstFocusable(overlay))?.requestFocus() + } + + private fun hideOverlay(cue: Boolean = true) { + if (cue && overlayKind != null && overlayKind != "idle" && overlayKind != "incoming") FeelSounds.play(this, FeelSounds.Cue.CLOSE) + try { // close the TV keyboard too (search / add PC / PIN boxes), or it stays up and squashes the screen + (getSystemService(Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager) + .hideSoftInputFromWindow(window.decorView.windowToken, 0) + } catch (e: Exception) { } + overlayKind = null + overlay.removeAllViews() + overlay.visibility = View.GONE + main.descendantFocusability = ViewGroup.FOCUS_AFTER_DESCENDANTS + entryCancel?.let { entryCancel = null; it() } // an entry box closed without saving + render(force = true) + // back to the row that opened the overlay, not the first item of the page (#44) + overlayReturn?.let { t -> content.findViewWithTag(t)?.requestFocus() } + overlayReturn = null + } + + /** Tag of the view that had focus when an overlay opened. */ + private var overlayReturn: String? = null + + private fun pickLanguage(langs: Array, names: Array, cur: Int) { + val card = dialogCard() + card.addView(tv.text("Language while playing", 40, tv.p.moon, tv.display(700))) + card.addView(tv.text("Takes effect from the next game.", 22, tv.p.dust, tv.ui(500)), + tv.lp(-1, -2).apply { topMargin = tv.px(12); bottomMargin = tv.px(20) }) + val list = tv.col() + langs.forEachIndexed { i, v -> + list.addView(tv.row().apply { + tag = if (i == cur) "primary" else "lang:$v" + background = tv.focusable(0, 16, outline = false) + setPadding(tv.px(20), 0, tv.px(20), 0) + addView(tv.text(if (i == 0) "Same as the TV" else names[i], 26, tv.p.moon, tv.ui(700)), LinearLayout.LayoutParams(0, -2, 1f)) + if (i == cur) addView(tv.status("Now", "live")) + tv.focusableView(this) + setOnClickListener { stream.edit().putString("list_languages", v).apply(); hideOverlay() } + }, tv.lp(-1, 72)) + } + card.addView(ScrollView(this).apply { isVerticalScrollBarEnabled = false; addView(list) }, tv.lp(-1, 560)) + showOverlay("lang", FrameLayout(this).apply { addView(card, tv.flp(900, -2, Gravity.CENTER)) }) + } + + private fun dialogCard(): LinearLayout = tv.col().apply { + background = tv.round(tv.p.deep, 24, tv.p.line, 1) + setPadding(tv.px(44), tv.px(44), tv.px(44), tv.px(40)) + } + + private fun confirm(title: String, body: String, yes: String, danger: Boolean = false, no: String = "Cancel", + noPicked: Boolean = danger, onNo: () -> Unit = {}, onYes: () -> Unit) { + val card = dialogCard() + card.addView(tv.text(title, 40, tv.p.moon, tv.display(700), 2)) + card.addView(tv.text(body, 22, tv.p.dust, tv.ui(500), 4), tv.lp(-1, -2).apply { topMargin = tv.px(12); bottomMargin = tv.px(28) }) + // danger (quit, unpair, remove): Cancel is picked, so a habitual OK on the remote doesn't do it + card.addView(tv.row().apply { + addView(tv.pill(yes, if (noPicked) null else "A", !noPicked) { + // one sound per action: what the answer starts (a launch) speaks for it, else the dialog's close + val n = FeelSounds.serial + hideOverlay(cue = false); onYes() + if (n == FeelSounds.serial) FeelSounds.play(this@HomeActivity, FeelSounds.Cue.CLOSE) + }.apply { + tag = if (noPicked) "yes" else "primary"; if (danger) (getChildAt(childCount - 1) as TextView).setTextColor(Tv.DANGER) }) + addView(tv.pill(no, "B", false) { hideOverlay(); onNo() }.apply { if (noPicked) tag = "primary" }) + }) + showOverlay("confirm", FrameLayout(this).apply { addView(card, tv.flp(900, -2, Gravity.CENTER)) }) + } + + private fun showPin(d: ComputerDetails, pin: String) { + val v = tv.row(Gravity.TOP).apply { setPadding(tv.px(96), tv.px(200), tv.px(96), 0) } + val left = tv.col() + left.addView(tv.row().apply { addView(tv.status("Pairing with ${d.name}", "rest")) }) + left.addView(tv.text("Enter this PIN\nin Sunshine", 72, tv.p.moon, tv.display(700), 2), tv.lp(-2, -2).apply { topMargin = tv.px(28) }) + left.addView(tv.row().apply { + pin.forEach { ch -> + addView(tv.text(ch.toString(), 120, tv.p.moon, tv.display(700)).apply { + gravity = Gravity.CENTER; background = tv.round(tv.p.deep, 24, tv.p.line, 1) + }, tv.lp(168, 208).apply { rightMargin = tv.px(24) }) + } + }, tv.lp(-2, -2).apply { topMargin = tv.px(48) }) + left.addView(tv.text("Waiting for the PC to accept. B cancels.", 24, tv.p.dust, tv.ui(600)), tv.lp(-2, -2).apply { topMargin = tv.px(40) }) + v.addView(left, LinearLayout.LayoutParams(0, -2, 1f)) + val steps = tv.col().apply { background = tv.round(tv.p.deep, 24, tv.p.line, 1); setPadding(tv.px(40), tv.px(36), tv.px(40), tv.px(24)) } + steps.addView(tv.text("On the PC", 26, tv.p.moon, tv.display(600))) + val host = d.activeAddress?.address ?: "the PC" + listOf("Open https://$host:47990 in a browser on the same network", "Go to the PIN page", + "Type $pin, name it ${deviceName()}, and press Send").forEachIndexed { i, s -> + steps.addView(tv.row(Gravity.TOP).apply { + if (i > 0) background = topLine() + setPadding(0, tv.px(18), 0, tv.px(18)) + addView(tv.text("${i + 1}", 17, tv.p.moon, tv.display(700)).apply { gravity = Gravity.CENTER; background = tv.round(tv.p.raise, 20, tv.p.line, 1) }, + tv.lp(40, 40).apply { rightMargin = tv.px(18) }) + addView(tv.text(s, 23, tv.p.soft, tv.ui(500), 3), LinearLayout.LayoutParams(0, -2, 1f)) + }) + } + v.addView(steps, tv.lp(680, -2)) + showOverlay("pin", v, dim = false) + } + + private fun deviceName(): String = + (if (Build.VERSION.SDK_INT >= 25) Settings.Global.getString(contentResolver, Settings.Global.DEVICE_NAME) else null) ?: Build.MODEL + + override fun onIncoming(title: String, from: String) { + val v = tv.col().apply { gravity = Gravity.CENTER_HORIZONTAL } + v.addView(tv.row().apply { + background = tv.round(tv.p.deep, 26, tv.p.line, 1); setPadding(tv.px(22), 0, tv.px(22), 0) + addView(View(this@HomeActivity).apply { background = tv.round(Tv.BEAM, 5) }, tv.lp(10, 10).apply { rightMargin = tv.px(14) }) + addView(tv.text("Sent from $from", 23, tv.p.moon, tv.ui(700))) + }, tv.lp(-2, 52)) + v.addView(tv.text(title, 84, tv.p.moon, tv.display(700), 2).apply { gravity = Gravity.CENTER }, tv.lp(-2, -2).apply { topMargin = tv.px(34) }) + v.addView(tv.text("Starting on this TV. Your phone is the controller.", 27, tv.p.soft, tv.ui(500)), tv.lp(-2, -2).apply { topMargin = tv.px(18) }) + showOverlay("incoming", FrameLayout(this).apply { addView(v, tv.flp(-2, -2, Gravity.CENTER)) }, dim = false) + ui.postDelayed({ if (overlayKind == "incoming") hideOverlay() }, 20_000) + } + + override fun onConsolesChanged() { consoles = Ps5Store.all(this); render(force = true) } + + private fun askOled() { + if (overlayKind != null) return + val card = dialogCard() + card.addView(tv.text("Is this TV an OLED?", 40, tv.p.moon, tv.display(700))) + card.addView(tv.text("OLED screens get true black, quieter menus and a moving screen saver, so nothing burns in. You can change it later in Settings.", + 22, tv.p.dust, tv.ui(500), 4), tv.lp(-1, -2).apply { topMargin = tv.px(12); bottomMargin = tv.px(28) }) + card.addView(tv.row().apply { + addView(tv.pill("Yes, it's OLED", "A", true) { prefs.edit().putBoolean("oled_asked", true).putBoolean("oled", true).apply(); recreate() }.apply { tag = "primary" }) + addView(tv.pill("No", null, false) { prefs.edit().putBoolean("oled_asked", true).apply(); hideOverlay() }) + }) + showOverlay("oled", FrameLayout(this).apply { addView(card, tv.flp(900, -2, Gravity.CENTER)) }) + } + + private var idleClock: TextView? = null + private fun showIdle() { + val f = FrameLayout(this) + val c = tv.col() + idleClock = tv.text(DateFormat.getTimeFormat(this).format(Date()), 64, 0xFF5E6676.toInt(), tv.display(500)) + c.addView(idleClock) + c.addView(tv.text("Ready to play. Send a game from your phone.", 24, 0xFF4C5363.toInt(), tv.ui(600)), tv.lp(-2, -2).apply { topMargin = tv.px(12) }) + f.addView(c, FrameLayout.LayoutParams(-2, -2)) + overlayKind = "idle" + overlay.removeAllViews(); overlay.setBackgroundColor(0xFF000000.toInt()); overlay.addView(f); overlay.visibility = View.VISIBLE + main.descendantFocusability = ViewGroup.FOCUS_BLOCK_DESCENDANTS + moveIdle() + } + + /** The screen saver drifts so no pixel stays lit. */ + private fun moveIdle() { + val f = overlay.getChildAt(0) as? FrameLayout ?: return + val c = f.getChildAt(0) ?: return + idleClock?.text = DateFormat.getTimeFormat(this).format(Date()) + val w = resources.displayMetrics.widthPixels; val h = resources.displayMetrics.heightPixels + c.translationX = (Math.random() * (w * 0.6)).toFloat() + w * 0.1f + c.translationY = (Math.random() * (h * 0.6)).toFloat() + h * 0.1f + } + + // ---------------------------------------------------------------- input + + private var shiftTick = 0 + override fun dispatchKeyEvent(e: KeyEvent): Boolean { + lastInput = System.currentTimeMillis() + if (hintsBar.alpha < (if (tv.oled) 0.72f else 1f)) hintsBar.animate().alpha(if (tv.oled) 0.72f else 1f).setDuration(150).start() + if (overlayKind == "idle") { if (e.action == KeyEvent.ACTION_UP) hideOverlay(); return true } + if (overlayKind == "code" && e.action == KeyEvent.ACTION_DOWN) { + when (e.keyCode) { + in KeyEvent.KEYCODE_0..KeyEvent.KEYCODE_9 -> { codeKey((e.keyCode - KeyEvent.KEYCODE_0).toString()); return true } + KeyEvent.KEYCODE_DEL -> { codeKey("⌫"); return true } + } + } + if (e.action == KeyEvent.ACTION_DOWN && overlayKind == null && detail == null) { + val i = Tab.values().indexOf(tab) + when (e.keyCode) { + KeyEvent.KEYCODE_BUTTON_Y, KeyEvent.KEYCODE_SEARCH -> if (tab == Tab.LIBRARY) { openSearch(); return true } + KeyEvent.KEYCODE_BUTTON_L1 -> { FeelSounds.play(this, FeelSounds.Cue.TAB, -0.3f); go(Tab.values()[(i + 3) % 4]); return true } + KeyEvent.KEYCODE_BUTTON_R1 -> { FeelSounds.play(this, FeelSounds.Cue.TAB, 0.3f); go(Tab.values()[(i + 1) % 4]); return true } + } + } + return feel(e) + } + + /** + * Sound and motion for ordinary navigation: a focus tick when the d-pad moves the focus, a soft refusal and a small + * shake when it hits the end of a row, and a select sound for a confirm press nothing else answered. + */ + private fun feel(e: KeyEvent): Boolean { + val dir = Feel.direction(e.keyCode) + if (dir != 0 && e.action == KeyEvent.ACTION_DOWN) { + val before = currentFocus + if (super.dispatchKeyEvent(e)) { // a view moved the focus itself (a row's own Left, a list) + val after = currentFocus + if (after != null && after !== before) FeelSounds.focus(after) + return true + } + if (before == null || before is EditText || overlayKind == "code") return false + // Nothing took the key, so Android would move the focus next and play its own click. Move it here the same + // way instead, so MoonCast's sound replaces the system one rather than playing on top of it. + val root = window.decorView as ViewGroup + val next = before.focusSearch(dir) + if (next != null && next !== before) { + val r = android.graphics.Rect() + before.getFocusedRect(r) + root.offsetDescendantRectToMyCoords(before, r) + root.offsetRectIntoDescendantCoords(next, r) + if (next.requestFocus(dir, r)) { + if (FeelSounds.enabled(this)) FeelSounds.focus(next) + else next.playSoundEffect(android.view.SoundEffectConstants.getContantForFocusDirection(dir)) + return true + } + return false + } + Feel.edgeNudge(before, dir, e, tv.pxf(14)) + return true + } + val confirm = e.keyCode == KeyEvent.KEYCODE_DPAD_CENTER || e.keyCode == KeyEvent.KEYCODE_ENTER || + e.keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER || e.keyCode == KeyEvent.KEYCODE_BUTTON_A + val focused = currentFocus + if (confirm && e.action == KeyEvent.ACTION_UP && focused != null && focused !is EditText) { + val pan = FeelSounds.panFor(focused) + val n = FeelSounds.serial + val handled = super.dispatchKeyEvent(e) + // the click may have played its own cue (a launch, a dialog opening); then stay quiet + if (handled && n == FeelSounds.serial) FeelSounds.play(this, FeelSounds.Cue.SELECT, pan) + return handled + } + return super.dispatchKeyEvent(e) + } + + @Deprecated("Deprecated in Java") + override fun onBackPressed() { + when { + overlayKind != null -> hideOverlay() + licencesOpen && tab == Tab.SETTINGS -> { + FeelSounds.play(this, FeelSounds.Cue.BACK); licencesOpen = false; render(force = true) + content.findViewWithTag("set:licences")?.requestFocus() + } + detail != null -> { FeelSounds.play(this, FeelSounds.Cue.BACK); val d = detail; detail = null; render(force = true); content.findViewWithTag(d)?.requestFocus() } + tab != Tab.HOME -> { FeelSounds.play(this, FeelSounds.Cue.BACK); go(Tab.HOME) } + else -> { FeelSounds.play(this, FeelSounds.Cue.BACK); moveTaskToBack(true) } // keep listening for the phone + } + } + + private fun rounded(r: Number) = object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: Outline) = outline.setRoundRect(0, 0, view.width, view.height, tv.pxf(r)) + } + + companion object { + private const val Service_BIND = Context.BIND_AUTO_CREATE + private const val REQ_PSN_REGISTER = 41 + private const val REQ_PSN = 42 + } +} diff --git a/app/src/tv/java/com/limelight/home/PhoneLinkService.kt b/app/src/tv/java/com/limelight/home/PhoneLinkService.kt new file mode 100644 index 0000000000..8c98ed024c --- /dev/null +++ b/app/src/tv/java/com/limelight/home/PhoneLinkService.kt @@ -0,0 +1,88 @@ +package com.limelight.home + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import com.limelight.PhoneLink +import com.limelight.R + +/** + * Keeps the phone's channel (:8099) open whether or not MoonCast is on screen, from boot onwards, so the Odin or a + * phone always finds this TV and can start a game on it. A foreground service, so Android doesn't stop it in the + * background. The home screen, when it's showing, listens in to show "Sent from …". + */ +class PhoneLinkService : Service(), PhoneLink.Listener { + private var link: PhoneLink? = null + + override fun onCreate() { + super.onCreate() + goForeground() + link = PhoneLink(this, this).also { it.start() } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = START_STICKY + + override fun onDestroy() { + link?.stop() + link = null + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onIncoming(title: String, from: String) { listener?.onIncoming(title, from) } + override fun onConsolesChanged() { listener?.onConsolesChanged() } + override fun onRegistering(host: String) { listener?.onRegistering(host) } + override fun onRegistered(host: String, name: String?) { listener?.onRegistered(host, name) } + + private fun goForeground() { + // the name-based lookup: getSystemService(Class) is Android 6+, and the TV app runs on Android 5 sticks too + val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + val b = if (Build.VERSION.SDK_INT >= 26) { + nm.createNotificationChannel(NotificationChannel(CHANNEL, "Ready for your phone", NotificationManager.IMPORTANCE_MIN).apply { + description = "Keeps MoonCast TV reachable so your phone can start games here" + setShowBadge(false) + }) + Notification.Builder(this, CHANNEL) + } else @Suppress("DEPRECATION") Notification.Builder(this).setPriority(Notification.PRIORITY_MIN) + val n = b.setSmallIcon(R.drawable.ic_play) + .setContentTitle("MoonCast TV") + .setContentText("Ready for your phone") + .setOngoing(true) + .build() + if (Build.VERSION.SDK_INT >= 34) startForeground(ID, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE) + else startForeground(ID, n) + } + + companion object { + private const val CHANNEL = "mc_phone_link" + private const val ID = 8099 + + /** The home screen while it's showing; gets "Sent from …" and console changes. */ + @Volatile var listener: PhoneLink.Listener? = null + + fun start(ctx: Context) { + val i = Intent(ctx, PhoneLinkService::class.java) + try { + if (Build.VERSION.SDK_INT >= 26) ctx.startForegroundService(i) else ctx.startService(i) + } catch (e: Exception) { android.util.Log.w("MoonCast", "phone link service: $e") } + } + } +} + +/** Start listening for the phone as soon as the TV boots (and after an app update). */ +class PhoneLinkBoot : BroadcastReceiver() { + override fun onReceive(ctx: Context, intent: Intent) { + when (intent.action) { + Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED, "android.intent.action.QUICKBOOT_POWERON" -> + PhoneLinkService.start(ctx) + } + } +} diff --git a/doc/MOONCAST-TV.md b/doc/MOONCAST-TV.md new file mode 100644 index 0000000000..72461618f1 --- /dev/null +++ b/doc/MOONCAST-TV.md @@ -0,0 +1,32 @@ +# MoonCast TV — native Android TV client (fork of moonlight-android) + +This is MoonCast's native TV client, forked from the excellent +[moonlight-android](https://github.com/moonlight-stream/moonlight-android) (GPL-3). Moonlight already does the hard +part perfectly: **native MediaCodec hardware decode + Sunshine's own low-latency protocol** (`NvConnection`, RTSP, +ENet, via the `moonlight-common-c` native lib). That is why, on the same Mi TV Stick where a cast-browser page managed +~28 fps and seconds of lag, native Moonlight does 1080p60 with ~15 ms latency (measured 2026-10-15). + +## Why fork rather than keep the WebView app +MoonCast's first TV app was a thin shell around the system WebView loading the helper stream. It fixed the picture +(hardware WebCodecs, 1080p60) but inherits the browser WebRTC stack. The native client removes that layer entirely. + +## The two transports (the plan) +| Transport | When | How | +|---|---|---| +| **Native (this app, as-is)** | the PC is reachable: home LAN, or JAL over the home↔JAL WireGuard link | Moonlight's own protocol straight to Sunshine. Lowest latency. | +| **Helper fallback** | strict NAT / the PC is not directly reachable | the MoonCast PC helper (WebRTC P2P, no port forwarding). To be added. | + +The phone stays the controller in both (MoonCast's InputSession), and launches the TV app by code / LAN discovery +(the MoonCast TV LAN listener is already in the WebView app; it will move here). + +## Status +- [x] Forked to `MoonCastDev/moonlight-android`, submodules in. +- [x] Rebranded: applicationId `me.mooncast.tv`, app name **MoonCast TV**, own id (not Moonlight's, as upstream asks). +- [ ] Icon / banner (MoonCast blood-moon). +- [ ] Build + install, confirm native 1080p60 on the stick and the JAL TV. +- [ ] Helper fallback transport. +- [ ] Launch-from-phone (LAN discovery + the `/play` listener) ported from the WebView app. +- [ ] Release signing with MoonCast's key. + +## Credit +Built on moonlight-stream/moonlight-android (GPL-3). Huge thanks to the Moonlight authors. diff --git a/docs/mockups/about/about.png b/docs/mockups/about/about.png new file mode 100644 index 0000000000..41b40a6215 Binary files /dev/null and b/docs/mockups/about/about.png differ diff --git a/docs/mockups/about/licences.png b/docs/mockups/about/licences.png new file mode 100644 index 0000000000..cfc38a4004 Binary files /dev/null and b/docs/mockups/about/licences.png differ diff --git a/docs/mockups/fsr/compare.html b/docs/mockups/fsr/compare.html new file mode 100644 index 0000000000..6778bca861 --- /dev/null +++ b/docs/mockups/fsr/compare.html @@ -0,0 +1,217 @@ + +
Super resolution: a 720p stream on a 2448 × 1696 screen
+
The same frame, enlarged 3.4×; a close-up of the middle of the picture
+
Off: plain enlargement
+
FSR 1.0, sharpening 60%
+
Rendered by MoonCast's own FSR 1.0 shaders (EASU + RCAS, the code in the app), run here in WebGL 2.
+
+ \ No newline at end of file diff --git a/docs/mockups/fsr/fsr_compare.png b/docs/mockups/fsr/fsr_compare.png new file mode 100644 index 0000000000..030451f3a4 Binary files /dev/null and b/docs/mockups/fsr/fsr_compare.png differ diff --git a/docs/mockups/fsr/fsr_settings.png b/docs/mockups/fsr/fsr_settings.png new file mode 100644 index 0000000000..4bec8357c9 Binary files /dev/null and b/docs/mockups/fsr/fsr_settings.png differ diff --git a/docs/mockups/onscreen-pad/v10_PlayStation.png b/docs/mockups/onscreen-pad/v10_PlayStation.png new file mode 100644 index 0000000000..b52648677d Binary files /dev/null and b/docs/mockups/onscreen-pad/v10_PlayStation.png differ diff --git a/docs/mockups/onscreen-pad/v11_PlayStation.png b/docs/mockups/onscreen-pad/v11_PlayStation.png new file mode 100644 index 0000000000..a9c9174b91 Binary files /dev/null and b/docs/mockups/onscreen-pad/v11_PlayStation.png differ diff --git a/docs/mockups/onscreen-pad/v12_PlayStation.png b/docs/mockups/onscreen-pad/v12_PlayStation.png new file mode 100644 index 0000000000..c68829fcd2 Binary files /dev/null and b/docs/mockups/onscreen-pad/v12_PlayStation.png differ diff --git a/docs/mockups/onscreen-pad/v13_PlayStation.png b/docs/mockups/onscreen-pad/v13_PlayStation.png new file mode 100644 index 0000000000..0b88cad8cc Binary files /dev/null and b/docs/mockups/onscreen-pad/v13_PlayStation.png differ diff --git a/docs/mockups/onscreen-pad/v14_PlayStation.png b/docs/mockups/onscreen-pad/v14_PlayStation.png new file mode 100644 index 0000000000..04fc6e5b92 Binary files /dev/null and b/docs/mockups/onscreen-pad/v14_PlayStation.png differ diff --git a/docs/mockups/onscreen-pad/v8_PlayStation.png b/docs/mockups/onscreen-pad/v8_PlayStation.png new file mode 100644 index 0000000000..dc1c644e80 Binary files /dev/null and b/docs/mockups/onscreen-pad/v8_PlayStation.png differ diff --git a/docs/mockups/onscreen-pad/v9_PlayStation.png b/docs/mockups/onscreen-pad/v9_PlayStation.png new file mode 100644 index 0000000000..a04e03fdf5 Binary files /dev/null and b/docs/mockups/onscreen-pad/v9_PlayStation.png differ diff --git a/docs/mockups/onscreen-pad/vn1_Switch.png b/docs/mockups/onscreen-pad/vn1_Switch.png new file mode 100644 index 0000000000..6ee18f6e37 Binary files /dev/null and b/docs/mockups/onscreen-pad/vn1_Switch.png differ diff --git a/docs/mockups/onscreen-pad/vx1_Xbox.png b/docs/mockups/onscreen-pad/vx1_Xbox.png new file mode 100644 index 0000000000..abfc285c8f Binary files /dev/null and b/docs/mockups/onscreen-pad/vx1_Xbox.png differ diff --git a/docs/mockups/onscreen-pad/vx2_Xbox.png b/docs/mockups/onscreen-pad/vx2_Xbox.png new file mode 100644 index 0000000000..6a9918a05e Binary files /dev/null and b/docs/mockups/onscreen-pad/vx2_Xbox.png differ diff --git a/gradle.properties b/gradle.properties index e0212117d6..402ec180b0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,10 +10,13 @@ # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. # Default value: -Xmx10248m -XX:MaxPermSize=256m -# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -org.gradle.jvmargs=-Xmx3072m +org.gradle.jvmargs=-Xmx4g +org.gradle.jvmargs=-Xmx4g # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects -# org.gradle.parallel=true \ No newline at end of file +# org.gradle.parallel=true +org.gradle.workers.max=1 +org.gradle.parallel=false +org.gradle.configureondemand=false diff --git a/native/build-nano.sh b/native/build-nano.sh new file mode 100755 index 0000000000..2d6c7e000f --- /dev/null +++ b/native/build-nano.sh @@ -0,0 +1,10 @@ +#!/bin/sh +# Build XStreaming nano-rs (Xbox Remote Play engine) for Android. Usage: build-nano.sh +set -e +export RUSTUP_HOME=/root/mcbuild/rust/rustup CARGO_HOME=/root/mcbuild/rust/cargo PATH=/root/mcbuild/rust/cargo/bin:$PATH +NDK=$(ls -d /root/mcbuild/sdk/ndk/* | tail -1); TC=$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin +T=$1; P=$2; U=$(echo $T | tr a-z- A-Z_); L=$(echo $T | tr - _) +export CARGO_TARGET_${U}_LINKER=$TC/${P}23-clang CC_$L=$TC/${P}23-clang AR_$L=$TC/llvm-ar +cd /root/mcbuild/xstreaming/nano-rs +nice -n 10 cargo build --target $T --release -j 6 +ls -la target/$T/release/libnano_rs.so diff --git a/native/mc_curl_ca_patch.py b/native/mc_curl_ca_patch.py new file mode 100644 index 0000000000..bed0c69203 --- /dev/null +++ b/native/mc_curl_ca_patch.py @@ -0,0 +1,28 @@ +import sys +p = sys.argv[1] +s = open(p).read() +if "mc_curl_easy_init" in s: + print("already patched"); sys.exit(0) +inc = "#include " +assert s.count(inc) == 1 +wrapper = inc + r""" + +#ifdef __ANDROID__ +#include +// MoonCast: Android's libcurl has no CA store, so every PSN HTTPS call failed with "SSL peer certificate +// ... was not OK". The app writes the device's trusted roots (AndroidCAStore) to a PEM file and exports its +// path as MC_CA_BUNDLE; give it to every curl handle here (the AIA chain repair below builds on CURLINFO_CAINFO). +static CURL *mc_curl_easy_init(void) +{ + CURL *c = curl_easy_init(); + const char *ca = getenv("MC_CA_BUNDLE"); + if(c && ca && *ca) + curl_easy_setopt(c, CURLOPT_CAINFO, ca); + return c; +} +#define curl_easy_init mc_curl_easy_init +#endif +""" +s = s.replace(inc, wrapper, 1) +open(p, "w").write(s) +print("patched") diff --git a/native/mc_curl_ca_patch2.py b/native/mc_curl_ca_patch2.py new file mode 100644 index 0000000000..c73858e9ae --- /dev/null +++ b/native/mc_curl_ca_patch2.py @@ -0,0 +1,40 @@ +import sys +root = sys.argv[1] # chiaki-ng/lib/src + +# 1) aia_openssl.c: the chain-repair trust store asks curl for CURLINFO_CAINFO, which is curl's *built-in* +# default (empty on Android), not CURLOPT_CAINFO -> fall back to the app's MC_CA_BUNDLE. +p = root + "/aia_openssl.c" +s = open(p).read() +if "MC_CA_BUNDLE" not in s: + a = """ goto error; +#ifdef _WIN32""" + assert s.count(a) == 1 + s = s.replace(a, """ goto error; +#ifdef __ANDROID__ + if(!file && !path) + file = getenv("MC_CA_BUNDLE"); // MoonCast: Android curl has no built-in CA path +#endif +#ifdef _WIN32""", 1) + if "#include " not in s: + s = "#include \n" + s + open(p, "w").write(s) + print("aia_openssl.c patched") +else: + print("aia_openssl.c already patched") + +# 2) holepunch.c: the CAINFO_BLOB it builds (which *replaces* CAINFO) starts from CURLINFO_CAINFO's file -> +# same fallback, so the repaired chain keeps the device roots. +p = root + "/remote/holepunch.c" +s = open(p).read() +if "MC_CA_BUNDLE (aia blob)" not in s: + a = "CURLcode ca_info_res = curl_easy_getinfo(curl, CURLINFO_CAINFO, &ca_path);" + assert s.count(a) == 1 + s = s.replace(a, a + """ +#ifdef __ANDROID__ + if(ca_info_res == CURLE_OK && !ca_path) + ca_path = getenv("MC_CA_BUNDLE"); // MoonCast: MC_CA_BUNDLE (aia blob) +#endif""", 1) + open(p, "w").write(s) + print("holepunch.c patched") +else: + print("holepunch.c already patched") diff --git a/native/mc_decoder_nullbuf_patch.py b/native/mc_decoder_nullbuf_patch.py new file mode 100644 index 0000000000..9699fe3660 --- /dev/null +++ b/native/mc_decoder_nullbuf_patch.py @@ -0,0 +1,22 @@ +import sys +# MoonCast (#37): when the screen surface goes away under a running decoder, the codec errors out and +# AMediaCodec_getInputBuffer() returns NULL; chiaki then copied the frame into it (SIGSEGV in video_sample, seen on the +# Mi stick in test177). Drop the frame instead. Run after mc_decoder_shutdown_patch.py, with +# android/app/src/main/cpp/video-decoder.c as the argument. +p = sys.argv[1] +s = open(p).read() +if "MoonCast (#37): no input buffer" in s: + print("already patched"); sys.exit(0) +old = """ uint8_t *codec_buf = AMediaCodec_getInputBuffer(decoder->codec, (size_t)codec_buf_index, &codec_buf_size); +""" +new = old + """ if(!codec_buf) // MoonCast (#37): no input buffer, the codec has failed (e.g. its surface is gone) + { + CHIAKI_LOGE(decoder->log, "AMediaCodec_getInputBuffer() returned no buffer"); + r = false; + goto beach; + } +""" +assert s.count(old) == 1, "getInputBuffer call not found" +s = s.replace(old, new, 1) +open(p, "w").write(s) +print("patched") diff --git a/native/mc_decoder_shutdown_patch.py b/native/mc_decoder_shutdown_patch.py new file mode 100644 index 0000000000..6920a73864 --- /dev/null +++ b/native/mc_decoder_shutdown_patch.py @@ -0,0 +1,65 @@ +import re, sys +# MoonCast (#37): a clean decoder shutdown for chiaki's Android video decoder. Run after mc_surface_lock_patch.py, with +# android/app/src/main/cpp/video-decoder.c as the argument. Replaces mc_decoder_join_patch.py (test176), which only +# fixed part of it. +# +# On Android 9 (the Mi stick) any AMediaCodec dequeue after AMediaCodec_stop() posts a second activity request to +# MediaCodec and it aborts the app (F/MediaCodec CHECK(mActivityNotify == NULL)). chiaki's kill_decoder() stopped the +# codec first and only then let the other threads go: the stream thread, waiting on codec_mutex, fed one more frame +# into the stopped codec, and the output thread's endless dequeue returned after the stop. Ending a PS5 game then +# crashed the app at random (2 of 3 stops in testing). +# Fix: stop feeding and drain first, stop and delete last. +# - video_sample(): no input while shutting down. +# - output thread: polls every 50 ms instead of waiting forever, so it sees shutdown_output and exits by itself. +# - kill_decoder(): flag shutdown, send EOS if a buffer is free, let the output thread end, then stop and delete +# the codec under the mutex, so no thread can call into it afterwards. +p = sys.argv[1] +s = open(p).read() +if "MoonCast (#37): clean shutdown" in s: + print("already patched"); sys.exit(0) + +kill = re.compile(r"static void kill_decoder\(AndroidChiakiVideoDecoder \*decoder\)\n\{\n.*?\n\tdecoder->shutdown_output = false;\n\}\n", re.S) +assert len(kill.findall(s)) == 1, "kill_decoder() not found" +s = kill.sub(lambda m: """static void kill_decoder(AndroidChiakiVideoDecoder *decoder) +{ + // MoonCast (#37): clean shutdown. No AMediaCodec call may follow AMediaCodec_stop(): on Android 9 that aborts. + chiaki_mutex_lock(&decoder->codec_mutex); + decoder->shutdown_output = true; // video_sample() stops feeding, the output thread ends on its next poll + ssize_t codec_buf_index = AMediaCodec_dequeueInputBuffer(decoder->codec, 1000); + if(codec_buf_index >= 0) + { + CHIAKI_LOGI(decoder->log, "Video Decoder sending EOS buffer"); + AMediaCodec_queueInputBuffer(decoder->codec, (size_t)codec_buf_index, 0, 0, decoder->timestamp_cur++, AMEDIACODEC_BUFFER_FLAG_END_OF_STREAM); + } + else + CHIAKI_LOGE(decoder->log, "Failed to get input buffer for shutting down Video Decoder!"); + chiaki_mutex_unlock(&decoder->codec_mutex); + chiaki_thread_join(&decoder->output_thread, NULL); + chiaki_mutex_lock(&decoder->codec_mutex); + AMediaCodec_stop(decoder->codec); + AMediaCodec_delete(decoder->codec); + decoder->codec = NULL; + decoder->shutdown_output = false; + chiaki_mutex_unlock(&decoder->codec_mutex); +} +""", s, count=1) + +old_feed = """ if(!decoder->codec) + { + CHIAKI_LOGE(decoder->log, "Received video data, but decoder is not initialized!"); + goto beach; + } +""" +new_feed = old_feed + """ if(decoder->shutdown_output) + goto beach; // MoonCast (#37): shutting down, the codec takes no more input +""" +assert s.count(old_feed) == 1, "video_sample() check not found" +s = s.replace(old_feed, new_feed, 1) + +old_out = "ssize_t status = AMediaCodec_dequeueOutputBuffer(decoder->codec, &info, -1);" +new_out = "ssize_t status = AMediaCodec_dequeueOutputBuffer(decoder->codec, &info, 50000); // MoonCast (#37): poll, see shutdown_output" +assert s.count(old_out) == 1, "output thread dequeue not found" +s = s.replace(old_out, new_out, 1) + +open(p, "w").write(s) +print("patched") diff --git a/native/mc_gotobed_patch.py b/native/mc_gotobed_patch.py new file mode 100644 index 0000000000..9659c9ac05 --- /dev/null +++ b/native/mc_gotobed_patch.py @@ -0,0 +1,17 @@ +import sys +# MoonCast: expose chiaki_session_goto_bed to Kotlin, so a player can put the PS5 into rest mode when you leave. +p = sys.argv[1] +s = open(p).read() +if "JNI_FCN(sessionGotoBed)" in s: + print("already patched"); sys.exit(0) +anchor = "JNIEXPORT void JNICALL JNI_FCN(sessionSetLoginPin)" +assert anchor in s, "sessionSetLoginPin not found" +s = s.replace(anchor, """JNIEXPORT jint JNICALL JNI_FCN(sessionGotoBed)(JNIEnv *env, jobject obj, jlong ptr) +{ + AndroidChiakiSession *session = (AndroidChiakiSession *)ptr; + return session ? chiaki_session_goto_bed(&session->session) : CHIAKI_ERR_UNINITIALIZED; +} + +""" + anchor, 1) +open(p, "w").write(s) +print("patched") diff --git a/native/mc_jni_patch.py b/native/mc_jni_patch.py new file mode 100644 index 0000000000..5682761a13 --- /dev/null +++ b/native/mc_jni_patch.py @@ -0,0 +1,113 @@ +import sys +p = sys.argv[1] +s = open(p).read() +if "mc_psn_connect" in s: + print("already patched"); sys.exit(0) + +helpers = r""" +// ---- MoonCast: Remote Play over the internet (PSN hole-punch, no VPN / no port-forward) ---- +// Optional ConnectInfo field: absent in older Kotlin bindings -> NULL (LAN behaviour unchanged). +static jobject mc_opt_field(JNIEnv *env, jobject obj, jclass cls, const char *name, const char *sig) +{ + jfieldID fid = E->GetFieldID(env, cls, name, sig); + if(E->ExceptionCheck(env)) { E->ExceptionClear(env); return NULL; } + return fid ? E->GetObjectField(env, obj, fid) : NULL; +} + +static int mc_hex_nibble(char c) +{ + if(c >= '0' && c <= '9') return c - '0'; + if(c >= 'a' && c <= 'f') return c - 'a' + 10; + if(c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} + +// Same steps as chiaki-ng's StreamSession::InitiatePsnConnection + ConnectPsnConnection: PSN brokers the +// connection and only the control hole is punched here - chiaki_session punches the data hole itself. +static ChiakiHolepunchSession mc_psn_connect(ChiakiLog *log, const char *token, const char *duid_hex, bool ps5) +{ + size_t hex_len = strlen(duid_hex); + uint8_t duid[64]; + if(hex_len == 0 || hex_len % 2 || hex_len / 2 > sizeof(duid)) + { + CHIAKI_LOGE(log, "MoonCast PSN: invalid console duid"); + return NULL; + } + for(size_t i = 0; i < hex_len / 2; i++) + { + int hi = mc_hex_nibble(duid_hex[2 * i]), lo = mc_hex_nibble(duid_hex[2 * i + 1]); + if(hi < 0 || lo < 0) + { + CHIAKI_LOGE(log, "MoonCast PSN: invalid console duid"); + return NULL; + } + duid[i] = (uint8_t)((hi << 4) | lo); + } + ChiakiHolepunchSession hs = chiaki_holepunch_session_init(token, log); + if(!hs) + { + CHIAKI_LOGE(log, "MoonCast PSN: hole-punch session init failed (token?)"); + return NULL; + } + // No UPnP probe: it only ever timed out (7 s per attempt) and the hole-punch works without it. + ChiakiErrorCode err = chiaki_holepunch_session_create(hs); + if(err == CHIAKI_ERR_SUCCESS) + err = holepunch_session_create_offer(hs); + if(err == CHIAKI_ERR_SUCCESS) + err = chiaki_holepunch_session_start(hs, duid, ps5 ? CHIAKI_HOLEPUNCH_CONSOLE_TYPE_PS5 : CHIAKI_HOLEPUNCH_CONSOLE_TYPE_PS4); + if(err == CHIAKI_ERR_SUCCESS) + err = chiaki_holepunch_session_punch_hole(hs, CHIAKI_HOLEPUNCH_PORT_TYPE_CTRL); + if(err != CHIAKI_ERR_SUCCESS) + { + CHIAKI_LOGE(log, "MoonCast PSN: hole-punch failed: %s", chiaki_error_string(err)); + chiaki_holepunch_session_fini(hs); + return NULL; + } + CHIAKI_LOGI(log, "MoonCast PSN: control hole punched, starting session over the internet"); + return hs; +} + +""" +anchor = "JNIEXPORT void JNICALL JNI_FCN(sessionCreate)" +assert s.count(anchor) == 1 +s = s.replace(anchor, helpers + anchor, 1) + +block = r""" // MoonCast: Remote Play over the internet when the app passes a PSN token + console duid + { + jstring psn_token_s = mc_opt_field(env, connect_info_obj, connect_info_class, "psnToken", "Ljava/lang/String;"); + jstring duid_s = mc_opt_field(env, connect_info_obj, connect_info_class, "duid", "Ljava/lang/String;"); + jbyteArray psn_account_id_a = mc_opt_field(env, connect_info_obj, connect_info_class, "psnAccountId", "[B"); + if(psn_token_s && duid_s) + { + const char *tok = E->GetStringUTFChars(env, psn_token_s, NULL); + const char *duid = E->GetStringUTFChars(env, duid_s, NULL); + connect_info.holepunch_session = mc_psn_connect(log, tok, duid, connect_info.ps5); + E->ReleaseStringUTFChars(env, psn_token_s, tok); + E->ReleaseStringUTFChars(env, duid_s, duid); + if(!connect_info.holepunch_session) + { + err = CHIAKI_ERR_HOST_UNREACH; + android_chiaki_video_decoder_fini(&session->video_decoder); + android_chiaki_audio_decoder_fini(&session->audio_decoder); + android_chiaki_audio_output_free(session->audio_output); + free(session); + session = NULL; + goto beach; + } + if(psn_account_id_a && E->GetArrayLength(env, psn_account_id_a) == CHIAKI_PSN_ACCOUNT_ID_SIZE) + { + jbyte *acc = E->GetByteArrayElements(env, psn_account_id_a, NULL); + memcpy(connect_info.psn_account_id, acc, CHIAKI_PSN_ACCOUNT_ID_SIZE); + E->ReleaseByteArrayElements(env, psn_account_id_a, acc, JNI_ABORT); + } + } + } + + err = chiaki_session_init(&session->session, &connect_info, log);""" +a2 = "\terr = chiaki_session_init(&session->session, &connect_info, log);" +assert s.count(a2) == 1 +s = s.replace(a2, block, 1) +if "remote/holepunch.h" not in s: + s = s.replace("#include ", "#include \n#include ", 1) +open(p, "w").write(s) +print("patched") diff --git a/native/mc_nano_path_log.py b/native/mc_nano_path_log.py new file mode 100644 index 0000000000..4a0fa3506c --- /dev/null +++ b/native/mc_nano_path_log.py @@ -0,0 +1,49 @@ +import sys +# MoonCast: log which ICE path nano-rs uses (direct on the LAN, server-reflexive, or a relay), every ~10 s of stats. +p = sys.argv[1] + "/src/webrtc_backend.rs" +s = open(p).read() +if "mc path" in s: + print("already patched"); sys.exit(0) +old = ''' StatsReportType::CandidatePair(candidate) + if candidate.state.to_string() == "succeeded" => + { +''' +new = ''' StatsReportType::CandidatePair(candidate) + if candidate.state.to_string() == "succeeded" => + { + if mc_log_now { + crate::nano_log!("mc path pair local={} remote={} nominated={} rtt={:.1}ms", + candidate.local_candidate_id, candidate.remote_candidate_id, candidate.nominated, + candidate.current_round_trip_time * 1000.0); + } +''' +assert s.count(old) == 1 +s = s.replace(old, new) +old2 = ''' _ => {} + } + } + + if stats.packets_received == 0 {''' +new2 = ''' StatsReportType::LocalCandidate(c) | StatsReportType::RemoteCandidate(c) if mc_log_now => { + crate::nano_log!("mc path candidate id={} type={:?} addr={}:{} net={:?}", + c.id, c.candidate_type, c.ip, c.port, c.network_type); + } + _ => {} + } + } + + if stats.packets_received == 0 {''' +assert s.count(old2) == 1 +s = s.replace(old2, new2) +old3 = ''' for report in report.reports.values() { + match report { + StatsReportType::CandidatePair(candidate)''' +new3 = ''' static MC_STATS_TICK: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + let mc_log_now = MC_STATS_TICK.fetch_add(1, Ordering::Relaxed) % 10 == 2; + for report in report.reports.values() { + match report { + StatsReportType::CandidatePair(candidate)''' +assert s.count(old3) == 1 +s = s.replace(old3, new3) +open(p, "w").write(s) +print("patched") diff --git a/native/mc_port_guessing_patch.py b/native/mc_port_guessing_patch.py new file mode 100644 index 0000000000..bcfc80107c --- /dev/null +++ b/native/mc_port_guessing_patch.py @@ -0,0 +1,30 @@ +import sys +p = sys.argv[1] # android/app/src/main/cpp/chiaki-jni.c (already patched by mc_jni_patch.py) +s = open(p).read() +if "force_port_guessing(hs, true)" in s: + print("already patched"); sys.exit(0) + +a = """ ChiakiHolepunchSession hs = chiaki_holepunch_session_init(token, log); + if(!hs) + { + CHIAKI_LOGE(log, "MoonCast PSN: hole-punch session init failed (token?)"); + return NULL; + }""" +assert s.count(a) == 1 +s = s.replace(a, a + """ + // Port guessing (chiaki-ng's fix for port-rewriting NATs): when the STUN test sees a router that rewrites our + // port, advertise a spread of guessed ports and open many sockets so the console's packets hit one of them. + // Some JAL<->home punches got no candidate response at all; this targets that. Defaults: 75 guesses, 250 sockets. + chiaki_holepunch_session_force_port_guessing(hs, true);""", 1) + +b = """ if(err == CHIAKI_ERR_SUCCESS) + err = holepunch_session_create_offer(hs);""" +assert s.count(b) == 1 +s = s.replace(b, b + """ + { + int32_t inc = 0; bool rnd = false; + if(chiaki_holepunch_session_get_stun_allocation(hs, &inc, &rnd)) + CHIAKI_LOGI(log, "MoonCast PSN: our NAT allocation increment %d, random %d", (int)inc, (int)rnd); + }""", 1) +open(p, "w").write(s) +print("patched") diff --git a/native/mc_quality_patch.py b/native/mc_quality_patch.py new file mode 100644 index 0000000000..f9e5d124da --- /dev/null +++ b/native/mc_quality_patch.py @@ -0,0 +1,16 @@ +import sys +p = sys.argv[1] # android/app/src/main/cpp/chiaki-jni.c +s = open(p).read() +if "packet_loss_max = 0.05" in s: + print("already patched"); sys.exit(0) +a = "\tconnect_info.video_profile_auto_downgrade = true;" +assert s.count(a) == 1 +s = s.replace(a, a + """ + // MoonCast: the Android binding left these zeroed. packet_loss_max 0 made chiaki always report 0% loss to the + // console ("Clamping reported packet loss: measured=12.1% reported_max=0.0%"), so the PS5 never lowered its + // bitrate on a lossy link. Same values as chiaki-ng desktop: report real loss up to 5%, and request a fresh + // keyframe (IDR) when FEC can't repair a frame instead of decoding corrupt frames until the next one. + connect_info.packet_loss_max = 0.05; + connect_info.enable_idr_on_fec_failure = true;""", 1) +open(p, "w").write(s) +print("patched") diff --git a/native/mc_stats_patch.py b/native/mc_stats_patch.py new file mode 100644 index 0000000000..b2f6eb807e --- /dev/null +++ b/native/mc_stats_patch.py @@ -0,0 +1,66 @@ +import os, sys +# MoonCast: live stream stats for the PS5 Quick menu. Counts shown and lost frames in the Android decoder, keeps the +# delay the PS5 reports in its connection-quality messages, and exposes them with chiaki's measured bitrate and packet +# loss as sessionStats(). Run with the chiaki-ng root as the argument. +root = sys.argv[1] + +def patch(rel, pairs, marker): + p = os.path.join(root, rel) + s = open(p).read() + if marker in s: + print(rel, "already patched"); return + for old, new in pairs: + assert s.count(old) == 1, (rel, old[:50]) + s = s.replace(old, new) + open(p, "w").write(s) + print(rel, "patched") + +patch("lib/include/chiaki/streamconnection.h", [( + "\tdouble measured_bitrate;\n} ChiakiStreamConnection;", + "\tdouble measured_bitrate;\n\tdouble mc_rtt; // MoonCast: rtt from the latest connection-quality message\n} ChiakiStreamConnection;")], + "mc_rtt") + +patch("lib/src/streamconnection.c", [( + "\t\tstream_connection->measured_bitrate = chiaki_stream_stats_bitrate(", + "\t\tstream_connection->mc_rtt = q.rtt;\n\t\tstream_connection->measured_bitrate = chiaki_stream_stats_bitrate(")], + "mc_rtt") + +patch("android/app/src/main/cpp/video-decoder.h", [( + "\tChiakiCodec target_codec;\n} AndroidChiakiVideoDecoder;", + "\tChiakiCodec target_codec;\n\tuint64_t mc_frames_shown; // MoonCast stats\n\tuint64_t mc_frames_lost;\n} AndroidChiakiVideoDecoder;")], + "mc_frames_shown") + +patch("android/app/src/main/cpp/video-decoder.c", [ + ("\tdecoder->shutdown_output = false;\n\treturn chiaki_mutex_init(", + "\tdecoder->shutdown_output = false;\n\tdecoder->mc_frames_shown = 0;\n\tdecoder->mc_frames_lost = 0;\n\treturn chiaki_mutex_init("), + ("\t\t\tAMediaCodec_releaseOutputBuffer(decoder->codec, (size_t)status, info.size != 0);\n", + "\t\t\tAMediaCodec_releaseOutputBuffer(decoder->codec, (size_t)status, info.size != 0);\n\t\t\tif(info.size != 0)\n\t\t\t\tdecoder->mc_frames_shown++;\n"), + ("\tAndroidChiakiVideoDecoder *decoder = user;\n\tchiaki_mutex_lock(&decoder->codec_mutex);\n", + "\tAndroidChiakiVideoDecoder *decoder = user;\n\tif(frames_lost > 0)\n\t\tdecoder->mc_frames_lost += frames_lost;\n\tchiaki_mutex_lock(&decoder->codec_mutex);\n"), +], "mc_frames_shown") + +patch("android/app/src/main/cpp/chiaki-jni.c", [( + "JNIEXPORT jint JNICALL JNI_FCN(sessionGotoBed)", + """/** MoonCast: [measured Mbps, packet loss 0..1, PS5-reported rtt, rtt measured at connect (ms), frames shown, frames lost] */ +JNIEXPORT jdoubleArray JNICALL JNI_FCN(sessionStats)(JNIEnv *env, jobject obj, jlong ptr) +{ + AndroidChiakiSession *session = (AndroidChiakiSession *)ptr; + jdouble v[6] = { 0, 0, 0, 0, 0, 0 }; + if(session) + { + ChiakiStreamConnection *sc = &session->session.stream_connection; + v[0] = sc->measured_bitrate; + v[1] = sc->congestion_control.packet_loss; + v[2] = sc->mc_rtt; + v[3] = session->session.rtt_us / 1000.0; + v[4] = (jdouble)session->video_decoder.mc_frames_shown; + v[5] = (jdouble)session->video_decoder.mc_frames_lost; + } + jdoubleArray r = E->NewDoubleArray(env, 6); + if(r) + E->SetDoubleArrayRegion(env, r, 0, 6, v); + return r; +} + +JNIEXPORT jint JNICALL JNI_FCN(sessionGotoBed)""")], + "JNI_FCN(sessionStats)") diff --git a/native/mc_surface_lock_patch.py b/native/mc_surface_lock_patch.py new file mode 100644 index 0000000000..59de538226 --- /dev/null +++ b/native/mc_surface_lock_patch.py @@ -0,0 +1,40 @@ +import sys +# MoonCast (#37): removing the surface froze the PS5 decoder for good. set_surface(NULL) holds codec_mutex and then +# calls kill_decoder(), which takes the same (non-recursive) mutex again, so the thread deadlocks. It also returned +# with the mutex still held. The session could then never be freed, so every PS5 game kept one hardware decoder +# until the app was killed, and PC streams on the stick failed (-5) once they ran out. +# Fix: release the mutex before kill_decoder() (it locks it itself), and release the old window too. +# Run with android/app/src/main/cpp/video-decoder.c as the argument. +p = sys.argv[1] +s = open(p).read() +if "MoonCast (#37)" in s: + print("already patched"); sys.exit(0) +old = """ if(!surface) + { + if(decoder->codec) + { + kill_decoder(decoder); + CHIAKI_LOGI(decoder->log, "Decoder shut down after surface was removed"); + } + return; + }""" +new = """ if(!surface) + { + // MoonCast (#37): kill_decoder() takes codec_mutex itself and it isn't recursive, so release it first + chiaki_mutex_unlock(&decoder->codec_mutex); + if(decoder->codec) + { + kill_decoder(decoder); + CHIAKI_LOGI(decoder->log, "Decoder shut down after surface was removed"); + } + if(decoder->window) + { + ANativeWindow_release(decoder->window); + decoder->window = NULL; + } + return; + }""" +assert s.count(old) == 1, "set_surface(NULL) block not found" +s = s.replace(old, new, 1) +open(p, "w").write(s) +print("patched") diff --git a/native/mc_wake_timeout_patch.py b/native/mc_wake_timeout_patch.py new file mode 100644 index 0000000000..b7b688bb1f --- /dev/null +++ b/native/mc_wake_timeout_patch.py @@ -0,0 +1,21 @@ +import sys +p = sys.argv[1] # lib/src/remote/holepunch.c +s = open(p).read() +if "SESSION_START_WAKE_TIMEOUT_SEC" in s: + print("already patched"); sys.exit(0) +d = "#define SESSION_START_TIMEOUT_SEC 30" +assert s.count(d) == 1 +s = s.replace(d, d + """ +// MoonCast: a console in rest mode only joins the PSN session (MEMBER_CREATED) once it has woken up, which +// took ~46 s in testing (30 s always failed). Not longer: a PS5 still *entering* rest mode ignores the start request, +// and waiting on the same request never helps - a fresh attempt does (connect() retries). +#define SESSION_START_WAKE_TIMEOUT_SEC 60""", 1) +w = """ int notif_query = NOTIFICATION_TYPE_MEMBER_CREATED + | NOTIFICATION_TYPE_CUSTOM_DATA1_UPDATED; + while (!finished) + { + err = wait_for_notification(session, ¬if, notif_query, SESSION_START_TIMEOUT_SEC * 1000);""" +assert s.count(w) == 1 +s = s.replace(w, w.replace("SESSION_START_TIMEOUT_SEC * 1000", "SESSION_START_WAKE_TIMEOUT_SEC * 1000"), 1) +open(p, "w").write(s) +print("patched") diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000000..27f20fed07 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,32 @@ +#!/bin/sh +# Builds signed release APKs of both apps and checks their signature. +# Usage: MOONCAST_KEYSTORE=/path/to/keystore.properties scripts/release.sh +# A beta: VERSION_NAME=1.0-beta1 VERSION_CODE=991 scripts/release.sh (the code stays below the release it leads to) +# (or keystore.properties next to settings.gradle). Writes out/MoonCast-.apk and out/MoonCastTV-.apk. +set -e +cd "$(dirname "$0")/.." +PROPS=${MOONCAST_KEYSTORE:-keystore.properties} +[ -f "$PROPS" ] || { echo "release.sh: no keystore.properties (set MOONCAST_KEYSTORE)" >&2; exit 1; } +export MOONCAST_KEYSTORE=$(cd "$(dirname "$PROPS")" && pwd)/$(basename "$PROPS") + +# apksigner runs on Java too +[ -n "$JAVA_HOME" ] && PATH="$JAVA_HOME/bin:$PATH" +SDK=${ANDROID_HOME:-$(sed -n 's/^sdk\.dir=//p' local.properties 2>/dev/null)} +APKSIGNER=$(ls "$SDK"/build-tools/*/apksigner 2>/dev/null | sort -V | tail -1) +[ -n "$APKSIGNER" ] || { echo "release.sh: apksigner not found under $SDK/build-tools" >&2; exit 1; } + +if [ -n "$VERSION_NAME" ]; then + [ -n "$VERSION_CODE" ] || { echo "release.sh: VERSION_NAME needs a VERSION_CODE" >&2; exit 1; } + ./gradlew --no-daemon -PmcVersionName="$VERSION_NAME" -PmcVersionCode="$VERSION_CODE" assembleNonRootHandheldRelease assembleNonRootTvRelease + VERSION=$VERSION_NAME +else + ./gradlew --no-daemon assembleNonRootHandheldRelease assembleNonRootTvRelease + VERSION=$(sed -n 's/.*?: "\([0-9][^"]*\)")$/\1/p' app/build.gradle | head -1) +fi +mkdir -p out +cp app/build/outputs/apk/nonRootHandheld/release/app-nonRoot-handheld-release.apk "out/MoonCast-$VERSION.apk" +cp app/build/outputs/apk/nonRootTv/release/app-nonRoot-tv-release.apk "out/MoonCastTV-$VERSION.apk" +for a in "out/MoonCast-$VERSION.apk" "out/MoonCastTV-$VERSION.apk"; do + echo "== $a" + "$APKSIGNER" verify --print-certs "$a" | grep -E "Signer #1 certificate (DN|SHA-256)" +done diff --git a/third-party/chiaki-ng b/third-party/chiaki-ng new file mode 160000 index 0000000000..c5fa7fad63 --- /dev/null +++ b/third-party/chiaki-ng @@ -0,0 +1 @@ +Subproject commit c5fa7fad63d56e08fc503c3b746bec4824dfaabc