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 [![Watch the original Android screen mirroring demo on YouTube](https://img.youtube.com/vi/2AN6EfArfZE/0.jpg)](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. + + + + + + + + + + + + + + + + + +
+ ScreenCaster. + +
+
+
+
+

+ 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 senderJava +
+
+
+ Actual ScreenCaster app with TCP/H.264 settings and Start and Stop controls +
+
+ +

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 companionDocker Compose +
+
+
+ localhost:8080 +
+ Actual browser viewer displaying the Android moving test screen during physical-device validation +
+
+

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 phoneMediaProjectioncaptures the screen +
+ +
+ In the encoderMediaCodecmakes H.264 or VP8 +
+ +
+ On your computerFFplay / WebRTCbrings 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. +

+
    +
  1. + 01 +
    + Install the Android APK +

    + Use the example debug APK from the release. The download + includes SHA-256 checksums. +

    +
    +
  2. +
  3. + 02 +
    + Start your receiver +

    + Choose FFplay for desktop playback or the companion for + browser viewing. +

    +
    +
  4. +
  5. + 03 +
    + Press Start on the phone +

    + Enter the receiver’s LAN IP. Choose TCP / H264, try 640×360 + at 1 Mbps, and approve sharing. +

    +
    +
  6. +
+
+
+
+ +
+
+

+ On your receiver + computer +

+

Start listening.

+

+ Install FFmpeg, then run this command before starting capture. +

+
+
+ Terminal +
+
ffplay -f h264 \
+  'tcp://0.0.0.0:49152?listen=1'
+
+

+ Tap Stop on the phone to finish. Start FFplay again before the + next TCP session. +

+ Full Android 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 test4 modes +

+ TCP and UDP × H.264 and VP8 decoded during 125-second + physical-device checks. +

+
+
+ Browser / device test160 seconds +

+ Continuous physical-device streaming, with late joining and + reconnect checked. +

+
+
+ Source / automated checksBuild + 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())