diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
new file mode 100644
index 0000000..89fbaa1
--- /dev/null
+++ b/.github/workflows/pages.yml
@@ -0,0 +1,64 @@
+name: Project website
+on:
+ pull_request:
+ branches: [master]
+ push:
+ branches: [master]
+ workflow_dispatch:
+permissions:
+ contents: read
+concurrency:
+ group: pages-${{ github.ref }}
+ cancel-in-progress: false
+jobs:
+ check:
+ name: Pages checks
+ runs-on: ubuntu-24.04
+ timeout-minutes: 10
+ permissions:
+ contents: read
+ pages: read
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
+ with:
+ python-version: '3.13'
+ - name: Install browser checks
+ run: |
+ python -m pip install -r tools/requirements-site.txt
+ python -m playwright install --with-deps chromium
+ - name: Check the website
+ run: python tools/site_checks.py
+ - name: Upload browser evidence
+ if: always()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: pages-browser-checks
+ path: artifacts/pages/
+ retention-days: 3
+ - name: Configure Pages
+ if: github.event_name != 'pull_request' && github.ref == 'refs/heads/master'
+ uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
+ - name: Upload website
+ if: github.event_name != 'pull_request' && github.ref == 'refs/heads/master'
+ uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
+ with:
+ path: site/
+ deploy:
+ name: Publish GitHub Pages
+ if: github.event_name != 'pull_request' && github.ref == 'refs/heads/master'
+ needs: check
+ runs-on: ubuntu-24.04
+ timeout-minutes: 10
+ permissions:
+ pages: write
+ id-token: write
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - name: Publish
+ id: deployment
+ uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
diff --git a/.gitignore b/.gitignore
index 378c4d9..ea44617 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,4 @@ local.properties
captures/
.externalNativeBuild/
__pycache__/
+/artifacts/
diff --git a/README.md b/README.md
index 017c286..b7233a0 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,8 @@
A Java **Android screen mirroring** example using **MediaProjection** for capture and **MediaCodec** for H.264 or VP8 encoding. It sends video over TCP or UDP to **FFmpeg / FFplay**, demonstrating the complete capture → encode → transmit path on Android 5.0 (API 21) and later, when a suitable Surface encoder is available.
+[Explore the project website](https://magicsih.github.io/AndroidScreenCaster/) for the demo, both projects, and a quick start.
+
## Demo
[](https://www.youtube.com/watch?v=2AN6EfArfZE)
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index 67a6f8e..a58da68 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -13,3 +13,7 @@ Its Apache License 2.0 copyright/license notice remains in the source. A copy of
The generated Gradle wrapper scripts and JAR are distributed under Apache License 2.0. The scripts retain their original notices. See [Gradle's licensing information](https://github.com/gradle/gradle/blob/master/LICENSE) and the bundled license above.
JUnit is used only to run development tests; there are no external runtime library dependencies in the example APK.
+
+## Project website fonts
+
+The website bundles Manrope and IBM Plex Mono from [Google Fonts](https://github.com/google/fonts), under the SIL Open Font License 1.1. Their original license and copyright texts are included in [Manrope's notice](site/assets/manrope-OFL.txt) and [IBM Plex Mono's notice](site/assets/plex-mono-OFL.txt). The project-wide MIT license does not replace these font licenses.
diff --git a/docs/PAGES.md b/docs/PAGES.md
new file mode 100644
index 0000000..f8d091e
--- /dev/null
+++ b/docs/PAGES.md
@@ -0,0 +1,66 @@
+# Project website
+
+The public introduction is at **https://magicsih.github.io/AndroidScreenCaster/**.
+It introduces the Android sender and browser companion; the streaming receiver
+still runs on the user's own trusted network.
+
+## Source and deployment
+
+`site/` is a standalone static website: HTML, CSS and a small JavaScript file.
+There is no framework, package build or runtime server. GitHub Pages publishes
+only this directory, not the repository or its validation evidence.
+
+The `Project website` workflow checks desktop and mobile layouts, real image
+loading, receiver tabs, keyboard input, command copying and its failure fallback,
+demo activation, links and metadata. It saves screenshots and a JSON report.
+The browser check mocks the YouTube player; it does not assert video decoding.
+
+PRs run with read-only repository permissions and do not deploy. A successful
+`master` check uploads the site and a separate job deploys with `pages: write`
+and `id-token: write`. The `github-pages` environment restricts deployments to
+the protected default branch. Actions are pinned to verified stable commit SHAs.
+
+For local preview, serve the `site/` directory with an HTTP server. To run the
+same checks as CI, use an isolated Python environment with
+`tools/requirements-site.txt`, install Playwright Chromium, and run
+`python tools/site_checks.py`. The test starts and stops its own local server.
+
+## Content and asset sources
+
+- `assets/original-demo.jpg` is the existing project's YouTube thumbnail,
+ obtained from `https://img.youtube.com/vi/2AN6EfArfZE/maxresdefault.jpg`.
+ The page labels this as an earlier-version demo and loads the privacy-enhanced
+ YouTube embed only after a click. A direct YouTube link remains available.
+- `assets/android-app.png` is the API 37 screenshot from
+ `docs/images/screen-caster.png` in this repository.
+- `assets/browser-viewer.png` is the API 36 physical-device screenshot from the
+ companion's `docs/images/physical-viewer.png`.
+- Manrope and IBM Plex Mono are self-hosted from
+ [Google Fonts](https://github.com/google/fonts). Their SIL Open Font License
+ texts are included alongside the font files. The favicon is project artwork.
+
+Published capability and runtime claims follow the two repositories' validation
+reports. Do not add unmeasured latency, audio, remote control, or universal device
+compatibility claims.
+
+## Google Search Console
+
+Add **https://magicsih.github.io/AndroidScreenCaster/** as a **URL-prefix**
+property in [Search Console](https://search.google.com/search-console).
+Choose **HTML tag** verification and put the exact issued tag in the `
` of
+`site/index.html`. Submit it through the normal PR flow, wait for the Pages
+deployment, confirm the tag appears in the public page source, then select
+**Verify** in Search Console. Keep the tag in place.
+
+The published sitemap is
+**https://magicsih.github.io/AndroidScreenCaster/sitemap.xml**. Submit that URL
+in the property's Sitemaps screen, then inspect the homepage and request indexing.
+Indexing and search ranking are not guaranteed. Verifying the Pages address does
+not verify either `github.com` repository address.
+
+No verification token is invented or included by default. Search Console setup
+requires the token issued for the intended Google account and property.
+
+- [Google ownership verification](https://support.google.com/webmasters/answer/9008080)
+- [Google recrawl requests](https://developers.google.com/search/docs/crawling-indexing/ask-google-to-recrawl)
+- [GitHub Pages custom workflows](https://docs.github.com/en/pages/getting-started-with-github-pages/using-custom-workflows-with-github-pages)
diff --git a/site/.nojekyll b/site/.nojekyll
new file mode 100644
index 0000000..e69de29
diff --git a/site/assets/android-app.png b/site/assets/android-app.png
new file mode 100644
index 0000000..d219236
Binary files /dev/null and b/site/assets/android-app.png differ
diff --git a/site/assets/browser-viewer.png b/site/assets/browser-viewer.png
new file mode 100644
index 0000000..1eb7acb
Binary files /dev/null and b/site/assets/browser-viewer.png differ
diff --git a/site/assets/manrope-OFL.txt b/site/assets/manrope-OFL.txt
new file mode 100644
index 0000000..e271172
--- /dev/null
+++ b/site/assets/manrope-OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2018 The Manrope Project Authors (https://github.com/googlefonts/manrope)
+
+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/site/assets/manrope.ttf b/site/assets/manrope.ttf
new file mode 100644
index 0000000..75274da
Binary files /dev/null and b/site/assets/manrope.ttf differ
diff --git a/site/assets/mark.svg b/site/assets/mark.svg
new file mode 100644
index 0000000..1f1af03
--- /dev/null
+++ b/site/assets/mark.svg
@@ -0,0 +1 @@
+
diff --git a/site/assets/original-demo.jpg b/site/assets/original-demo.jpg
new file mode 100644
index 0000000..f8c4e4e
Binary files /dev/null and b/site/assets/original-demo.jpg differ
diff --git a/site/assets/plex-mono-OFL.txt b/site/assets/plex-mono-OFL.txt
new file mode 100644
index 0000000..c35c4c6
--- /dev/null
+++ b/site/assets/plex-mono-OFL.txt
@@ -0,0 +1,93 @@
+Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
+
+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/site/assets/plex-mono.ttf b/site/assets/plex-mono.ttf
new file mode 100644
index 0000000..33c546f
Binary files /dev/null and b/site/assets/plex-mono.ttf differ
diff --git a/site/index.html b/site/index.html
new file mode 100644
index 0000000..3a8685c
--- /dev/null
+++ b/site/index.html
@@ -0,0 +1,604 @@
+
+
+
+
+
+
+ AndroidScreenCaster — Your screen. Now streaming.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Skip to content
+
+
+
+
+
+ Android screen
+ mirroring · Open source
+
+
Your screen.Now streaming.
+
+ From a phone in your hand to a screen everyone can see. Capture,
+ encode, and stream Android video—with code you can open up.
+
+
+
v0.2.0 · Example debug APK · Android 5.0+
+
+
+
+ See it in
+ motion01 / Demo
+
+
+
+
+ The demo that started it.Recorded with an earlier version.
+
+
YouTube ↗
+
+
+
+
+
+
+ Java Open, readable sourceH.264 + VP8 MediaCodec encodingTCP + UDP FFmpeg playbackWebRTC Browser companion
+
+
+
+
+
+
Two projects. One stream.
+
+ The phone does the capturing. The rest is up to you.
+
+
+ Watch with FFplay, record with FFmpeg, or send the same H.264 stream
+ through our small browser server.
+
+
+
+
+
+ 01 / Android sender Java
+
+
+
+
+
+
+
+
+
Your screen. Your settings. Your consent.
+
MediaProjection ↓ MediaCodec
+
+
+
+
AndroidScreenCaster
+
+ A native Android example that captures your screen, encodes it
+ as H.264 or VP8, and sends video over TCP or UDP. Every session
+ starts with fresh sharing consent.
+
+
+ Android 5.0+ with a suitable Surface encoder
+ Stop from the app, notification, or system
+ Raw H.264 or VP8 in an IVF container
+
+
Explore the Android project
+ ↗
+
+
+
+
+ 02 / Browser companion Docker Compose
+
+
+
+ ● ● ● localhost:8080 ↗
+
+
+
+
+
AndroidScreenCasterWeb
+
+ A small companion that takes TCP/H.264 from the phone and
+ delivers WebRTC video to a browser. FFmpeg copies the video;
+ MediaMTX handles playback.
+
+
+ Start with Docker Compose
+ Reconnect, full screen, and connection status
+ One phone, up to four browser viewers
+
+
Explore the browser project
+ ↗
+
+
+
+
+ Real project screens: Android API 37 emulator and the API 36
+ physical-device browser test.
+
+
+
+
+
+
+
A small, understandable path
+
Capture. Encode. Pass it on.
+
+
+
+ On the phone MediaProjection captures the screen
+
+
→
+
+ In the encoder MediaCodec makes H.264 or VP8
+
+
→
+
+ On your computer FFplay / WebRTC brings it to a screen
+
+
+
+
+
+
+
+
+
Make your first stream
+
+ A phone. A receiver. A little motion.
+
+
+ Put both devices on a trusted network. Start the receiver, then
+ tell the Android app where to send the video.
+
+
+
+ 01
+
+
Install the Android APK
+
+ Use the example debug APK from the release. The download
+ includes SHA-256 checksums.
+
+
+
+
+ 02
+
+
Start your receiver
+
+ Choose FFplay for desktop playback or the companion for
+ browser viewing.
+
+
+
+
+ 03
+
+
Press Start on the phone
+
+ Enter the receiver’s LAN IP. Choose TCP / H264, try 640×360
+ at 1 Mbps, and approve sharing.
+
+
+
+
+
+
+
+
+ Desktop / FFplay
+ Browser / WebRTC
+
+
+
+
+ On your receiver
+ computer
+
+
Start listening.
+
+ Install FFmpeg, then run this command before starting capture.
+
+
+
+ Terminal
+ Copy ⧉
+
+
+
ffplay -f h264 \
+ 'tcp://0.0.0.0:49152?listen=1'
+
+
+
Full Android setup ↗
+
+
+
+ On your receiver
+ computer
+
+
Bring it to a browser.
+
Install Docker with Compose, then start the companion.
+
+
+ Terminal
+ Copy ⧉
+
+
+
git clone https://github.com/magicsih/AndroidScreenCasterWeb.git
+cd AndroidScreenCasterWeb
+docker compose up -d
+
+
+
Full browser & LAN setup
+ ↗
+
+
+
+
↳
+
+ Video only, for trusted networks. The sender has no
+ authentication or encryption. Keep the receiver off the public
+ internet.
+
+
+
+
+
+
+
+
+
+
Checked, then documented
+
The evidence is open, too.
+
+
+ Build checks are one thing. Seeing changing frames arrive and
+ watching a capture stop cleanly are another. We record both.
+
+
+
+
+
Android / device test 4 modes
+
+ TCP and UDP × H.264 and VP8 decoded during 125-second
+ physical-device checks.
+
+
+
+
Browser / device test 160 seconds
+
+ Continuous physical-device streaming, with late joining and
+ reconnect checked.
+
+
+
+
Source / automated checks Build + CI
+
+ Android build, unit tests and lint. Browser decoding and lifecycle
+ checks in Chromium.
+
+
+
+
+
+ Device support depends on the encoder. Direct Wi-Fi TCP, remote LAN
+ browser viewing, Safari and Firefox remain unverified. The browser
+ device check used optional ADB forwarding; no particular latency is
+ promised.
+
+
+
+
+
+
Before you press Start
+
A few good questions.
+
+
+
+
+ Can I watch directly in a browser?+
+
+
+ The Android app sends raw TCP/UDP video. Use
+ AndroidScreenCasterWeb to turn its TCP/H.264 stream into WebRTC.
+ You run that server yourself; this page introduces the projects
+ and plays the original demo.
+
+
+
+
+ Do I need USB or a custom server?+
+
+
+ USB is not required for normal Wi-Fi/LAN use. FFplay or FFmpeg can
+ receive the stream without a custom server. USB/ADB is optional
+ for installation and development; browser playback uses the
+ companion server.
+
+
+
+
+ Does it include sound or remote control?+
+
+
+ No. Both projects stream video only. They do not capture audio or
+ let the viewer control the phone.
+
+
+
+
+ Can I use it as a public streaming service?+
+
+
+ These are developer examples for trusted networks. The sender
+ stream and companion’s HTTP page lack authentication and
+ encryption. Public hosting needs a separately designed access and
+ transport setup.
+
+
+
+
+
+
+
+
Open source. Ready to explore.
+
See the screen. Understand the stream.
+
+ Open the project ↗
+
+
+
+
+
diff --git a/site/site.js b/site/site.js
new file mode 100644
index 0000000..5f4bf63
--- /dev/null
+++ b/site/site.js
@@ -0,0 +1,60 @@
+"use strict";
+
+const tabs = Array.from(document.querySelectorAll("[data-receiver]"));
+function selectReceiver(tab) {
+ for (const item of tabs) {
+ const selected = item === tab;
+ item.setAttribute("aria-selected", String(selected));
+ item.tabIndex = selected ? 0 : -1;
+ document.getElementById(item.getAttribute("aria-controls")).hidden =
+ !selected;
+ }
+ document.querySelector(".copy-status").textContent = "";
+}
+for (const tab of tabs) {
+ tab.addEventListener("click", () => selectReceiver(tab));
+ tab.addEventListener("keydown", (event) => {
+ if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
+ event.preventDefault();
+ let index = tabs.indexOf(tab);
+ if (event.key === "Home") index = 0;
+ else if (event.key === "End") index = tabs.length - 1;
+ else
+ index =
+ (index + (event.key === "ArrowRight" ? 1 : -1) + tabs.length) %
+ tabs.length;
+ selectReceiver(tabs[index]);
+ tabs[index].focus();
+ });
+}
+
+for (const button of document.querySelectorAll("[data-copy]")) {
+ button.addEventListener("click", async () => {
+ const command = document.getElementById(button.dataset.copy);
+ const status = document.querySelector(".copy-status");
+ try {
+ await navigator.clipboard.writeText(command.textContent);
+ status.textContent = "Command copied. Paste it into your terminal.";
+ } catch {
+ const selection = window.getSelection();
+ const range = document.createRange();
+ range.selectNodeContents(command);
+ selection.removeAllRanges();
+ selection.addRange(range);
+ status.textContent = "Command selected. Use your browser’s Copy command.";
+ }
+ });
+}
+
+document.getElementById("play-demo").addEventListener("click", (event) => {
+ event.preventDefault();
+ const player = document.createElement("iframe");
+ player.title = "Original AndroidScreenCaster demonstration on YouTube";
+ player.src =
+ "https://www.youtube-nocookie.com/embed/2AN6EfArfZE?autoplay=1&rel=0";
+ player.allow = "autoplay; encrypted-media; picture-in-picture; fullscreen";
+ player.allowFullscreen = true;
+ player.referrerPolicy = "strict-origin-when-cross-origin";
+ document.querySelector(".video-slot").replaceChildren(player);
+ player.focus();
+});
diff --git a/site/sitemap.xml b/site/sitemap.xml
new file mode 100644
index 0000000..9bcde34
--- /dev/null
+++ b/site/sitemap.xml
@@ -0,0 +1,4 @@
+
+
+ https://magicsih.github.io/AndroidScreenCaster/
+
diff --git a/site/style.css b/site/style.css
new file mode 100644
index 0000000..46ad838
--- /dev/null
+++ b/site/style.css
@@ -0,0 +1,1353 @@
+@font-face {
+ font-family: Manrope;
+ src: url("assets/manrope.ttf") format("truetype");
+ font-style: normal;
+ font-weight: 200 800;
+ font-display: swap;
+}
+@font-face {
+ font-family: Plex;
+ src: url("assets/plex-mono.ttf") format("truetype");
+ font-style: normal;
+ font-weight: 500;
+ font-display: swap;
+}
+:root {
+ --paper: #f5f3ec;
+ --ink: #202421;
+ --muted: #656a60;
+ --line: #d9dbcf;
+ --accent: #d74722;
+ --dark: #202923;
+ --lime: #d2eea0;
+ --wrap: 1248px;
+}
+* {
+ box-sizing: border-box;
+}
+html {
+ scroll-behavior: smooth;
+ scroll-padding-top: 110px;
+}
+body {
+ margin: 0;
+ background: var(--paper);
+ color: var(--ink);
+ font-family: Manrope, Arial, sans-serif;
+ -webkit-font-smoothing: antialiased;
+}
+a {
+ color: inherit;
+ text-decoration: none;
+}
+button {
+ font: inherit;
+ cursor: pointer;
+}
+img {
+ max-width: 100%;
+ height: auto;
+ display: block;
+}
+button,
+a,
+summary {
+ -webkit-tap-highlight-color: transparent;
+}
+a:focus-visible,
+button:focus-visible,
+summary:focus-visible {
+ outline: 3px solid var(--accent);
+ outline-offset: 5px;
+}
+p {
+ line-height: 1.65;
+}
+h1,
+h2,
+h3,
+p {
+ margin-top: 0;
+}
+h2 {
+ font-size: clamp(30px, 3.3vw, 46px);
+ font-weight: 600;
+ line-height: 1.2;
+ letter-spacing: -2px;
+}
+h3 {
+ font-size: 26px;
+ letter-spacing: -1px;
+ font-weight: 650;
+}
+ul {
+ padding: 0;
+ list-style: none;
+}
+.wrap {
+ max-width: var(--wrap);
+ margin-inline: auto;
+ padding-inline: 32px;
+}
+.mono,
+.eyebrow {
+ font-family: Plex, monospace;
+ font-size: 11px;
+ font-weight: 500;
+ letter-spacing: 0.06em;
+}
+.eyebrow {
+ text-transform: uppercase;
+ line-height: 1.6;
+ margin-bottom: 24px;
+}
+.section {
+ padding-block: 100px;
+}
+.skip-link {
+ position: fixed;
+ left: 16px;
+ top: -60px;
+ z-index: 10;
+ background: var(--ink);
+ color: white;
+ padding: 12px 20px;
+}
+.skip-link:focus {
+ top: 12px;
+}
+.header {
+ max-width: var(--wrap);
+ margin: auto;
+ padding: 28px 32px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ border-bottom: 1px solid var(--line);
+}
+.brand {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-size: 22px;
+ letter-spacing: -1px;
+ font-weight: 800;
+}
+.brand-dot {
+ color: var(--accent);
+}
+.header nav {
+ display: flex;
+ gap: 30px;
+ align-items: center;
+ font-size: 13px;
+ font-weight: 600;
+}
+.header nav a:hover,
+.text-link:hover,
+.footer a:hover {
+ color: var(--accent);
+}
+.nav-source {
+ border: 1px solid var(--line);
+ padding: 10px 16px;
+ border-radius: 6px;
+}
+.nav-source span {
+ margin-left: 8px;
+}
+.hero {
+ padding-top: 84px;
+ padding-bottom: 84px;
+ display: grid;
+ grid-template-columns: 1.05fr 1fr;
+ gap: 46px;
+ align-items: center;
+}
+.hero .eyebrow {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 23px;
+ font-size: 10px;
+ letter-spacing: 0.045em;
+}
+.signal {
+ display: inline-block;
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: var(--accent);
+ flex-shrink: 0;
+}
+.hero h1 {
+ font-size: clamp(50px, 5.9vw, 78px);
+ font-weight: 650;
+ line-height: 1.04;
+ letter-spacing: -5px;
+ margin-bottom: 28px;
+}
+.hero h1 span {
+ color: var(--accent);
+}
+.hero-description {
+ font-size: 16px;
+ max-width: 440px;
+ color: var(--muted);
+ margin-bottom: 30px;
+}
+.hero-actions {
+ display: flex;
+ gap: 22px;
+ align-items: center;
+ flex-wrap: wrap;
+}
+.button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 24px;
+ border-radius: 5px;
+ padding: 17px 21px;
+ font-size: 13px;
+ font-weight: 700;
+ transition:
+ background 0.18s,
+ transform 0.18s;
+}
+.button-primary {
+ background: var(--accent);
+ color: #fff;
+}
+.button-primary:hover {
+ background: #b93a1a;
+ transform: translateY(-2px);
+}
+.text-link {
+ display: inline-flex;
+ gap: 14px;
+ font-size: 13px;
+ align-items: center;
+ font-weight: 700;
+}
+.release-note {
+ font-family: Plex, monospace;
+ font-size: 10px;
+ margin: 17px 0 0;
+ color: var(--muted);
+}
+.demo-card {
+ position: relative;
+ background: #e9eadd;
+ border: 1px solid #d4d7c9;
+ border-radius: 10px;
+ overflow: hidden;
+ box-shadow: 0 14px 30px #20242109;
+ transform: rotate(1deg);
+}
+.demo-top {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 17px 19px;
+ color: #4c5645;
+}
+.demo-top .mono {
+ font-size: 9px;
+}
+.tiny-square {
+ display: inline-block;
+ background: var(--accent);
+ width: 7px;
+ height: 7px;
+ margin-right: 7px;
+}
+.video-slot {
+ aspect-ratio: 16/9;
+ background: #101510;
+ position: relative;
+}
+.demo-launch {
+ position: relative;
+ display: block;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+}
+.demo-launch > img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ filter: brightness(0.78);
+ transition:
+ filter 0.3s,
+ transform 0.4s;
+}
+.demo-launch:hover > img {
+ filter: brightness(0.9);
+ transform: scale(1.02);
+}
+.demo-launch::after {
+ content: "";
+ position: absolute;
+ inset: 60% 0 0;
+ background: linear-gradient(transparent, #0009);
+ pointer-events: none;
+}
+.play-disc {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ translate: -50% -50%;
+ display: grid;
+ place-items: center;
+ width: 76px;
+ height: 76px;
+ border-radius: 50%;
+ color: var(--ink);
+ background: var(--lime);
+ border: 7px solid #d2eea033;
+ background-clip: padding-box;
+ box-shadow: 0 0 0 1px #d2eea060;
+ z-index: 1;
+ transition: scale 0.2s;
+}
+.play-disc svg {
+ margin-left: 3px;
+}
+.demo-launch:hover .play-disc {
+ scale: 1.08;
+}
+.play-label {
+ position: absolute;
+ bottom: 19px;
+ left: 20px;
+ right: 20px;
+ color: white;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-size: 12px;
+ font-weight: 700;
+ z-index: 1;
+}
+.video-slot iframe {
+ display: block;
+ width: 100%;
+ height: 100%;
+ border: 0;
+}
+.demo-caption {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ align-items: center;
+ padding: 16px 19px;
+}
+.demo-caption p {
+ font-size: 11px;
+ line-height: 1.5;
+ margin: 0;
+ font-weight: 700;
+}
+.demo-caption p span {
+ font-weight: 400;
+ color: var(--muted);
+ font-size: 10px;
+}
+.demo-caption a {
+ font-size: 11px;
+ font-weight: 700;
+}
+.spec-strip {
+ border-block: 1px solid var(--line);
+}
+.spec-strip .wrap {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ padding-block: 24px;
+ gap: 20px;
+}
+.spec-strip span {
+ font-size: 11px;
+ color: var(--muted);
+}
+.spec-strip strong {
+ display: block;
+ font-size: 14px;
+ font-weight: 800;
+ margin-bottom: 3px;
+ color: var(--ink);
+}
+.section-heading {
+ margin-bottom: 42px;
+ max-width: 700px;
+}
+.section-heading .eyebrow {
+ color: var(--accent);
+ margin-bottom: 16px;
+}
+.section-heading h2 {
+ margin-bottom: 18px;
+}
+.section-heading > p:last-child {
+ font-size: 15px;
+ color: var(--muted);
+ max-width: 560px;
+}
+.project-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 24px;
+}
+.project-card {
+ min-width: 0;
+ border: 1px solid var(--line);
+ border-radius: 9px;
+ overflow: hidden;
+ background: #faf9f5;
+}
+.project-card-heading {
+ padding: 21px 24px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 12px;
+ border-bottom: 1px solid var(--line);
+}
+.project-index {
+ font-size: 9px;
+ text-transform: uppercase;
+ color: var(--muted);
+}
+.tag {
+ border: 1px solid var(--line);
+ padding: 5px 9px;
+ border-radius: 4px;
+ font-size: 9px;
+ font-weight: 700;
+}
+.sender-preview {
+ height: 320px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 55px;
+ background: #e5e9d9;
+ overflow: hidden;
+ padding-top: 35px;
+ border-bottom: 1px solid var(--line);
+}
+.phone {
+ width: 154px;
+ min-width: 154px;
+ padding: 6px;
+ border-radius: 17px;
+ background: #29322a;
+ box-shadow: 14px 15px 0 #b5bcaa;
+}
+.phone img {
+ width: 100%;
+ border-radius: 12px;
+}
+.preview-note {
+ align-self: center;
+ max-width: 180px;
+}
+.preview-plus {
+ display: block;
+ font-size: 36px;
+ font-weight: 400;
+ line-height: 1;
+ margin-bottom: 15px;
+ color: #737e68;
+}
+.preview-note p {
+ font-size: 17px;
+ font-weight: 600;
+ line-height: 1.4;
+ letter-spacing: -0.5px;
+}
+.preview-note .mono {
+ font-size: 9px;
+ line-height: 1.7;
+ color: #69735f;
+}
+.browser-preview {
+ height: 320px;
+ overflow: hidden;
+ background: #182117;
+ border-bottom: 1px solid var(--line);
+ padding: 38px 26px 0;
+}
+.browser-chrome {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ background: #344135;
+ color: #adbaac;
+ height: 28px;
+ border-radius: 7px 7px 0 0;
+ padding-inline: 12px;
+ font-size: 9px;
+}
+.browser-chrome .mono {
+ font-size: 8px;
+}
+.browser-preview > img {
+ width: 100%;
+ border: 1px solid #344135;
+ border-top: 0;
+}
+.project-info {
+ padding: 27px 28px;
+}
+.project-info h3 {
+ margin-bottom: 13px;
+ font-size: 24px;
+ overflow-wrap: anywhere;
+}
+.project-info > p {
+ font-size: 13px;
+ line-height: 1.8;
+ color: var(--muted);
+ min-height: 92px;
+}
+.feature-list {
+ margin: 22px 0 25px;
+ font-size: 11px;
+ line-height: 1.6;
+}
+.feature-list li {
+ display: flex;
+ gap: 9px;
+ margin-bottom: 9px;
+}
+.feature-list li::before {
+ content: "↳";
+ color: var(--accent);
+}
+.project-link {
+ border-top: 1px solid var(--line);
+ padding-top: 18px;
+ display: flex;
+ justify-content: space-between;
+ font-size: 12px;
+ font-weight: 800;
+ gap: 12px;
+}
+.project-link:hover {
+ color: var(--accent);
+}
+.image-note {
+ font-size: 10px;
+ color: var(--muted);
+ margin: 16px 0 0;
+}
+.flow-section {
+ background: #e8ebdf;
+ border-block: 1px solid #d7dccd;
+ padding-block: 55px;
+}
+.flow-wrap {
+ display: grid;
+ grid-template-columns: 1fr 2fr;
+ gap: 45px;
+ align-items: center;
+}
+.flow-wrap .eyebrow {
+ font-size: 9px;
+ margin-bottom: 14px;
+ color: var(--muted);
+}
+.flow-wrap h2 {
+ font-size: 30px;
+ letter-spacing: -1.3px;
+ margin: 0;
+}
+.flow {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 16px;
+}
+.flow > div {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+.flow .mono {
+ color: var(--muted);
+ font-size: 8px;
+ text-transform: uppercase;
+}
+.flow strong {
+ font-size: 15px;
+ letter-spacing: -0.4px;
+}
+.flow > div > span:last-child {
+ font-size: 10px;
+ color: var(--muted);
+}
+.flow-arrow {
+ color: var(--accent);
+ font-size: 22px;
+}
+.start-grid {
+ display: grid;
+ grid-template-columns: 0.9fr 1.1fr;
+ gap: 85px;
+ align-items: start;
+}
+.start-copy .eyebrow {
+ color: var(--accent);
+}
+.start-copy > p:not(.eyebrow) {
+ font-size: 14px;
+ color: var(--muted);
+ max-width: 345px;
+ margin-bottom: 32px;
+}
+.start-copy h2 {
+ font-size: 48px;
+ line-height: 1.1;
+ letter-spacing: -2.6px;
+ margin-bottom: 25px;
+}
+.steps {
+ padding: 0;
+ list-style: none;
+ margin: 0;
+}
+.steps > li {
+ display: flex;
+ gap: 18px;
+ border-top: 1px solid var(--line);
+ padding: 19px 0 6px;
+}
+.steps > li > span {
+ font-family: Plex, monospace;
+ font-size: 10px;
+ line-height: 23px;
+ color: var(--accent);
+}
+.steps strong {
+ font-size: 13px;
+ line-height: 1.7;
+}
+.steps p {
+ font-size: 11px;
+ color: var(--muted);
+ margin-top: 7px;
+ max-width: 320px;
+ line-height: 1.7;
+}
+.receiver-card {
+ background: var(--dark);
+ border-radius: 9px;
+ padding: 27px;
+ color: #f0f2e8;
+ margin-top: 6px;
+ min-width: 0;
+}
+.receiver-tabs {
+ display: flex;
+ gap: 7px;
+ background: #161e19;
+ border: 1px solid #3d473d;
+ padding: 4px;
+ border-radius: 5px;
+ margin-bottom: 32px;
+}
+.receiver-tabs button {
+ border: 0;
+ background: transparent;
+ color: #bdc5b9;
+ padding: 11px 12px;
+ flex: 1;
+ border-radius: 3px;
+ font-size: 11px;
+ font-weight: 700;
+}
+.receiver-tabs button[aria-selected="true"] {
+ background: var(--lime);
+ color: #1e291b;
+}
+.receiver-panel h3 {
+ font-size: 28px;
+ letter-spacing: -1.2px;
+ margin-bottom: 15px;
+}
+.receiver-panel > p:not(.terminal-label) {
+ font-size: 12px;
+ color: #bdc5b9;
+ line-height: 1.8;
+}
+.terminal-label {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: #9dab97;
+ font-size: 8px;
+ text-transform: uppercase;
+ margin-bottom: 17px;
+}
+.terminal-label .signal {
+ width: 5px;
+ height: 5px;
+ background: var(--lime);
+}
+.code-block {
+ margin-top: 25px;
+ border: 1px solid #3d473d;
+ border-radius: 5px;
+ background: #161e19;
+ overflow: hidden;
+ min-width: 0;
+}
+.code-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ border-bottom: 1px solid #3d473d;
+ padding: 10px 13px;
+ color: #9dab97;
+}
+.code-bar .mono {
+ font-size: 9px;
+}
+.code-bar button {
+ border: 0;
+ background: transparent;
+ color: #cad4c4;
+ font-size: 10px;
+ padding: 4px;
+}
+.code-bar button:hover {
+ color: var(--lime);
+}
+.code-bar button span {
+ margin-left: 6px;
+}
+.code-block pre {
+ padding: 24px 17px;
+ margin: 0;
+ max-width: 100%;
+ overflow-x: auto;
+ color: var(--lime);
+ font:
+ 11px/1.9 Plex,
+ monospace;
+ scrollbar-color: #66765f #161e19;
+}
+.panel-footnote {
+ margin-top: 18px;
+ min-height: 65px;
+}
+.panel-footnote code {
+ font:
+ 11px Plex,
+ monospace;
+ color: #edf4e4;
+ overflow-wrap: anywhere;
+}
+.light-link {
+ font-size: 12px;
+ color: var(--lime);
+ display: inline-flex;
+ gap: 15px;
+ font-weight: 600;
+}
+.light-link:hover {
+ text-decoration: underline;
+}
+.network-note {
+ display: flex;
+ gap: 12px;
+ border-top: 1px solid #445043;
+ padding-top: 20px;
+ margin-top: 28px;
+ color: #b3bdae;
+}
+.network-note > span {
+ font-size: 20px;
+ color: var(--lime);
+}
+.network-note p {
+ font-size: 10px;
+ line-height: 1.8;
+ margin: 0;
+}
+.copy-status {
+ font-size: 10px;
+ min-height: 15px;
+ margin: 10px 0 0;
+ color: var(--lime);
+}
+.evidence {
+ border-top: 1px solid var(--line);
+ padding-top: 75px;
+ padding-bottom: 76px;
+}
+.evidence-heading {
+ max-width: none;
+ display: grid;
+ grid-template-columns: 1.5fr 1fr;
+ gap: 70px;
+ align-items: end;
+}
+.evidence-heading h2 {
+ margin: 0;
+}
+.evidence-heading > p:last-child {
+ margin: 0;
+ font-size: 13px;
+}
+.evidence-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 30px;
+}
+.evidence-grid > div {
+ border-top: 1px solid var(--line);
+ padding-top: 25px;
+}
+.evidence-grid .mono {
+ display: block;
+ font-size: 9px;
+ text-transform: uppercase;
+ color: var(--muted);
+ margin-bottom: 12px;
+}
+.evidence-grid strong {
+ display: block;
+ font-weight: 600;
+ font-size: 35px;
+ letter-spacing: -1.6px;
+ margin-bottom: 13px;
+}
+.evidence-grid p {
+ font-size: 12px;
+ line-height: 1.8;
+ color: var(--muted);
+ max-width: 295px;
+}
+.evidence-links {
+ display: flex;
+ gap: 40px;
+ flex-wrap: wrap;
+ margin-top: 17px;
+}
+.evidence-limit {
+ font-size: 10px;
+ color: var(--muted);
+ max-width: 900px;
+ margin: 23px 0 0;
+ line-height: 1.8;
+}
+.faq-section {
+ display: grid;
+ grid-template-columns: 1fr 2fr;
+ gap: 85px;
+ border-top: 1px solid var(--line);
+ padding-block: 65px;
+}
+.faq-section .eyebrow {
+ color: var(--accent);
+ font-size: 10px;
+}
+.faq-section h2 {
+ font-size: 35px;
+ letter-spacing: -1.5px;
+}
+.faq-list details {
+ border-bottom: 1px solid var(--line);
+ padding-block: 20px;
+}
+.faq-list details:first-child {
+ padding-top: 2px;
+}
+.faq-list summary {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 20px;
+ list-style: none;
+ cursor: pointer;
+ font-size: 13px;
+ font-weight: 700;
+ line-height: 1.7;
+}
+.faq-list summary::-webkit-details-marker {
+ display: none;
+}
+.faq-list summary span {
+ font-size: 21px;
+ color: var(--accent);
+ font-weight: 400;
+}
+.faq-list details[open] summary span {
+ transform: rotate(45deg);
+}
+.faq-list details > p {
+ font-size: 12px;
+ color: var(--muted);
+ margin: 16px 35px 0 0;
+ line-height: 1.8;
+}
+.closing {
+ padding-block: 65px 75px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 35px;
+}
+.closing .eyebrow {
+ color: var(--accent);
+ font-size: 10px;
+}
+.closing h2 {
+ font-size: 48px;
+ letter-spacing: -2.5px;
+ margin: 0;
+}
+.footer {
+ border-top: 1px solid var(--line);
+ padding-block: 28px;
+ display: flex;
+ justify-content: space-between;
+ gap: 25px;
+ align-items: center;
+}
+.footer .brand {
+ font-size: 18px;
+}
+.footer p {
+ margin: 0;
+ font-size: 10px;
+ color: var(--muted);
+}
+.footer nav {
+ display: flex;
+ gap: 20px;
+ font-size: 10px;
+ font-weight: 700;
+}
+[hidden] {
+ display: none !important;
+}
+@media (min-width: 1500px) {
+ .hero {
+ padding-top: 100px;
+ padding-bottom: 100px;
+ }
+ .hero h1 {
+ font-size: 82px;
+ }
+}
+@media (max-width: 1050px) {
+ .hero {
+ gap: 30px;
+ }
+ .hero h1 {
+ font-size: 62px;
+ letter-spacing: -3.7px;
+ }
+ .hero-actions {
+ gap: 18px;
+ }
+ .start-grid {
+ gap: 45px;
+ }
+ .hero-description {
+ font-size: 14px;
+ }
+ .flow-wrap {
+ grid-template-columns: 1fr;
+ gap: 35px;
+ }
+ .flow-wrap h2 br {
+ display: none;
+ }
+ .flow {
+ justify-content: flex-start;
+ gap: 40px;
+ }
+ .sender-preview {
+ gap: 28px;
+ }
+ .preview-note p {
+ font-size: 15px;
+ }
+ .evidence-heading {
+ gap: 35px;
+ }
+ .footer {
+ flex-wrap: wrap;
+ }
+}
+@media (max-width: 780px) {
+ .wrap {
+ padding-inline: 24px;
+ }
+ .header {
+ padding: 22px 24px;
+ }
+ .header .brand {
+ font-size: 20px;
+ }
+ .header nav {
+ gap: 18px;
+ }
+ .header nav > a:first-child {
+ display: none;
+ }
+ .hero {
+ grid-template-columns: 1fr;
+ gap: 40px;
+ padding-block: 56px;
+ }
+ .hero h1 {
+ font-size: 76px;
+ letter-spacing: -4.5px;
+ }
+ .hero-description {
+ font-size: 16px;
+ max-width: 490px;
+ }
+ .hero .eyebrow {
+ font-size: 9px;
+ }
+ .demo-card {
+ max-width: 600px;
+ transform: rotate(0.7deg);
+ }
+ .spec-strip .wrap {
+ grid-template-columns: 1fr 1fr;
+ gap: 25px;
+ padding-block: 26px;
+ }
+ .section {
+ padding-block: 65px;
+ }
+ .project-grid {
+ gap: 20px;
+ grid-template-columns: 1fr;
+ }
+ .project-card {
+ max-width: 600px;
+ }
+ .sender-preview {
+ height: 300px;
+ gap: 55px;
+ }
+ .project-info > p {
+ min-height: 0;
+ }
+ .project-info {
+ padding: 25px;
+ }
+ .browser-preview {
+ height: 320px;
+ padding: 28px 25px 0;
+ }
+ .flow-wrap h2 {
+ font-size: 28px;
+ }
+ .flow {
+ gap: 20px;
+ justify-content: space-between;
+ }
+ .flow strong {
+ font-size: 13px;
+ }
+ .flow > div > span:last-child {
+ font-size: 9px;
+ }
+ .flow-arrow {
+ font-size: 18px;
+ }
+ .flow .mono {
+ font-size: 7px;
+ }
+ .start-grid {
+ grid-template-columns: 1fr;
+ gap: 35px;
+ }
+ .start-copy h2 {
+ font-size: 43px;
+ }
+ .start-copy h2 br {
+ display: none;
+ }
+ .start-copy > p:not(.eyebrow) {
+ max-width: 500px;
+ }
+ .steps p {
+ max-width: 500px;
+ }
+ .receiver-card {
+ padding: 25px;
+ }
+ .evidence-heading {
+ grid-template-columns: 1fr;
+ gap: 20px;
+ }
+ .evidence-grid {
+ gap: 22px;
+ }
+ .evidence-grid strong {
+ font-size: 27px;
+ letter-spacing: -1px;
+ }
+ .evidence-grid p {
+ font-size: 11px;
+ }
+ .evidence-grid .mono {
+ font-size: 8px;
+ }
+ .evidence-links {
+ gap: 25px;
+ }
+ .faq-section {
+ grid-template-columns: 1fr;
+ gap: 20px;
+ padding-block: 50px;
+ }
+ .faq-section h2 br {
+ display: none;
+ }
+ .closing {
+ padding-block: 50px 60px;
+ }
+ .closing h2 {
+ font-size: 35px;
+ letter-spacing: -1.8px;
+ }
+ .footer {
+ gap: 20px;
+ }
+ .footer .brand {
+ width: 100%;
+ }
+}
+@media (max-width: 440px) {
+ .wrap {
+ padding-inline: 20px;
+ }
+ .header {
+ padding: 20px;
+ }
+ .header nav {
+ gap: 14px;
+ font-size: 11px;
+ }
+ .header .brand {
+ font-size: 19px;
+ gap: 7px;
+ }
+ .brand img {
+ width: 26px;
+ height: 26px;
+ }
+ .nav-source {
+ padding: 8px 10px;
+ }
+ .header nav > a:nth-child(2) {
+ display: none;
+ }
+ .hero {
+ padding-top: 43px;
+ gap: 35px;
+ }
+ .hero h1 {
+ font-size: clamp(42px, 12.3vw, 54px);
+ letter-spacing: -2.7px;
+ }
+ .hero .eyebrow {
+ font-size: 8px;
+ gap: 7px;
+ }
+ .hero-description {
+ font-size: 14px;
+ line-height: 1.8;
+ }
+ .hero-actions {
+ gap: 20px;
+ }
+ .hero-actions .button {
+ padding: 15px 17px;
+ font-size: 12px;
+ gap: 15px;
+ }
+ .hero-actions .text-link {
+ font-size: 12px;
+ gap: 8px;
+ }
+ .release-note {
+ font-size: 9px;
+ }
+ .play-disc {
+ width: 61px;
+ height: 61px;
+ }
+ .play-label {
+ font-size: 10px;
+ left: 14px;
+ right: 14px;
+ bottom: 14px;
+ }
+ .demo-top {
+ padding: 14px;
+ }
+ .demo-top .mono {
+ font-size: 8px;
+ }
+ .demo-caption {
+ padding: 14px;
+ }
+ .demo-caption p {
+ font-size: 10px;
+ }
+ .spec-strip span {
+ font-size: 10px;
+ }
+ .spec-strip strong {
+ font-size: 13px;
+ }
+ .section-heading h2 {
+ font-size: 31px;
+ letter-spacing: -1.5px;
+ }
+ .section-heading > p:last-child {
+ font-size: 13px;
+ }
+ .eyebrow {
+ font-size: 9px;
+ }
+ .project-card-heading {
+ padding: 18px;
+ }
+ .project-index {
+ font-size: 8px;
+ }
+ .tag {
+ font-size: 8px;
+ }
+ .sender-preview {
+ height: 265px;
+ gap: 28px;
+ }
+ .phone {
+ width: 132px;
+ min-width: 132px;
+ }
+ .preview-note p {
+ font-size: 14px;
+ }
+ .project-info {
+ padding: 22px;
+ }
+ .project-info h3 {
+ font-size: 22px;
+ }
+ .project-info > p {
+ font-size: 12px;
+ }
+ .browser-preview {
+ height: 265px;
+ padding: 22px 16px 0;
+ }
+ .flow {
+ gap: 18px;
+ align-items: flex-start;
+ flex-direction: column;
+ }
+ .flow > div {
+ gap: 5px;
+ }
+ .flow strong {
+ font-size: 17px;
+ }
+ .flow .mono {
+ font-size: 8px;
+ }
+ .flow > div > span:last-child {
+ font-size: 11px;
+ }
+ .flow-arrow {
+ transform: rotate(90deg);
+ margin-left: 2px;
+ }
+ .flow-wrap h2 {
+ font-size: 27px;
+ }
+ .start-copy h2 {
+ font-size: 34px;
+ letter-spacing: -1.7px;
+ }
+ .start-copy > p:not(.eyebrow) {
+ font-size: 13px;
+ }
+ .receiver-card {
+ padding: 20px;
+ }
+ .receiver-tabs button {
+ font-size: 10px;
+ padding: 10px 7px;
+ }
+ .receiver-panel h3 {
+ font-size: 25px;
+ }
+ .code-block pre {
+ font-size: 10px;
+ padding: 20px 13px;
+ }
+ .evidence-grid {
+ grid-template-columns: 1fr;
+ gap: 18px;
+ }
+ .evidence-grid > div {
+ padding-top: 20px;
+ }
+ .evidence-grid strong {
+ font-size: 35px;
+ margin-bottom: 8px;
+ }
+ .evidence-grid p {
+ max-width: none;
+ font-size: 12px;
+ }
+ .evidence-links {
+ flex-direction: column;
+ gap: 17px;
+ }
+ .faq-section h2 {
+ font-size: 30px;
+ }
+ .faq-list summary {
+ font-size: 12px;
+ }
+ .closing {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 28px;
+ }
+ .closing h2 {
+ font-size: 37px;
+ }
+ .footer nav {
+ gap: 22px;
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ html {
+ scroll-behavior: auto;
+ }
+ *,
+ *::before,
+ *::after {
+ transition: none !important;
+ animation: none !important;
+ }
+}
diff --git a/tools/requirements-site.txt b/tools/requirements-site.txt
new file mode 100644
index 0000000..984b515
--- /dev/null
+++ b/tools/requirements-site.txt
@@ -0,0 +1 @@
+playwright==1.63.0
diff --git a/tools/site_checks.py b/tools/site_checks.py
new file mode 100644
index 0000000..24fee0f
--- /dev/null
+++ b/tools/site_checks.py
@@ -0,0 +1,136 @@
+"""Check the public project page at its actual GitHub Pages subpath."""
+
+import asyncio
+from functools import partial
+from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
+import json
+from pathlib import Path
+import re
+import tempfile
+from threading import Thread
+
+from playwright.async_api import async_playwright, expect
+
+
+async def check_page(page, url, capture):
+ errors = []
+ page.on("pageerror", lambda error: errors.append(str(error)))
+ views = []
+ for width in (1440, 768, 390, 320):
+ await page.set_viewport_size({"width": width, "height": 900})
+ response = await page.goto(url, wait_until="networkidle")
+ assert response.status == 200
+ await page.evaluate("document.fonts.ready")
+ await expect(page.locator("h1")).to_have_text(re.compile(r"Your screen\.\s*Now streaming\."))
+ assert not await page.evaluate("document.documentElement.scrollWidth > innerWidth"), width
+ assert await page.locator("img").evaluate_all(
+ "images => images.every(image => image.complete && image.naturalWidth > 0)"
+ ), width
+ assert await page.evaluate("document.fonts.check('16px Manrope')")
+ views.append({"width": width, "screenshot": await capture(f"page-{width}.png", True)})
+
+ # Receiver tabs must work with both pointer and keyboard input.
+ browser_tab = page.get_by_role("tab", name="Browser / WebRTC")
+ await browser_tab.click()
+ await expect(page.locator("#panel-browser")).to_be_visible()
+ await expect(page.locator("#panel-ffplay")).to_be_hidden()
+ await browser_tab.press("ArrowLeft")
+ await expect(page.get_by_role("tab", name="Desktop / FFplay")).to_be_focused()
+ await expect(page.locator("#panel-ffplay")).to_be_visible()
+ await page.get_by_role("tab", name="Desktop / FFplay").press("End")
+ await expect(browser_tab).to_be_focused()
+ await expect(page.locator("#panel-browser")).to_be_visible()
+
+ # Exercise the full command-copy UI without writing to the host clipboard.
+ await page.evaluate("""() => {
+ window.copiedCommand = null;
+ Object.defineProperty(navigator, 'clipboard', { configurable: true,
+ value: { writeText: async text => { window.copiedCommand = text; } } });
+ }""")
+ await page.get_by_role("button", name="Copy browser server commands").click()
+ await expect(page.get_by_role("status")).to_contain_text("Command copied")
+ command = await page.evaluate("window.copiedCommand")
+ assert "git clone https://github.com/magicsih/AndroidScreenCasterWeb.git" in command
+ assert command.endswith("docker compose up -d")
+ await page.evaluate("""() => {
+ navigator.clipboard.writeText = async () => { throw new Error('Clipboard unavailable'); };
+ }""")
+ await page.get_by_role("button", name="Copy browser server commands").click()
+ await expect(page.get_by_role("status")).to_contain_text("Command selected")
+ assert await page.evaluate("window.getSelection().toString()") == command
+
+ await page.get_by_role("tab", name="Desktop / FFplay").click()
+ await page.get_by_role("button", name="Copy FFplay command").click()
+ assert "tcp://0.0.0.0:49152?listen=1" in await page.evaluate("window.getSelection().toString()")
+ await page.locator("details summary").first.click()
+ await expect(page.locator("details").first).to_have_attribute("open", "")
+
+ hrefs = await page.locator("a[href]").evaluate_all("links => links.map(link => link.getAttribute('href'))")
+ for link in (
+ "https://github.com/magicsih/AndroidScreenCaster",
+ "https://github.com/magicsih/AndroidScreenCasterWeb",
+ "https://github.com/magicsih/AndroidScreenCaster/releases/tag/v0.2.0",
+ "https://www.youtube.com/watch?v=2AN6EfArfZE",
+ ):
+ assert link in hrefs, link
+ for href in hrefs:
+ if href.startswith("#") and len(href) > 1:
+ assert await page.locator(href).count() == 1, href
+ structured_data = json.loads(await page.locator('script[type="application/ld+json"]').text_content())
+ assert structured_data["softwareVersion"] == "0.2.0"
+ canonical = "https://magicsih.github.io/AndroidScreenCaster/"
+ assert await page.locator('link[rel="canonical"]').get_attribute("href") == canonical
+
+ # The initial page must not load YouTube. Playback is requested by a click.
+ assert await page.locator("iframe").count() == 0
+ await page.route("https://www.youtube-nocookie.com/**", lambda route: route.fulfill(
+ status=200, content_type="text/html", body="Test player "
+ ))
+ await page.locator("#play-demo").click()
+ await expect(page.locator("iframe")).to_have_attribute(
+ "src", "https://www.youtube-nocookie.com/embed/2AN6EfArfZE?autoplay=1&rel=0"
+ )
+ await expect(page.locator("iframe")).to_have_attribute(
+ "title", "Original AndroidScreenCaster demonstration on YouTube"
+ )
+ assert not errors, errors
+ return {"views": views, "receiver_tabs": "passed", "command_copy_and_fallback": "passed",
+ "demo_click": "passed (player mocked; no video decoding assertion)",
+ "links_and_metadata": "passed", "page_errors": errors}
+
+
+class QuietHandler(SimpleHTTPRequestHandler):
+ def log_message(self, *_args):
+ pass
+
+
+async def main():
+ root = Path(__file__).resolve().parents[1]
+ output = root / "artifacts/pages"
+ output.mkdir(parents=True, exist_ok=True)
+ with tempfile.TemporaryDirectory() as directory:
+ (Path(directory) / "AndroidScreenCaster").symlink_to(root / "site", target_is_directory=True)
+ server = ThreadingHTTPServer(("127.0.0.1", 0), partial(QuietHandler, directory=directory))
+ thread = Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ try:
+ async with async_playwright() as playwright:
+ browser = await playwright.chromium.launch()
+ page = await browser.new_page()
+
+ async def capture(name, full_page):
+ await page.screenshot(path=str(output / name), full_page=full_page)
+ return name
+
+ report = await check_page(page, f"http://127.0.0.1:{server.server_port}/AndroidScreenCaster/", capture)
+ (output / "report.json").write_text(json.dumps(report, indent=2) + "\n")
+ print(json.dumps(report))
+ await browser.close()
+ finally:
+ server.shutdown()
+ server.server_close()
+ thread.join()
+
+
+if __name__ == "__main__":
+ asyncio.run(main())