diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index 300ecb10..f4cc4734 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -167,7 +167,7 @@ jobs: id-token: write attestations: write steps: - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: '*' path: dist @@ -203,7 +203,7 @@ jobs: with: node-version: 22 - name: Download platform artifacts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: '*' path: dist diff --git a/.github/workflows/hosted-build.yml b/.github/workflows/hosted-build.yml index 3c92dd53..072a08d4 100644 --- a/.github/workflows/hosted-build.yml +++ b/.github/workflows/hosted-build.yml @@ -33,6 +33,8 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 + - name: Install dependencies + run: npm ci --omit=dev - name: Test hosted service run: node --test test/hosted-*.test.js - name: Write Vercel project settings (mirrors the live project) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f8864ecf..2628536a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -107,10 +107,10 @@ jobs: uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 with: egress-policy: audit - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: release-${{ github.ref_name }} - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: release-windows-${{ github.ref_name }} - name: One SHA256SUMS for every asset diff --git a/README.md b/README.md index 21b74c8e..75fa1245 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,13 @@ Your media server knows what you are playing. nowplaying turns that into a card for your GitHub README and a status in Discord, without exposing your server to the internet. It runs on your PC and reads playback from Plex, Jellyfin, Emby or Navidrome. Spotify and a paired YouTube extension can also feed the card. -| Music | Film | TV | +| Studio · music | Editorial · film | Hi-Fi · TV | | :---: | :---: | :---: | -| ![Example card showing Holocene by Bon Iver](docs/assets/cards/music-playing.svg) | ![Example card showing Spirited Away](docs/assets/cards/movie-playing.svg) | ![Example card showing Lost, S04E05](docs/assets/cards/episode-playing.svg) | +| ![Studio: Blue Monday by New Order with sample cover art](docs/assets/cards/studio.svg) | ![Editorial: Dune Part Two with sample poster art](docs/assets/cards/editorial-film.svg) | ![Hi-Fi: Lost, The Constant, with sample poster art](docs/assets/cards/terminal-episode.svg) | -*Example cards, not live playback. [More states and styles](docs/card-examples.md).* +Studio puts artwork behind the card. Editorial pairs a large cover with warm paper and serif type. Hi-Fi adds record grooves and an amber timeline. Classic, Paper and Compact are also available. **[Explore all six styles, with music, film and TV examples →](docs/card-examples.md)** + +*Examples rendered by the app with illustrated sample artwork. Your card uses the current album cover or poster; hosted covers require **Include cover art** in Settings.* > **Development builds:** The [`dev` prerelease](https://github.com/rowkavdev/nowplaying/releases/tag/dev) is a moving test build, not an accepted stable release. It can break. Downloads are not code-signed; verify the checksum before running one. Windows has an installer and portable ZIP. **Linux x86_64 is experimental:** RPM, DEB, Arch and portable tarball downloads are available for desktop testing. [Linux installation and requirements](docs/wiki/Install-on-Linux.md). macOS desktop support is still experimental. @@ -27,7 +29,7 @@ On Linux, follow [Install on Linux](docs/wiki/Install-on-Linux.md) to choose an 2. Install it for your Windows user. On first launch, **Settings opens in your browser**. Find a media server or enter its address, then sign in. A full subnet scan only runs when you enter that subnet yourself. You can connect more than one server. 3. Play something. Open the tray icon's status page, or visit [`http://127.0.0.1:47832/card.svg`](http://127.0.0.1:47832/card.svg) on that PC. The page at [`/healthz`](http://127.0.0.1:47832/healthz) says only that the configured app is responding, not that your server is connected. -4. If you want a public README card, turn on **Hosted card** in Settings and copy its Markdown snippet. If you want Discord Rich Presence, turn it on separately in **Settings > Discord** and keep the Discord desktop app open. +4. If you want a public README card, choose a style in **Settings > Appearance** and save it. Turn on **Hosted card** under **Sharing**, enable **Include cover art** if you want covers and posters, save, then copy its Markdown snippet. If you want Discord Rich Presence, turn it on separately in **Settings > Discord** and keep the Discord desktop app open. Provider sign-in: [Plex](docs/wiki/Connect-Plex.md) · [Jellyfin](docs/wiki/Connect-Jellyfin.md) · [Emby](docs/wiki/Connect-Emby.md) · [Navidrome](docs/wiki/Connect-Navidrome.md). For the full installation path, see [Quick start](docs/wiki/Quick-Start.md) and the [dev-build test checklist](docs/testing-dev-build.md). @@ -35,7 +37,7 @@ Provider sign-in: [Plex](docs/wiki/Connect-Plex.md) · [Jellyfin](docs/wiki/Conn | Output | Source | How it works | | --- | --- | --- | -| **Local card** | Media servers, Spotify, paired YouTube extension | An SVG on your PC. Choose a theme, width and visible fields in Settings. | +| **Local card** | Media servers, Spotify, paired YouTube extension | An SVG on your PC. Choose a style, width and visible fields in Settings. | | **Hosted README card** | The same card state | Opt-in. Your PC pushes filtered playback fields to the card service; it never calls your media server. Copy the public link from Settings. Optional GitHub sign-in joins up to 10 PCs under one card. | | **Discord Rich Presence** | Media servers and paired YouTube extension | Opt-in. Your PC talks directly to the Discord desktop app. Music, films and episodes get fitting statuses. **Spotify never goes to Discord.** | @@ -43,7 +45,7 @@ Provider sign-in: [Plex](docs/wiki/Connect-Plex.md) · [Jellyfin](docs/wiki/Conn ## Privacy is set on your PC -- **Hosted cards are off until you turn them on.** The public service receives the card's filtered state, not your server address, username, password, token, artwork or listening history. Its latest update expires after ten minutes. [See exactly what leaves your PC](docs/hosted-upload.md). +- **Hosted cards are off until you turn them on.** The public service receives the card's filtered state, not your server address, username, password, token, artwork links or listening history. **Include cover art** optionally adds a small sanitized image. Its latest update expires after ten minutes. [See exactly what leaves your PC](docs/hosted-upload.md). - **Sign-ins stay in the operating system credential store:** Windows Credential Manager or Linux Secret Service. Settings and server addresses are in `%LOCALAPPDATA%\nowplaying\config.json` on Windows or `~/.config/nowplaying/config.json` on Linux, with XDG directory overrides respected. These files do not contain passwords or tokens; check server addresses before sharing them. - **Settings > Privacy** can hide titles, artwork, progress or whole media types before they reach the card or Discord. A hidden type looks like nothing playing. [Privacy guide](docs/wiki/Privacy-and-safe-configuration.md). - **Discord cover art is uploaded to a temporary host.** Discord can't reach a private Plex, Jellyfin or Emby address, so "Show my server's cover" is on for a new setup and sends the cover to Litterbox (72-hour retention), with tmpfiles.org (48-hour retention) as a fallback when the first host fails. An existing install keeps it off until you tick it in Settings > Discord, and you can untick it there at any time. [Details](docs/artwork.md#discord-cover-upload). @@ -51,7 +53,7 @@ Provider sign-in: [Plex](docs/wiki/Connect-Plex.md) · [Jellyfin](docs/wiki/Conn ## Settings, updates and problems -Open **Settings** from the tray icon to manage servers, Discord, hosted cards, privacy and the card's appearance. The local status page shows connections and logs. [Customization](docs/customization.md) · [Updates and release channels](docs/wiki/Updates-and-release-channels.md) · [Troubleshooting](docs/wiki/Troubleshooting.md). +Open **Settings** from the tray icon to manage servers, Discord, hosted cards, privacy and the card's appearance. Settings keeps configuration beside live logs, with shortcuts for servers, appearance, Discord, privacy, sharing and startup. Choose a card style from the visual gallery, preview changes, and save each section separately. [Settings guide](docs/settings.md) · [Customization](docs/customization.md) · [Updates and release channels](docs/wiki/Updates-and-release-channels.md) · [Troubleshooting](docs/wiki/Troubleshooting.md). If something fails on a dev build, [open an issue](https://github.com/rowkavdev/nowplaying/issues/new) with the build number, operating system and desktop environment, package format, what you tried and what happened. Leave out server addresses and tokens. diff --git a/docs/README.md b/docs/README.md index 8f4d1efe..e2d33f2e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,8 +10,9 @@ Two audiences, two homes: | Guide | Covers | Status | | --- | --- | --- | | [Provider configuration](providers.md) | Plex, Jellyfin, Navidrome and Emby adapter options and normalized output | Current | +| [Settings](settings.md) | Finding controls, previews, section saves and logs | Current | | [Customization](customization.md) | Templates, privacy controls, card themes and Discord settings | Current | -| [Card examples](card-examples.md) | Renderer-generated gallery of every card state and theme | Current | +| [Card examples](card-examples.md) | Six styles, artwork examples for music, film and TV, and playback states | Current | | [Hosted card deployment](hosted-card.md) | Running the card endpoint: routes, headers, caching, health checks | Current | | [Hosted card: what leaves your PC](hosted-upload.md) | Exactly which fields the app pushes to the card service | Current | | [Updates](updates.md) | Update policy, channels, verification, atomic install and rollback | Current | diff --git a/docs/assets/cards/compact.svg b/docs/assets/cards/compact.svg index f5d0ea31..170a8fd0 100644 --- a/docs/assets/cards/compact.svg +++ b/docs/assets/cards/compact.svg @@ -1,13 +1,18 @@ Now playing: HoloceneNow playing - + - - - - - Holocene - - + + + + + + + + + + Holocene + + \ No newline at end of file diff --git a/docs/assets/cards/editorial-episode.svg b/docs/assets/cards/editorial-episode.svg new file mode 100644 index 00000000..8cff84fa --- /dev/null +++ b/docs/assets/cards/editorial-episode.svg @@ -0,0 +1,18 @@ + + + NOW PLAYING: LostS04E05 · The Constant + + + + + + + + + + NOW PLAYING + 20:00 / 43:00 + Lost + S04E05 · The Constant + + \ No newline at end of file diff --git a/docs/assets/cards/editorial-film.svg b/docs/assets/cards/editorial-film.svg new file mode 100644 index 00000000..1e17b641 --- /dev/null +++ b/docs/assets/cards/editorial-film.svg @@ -0,0 +1,18 @@ + + + NOW PLAYING: Dune: Part Two (2024)Denis Villeneuve + + + + + + + + + + NOW PLAYING + 50:00 / 2:46:00 + Dune: Part Two(2024) + Denis Villeneuve + + \ No newline at end of file diff --git a/docs/assets/cards/editorial.svg b/docs/assets/cards/editorial.svg new file mode 100644 index 00000000..b37e8e4f --- /dev/null +++ b/docs/assets/cards/editorial.svg @@ -0,0 +1,18 @@ + + + NOW PLAYING: Blue MondayNew Order + + + + + + + + + + NOW PLAYING + 1:23 / 7:28 + Blue Monday + New Order + + \ No newline at end of file diff --git a/docs/assets/cards/episode-playing.svg b/docs/assets/cards/episode-playing.svg index 17869d22..1ad97e7b 100644 --- a/docs/assets/cards/episode-playing.svg +++ b/docs/assets/cards/episode-playing.svg @@ -1,13 +1,18 @@ Now playing: LostS04E05 · The Constant - + - - - Now playing + + + + + + + + Now playing 16:20 / 43:00 - Lost - S04E05 · The Constant + Lost + S04E05 · The Constant \ No newline at end of file diff --git a/docs/assets/cards/idle.svg b/docs/assets/cards/idle.svg index db141784..5757d3b1 100644 --- a/docs/assets/cards/idle.svg +++ b/docs/assets/cards/idle.svg @@ -1,13 +1,18 @@ Not playing: Nothing playingMusic - + - - - Not playing - - Nothing playing - Music + + + + + + + + Not playing + + Nothing playing + Music \ No newline at end of file diff --git a/docs/assets/cards/movie-playing.svg b/docs/assets/cards/movie-playing.svg index fcee7fdd..f3e2c166 100644 --- a/docs/assets/cards/movie-playing.svg +++ b/docs/assets/cards/movie-playing.svg @@ -1,13 +1,18 @@ Now playing: Spirited Away (2001)Movie - + - - - Now playing + + + + + + + + Now playing 1:00:00 / 2:05:00 - Spirited Away (2001) - Movie + Spirited Away (2001) + Movie \ No newline at end of file diff --git a/docs/assets/cards/music-light.svg b/docs/assets/cards/music-light.svg index 34d3fc92..970a9c22 100644 --- a/docs/assets/cards/music-light.svg +++ b/docs/assets/cards/music-light.svg @@ -1,13 +1,18 @@ Now playing: HoloceneBon Iver - + - - - Now playing + + + + + + + + Now playing 2:34 / 5:36 - Holocene - Bon Iver + Holocene + Bon Iver \ No newline at end of file diff --git a/docs/assets/cards/music-playing.svg b/docs/assets/cards/music-playing.svg index 4a2fabbb..c244e25c 100644 --- a/docs/assets/cards/music-playing.svg +++ b/docs/assets/cards/music-playing.svg @@ -1,13 +1,18 @@ Now playing: HoloceneBon Iver - + - - - Now playing + + + + + + + + Now playing 2:34 / 5:36 - Holocene - Bon Iver + Holocene + Bon Iver \ No newline at end of file diff --git a/docs/assets/cards/paused.svg b/docs/assets/cards/paused.svg index cd4eceb3..92a6d69f 100644 --- a/docs/assets/cards/paused.svg +++ b/docs/assets/cards/paused.svg @@ -1,13 +1,18 @@ Paused: HoloceneBon Iver - + - - - Paused + + + + + + + + Paused 2:34 / 5:36 - Holocene - Bon Iver + Holocene + Bon Iver \ No newline at end of file diff --git a/docs/assets/cards/privacy-redacted.svg b/docs/assets/cards/privacy-redacted.svg index 7f17aa44..8777a13b 100644 --- a/docs/assets/cards/privacy-redacted.svg +++ b/docs/assets/cards/privacy-redacted.svg @@ -1,13 +1,18 @@ Now playing: Private mediaMusic - + - - - Now playing + + + + + + + + Now playing 2:34 / 5:36 - Private media - Music + Private media + Music \ No newline at end of file diff --git a/docs/assets/cards/studio-episode.svg b/docs/assets/cards/studio-episode.svg new file mode 100644 index 00000000..492abef0 --- /dev/null +++ b/docs/assets/cards/studio-episode.svg @@ -0,0 +1,18 @@ + + + Now playing: LostS04E05 · The Constant + + + + + + + + + + Now playing + 20:00 / 43:00 + Lost + S04E05 · The Constant + + \ No newline at end of file diff --git a/docs/assets/cards/studio-film.svg b/docs/assets/cards/studio-film.svg new file mode 100644 index 00000000..c82882fe --- /dev/null +++ b/docs/assets/cards/studio-film.svg @@ -0,0 +1,18 @@ + + + Now playing: Dune: Part Two (2024)Denis Villeneuve + + + + + + + + + + Now playing + 50:00 / 2:46:00 + Dune: Part Two (2024) + Denis Villeneuve + + \ No newline at end of file diff --git a/docs/assets/cards/studio.svg b/docs/assets/cards/studio.svg new file mode 100644 index 00000000..e0c88da5 --- /dev/null +++ b/docs/assets/cards/studio.svg @@ -0,0 +1,18 @@ + + + Now playing: Blue MondayNew Order + + + + + + + + + + Now playing + 1:23 / 7:28 + Blue Monday + New Order + + \ No newline at end of file diff --git a/docs/assets/cards/terminal-episode.svg b/docs/assets/cards/terminal-episode.svg new file mode 100644 index 00000000..54d58879 --- /dev/null +++ b/docs/assets/cards/terminal-episode.svg @@ -0,0 +1,18 @@ + + + NOW PLAYING: LostS04E05 · The Constant + + + + + + + + + + NOW PLAYING + 20:00 / 43:00 + Lost + S04E05 · The Constant + + \ No newline at end of file diff --git a/docs/assets/cards/terminal-film.svg b/docs/assets/cards/terminal-film.svg new file mode 100644 index 00000000..830e34fd --- /dev/null +++ b/docs/assets/cards/terminal-film.svg @@ -0,0 +1,18 @@ + + + NOW PLAYING: Dune: Part Two (2024)Denis Villeneuve + + + + + + + + + + NOW PLAYING + 50:00 / 2:46:00 + Dune: Part Two (2024) + Denis Villeneuve + + \ No newline at end of file diff --git a/docs/assets/cards/terminal.svg b/docs/assets/cards/terminal.svg new file mode 100644 index 00000000..5dc44899 --- /dev/null +++ b/docs/assets/cards/terminal.svg @@ -0,0 +1,18 @@ + + + NOW PLAYING: Blue MondayNew Order + + + + + + + + + + NOW PLAYING + 1:23 / 7:28 + Blue Monday + New Order + + \ No newline at end of file diff --git a/docs/card-examples.md b/docs/card-examples.md index 51e49484..b5bdefd9 100644 --- a/docs/card-examples.md +++ b/docs/card-examples.md @@ -1,41 +1,91 @@ -# Card examples +# Card styles -Every image below is generated from the shipped renderer by `node scripts/render-card-examples.js`, so what you see is exactly what the current build draws. A test fails if the renderer changes and these are not regenerated. Live cards also embed sanitized artwork from your media server on the left. +Six styles, one live card. Choose **Settings → Appearance → Card Settings**, select a style and press **Save**. These examples are generated by the app's SVG renderer using illustrated sample artwork. During playback, the app uses your actual album cover or poster. -## Music +[Studio](#studio) · [Editorial](#editorial) · [Hi-Fi](#hi-fi) · [Classic](#classic) · [Paper](#paper) · [Compact](#compact) -![Card showing Now playing with the track Holocene by Bon Iver and a progress bar, on a dark background](assets/cards/music-playing.svg) +## Studio -## Music, paper theme +Artwork fills the background beneath a dark cinema shade. A framed cover sits on the right, with room for a two-line title on the left. -![The same music card in the light paper theme](assets/cards/music-light.svg) +**Music** -## TV episode +![Studio music card: Blue Monday by New Order](assets/cards/studio.svg) -![Card showing the series Lost as the title with the episode code S04E05 and episode title The Constant below](assets/cards/episode-playing.svg) +**Film** -## Movie +![Studio film card: Dune Part Two](assets/cards/studio-film.svg) -![Card showing the film Spirited Away (2001) with a progress bar](assets/cards/movie-playing.svg) +**TV** -## Paused +![Studio TV card: Lost, S04E05, The Constant](assets/cards/studio-episode.svg) -![Card in the paused state with a partially filled progress bar](assets/cards/paused.svg) +## Editorial -## Nothing playing +Warm paper, a large cover and serif titles give this style a printed sleeve feel. A perforated seam separates the artwork from the playback details. -![Card in the idle state showing Nothing playing](assets/cards/idle.svg) +**Music** -## Compact theme +![Editorial music card: Blue Monday by New Order](assets/cards/editorial.svg) -![The music card in the compact theme, text only with no progress bar](assets/cards/compact.svg) +**Film** -## Privacy: titles hidden +![Editorial film card: Dune Part Two](assets/cards/editorial-film.svg) -![Card showing Private media instead of a title, with progress only](assets/cards/privacy-redacted.svg) +**TV** -The exact set shown depends on your privacy settings - with a media type hidden, anyone looking at the card sees the idle state instead. +![Editorial TV card: Lost, S04E05, The Constant](assets/cards/editorial-episode.svg) -## Discord Rich Presence +## Hi-Fi -The same presence feeds Discord: music shows as **Listening**, films and episodes as **Watching**, and TV episodes use the series name with the episode code (for example "Lost" and "S04E05 · The Constant"). Word for word Discord examples live in the [wiki](https://github.com/rowkavdev/nowplaying/wiki/Enable-Discord-Rich-Presence). +A record display with circular artwork and grooves, monospace type and an amber segmented timeline. Its theme value remains `terminal` for existing card links. + +**Music** + +![Hi-Fi music card: Blue Monday by New Order](assets/cards/terminal.svg) + +**Film** + +![Hi-Fi film card: Dune Part Two](assets/cards/terminal-film.svg) + +**TV** + +![Hi-Fi TV card: Lost, S04E05, The Constant](assets/cards/terminal-episode.svg) + +## Classic + +A dark card with blue progress. + +![Classic card showing Holocene by Bon Iver](assets/cards/music-playing.svg) + +## Paper + +White background and clear dark text. + +![Paper card showing Holocene by Bon Iver](assets/cards/music-light.svg) + +## Compact + +Just the title, without artwork or progress by default. + +![Compact card showing Holocene](assets/cards/compact.svg) + +## Use a style in your README + +After saving your appearance, open **Sharing → Hosted Card Settings**. Enable uploads and, to show album covers and posters, **Include cover art**, then save. Copy the README snippet into your profile or repository. After an appearance change, copy it again: the style and layout are part of the image URL. See [Add a README card](wiki/Add-a-README-card.md) for the full setup. + +The gallery above shows local cards. Hosted cards use the same styles, but detailed season and episode numbers stay on your PC. Cover sharing sends a small sanitized image; it never sends the original artwork URL. **Hide artwork** and **Hide titles** override cover sharing. If artwork is unavailable, the card still shows the playback text. + +## Other playback states + +| State | Example | +| --- | --- | +| Paused | ![Paused music card](assets/cards/paused.svg) | +| Nothing playing | ![Idle card](assets/cards/idle.svg) | +| Titles hidden | ![Private media with progress](assets/cards/privacy-redacted.svg) | + +Hiding a whole media type shows the idle state. Hiding progress also removes the timer. Privacy applies before appearance choices. + +See [customization](customization.md) for width, typography, artwork size and layout controls. + +For contributors: `node scripts/render-card-examples.js` regenerates these images. `node --test test/card-examples.test.js` checks that every committed example matches the renderer. Visual fixtures also cover narrow cards, artwork, RTL and reordered fields. diff --git a/docs/customization.md b/docs/customization.md index 834ead27..eea5e349 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -1,62 +1,64 @@ -# Customization reference +# Customize NowPlaying -Customization is provider-neutral and applied after polling. The same normalized presence can feed cards and Discord without changing provider behavior. +Open **Settings** from the tray icon. Each section saves separately; changing the preview does not save a setting. -## Shipped settings +## Card appearance -`createSettings()` validates unknown keys and deep-merges immutable defaults for: +Choose **Appearance** in the settings navigation to open **Card Settings**. Pick a style, adjust the width, and press **Save**. The local card changes immediately. If you already embedded a hosted card, copy the updated README snippet from **Sharing** and replace the old one: appearance is part of that image's URL. -- locale; -- privacy mode and field removal; -- shared title/detail/state templates and separators; -- card theme, width and field visibility; -- Discord enablement, templates, timestamps and idle behavior. +| Style | Look | Theme value | +| --- | --- | --- | +| Classic | Dark background, sans-serif type, blue progress | `midnight-blue` | +| Paper | White background and dark type | `paper` | +| Studio | Full-bleed art, cinema shade, framed cover, two-line title | `studio` | +| Editorial | Printed sleeve, large cover, serif title, perforated seam | `editorial` | +| Hi-Fi | Record deck, amber display, segmented timeline | `terminal` | +| Compact | A single title without artwork or progress | `compact` | -Templates support documented named presence fields. Missing values remove their associated empty segments rather than leaving doubled separators. Output escaping remains destination-specific. +[See the card gallery](card-examples.md). Width can be 280–800 pixels. The preview uses your current playback and artwork when available; when nothing is playing, it uses sample media and illustrated artwork. Hosted cards can include covers and posters when **Sharing → Hosted Card Settings → Include cover art** is enabled and saved. Privacy controls still apply. [Set up a README card](wiki/Add-a-README-card.md). -## Cards +**Fine-tune typography, artwork and layout** opens the detailed controls: -`renderCard(presence, options)` supports: +- padding and corner radius; +- named font or a custom stack of up to five plain font names; +- status text, border, background and progress bar appearance; +- artwork size, shape and side; +- line order, alignment and text direction. -- `theme`: `midnight-blue`, `paper` or `compact`; -- `width`: integer from 280 to 800; -- `colors`: validated six-digit overrides for the selected palette; -- `show`: booleans for artwork, media type, progress, state and subtitle; -- `artworkDataUri`: validated, sanitized PNG/JPEG/WebP data. +Classic and Paper choose artwork size by media type. Studio uses a framed cover, Editorial a larger sleeve, and Hi-Fi a circular record display. The size sliders override those defaults. At narrow widths the renderer can reduce the displayed cover to leave room for text. The preview explains when this happens; your saved preference is kept. -`card.artworkTint` (config.json only, default `true`): the card background takes a tint from the cover's dominant colour, kept in a safe lightness range so text stays readable on white or black covers. Set it to `false` in `config.json` to turn it off; settings saves keep your choice. +Selecting a style resets these appearance controls to that style's defaults. It keeps the current width and reading direction. **Back to defaults** restores Classic at 440 pixels. Both actions are drafts until you press **Save**. -The hosted endpoint exposes a deliberately smaller public allowlist: `theme`, `width`, comma-separated `show` (with `notime` to hide the clock) and the layout and look options listed in the hosted card guide. Secrets, arbitrary colors and templates never enter public query strings. +Fonts must be installed on the device viewing the SVG. A custom name does not download or embed a font. The local card can take a subtle tint from its cover; set `card.artworkTint` to `false` in `config.json` to disable that. Settings saves preserve your tint choice. -Built-in palettes use color combinations selected to remain distinguishable for deuteranopia. Accessible title/description text is always emitted. +## Renderer and card links -## Discord - -Discord settings (Settings > Discord, saved to `config.json`): - -- elapsed, remaining, both or no timestamps (`discord.timestamps`); -- idle behaviour (`discord.idleBehavior`); -- the status text next to your name (`discord.name`) and which line Discord shows there (`discord.statusDisplayType`: `name`, `state` or `details`); -- album art lookup (`discord.artworkLookup`, off by default). +`renderCard(presence, options)` accepts `theme`, `width`, validated six-digit `colors`, field visibility in `show`, `layout`, sanitized raster artwork in `artworkDataUri`, and a six-digit `tint`. Explicit layout and visibility options override the selected style. -Wording templates for the other lines, buttons, asset keys and the update interval can't be changed yet. Values for them in `config.json` are ignored, and a settings save removes them. +```js +import { renderCard } from "nowplaying/card"; -TV episodes with a known series show the series and episode code, for example "Lost" and "S04E05 · The Constant". +const svg = renderCard(presence, { + theme: "editorial", + width: 480, + layout: { radius: 4, textAlign: "start" }, +}); +``` -Discord Rich Presence runs locally and communicates with the user's Discord IPC socket. Analytics, when explicitly enabled, sends only one anonymous installation ping. +`show` accepts booleans for `artwork`, `mediaType`, `progress`, `state`, `subtitle` and `time`. A privacy filter must run before rendering; showing a field cannot recover data removed by that filter. -## Status text +The local `/card.svg` URL accepts `theme`, `width` and comma-separated `show`. Hosted links also accept the public layout options documented in [hosted card deployment](hosted-card.md). Add `notime` to `show` to hide the clock. Credentials and custom text templates do not belong in a card URL. -For music, Discord shows ` - ` by default, for example "Holocene - Bon Iver" from Plex. Films and episodes keep Discord's own app name unless you set a status text. Change it in Settings > Discord > Status text, or set `discord.name` in `config.json`. Leave it empty for the default. Saved custom templates keep their wording, including `{artist} on {service}` if you prefer it. +## Discord -Write fields in braces: `{artist}`, `{title}`, `{subtitle}`, `{album}`, `{year}`, `{series}`, `{season}`, `{episode}`, `{episodeCode}`, `{service}` (Spotify, Navidrome, Plex and so on), `{provider}`, `{mediaType}`, `{state}`, `{stateLabel}`, `{position}`, `{duration}` and `{progressPercent}`. Other text is kept as typed. Write `{{` or `}}` for a literal brace. A field that is not in this list is rejected when you save. The text can be 128 characters at most. With hidden titles on, `{artist}` is blank. +In **Discord Settings**, choose whether to publish presence, which timer to use, what happens when playback stops, and the status text shown beside your name. Spotify appears on cards only; it never feeds Discord. -## Privacy +For music the default status text is `{title} - {artist}`. Films and episodes use the app name unless you provide a custom status text. Leave the field empty to use those defaults. -Privacy is applied before formatting. Removed data cannot be restored by card or Discord visibility settings. +Available fields: `{artist}`, `{title}`, `{subtitle}`, `{album}`, `{year}`, `{series}`, `{season}`, `{episode}`, `{episodeCode}`, `{service}`, `{provider}`, `{mediaType}`, `{state}`, `{stateLabel}`, `{position}`, `{duration}` and `{progressPercent}`. Write `{{` or `}}` for a literal brace. Unknown fields are rejected on save; the maximum is 128 characters. -Available controls include private/friends/public/custom modes, title redaction/replacement, artwork removal, progress removal and media-kind suppression. Public deployments should start with conservative policy and widen fields intentionally. +**Show my server's cover** uploads a cover to a temporary public host so Discord can display it. The fallback MusicBrainz lookup sends a track title and artist. Read [artwork behavior](artwork.md#discord-cover-upload) before enabling either on an existing install. Other Discord line templates, buttons and the update interval are not editable in Settings. -## Further layout work +## Privacy -The next customization slices will remain backward-compatible and small. Candidate controls include padding/corner radius, typography scale, artwork placement, progress dimensions/position, field order and RTL-aware alignment. These are not yet accepted renderer options; unsupported keys continue to fail validation rather than silently doing nothing. +**Privacy Settings** can hide titles, artwork, progress or whole media types across the local card, hosted card and Discord. A hidden media type appears as nothing playing. Privacy is applied before formatting or artwork lookup. [Privacy and safe configuration](wiki/Privacy-and-safe-configuration.md) explains these controls and where credentials are stored. diff --git a/docs/hosted-card.md b/docs/hosted-card.md index 56ffe8fc..5e327819 100644 --- a/docs/hosted-card.md +++ b/docs/hosted-card.md @@ -20,7 +20,7 @@ Bind to `127.0.0.1` behind a TLS reverse proxy unless the process is isolated by Layout options, also set only in the card URL: `padding` (12-48), `radius` (0-24), `titleSize` (14-30), `subtitleSize` (10-20), `progressHeight` (2-12), `textAlign` (`start`/`middle`/`end`), `fieldOrder` (`state`, `title`, `subtitle` once each, comma-separated), `progressPosition` (`bottom`/`text`), `progressWidth` (`content`/`full`), `direction` (`ltr`/`rtl`/`auto`). -Look options, also card URL only: `fontFamily` (`system`/`serif`/`mono`/`humanist`), `fontStack` (a custom font name list, names only), `statusStyle` (`plain`/`caps`/`dot`), `progressStyle` (`square`/`rounded`), `border` (`none`/`thin`) and `background` (`solid`/`transparent`). The hosted card never draws artwork, so the artwork options do not apply to it. Out-of-range or repeated values get a 400 `invalid_layout`. These options belong to the card URL only; the push payload from the app never carries them. The app's Settings page does add your saved look and layout choices to the card link it generates for you, and you can also edit them by hand. +Look options, also card URL only: `fontFamily` (`system`/`serif`/`mono`/`humanist`), `fontStack` (a custom font name list, names only), `statusStyle` (`plain`/`caps`/`dot`), `progressStyle` (`square`/`rounded`), `border` (`none`/`thin`) and `background` (`solid`/`transparent`). Hosted cards draw cover art when sharing is enabled on the PC. `artworkPosition` (`left`/`right`), `artworkWidth` (48–160), `artworkHeight` (48–180) and `artShape` (`square`/`rounded`/`circle`) apply to that image. Out-of-range or repeated values get a 400 `invalid_layout`. These options belong to the card URL only; the push payload from the app never carries them. The app's Settings page does add your saved look and layout choices to the card link it generates for you, and you can also edit them by hand. Successful cards carry a strong ETag and a short public cache lifetime. Resolver and adapter failures are generic and `no-store`. diff --git a/docs/hosted-upload.md b/docs/hosted-upload.md index e5ee1628..4063026d 100644 --- a/docs/hosted-upload.md +++ b/docs/hosted-upload.md @@ -14,6 +14,7 @@ Only these fields, and only the ones your card shows: | Title | Always, unless privacy mode hides it. With "redact titles" on, the card gets "Private media" instead | | Artist / show | Card shows the subtitle and titles aren't redacted | | Position and length | Card shows progress and privacy doesn't hide it | +| Small cover image | Only with **Include cover art** enabled, artwork visible, and titles unredacted | | Track, episode or movie | Card shows the media type | | Time of the update and a counter | Always (stops old or replayed updates) | @@ -23,10 +24,16 @@ With privacy mode set to private, or for a media type you've hidden, the card on - Your media server's address, your username or anyone else on the server - Your server sign-in or API key -- Artwork or artwork links +- Artwork links (optional uploads contain image pixels only) - Discord details (Discord presence stays on your PC) - A history of what you've played +## Cover art + +Enable **Settings → Hosted Card Settings → Include cover art** to share album covers and posters. It is off by default, including after upgrades. The app fetches artwork locally, strips metadata, resizes it to fit within 256 × 256 pixels, and uploads a PNG of at most 48 KiB. The image is public with the card and expires with the playback state. The service never fetches an artwork URL or contacts your media server. **Hide artwork**, **Hide titles**, private mode and hidden media types prevent cover uploads. Compact cards remain text-only. Turning cover sharing off replaces the stored image on the next successful update; CDN copies can remain for the normal 30-second cache lifetime. + +Deploy the updated hosted service before enabling cover sharing in the updated desktop app; older service versions reject the new optional image field. + ## How it's stored - The service keeps only the latest update from each of your PCs, and deletes it 10 minutes after the last one. Stop playing or close the app and the card goes back to "Not playing". @@ -58,7 +65,7 @@ Disconnecting a PC deletes that PC's state and device key on the service, and re ## For developers -- Config: `hosted: { "enabled": true, "url": "https://..." }` in `config.json`. `url` is optional and must be HTTPS. +- Config: `hosted: { "enabled": true, "artwork": false, "url": "https://..." }` in `config.json`. `url` is optional and must be HTTPS. - Payload: built by `projectHostedState` in `src/hosted-projection.js`; tests in `test/hosted-projection.test.js` check that fields turned off for the card never reach the wire. - Client: `src/hosted-uploader.js`; GitHub sign-in (device flow): `src/hosted-signin.js`. Service and schema: [hosted/README.md](../hosted/README.md). Design: #140. diff --git a/docs/settings.md b/docs/settings.md new file mode 100644 index 00000000..62c9bd10 --- /dev/null +++ b/docs/settings.md @@ -0,0 +1,45 @@ +# Settings + +Open **Settings** from the tray icon, or visit `http://127.0.0.1:47832/settings` on the PC running NowPlaying. If you changed the local port, use that port instead. The page is local to your computer. + +Configuration is on the left and recent app logs are on the right. On a small screen the logs sit below configuration. Use the shortcuts at the top to open and jump to a section. Accordion headings work with the keyboard too. + +## Find a setting + +| Section | Use it for | +| --- | --- | +| Media servers | Find, connect and remove Plex, Jellyfin, Emby or Navidrome servers | +| Connected services | Connect Spotify or sign in with GitHub for a shared hosted card | +| Card Settings / Appearance | Pick a card style, preview it, change width and fine-tune layout | +| Discord Settings | Enable presence, choose timers and status text, manage cover art | +| Privacy Settings | Hide titles, artwork, progress or media types across outputs | +| YouTube Settings | Copy or replace a browser-extension pairing code | +| Startup Settings | Start the app when you sign in | +| Hosted Card Settings / Sharing | Enable uploads, choose whether to share cover art, and copy a README snippet | +| Hosted Card Devices | Manage PCs connected to your GitHub-linked card | + +Sections that need an unavailable feature are hidden. For example, device management appears when the hosted account supplies that capability. First-run setup focuses on connecting a server; the full page appears once configuration is ready. + +## Preview and save + +Each section has its own **Save** button and feedback. A card preview updates before saving. Selecting a style resets that draft's look controls while keeping its width and reading direction. Detailed layout controls are under **Fine-tune typography, artwork and layout**. [Card styles and controls](customization.md). + +A successful save applies to the running app. After changing the appearance of a hosted card, copy the new Markdown snippet into your README. Existing snippets keep their original URL options. GitHub may cache the card image; open the card link directly to compare. + +A failed save leaves the stored configuration unchanged. Reloading abandons unsaved drafts. Sign-in, server removal, pairing-code changes and connection buttons perform their named action directly; they do not wait for a section's Save button. + +## Cover art + +The live preview uses the current album cover or poster. With nothing playing, it uses sample artwork. Hosted cards need **Hosted Card Settings → Include cover art** enabled separately. This shares a small sanitized image publicly, never your server address or the artwork URL. Hide artwork and Hide titles override hosted cover sharing. Compact is intentionally text-only. + +[Browse all six styles with music, film and TV examples](card-examples.md). + +## Logs + +The log panel polls recent app events. **Search logs** accepts regular expressions; an invalid expression shows an error so you can correct it. **Auto Scroll** follows new entries; turn it off to inspect older messages. **Wrap Text** keeps long lines within the panel. These display preferences stay in this browser and do not change your app configuration. + +Logs and Settings can include local server details. Check them before sharing a screenshot. [Troubleshooting](wiki/Troubleshooting.md) covers connection failures and useful diagnostic details. + +## About and updates + +**Info** opens the README, license and open-source attribution shipped with the app. The header shows the installed version when available. Use [updates and release channels](wiki/Updates-and-release-channels.md) for update behavior; Settings does not provide a manual update button. diff --git a/docs/wiki/Add-a-README-card.md b/docs/wiki/Add-a-README-card.md index 652e42cd..eb6dd563 100644 --- a/docs/wiki/Add-a-README-card.md +++ b/docs/wiki/Add-a-README-card.md @@ -6,8 +6,8 @@ It's off until you turn it on. ## Steps -1. Open the nowplaying **Settings** page (tray icon, **Settings**) and find the **hosted card** section. -2. Tick **Upload my card to the hosted service** and press **Save**. Settings shows an upload state, a per-PC card link with a random ID and a ready-made README snippet. Want one card shared by all your PCs? Sign in with GitHub in **Settings > Spotify and hosted card** - see below. +1. Open the nowplaying **Settings** page (tray icon, **Settings**) and choose a style under **Appearance → Card Settings**. Press **Save**, then open **Sharing → Hosted Card Settings**. +2. Tick **Upload my card to the hosted service**. For album covers and posters, also tick **Include cover art**. Press **Save**. Settings shows an upload state, a per-PC card link with a random ID and a ready-made README snippet. Want one card shared by all your PCs? Sign in with GitHub in **Settings > Spotify and hosted card** - see below. 3. Press **Copy** beside **README** and paste that exact snippet into your README. A GitHub-linked card might look like: ```markdown @@ -30,13 +30,14 @@ By default each PC gets its own card link with a random ID. If you have more tha ## What the card shows -- Music shows as "Listening", films and TV episodes as "Watching". -- Paused, idle and offline each have their own look. -- Themes, width and which fields to show are options on the card link; secrets never go in the URL. +- Playing cards say "Now playing"; paused cards say "Paused". Music shows the track and artist, and TV episodes show the episode name and series when available. Detailed season and episode numbers stay on your PC. +- Paused media retains its title. Idle or expired updates show nothing playing. +- Studio uses full-bleed artwork, Editorial pairs a large cover with warm paper and serif type, and Hi-Fi uses a record display with an amber timeline. Classic, Paper and Compact are also available. +- Choose one of six styles in **Settings > Appearance**, adjust the width, and press **Save**. Copy the updated README snippet from **Sharing** after an appearance change; the style is part of its URL. [Style gallery](https://github.com/rowkavdev/nowplaying/blob/main/docs/card-examples.md). ## Privacy -You choose what the card can ever receive. With a media type hidden or privacy set to private, the card only ever gets "nothing playing". Your server address, username and artwork never leave your PC. Full detail: [Privacy and safe configuration](Privacy-and-safe-configuration) and [what leaves your PC](https://github.com/rowkavdev/nowplaying/blob/main/docs/hosted-upload.md). +You choose what the card can ever receive. With a media type hidden or privacy set to private, the card only ever gets "nothing playing". Your server address and username never leave your PC. **Include cover art** optionally shares a small image; artwork URLs are never sent. Full detail: [Privacy and safe configuration](Privacy-and-safe-configuration) and [what leaves your PC](https://github.com/rowkavdev/nowplaying/blob/main/docs/hosted-upload.md). ## If the README looks stale diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 16f15002..8e650e50 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -5,7 +5,7 @@ nowplaying shows what is playing on your own media server (Plex, Jellyfin, Navid - a **card** image you can embed in a GitHub README or any web page - **Discord Rich Presence**, the "Listening to / Watching" status on your Discord profile -Everything runs on your own machine or server. Your server address, sign-in details and media titles never appear in the card or in Discord unless you choose to show them. +Everything runs on your own machine or server. Your server address and sign-in details stay private. Titles are shown by default; Privacy Settings can hide them before they reach the card or Discord. ## Choose your path @@ -18,6 +18,7 @@ Everything runs on your own machine or server. Your server address, sign-in deta ## After setup +- [Settings guide](https://github.com/rowkavdev/nowplaying/blob/main/docs/settings.md): appearance, section saves and live logs - [Privacy and safe configuration](Privacy-and-safe-configuration): hide titles, artwork or progress, and keep credentials safe - [Updates and release channels](Updates-and-release-channels): stable releases versus development builds - [Troubleshooting](Troubleshooting): fixes organised by what you see going wrong diff --git a/docs/wiki/Privacy-and-safe-configuration.md b/docs/wiki/Privacy-and-safe-configuration.md index 65c7c0b3..33f4725b 100644 --- a/docs/wiki/Privacy-and-safe-configuration.md +++ b/docs/wiki/Privacy-and-safe-configuration.md @@ -19,7 +19,7 @@ Provider sign-ins and the hosted-card device key live in **Windows Credential Ma ## The card never exposes your server -- Your media server's address and username never leave your PC, and neither does its artwork unless you leave on **Show my server's cover** in Settings > Discord. That option uploads the cover image (nothing else) to temporary public hosts: Litterbox (deleted after 72 hours), or tmpfiles.org if the first host fails (deleted after 48 hours); **Hide album art** turns it off. The hosted card receives only the fields your card shows, and with privacy on it only ever receives "nothing playing". +- Your media server's address and username never leave your PC, and artwork is shared only through the separate cover switches: **Include cover art** under Hosted Card Settings, or **Show my server's cover** under Discord Settings. Hosted images are sanitized on your PC and expire with the latest playback state. The Discord option uploads the cover image (nothing else) to temporary public hosts: Litterbox (deleted after 72 hours), or tmpfiles.org if the first host fails (deleted after 48 hours); **Hide album art** turns it off. The hosted card receives only the fields your card shows, and with privacy on it only ever receives "nothing playing". - Without GitHub sign-in, your card link uses a random ID that can't be traced to your account. With GitHub sign-in, the link is your GitHub username (it sits on your public README anyway). The card service stores your GitHub user ID, username and the names of your signed-in PCs, never your GitHub sign-in itself. - Never put tokens in a card URL or post a card URL with extra query strings in public. diff --git a/hosted/README.md b/hosted/README.md index 7cd3f784..dcf52ffc 100644 --- a/hosted/README.md +++ b/hosted/README.md @@ -19,7 +19,7 @@ What it never does: | `POST` / `DELETE` | `/api/revoke` | Delete the device token and any stored state. | | `GET` / `HEAD` | `/card/.svg` | Public SVG. Options: `theme`, `width`, `show` (same as the local card; add `notime` to `show` to hide the elapsed and total clock). | | | | Layout options, all optional: `padding` (12-48), `radius` (0-24), `titleSize` (14-30), `subtitleSize` (10-20), `progressHeight` (2-12), `textAlign` (`start`/`middle`/`end`), `fieldOrder` (`state`, `title`, `subtitle` once each, comma-separated), `progressPosition` (`bottom`/`text`), `progressWidth` (`content`/`full`), `direction` (`ltr`/`rtl`/`auto`). Out-of-range or repeated values return 400 `invalid_layout`. Set only in the URL, never sent by the app. | -| | | Look options, all optional: `fontFamily` (`system`/`serif`/`mono`/`humanist`), `fontStack` (custom font names only), `statusStyle` (`plain`/`caps`/`dot`), `progressStyle` (`square`/`rounded`), `border` (`none`/`thin`), `background` (`solid`/`transparent`). Same 400 `invalid_layout` rule, same URL-only rule. The hosted card never draws artwork. | +| | | Look options, all optional: `fontFamily` (`system`/`serif`/`mono`/`humanist`), `fontStack` (custom font names only), `statusStyle` (`plain`/`caps`/`dot`), `progressStyle` (`square`/`rounded`), `border` (`none`/`thin`), `background` (`solid`/`transparent`). Same 400 `invalid_layout` rule, same URL-only rule. Hosted cards can draw an optional sanitized `artworkDataUri` PNG, up to 48 KiB and 256 × 256 pixels. Raw artwork URLs are rejected; images expire with playback state. Cover layout options belong in the card URL. | | `GET` / `HEAD` | `/u/.svg` | The signed-in user's card, same options as above. | | `GET` | `/healthz` | Liveness. | diff --git a/hosted/lib/http.js b/hosted/lib/http.js index 7a16f0d7..c85b4ec6 100644 --- a/hosted/lib/http.js +++ b/hosted/lib/http.js @@ -69,17 +69,16 @@ export function parseCardOptions(searchParams) { // Card layout from the URL only (#94). Nothing here comes from the PC's // ingest payload. Numeric ranges match settings.js card.layout; the choices -// match what the renderer accepts. Artwork options are left out because the -// hosted card never draws artwork. -const LAYOUT_NUMBERS = Object.freeze({ padding: [12, 48], radius: [0, 24], titleSize: [14, 30], subtitleSize: [10, 20], progressHeight: [2, 12] }); -const LAYOUT_CHOICES = Object.freeze({ textAlign: ["start", "middle", "end"], progressPosition: ["bottom", "text"], progressWidth: ["content", "full"], direction: ["ltr", "rtl", "auto"], fontFamily: ["system", "serif", "mono", "humanist"], statusStyle: ["plain", "caps", "dot"], progressStyle: ["square", "rounded"], border: ["none", "thin"], background: ["solid", "transparent"] }); +// match what the renderer accepts. Artwork layout applies when the PC shares a cover. +const LAYOUT_NUMBERS = Object.freeze({ padding: [12, 48], radius: [0, 24], titleSize: [14, 30], subtitleSize: [10, 20], progressHeight: [2, 12], artworkWidth: [48, 160], artworkHeight: [48, 180] }); +const LAYOUT_CHOICES = Object.freeze({ artworkPosition: ["left", "right"], artShape: ["square", "rounded", "circle"], textAlign: ["start", "middle", "end"], progressPosition: ["bottom", "text"], progressWidth: ["content", "full"], direction: ["ltr", "rtl", "auto"], fontFamily: ["system", "serif", "mono", "humanist"], statusStyle: ["plain", "caps", "dot"], progressStyle: ["square", "rounded"], border: ["none", "thin"], background: ["solid", "transparent"] }); const CARD_FIELDS = Object.freeze(["state", "title", "subtitle"]); function parseLayoutOptions(searchParams) { const layout = {}; for (const [key, [min, max]] of Object.entries(LAYOUT_NUMBERS)) { const raw = single(searchParams, key); if (raw === null) continue; - const value = /^\d{1,2}$/.test(raw) ? Number(raw) : NaN; + const value = /^\d{1,3}$/.test(raw) ? Number(raw) : NaN; if (!Number.isInteger(value) || value < min || value > max) throw new ServiceError(400, "invalid_layout"); layout[key] = value; } @@ -110,7 +109,7 @@ function single(searchParams, key) { } export function sendCard(req, res, presence, options) { - const svg = renderCard(presence, { ...options, show: { ...options.show, artwork: false } }); + const svg = renderCard(presence, { ...options, artworkDataUri: presence.artworkDataUri ?? null }); const etag = `"${createHash("sha256").update(svg).digest("base64url").slice(0, 27)}"`; res.setHeader("content-type", "image/svg+xml; charset=utf-8"); res.setHeader("cache-control", "public, max-age=30, s-maxage=30"); diff --git a/hosted/lib/service.js b/hosted/lib/service.js index 7ea272df..8a5d9884 100644 --- a/hosted/lib/service.js +++ b/hosted/lib/service.js @@ -3,11 +3,12 @@ // connects back to a media server, never stores provider credentials and never // logs media fields. State expires on its own so a card never shows stale media. +import { isHostedArtwork, MAX_HOSTED_ARTWORK_LENGTH } from "../../src/hosted-artwork-format.js"; import { createHash, randomBytes } from "node:crypto"; import { currentPosition, resolveDeviceStates } from "./resolve.js"; export const SCHEMA_VERSION = 1; -export const MAX_INGEST_BYTES = 2048; +export const MAX_INGEST_BYTES = MAX_HOSTED_ARTWORK_LENGTH + 2048; export const STATE_TTL_SECONDS = 600; export const MAX_CLOCK_SKEW_MS = 120_000; export const SEQ_TTL_SECONDS = 60 * 60 * 24 * 30; @@ -30,7 +31,7 @@ const DEVICE_NAME_LIMIT = 40; const STATES = new Set(["playing", "paused", "idle"]); const KINDS = new Set(["track", "episode", "movie", "show", "unknown"]); -const INGEST_KEYS = new Set(["v", "seq", "observedAt", "state", "kind", "title", "subtitle", "positionMs", "durationMs"]); +const INGEST_KEYS = new Set(["v", "seq", "observedAt", "state", "kind", "title", "subtitle", "positionMs", "durationMs", "artworkDataUri"]); const TEXT_LIMIT = 200; const ID_PATTERN = /^[A-Za-z0-9_-]{22}$/; @@ -172,7 +173,8 @@ export function validateIngest(payload, { now = Date.now() } = {}) { const positionMs = ms(payload.positionMs); const durationMs = ms(payload.durationMs); if (positionMs !== null && durationMs !== null && positionMs > durationMs) throw new ServiceError(400, "invalid_time"); - return { seq: payload.seq, observedAt: payload.observedAt, state: payload.state, kind, title: text(payload.title), subtitle: text(payload.subtitle), positionMs, durationMs }; + if (payload.artworkDataUri !== undefined && !isHostedArtwork(payload.artworkDataUri)) throw new ServiceError(400, "invalid_artwork"); + return { ...(payload.state !== "idle" && payload.artworkDataUri ? { artworkDataUri: payload.artworkDataUri } : {}), seq: payload.seq, observedAt: payload.observedAt, state: payload.state, kind, title: text(payload.title), subtitle: text(payload.subtitle), positionMs, durationMs }; } export function isLogin(value) { return typeof value === "string" && LOGIN_PATTERN.test(value); } @@ -409,7 +411,7 @@ export function createService({ redis, now = () => Date.now(), githubUser = crea } const IDLE = Object.freeze({ state: "idle", kind: "unknown", title: null, subtitle: null, positionMs: null, durationMs: null }); - const shown = (stored) => ({ state: stored.state, kind: stored.kind, title: stored.title, subtitle: stored.subtitle, positionMs: currentPosition(stored, now()), durationMs: stored.durationMs }); + const shown = (stored) => ({ ...(stored.artworkDataUri ? { artworkDataUri: stored.artworkDataUri } : {}), state: stored.state, kind: stored.kind, title: stored.title, subtitle: stored.subtitle, positionMs: currentPosition(stored, now()), durationMs: stored.durationMs }); async function countRender() { await bump("np:stats:cards_rendered"); diff --git a/scripts/render-card-examples.js b/scripts/render-card-examples.js index c49e7eac..4e8be19f 100644 --- a/scripts/render-card-examples.js +++ b/scripts/render-card-examples.js @@ -4,9 +4,19 @@ // Default output: docs/assets/cards/. Test: test/card-examples.test.js. import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import { PREVIEW_EXAMPLES, previewArtwork } from "../src/setup-preview.js"; import { renderCard } from "../src/card.js"; export const EXAMPLES = [ + ...["studio", "editorial", "terminal"].flatMap((theme) => + ["music", "film", "episode"].map((kind) => ({ + file: kind === "music" ? `${theme}.svg` : `${theme}-${kind}.svg`, + title: `${theme} style: ${PREVIEW_EXAMPLES[kind].label}`, + options: { theme }, + artworkKind: kind, + presence: PREVIEW_EXAMPLES[kind].presence, + })), + ), { file: "music-playing.svg", title: "Music, playing (default midnight-blue theme)", @@ -63,7 +73,8 @@ export async function renderExamples(dir) { await mkdir(dir, { recursive: true }); const written = {}; for (const example of EXAMPLES) { - const svg = renderCard(example.presence, example.options); + const art = example.artworkKind ? await previewArtwork(example.artworkKind) : null; + const svg = renderCard(example.presence, { ...example.options, artworkDataUri: art }); await writeFile(join(dir, example.file), svg, "utf8"); written[example.file] = svg; } diff --git a/src/app-config.js b/src/app-config.js index 976213d3..30dece02 100644 --- a/src/app-config.js +++ b/src/app-config.js @@ -1,7 +1,7 @@ import { randomBytes } from "node:crypto"; import { readFile } from "node:fs/promises"; import { cardSettingsView, createAppSettingsStore, discordSettingsView, hostedSettingsView, privacySettingsView } from "./app-settings.js"; -import { renderCard } from "./card.js"; +import { cardPresets, renderCard } from "./card.js"; import { createCardHandler } from "./http-handler.js"; import { createCardPipeline } from "./card-pipeline.js"; import { createHttpServer } from "./http-server.js"; @@ -30,6 +30,7 @@ import { createHostedLoop } from "./hosted-loop.js"; import { createHostedUploader, DEFAULT_HOSTED_URL } from "./hosted-uploader.js"; import { withProviderBackoff } from "./provider-backoff.js"; import { createMultiServerProvider } from "./multi-server.js"; +import { createPublicCardArtwork } from "./artwork-public.js"; import { createArtworkCache } from "./artwork-cache.js"; import { createArtworkService } from "./artwork-service.js"; import { applyPrivacy } from "./privacy.js"; @@ -42,7 +43,7 @@ import { YOUTUBE_BRIDGE_PATH, createYouTubeBridge, createYouTubeBridgeHandler, l const MAX_CONFIG_BYTES = 16 * 1024; const CONFIG_KEYS = new Set(["version", "servers", "discord", "hosted", "privacy", "card", "spotify"]); -const HOSTED_KEYS = new Set(["enabled", "url"]); +const HOSTED_KEYS = new Set(["enabled", "url", "artwork"]); export class StartupError extends Error { constructor(startupCode, message) { @@ -82,7 +83,7 @@ export function parseAppConfig(text) { discordTimestamps: parsed.discord?.timestamps, discordName: parsed.discord?.name, discordStatusDisplayType: parsed.discord?.statusDisplayType, - ...(parsed.hosted ? { hostedEnabled: parsed.hosted.enabled, hostedUrl: parsed.hosted.url } : {}), + ...(parsed.hosted ? { hostedEnabled: parsed.hosted.enabled, hostedUrl: parsed.hosted.url, hostedArtwork: parsed.hosted.artwork } : {}), ...(parsed.privacy !== undefined ? { privacy: parsed.privacy } : {}), ...(parsed.card !== undefined ? { card: parsed.card } : {}), ...(parsed.spotify !== undefined ? { spotify: parsed.spotify } : {}), @@ -247,17 +248,18 @@ export function hostedUploadSettings(config) { const theme = config?.card?.theme ?? "midnight-blue"; return Object.freeze({ privacy: privacyPolicyFromConfig(config), - show: Object.freeze({ progress: config?.card?.showProgress ?? theme !== "compact", subtitle: theme !== "compact" }), + shareArtwork: config?.hosted?.artwork === true, + show: Object.freeze({ artwork: theme !== "compact", progress: config?.card?.showProgress ?? theme !== "compact", subtitle: theme !== "compact" }), }); } // Hosted card upload (#140) runs only when the config turns it on. It pushes // privacy-filtered state to the hosted service and is independent of Discord: // a host that's down or unreachable never affects presence. -export function startHostedFromConfig(config, provider, { credentials, fetchImpl = fetch, settings = {}, intervalMs, createUploader = createHostedUploader } = {}) { +export function startHostedFromConfig(config, provider, { credentials, fetchImpl = fetch, settings = {}, resolveArtwork, intervalMs, createUploader = createHostedUploader } = {}) { if (!config.hosted?.enabled) return Object.freeze({ status: "off", stop: async () => {}, cardUrl: async () => null, connection: () => null }); if (typeof credentials?.load !== "function") return Object.freeze({ status: "no_credentials", stop: async () => {}, cardUrl: async () => null, connection: () => null }); - const uploader = createUploader({ baseUrl: config.hosted.url ?? DEFAULT_HOSTED_URL, credentials, fetchImpl, settings }); + const uploader = createUploader({ baseUrl: config.hosted.url ?? DEFAULT_HOSTED_URL, credentials, fetchImpl, settings, resolveArtwork }); const loop = createHostedLoop({ getPresence: () => provider.getPresence(), uploader, ...(intervalMs ? { intervalMs } : {}) }); loop.start(); return Object.freeze({ status: "on", stop: () => loop.stop(), cardUrl: () => uploader.cardUrl(), connection: () => uploader.status(), disconnect: () => uploader.disconnect(), privacyChanged: () => loop.privacyChanged() }); @@ -321,7 +323,12 @@ export async function startAppFromConfig({ configFile, credentialStore, host = " // video shows as "Watching"; YouTube Music stays "Listening". Shorts never // reach the bridge. const discordProvider = youtube ? withPrivacy(combinePresence({ primary: tracked, secondary: youtubeForDiscord(youtube.bridge.provider) }), () => current) : provider; - const resolveCard = createResilientCardResolver({ resolveCard: createCardPipeline({ provider: cardProvider, artworkService: multi?.artwork, defaults: () => cardRenderOptions(current.card) }), diagnostics: true }); + const publicArtwork = createPublicCardArtwork({ fetchImpl }); + const cardArtwork = { + resolve: (ref, config, rendition) => ref?.publicUrl ? publicArtwork.resolve(ref.publicUrl, rendition) : multi?.artwork.resolve(ref, config, rendition) ?? null, + clear: async () => { publicArtwork.clear(); await multi?.artwork.clear(); }, + }; + const resolveCard = createResilientCardResolver({ resolveCard: createCardPipeline({ provider: cardProvider, artworkService: cardArtwork, defaults: () => cardRenderOptions(current.card) }), diagnostics: true }); let discord; // Safe mode (#122, after repeated failed starts): only the local card and // status page run. Discord and hosted uploads, which poll in the background, @@ -332,7 +339,7 @@ export async function startAppFromConfig({ configFile, credentialStore, host = " let hosted = offHosted; const launchHosted = (settings) => { if (safeMode) return paused; - try { return startHostedFromConfig(settings, cardProvider, { credentials: hostedCredentials, fetchImpl, settings: () => hostedUploadSettings(current), ...hostedOptions }); } + try { return startHostedFromConfig(settings, cardProvider, { credentials: hostedCredentials, fetchImpl, settings: () => hostedUploadSettings(current), resolveArtwork: (ref, presence) => cardArtwork.resolve(ref, null, presence.kind === "track" ? { width: 256, height: 256 } : { width: 192, height: 288 }), ...hostedOptions }); } catch { return Object.freeze({ status: "failed", stop: async () => {}, cardUrl: async () => null, connection: () => null }); } }; const launchDiscord = (settings) => { @@ -400,12 +407,25 @@ export async function startAppFromConfig({ configFile, credentialStore, host = " // or a sample track when nothing is, so layout changes are visible. async previewCard(card) { let presence = null; - try { presence = await cardProvider.getPresence(); } catch { presence = null; } - if (!presence || presence.state === "idle") presence = applyPrivacy(PREVIEW_SAMPLE, privacyPolicyFromConfig(current)); - // A plain grey square stands in for artwork so placement and size - // show in the preview; real art is fetched for /card.svg (#449). - const svg = renderCard(presence, { ...cardRenderOptions(card), artworkDataUri: PREVIEW_ARTWORK }); - const automaticWidth = presence.kind === "track" ? 100 : 68; + // Choose a sample only when the source itself is idle. A privacy-hidden + // movie/episode must stay idle instead of becoming sample music. + try { presence = await cardSource.getPresence(); } catch { presence = null; } + const sample = !presence || presence.state === "idle"; + if (sample) presence = PREVIEW_SAMPLE; + const privacy = privacyPolicyFromConfig(current); + presence = applyPrivacy(presence, privacy); + let artworkDataUri = null; + if (!privacy.hideArtwork && presence.state !== "idle" && card.theme !== "compact") { + if (sample) { + const { previewArtwork } = await import("./setup-preview.js"); + artworkDataUri = await previewArtwork("music"); + } else if (presence.artwork || presence.artworkUrl) { + const art = await cardArtwork.resolve(presence.artwork ?? { publicUrl: presence.artworkUrl }, null, presence.kind === "track" ? { width: 256, height: 256 } : { width: 192, height: 288 }); + artworkDataUri = typeof art === "string" ? art : art?.dataUri ?? null; + } + } + const svg = renderCard(presence, { ...cardRenderOptions(card), artworkDataUri }); + const automaticWidth = cardPresets[card.theme ?? "midnight-blue"].layout.artworkWidth ?? (presence.kind === "track" ? 100 : 68); return svg.replace(" ({ state: "idle" }) }); @@ -588,11 +607,11 @@ async function createServersProvider(config, credentialStore, fetchImpl, provide let lastCard = { state: "none", reason: "not_requested" }; const artwork = Object.freeze({ // Artwork is a nice-to-have: any failure shows the card without it. - async resolve(ref) { + async resolve(ref, _config, rendition = {}) { const source = ref && typeof ref === "object" && Number.isInteger(ref.sourceIndex) ? artworkSources.get(ref.sourceIndex) : null; if (!source) { lastCard = { state: "none", reason: "no_source" }; return null; } try { - const value = await source.resolve(ref); + const value = await source.resolve(ref, rendition); lastCard = value ? { state: "ok", reason: null } : { state: "none", reason: "no_image" }; return value; } catch (error) { @@ -638,12 +657,12 @@ function createServerArtwork(server, secret, fetchImpl) { let service = null; return Object.freeze({ async clear() { if (service) (await service.catch(() => null))?.clear(); }, - async resolve(ref) { + async resolve(ref, rendition = {}) { service ??= (async () => { const { createDefaultArtworkSanitizer } = await import("./artwork-sanitizer-runtime.js"); return createArtworkService({ cache: createArtworkCache(), sanitizer: createDefaultArtworkSanitizer(), fetchImpl, timeoutMs: 3000 }); })().catch((error) => { service = null; throw error; }); - const dataUri = await (await service).resolve(ref, providerConfig); + const dataUri = await (await service).resolve(ref, providerConfig, rendition); if (!dataUri) return null; return { dataUri, tint: await tintFor(dataUri) }; }, diff --git a/src/app-settings.js b/src/app-settings.js index 4f3ca3fa..3236cdee 100644 --- a/src/app-settings.js +++ b/src/app-settings.js @@ -1,3 +1,4 @@ +import { cardPresets } from "./card-presets.js"; import { randomBytes } from "node:crypto"; import { readFile, rename, rm, writeFile } from "node:fs/promises"; import { parseAppConfig } from "./app-config.js"; @@ -10,7 +11,7 @@ import { validateTemplate } from "./template.js"; // step (temp file + rename) so a crash never leaves half a file. const DISCORD_KEYS = new Set(["enabled", "timestamps", "artworkLookup", "artworkUpload", "idleBehavior", "name", "statusDisplayType"]); -const HOSTED_KEYS = new Set(["enabled"]); +const HOSTED_KEYS = new Set(["enabled", "artwork"]); const PRIVACY_KEYS = new Set(["hideTitles", "hideArtwork", "hideProgress", "hideMovies", "hideEpisodes", "hideMusic"]); const PRIVACY_KIND_KEYS = Object.freeze([["hideMovies", "movie"], ["hideEpisodes", "episode"], ["hideMusic", "track"]]); @@ -27,7 +28,7 @@ export function discordSettingsView(config) { } export function hostedSettingsView(config) { - return Object.freeze({ enabled: config.hosted?.enabled === true }); + return Object.freeze({ enabled: config.hosted?.enabled === true, ...(config.hosted?.artwork === true ? { artwork: true } : {}) }); } export function privacySettingsView(config) { @@ -48,29 +49,30 @@ const AUTO_CARD_KEYS = ["artworkWidth", "artworkHeight"]; export function cardSettingsView(config) { const card = config.card ?? {}; const theme = card.theme ?? "midnight-blue"; + const defaults = cardPresets[theme].layout; return Object.freeze({ theme, width: card.width ?? 440, - padding: card.padding ?? 24, - radius: card.radius ?? 10, - progressHeight: card.progressHeight ?? 4, + padding: card.padding ?? defaults.padding ?? 24, + radius: card.radius ?? defaults.radius ?? 10, + progressHeight: card.progressHeight ?? defaults.progressHeight ?? 4, // The compact theme hides progress unless it's turned on. showProgress: card.showProgress ?? theme !== "compact", - artworkPosition: card.artworkPosition ?? "left", + artworkPosition: card.artworkPosition ?? defaults.artworkPosition ?? "left", // null means automatic: the renderer picks by media kind (square for // music since #445), so a save never pins the old 68x100 default. - artworkWidth: card.artworkWidth ?? null, - artworkHeight: card.artworkHeight ?? null, + artworkWidth: card.artworkWidth ?? defaults.artworkWidth ?? null, + artworkHeight: card.artworkHeight ?? defaults.artworkHeight ?? null, fieldOrder: card.fieldOrder ?? ["state", "title", "subtitle"], textAlign: card.textAlign ?? "start", progressPosition: card.progressPosition ?? "bottom", progressWidth: card.progressWidth ?? "content", direction: card.direction ?? "ltr", artworkTint: card.artworkTint ?? true, - fontFamily: card.fontFamily ?? "system", - statusStyle: card.statusStyle ?? "plain", - artShape: card.artShape ?? "square", - progressStyle: card.progressStyle ?? "square", + fontFamily: card.fontFamily ?? defaults.fontFamily ?? "system", + statusStyle: card.statusStyle ?? defaults.statusStyle ?? "plain", + artShape: card.artShape ?? defaults.artShape ?? "square", + progressStyle: card.progressStyle ?? defaults.progressStyle ?? "square", border: card.border ?? "thin", background: card.background ?? "solid", fontStack: card.fontStack ?? "", @@ -96,7 +98,7 @@ function rewrite(config, { servers = config.servers, discord = { ...discordSetti discordTimestamps: discord.timestamps, discordName: discord.name, discordStatusDisplayType: discord.statusDisplayType, - ...(hosted ? { hostedEnabled: hosted.enabled, ...(hosted.url ? { hostedUrl: hosted.url } : {}) } : {}), + ...(hosted ? { hostedEnabled: hosted.enabled, hostedArtwork: hosted.artwork, ...(hosted.url ? { hostedUrl: hosted.url } : {}) } : {}), ...(privacy ? { privacy } : {}), ...(card ? { card } : {}), // Settings changes never drop the Spotify connection (#135). @@ -124,10 +126,11 @@ export function applyHostedDestinationChanges(config, changes) { return rewrite(config, { hosted: { enabled: true, ...(changes.url ? { url: changes.url } : {}) } }); } -// Only on/off: the service address stays whatever setup or config.json says. +// The service address stays whatever setup or config.json says. export function applyHostedChanges(config, changes) { checkChanges(changes, HOSTED_KEYS, "hosted"); - return rewrite(config, { hosted: { ...(config.hosted ?? {}), enabled: changes.enabled } }); + for (const [key, value] of Object.entries(changes)) if (typeof value !== "boolean") throw new TypeError(`hosted settings: ${key} must be true or false`); + return rewrite(config, { hosted: { ...config.hosted, ...changes } }); } export function applyPrivacyChanges(config, changes) { diff --git a/src/artwork-public.js b/src/artwork-public.js new file mode 100644 index 00000000..87537b03 --- /dev/null +++ b/src/artwork-public.js @@ -0,0 +1,29 @@ +import { createArtworkCache } from "./artwork-cache.js"; +import { fetchArtwork, artworkDataUri } from "./artwork-fetch.js"; +// Public provider covers need no credentials. Restrict fetches to provider +// CDNs; a playback URL can never turn the card into an arbitrary URL fetcher. +const HOSTS = new Set(["i.scdn.co", "mosaic.scdn.co", "image-cdn-ak.spotifycdn.com", "image-cdn-fa.spotifycdn.com", "i.ytimg.com"]); +export function createPublicCardArtwork({ fetchImpl = fetch } = {}) { + const cache = createArtworkCache({ maxEntries: 32 }); + let sanitizer; + let generation = 0; + return { + clear() { generation++; cache.clear(); }, + async resolve(url, rendition = { width: 256, height: 256 }) { + let parsed; + try { parsed = new URL(url); } catch { return null; } + if (parsed.protocol !== "https:" || !HOSTS.has(parsed.hostname) || parsed.port || parsed.username || parsed.password) return null; + const key = `${parsed.href}:${rendition.width}x${rendition.height}`; + const cached = cache.get(key); + if (cached !== undefined) return cached; + const owner = generation; + try { + sanitizer ??= import("./artwork-sanitizer-runtime.js").then(m => m.createDefaultArtworkSanitizer()); + const raw = await fetchArtwork({ url: parsed.href, headers: { Accept: "image/jpeg,image/png,image/webp" } }, { fetchImpl, timeoutMs: 3000 }); + const value = raw ? artworkDataUri(await (await sanitizer)(raw, rendition)) : null; + if (owner === generation) cache.set(key, value); + return value; + } catch { if (owner === generation) cache.set(key, null); return null; } + }, + }; +} diff --git a/src/card-pipeline.js b/src/card-pipeline.js index 93428357..0e3e0f05 100644 --- a/src/card-pipeline.js +++ b/src/card-pipeline.js @@ -16,8 +16,9 @@ export function createCardPipeline({ provider, privacy = {}, artworkService, pro const theme = options.theme ?? base.theme ?? "midnight-blue"; const show = options.show ?? base.show ?? {}; const showArtwork = show.artwork ?? theme !== "compact"; - const art = showArtwork && presence.artwork && artworkService - ? await artworkService.resolve(presence.artwork, providerConfig) + const reference = presence.artwork ?? (presence.artworkUrl ? { publicUrl: presence.artworkUrl } : null); + const art = showArtwork && reference && artworkService + ? await artworkService.resolve(reference, providerConfig, presence.kind === "track" ? { width: 256, height: 256 } : { width: 192, height: 288 }) : null; // A service can return the data URI alone, or with a tint taken from the // art (#447). diff --git a/src/card-presets.js b/src/card-presets.js new file mode 100644 index 00000000..6ed90dc7 --- /dev/null +++ b/src/card-presets.js @@ -0,0 +1,11 @@ +// Shared by the renderer, validation and Settings. A style includes type and +// spacing, not just a palette. Explicit layout options always take precedence. +const preset = (label, description, layout = {}, show = {}) => Object.freeze({ label, description, layout: Object.freeze(layout), show: Object.freeze(show) }); +export const cardPresets = Object.freeze({ + "midnight-blue": preset("Classic", "Dark background, blue accent."), + paper: preset("Paper", "A crisp white card."), + compact: preset("Compact", "A single line, without a cover.", {}, { artwork: false, progress: false, state: false, subtitle: false, time: false }), + studio: preset("Studio", "Cinema lighting, full-bleed art and a framed cover.", { padding: 24, radius: 20, titleSize: 22, subtitleSize: 13, artworkWidth: 112, artworkHeight: 148, artworkPosition: "right", artShape: "rounded", statusStyle: "dot", progressStyle: "rounded", progressHeight: 2 }), + editorial: preset("Editorial", "A printed sleeve: large art, serif type and a ticket seam.", { padding: 24, radius: 0, fontFamily: "serif", titleSize: 24, subtitleSize: 14, statusStyle: "caps", progressHeight: 2, artworkWidth: 120, artworkHeight: 164, artworkPosition: "left" }), + terminal: preset("Hi-Fi", "A record deck, amber display and segmented timeline.", { padding: 24, radius: 16, fontFamily: "mono", titleSize: 18, subtitleSize: 12, statusStyle: "caps", progressHeight: 4, artworkWidth: 112, artworkHeight: 112, artShape: "circle" }), +}); diff --git a/src/card.js b/src/card.js index 4767199b..aab7bc7e 100644 --- a/src/card.js +++ b/src/card.js @@ -1,5 +1,11 @@ +import { cardPresets } from "./card-presets.js"; +export { cardPresets } from "./card-presets.js"; + export const cardThemes = Object.freeze({ "midnight-blue": Object.freeze({ background: "#0d1117", border: "#30363d", primary: "#f0f6fc", secondary: "#9aa7b7", accent: "#58a6ff", track: "#30363d" }), + studio: Object.freeze({ background: "#181a1d", border: "#3a3d42", primary: "#f3f3f1", secondary: "#b1b3b7", accent: "#d6c8a9", track: "#393b40" }), + editorial: Object.freeze({ background: "#f8f5ee", border: "#c8c1b4", primary: "#25241f", secondary: "#625d52", accent: "#77583c", track: "#ddd6c8" }), + terminal: Object.freeze({ background: "#202323", border: "#4a504e", primary: "#f4e8d5", secondary: "#c1b49b", accent: "#f4b25b", track: "#464940" }), paper: Object.freeze({ background: "#ffffff", border: "#c7d2df", primary: "#172033", secondary: "#526173", accent: "#0969da", track: "#d8e0e8" }), compact: Object.freeze({ background: "#0d1117", border: "#30363d", primary: "#f0f6fc", secondary: "#9aa7b7", accent: "#58a6ff", track: "#30363d" }), }); @@ -10,7 +16,7 @@ const DATA_IMAGE_PATTERN = /^data:image\/(?:png|jpeg|webp);base64,[A-Za-z0-9+/]+ const DEFAULT_FIELD_ORDER = Object.freeze(["state", "title", "subtitle"]); const TEXT_ANCHORS = Object.freeze({ start: "start", middle: "middle", end: "end" }); const SHOW_DEFAULTS = Object.freeze({ artwork: true, mediaType: true, progress: true, state: true, subtitle: true, time: true }); -const COMPACT_SHOW = Object.freeze({ artwork: false, progress: false, state: false, subtitle: false, time: false }); + export function resolveCardTheme(theme = "midnight-blue", colors = {}) { @@ -30,6 +36,8 @@ export function renderCard(presence, { width = 440, show = {}, theme = "midnight if (!Number.isInteger(width) || width < 280 || width > 800) throw new RangeError("width must be an integer from 280 to 800"); if (show === null || typeof show !== "object" || Array.isArray(show)) throw new TypeError("show must be an object"); if (layout === null || typeof layout !== "object" || Array.isArray(layout)) throw new TypeError("layout must be an object"); + if (!Object.hasOwn(cardPresets, theme)) throw new TypeError(`Unknown card theme: ${theme}`); + layout = { ...cardPresets[theme].layout, ...layout }; const allowedLayout = new Set(["padding", "radius", "titleSize", "subtitleSize", "progressHeight", "artworkPosition", "artworkWidth", "artworkHeight", "fieldOrder", "textAlign", "progressPosition", "progressWidth", "direction", "fontFamily", "statusStyle", "artShape", "progressStyle", "border", "background", "fontStack"]); for (const key of Object.keys(layout)) if (!allowedLayout.has(key)) throw new TypeError(`Unknown card layout setting: ${key}`); const padding = bounded(layout.padding, 24, 12, 48, "layout.padding"); @@ -50,7 +58,7 @@ export function renderCard(presence, { width = 440, show = {}, theme = "midnight const borderStyle = oneOf(layout.border, "thin", ["none", "thin"], "layout.border"); const directionSetting = layout.direction ?? "ltr"; if (!new Set(["ltr", "rtl", "auto"]).has(directionSetting)) throw new TypeError("layout.direction: expected ltr, rtl or auto"); - const presetShow = theme === "compact" ? COMPACT_SHOW : {}; + const presetShow = cardPresets[theme].show; const visibility = { ...SHOW_DEFAULTS, ...presetShow, ...show }; for (const [key, value] of Object.entries(visibility)) { if (!Object.hasOwn(SHOW_DEFAULTS, key)) throw new TypeError(`Unknown card visibility setting: ${key}`); @@ -81,6 +89,7 @@ export function renderCard(presence, { width = 440, show = {}, theme = "midnight visibility.progress &&= presence.durationMs > 0 && Number.isFinite(presence.positionMs); } const palette = resolveCardTheme(theme, colors); + const featureStyle = ["studio", "editorial", "terminal"].includes(theme); if (tint !== null && (typeof tint !== "string" || !COLOR_PATTERN.test(tint))) throw new TypeError("tint must be a six-digit hex color"); const hasArtwork = visibility.artwork && artworkDataUri !== null; // Give titles room at the narrowest card width even when both padding and @@ -110,7 +119,12 @@ export function renderCard(presence, { width = 440, show = {}, theme = "midnight } const customOrder = fieldOrder.some((field, index) => field !== DEFAULT_FIELD_ORDER[index]); const underArtwork = progressSpan === "full" && hasArtwork && visibility.progress ? padding * 2 + artworkHeight + 12 + progressHeight : 0; - const height = Math.max(baseHeight, customOrder ? cursor + padding + 2 + (visibility.progress ? progressHeight + 16 : 0) : 0, underArtwork); + const titleLines = featureStyle && hasArtwork ? wrapCardTitle(title, Math.max(5, Math.floor(contentWidth / (titleSize * 0.52)))) : null; + const extraTitleHeight = titleLines ? (titleLines.length - 1) * (titleSize + 4) : 0; + // Reordered fields and the inline timeline follow the entire wrapped title. + for (const field of lines.slice(lines.indexOf("title") + 1)) baselines[field] += extraTitleHeight; + cursor += extraTitleHeight; + const height = Math.max(baseHeight, customOrder || extraTitleHeight ? cursor + padding + 2 + (visibility.progress ? progressHeight + 16 : 0) : 0, underArtwork); const titleY = baselines.title; const subtitleY = baselines.subtitle; const stateY = baselines.state; @@ -150,6 +164,9 @@ export function renderCard(presence, { width = 440, show = {}, theme = "midnight const barRx = progressStyle === "rounded" ? progressHeight / 2 : 0; const defs = [ ``, + ``, + theme === "studio" && hasArtwork ? `` : "", + theme === "terminal" ? `` : "", hasArtwork ? `` : "", ].join(""); const font = `font-family="${layout.fontStack !== undefined ? `${layout.fontStack.split(",").map((name) => name.trim()).join(",")},${FONTS.system}` : FONTS[fontFamily]}"`; @@ -160,14 +177,19 @@ export function renderCard(presence, { width = 440, show = {}, theme = "midnight ${escapeXml(status)}: ${escapeXml(title)}${escapeXml(description)} ${defs ? `${defs}` : ""} + ${theme === "studio" && hasArtwork && backgroundStyle !== "transparent" ? `` : ""} + ${theme === "terminal" && backgroundStyle !== "transparent" ? `` : ""} + ${theme === "terminal" && hasArtwork ? `${[2, 5, 8].map(inset => ``).join("")}` : ""} + ${theme === "editorial" && hasArtwork ? `` : ""} + ${theme === "studio" && hasArtwork ? `` : ""} ${hasArtwork ? `` : ""} ${dot ? `` : ""} - ${visibility.state ? `${status}` : ""} + ${visibility.state ? `${status}` : ""} ${showTime ? `${timeLabel}` : ""} - ${escapeXml(truncate(title, Math.max(4, Math.floor(contentWidth / (titleSize * 0.64)))))} - ${hasSubtitle ? `${escapeXml(truncate(subtitle, Math.max(4, Math.floor(contentWidth / (subtitleSize * 0.55)))))}` : ""} - ${visibility.progress ? `` : ""} -`; + ${titleLines ? titleLines.map((line, index) => `${escapeXml(line)}`).join("") : escapeXml(truncate(title, Math.max(4, Math.floor(contentWidth / (titleSize * 0.64)))))} + ${hasSubtitle ? `${escapeXml(truncate(subtitle, Math.max(4, Math.floor(contentWidth / (subtitleSize * 0.55)))))}` : ""} + ${visibility.progress && theme === "terminal" ? `${Array.from({ length: 24 }, (_, i) => { const segmentWidth = Math.max(1, (barWidth - 46) / 24); return ``; }).join("")}` : visibility.progress ? `` : ""} +`.split("\n").map((line) => line.trimEnd()).join("\n"); } @@ -230,3 +252,14 @@ function firstStrongIsRtl(text) { } return false; } + +function wrapCardTitle(title, length) { + if (title.length <= length) return [title]; + const words = title.split(/\s+/); const lines = []; let line = ""; + for (const word of words) { + if (line && (line + " " + word).length > length) { lines.push(line); line = word; } + else line += (line ? " " : "") + word; + } + if (line) lines.push(line); + return lines.length <= 2 ? lines.map(value => truncate(value, length)) : [lines[0], truncate(lines.slice(1).join(" "), length)]; +} diff --git a/src/hosted-artwork-format.js b/src/hosted-artwork-format.js new file mode 100644 index 00000000..d6298fb7 --- /dev/null +++ b/src/hosted-artwork-format.js @@ -0,0 +1,29 @@ +// Shared wire limits. Only small, metadata-free PNGs can enter public cards. +export const MAX_HOSTED_ARTWORK_BYTES = 48 * 1024; +export const MAX_HOSTED_ARTWORK_LENGTH = Math.ceil(MAX_HOSTED_ARTWORK_BYTES / 3) * 4 + 22; +const PREFIX = "data:image/png;base64,"; +export function isHostedArtwork(value) { + if (typeof value !== "string" || value.length > MAX_HOSTED_ARTWORK_LENGTH || !value.startsWith(PREFIX)) return false; + const encoded = value.slice(PREFIX.length); + if (!/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)) return false; + const bytes = Buffer.from(encoded, "base64"); + if (bytes.toString("base64") !== encoded || bytes.length > MAX_HOSTED_ARTWORK_BYTES || bytes.length < 45 || bytes.subarray(0, 8).toString("hex") !== "89504e470d0a1a0a") return false; + let offset = 8, header = false, data = false; + while (offset + 12 <= bytes.length) { + const size = bytes.readUInt32BE(offset); + if (size > bytes.length - offset - 12) return false; + const type = bytes.toString("ascii", offset + 4, offset + 8); + if (!["IHDR", "PLTE", "tRNS", "IDAT", "IEND", "pHYs"].includes(type)) return false; + if (!header && type !== "IHDR") return false; + if (type === "IHDR") { + if (header || size !== 13) return false; + const width = bytes.readUInt32BE(offset + 8), height = bytes.readUInt32BE(offset + 12); + if (width < 1 || height < 1 || width > 256 || height > 256) return false; + header = true; + } + if (type === "IDAT") data = true; + offset += size + 12; + if (type === "IEND") return size === 0 && data && offset === bytes.length; + } + return false; +} diff --git a/src/hosted-artwork.js b/src/hosted-artwork.js new file mode 100644 index 00000000..10b61552 --- /dev/null +++ b/src/hosted-artwork.js @@ -0,0 +1,17 @@ +import { isHostedArtwork, MAX_HOSTED_ARTWORK_BYTES } from "./hosted-artwork-format.js"; +// Covers already resolved on the PC are re-encoded to a bounded thumbnail. +// No source URLs, provider references or source metadata enter the payload. +const cache = new Map(); +export async function compactHostedArtwork(art) { + const dataUri = typeof art === "string" ? art : art?.dataUri; + if (typeof dataUri !== "string" || dataUri.length > 1_500_000 || !/^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/]+={0,2}$/.test(dataUri)) return null; + if (cache.has(dataUri)) return cache.get(dataUri); + const { default: sharp } = await import("sharp"); + const bytes = await sharp(Buffer.from(dataUri.slice(dataUri.indexOf(",") + 1), "base64"), { animated: false, limitInputPixels: 1_000_000 }) + .rotate().resize({ width: 256, height: 256, fit: "inside", withoutEnlargement: true }).png({ palette: true, colours: 128, compressionLevel: 9 }).toBuffer(); + const value = bytes.length <= MAX_HOSTED_ARTWORK_BYTES ? `data:image/png;base64,${bytes.toString("base64")}` : null; + const result = isHostedArtwork(value) ? value : null; + if (cache.size >= 24) cache.delete(cache.keys().next().value); + cache.set(dataUri, result); + return result; +} diff --git a/src/hosted-preview.js b/src/hosted-preview.js index 61408546..0739277f 100644 --- a/src/hosted-preview.js +++ b/src/hosted-preview.js @@ -21,11 +21,12 @@ const FIELD_LABELS = Object.freeze({ subtitle: "Artist or show name", durationMs: "Length", positionMs: "Position in the track", + artworkDataUri: "Small cover image (public, metadata removed)", }); export const ALWAYS_SENT = Object.freeze(["A format version", "An update counter", "The time of the update"]); export const NEVER_SENT = Object.freeze([ - "Artwork or artwork links", + "Artwork links", "Your media server address, username or server name", "Provider names, IDs or credentials", "Your Discord details", @@ -37,11 +38,12 @@ const PROTOCOL = new Set(["v", "seq", "observedAt"]); export function hostedUploadPreview(settings = {}) { const payloads = SAMPLES.map((presence) => projectHostedState(presence, settings, { seq: 1, now: Date.parse("2026-01-01T00:00:01.000Z") })); const keys = new Set(payloads.flatMap((p) => Object.keys(p)).filter((k) => !PROTOCOL.has(k))); + if (settings.shareArtwork && settings.show?.artwork !== false && !settings.privacy?.hideArtwork && !settings.privacy?.redactTitles && payloads.some(p => p.state !== "idle")) keys.add("artworkDataUri"); const sent = Object.keys(FIELD_LABELS).filter((k) => keys.has(k)).map((key) => ({ key, label: FIELD_LABELS[key] })); const unknown = [...keys].filter((k) => !(k in FIELD_LABELS)); // A new projection field must get a label here before it can ship. if (unknown.length) throw new Error(`hosted preview has no label for: ${unknown.join(", ")}`); - const withheld = Object.keys(FIELD_LABELS).filter((k) => !keys.has(k)).map((key) => ({ key, label: FIELD_LABELS[key] })); + const withheld = Object.keys(FIELD_LABELS).filter((k) => !keys.has(k) && (k !== "artworkDataUri" || settings.shareArtwork === true)).map((key) => ({ key, label: FIELD_LABELS[key] })); return { sent, withheld, alwaysSent: ALWAYS_SENT, neverSent: NEVER_SENT, sample: payloads[0] }; } diff --git a/src/hosted-projection.js b/src/hosted-projection.js index dfd307c8..0a57ce49 100644 --- a/src/hosted-projection.js +++ b/src/hosted-projection.js @@ -1,10 +1,10 @@ +import { isHostedArtwork } from "./hosted-artwork-format.js"; import { applyPrivacy } from "./privacy.js"; // Builds the one payload the desktop app may push to the hosted card service // (#140). Everything is opt-in: a field only leaves the PC when the privacy -// policy and the card's own `show` settings both allow it. Artwork, users, -// servers and provider IDs are never sent; the v1 ingest schema has no place -// for them. +// policy and the card's own `show` settings both allow it. Users, servers and provider IDs are never sent. Cover pixels are +// optional, sanitized on the PC, and gated by explicit sharing and privacy. export const HOSTED_SCHEMA_VERSION = 1; export const HOSTED_TEXT_LIMIT = 200; @@ -28,7 +28,7 @@ function wholeMs(value) { return Number.isFinite(value) && value >= 0 ? Math.floor(value) : null; } -export function projectHostedState(presence, { privacy = {}, show = {} } = {}, { seq, now = Date.now() } = {}) { +export function projectHostedState(presence, { privacy = {}, show = {}, shareArtwork = false } = {}, { seq, now = Date.now(), artworkDataUri = null } = {}) { if (!Number.isSafeInteger(seq) || seq < 0) throw new TypeError("seq must be a non-negative safe integer"); if (!Number.isSafeInteger(now)) throw new TypeError("now must be a safe integer timestamp"); const filtered = applyPrivacy(presence, privacy); @@ -36,6 +36,7 @@ export function projectHostedState(presence, { privacy = {}, show = {} } = {}, { const payload = { v: HOSTED_SCHEMA_VERSION, seq, observedAt: now, state }; if (state === "idle") return payload; + if (shareArtwork === true && show.artwork !== false && !privacy.hideArtwork && !privacy.redactTitles && isHostedArtwork(artworkDataUri)) payload.artworkDataUri = artworkDataUri; payload.kind = show.mediaType === false || !HOSTED_KINDS.has(filtered.kind) ? "unknown" : filtered.kind; const title = clip(filtered.title); if (title) payload.title = title; diff --git a/src/hosted-uploader.js b/src/hosted-uploader.js index 830a1f0e..08deb162 100644 --- a/src/hosted-uploader.js +++ b/src/hosted-uploader.js @@ -1,4 +1,5 @@ import { readBoundedJson } from "./bounded-response.js"; +import { compactHostedArtwork } from "./hosted-artwork.js"; import { projectHostedState } from "./hosted-projection.js"; // Pushes privacy-filtered playback state from the desktop app to the hosted @@ -47,6 +48,7 @@ export function createHostedUploader({ baseUrl = DEFAULT_HOSTED_URL, credentials, settings = {}, + resolveArtwork, fetchImpl = fetch, now = () => Date.now(), elapsedNow = () => performance.now(), @@ -158,7 +160,14 @@ export function createHostedUploader({ async function send(presence, isCurrent) { const at = now(); // `settings` can be a function so privacy and card changes apply without a restart. - const probe = projectHostedState(presence, typeof settings === "function" ? settings() : settings, { seq: 0, now: at + serverClockOffset }); + const currentSettings = () => typeof settings === "function" ? settings() : settings; + let artworkDataUri = null; + const allowed = () => { const s = currentSettings(); return s.shareArtwork === true && s.show?.artwork !== false && !s.privacy?.hideArtwork && !s.privacy?.redactTitles && s.privacy?.mode !== "private" && !s.privacy?.suppressMediaKinds?.includes(presence.kind) && presence.state !== "idle"; }; + if (allowed() && (presence.artwork || presence.artworkUrl) && typeof resolveArtwork === "function") { + try { artworkDataUri = await compactHostedArtwork(await resolveArtwork(presence.artwork ?? { publicUrl: presence.artworkUrl }, presence)); } catch { /* A missing cover must not stop playback uploads. */ } + } + if (!isCurrent()) return { sent: false, reason: "superseded" }; + const probe = projectHostedState(presence, currentSettings(), { seq: 0, now: at + serverClockOffset, artworkDataUri }); const key = changeKey(probe); if (!due(key, at, probe.positionMs)) return { sent: false, reason: "unchanged" }; const device = await ensureRegistered(); @@ -168,6 +177,9 @@ export function createHostedUploader({ let correctedSeq = false; while (true) { if (!isCurrent()) return { sent: false, reason: "superseded" }; + // Registration and retries can outlive a Settings save. Reapply the + // latest privacy choices immediately before each network write. + payload = projectHostedState(presence, currentSettings(), { seq: payload.seq, now: payload.observedAt, artworkDataUri }); try { await request("/api/ingest", { token: device.token, body: payload }); break; } catch (error) { // Never promote a rejected old payload above a newer local push. @@ -188,7 +200,7 @@ export function createHostedUploader({ } } if (!isCurrent()) return { sent: false, reason: "superseded" }; - lastSent = { key, at, positionMs: probe.positionMs ?? null, playing: probe.state === "playing" }; + lastSent = { key: changeKey(payload), at, positionMs: payload.positionMs ?? null, playing: payload.state === "playing" }; return { sent: true }; } diff --git a/src/http-handler.js b/src/http-handler.js index 158e794b..96eb206d 100644 --- a/src/http-handler.js +++ b/src/http-handler.js @@ -1,6 +1,7 @@ +import { cardPresets } from "./card-presets.js"; import { createHash } from "node:crypto"; -const THEMES = new Set(["midnight-blue", "paper", "compact"]); +const THEMES = new Set(Object.keys(cardPresets)); const VISIBILITY = new Set(["artwork", "mediaType", "progress", "state", "subtitle"]); export function parseCardQuery(searchParams) { diff --git a/src/settings-design.js b/src/settings-design.js new file mode 100644 index 00000000..8b8d02c3 --- /dev/null +++ b/src/settings-design.js @@ -0,0 +1,75 @@ +// Presentation layer for the existing Settings forms. Native details, labels +// and submit buttons keep keyboard behavior and section saves predictable. +export const SETTINGS_DESIGN_CSS = ` +html.drpp-root{height:100%;overflow:clip} +body.drpp-shell{height:100dvh;display:flex;flex-direction:column;overflow:clip;background:#191a1c;color:#e4e5e7;font:14px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif} +.drpp-shell .drpp-header{flex:none;min-height:72px;padding:16px 28px;background:#202124;gap:16px} +.drpp-shell h1{font-size:19px;font-weight:650;letter-spacing:-.5px} +.drpp-heading{gap:14px}.drpp-heading #drpp-version{font-size:12px;color:#adb0b8} +.drpp-actions{gap:8px}.drpp-actions a,.drpp-shell .drpp-actions button{border:0;background:transparent;color:#c3c6cc;padding:7px 10px;font-size:13px} +.drpp-actions a:hover,.drpp-shell .drpp-actions button:hover{background:#303237;color:#fff} +.drpp-columns{height:auto;flex:1;min-height:0} +.drpp-config{width:64%;background:#202124}.drpp-logs{width:36%;background:#191a1c} +.drpp-shell .drpp-panel-heading{padding:16px 24px;gap:12px;min-height:67px;border-color:#383a3f} +.drpp-shell .drpp-panel-heading h2{font-size:15px;font-weight:600} +.drpp-config>.drpp-panel-heading>span:not(.drpp-divider){color:#adb0b8;font-size:12px} +.drpp-shell .drpp-panel-heading label{font-size:12px;color:#c3c6cc} +.drpp-divider{background:#3d3f44;height:20px} +.settings-nav{display:flex;gap:6px;padding:12px 24px;border-bottom:1px solid #383a3f;flex:none;overflow-x:auto} +.drpp-shell .settings-nav a{color:#b4b7bf;font-size:13px;padding:5px 9px;white-space:nowrap;text-decoration:none;border-radius:4px} +.drpp-shell .settings-nav a:hover,.drpp-shell .settings-nav a[aria-current=true]{background:#33363c;color:#f3f4f6} +.drpp-config-scroll{padding:24px;scroll-behavior:smooth;scroll-padding-top:16px} +.drpp-config .drpp-accordion{background:#24262a;border:1px solid #42454b;border-radius:6px;margin:0 0 12px;overflow:clip} +.drpp-config .drpp-accordion>summary{padding:17px 20px;font-size:14px;font-weight:600;gap:16px} +.drpp-config .drpp-accordion>summary:hover{background:#2b2d32} +.drpp-config .drpp-accordion[open]>summary{border-color:#42454b} +.section-copy{display:flex;flex-direction:column;gap:2px}.section-copy small{font-size:12px;font-weight:400;color:#adb0b8} +.drpp-config .drpp-accordion section{padding:20px} +.drpp-shell .row{gap:10px 16px;margin:0 0 18px;align-items:center} +.drpp-shell .row label[for]{min-width:155px;max-width:100%;color:#d4d6dc;font-size:13px} +.drpp-config .hint,.drpp-config legend,.drpp-config dt,.drpp-config output,.drpp-config .unit{color:#b4b7bf;font-size:12px;line-height:1.7} +.drpp-config .hint{margin-bottom:18px;max-width:70ch} +.drpp-shell input,.drpp-shell select,.drpp-shell button{max-width:100%} +.drpp-shell button,.drpp-shell select,.drpp-shell input[type=number],.drpp-shell input[type=search],.drpp-shell input[type=text],.drpp-shell input[type=url],.drpp-shell input[type=password],.drpp-shell input:not([type]){border-radius:4px;background:#202124;color:#e4e5e7;padding:8px 10px;font-size:13px} +.drpp-shell button{border-color:#696d77;font-weight:500;min-height:34px} +.drpp-shell button:not(:disabled):hover{background:#35383f} +.drpp-shell button[type=submit]{background:#1971c2;border-color:#398bd7;color:#fff;padding:8px 18px} +.drpp-shell button[type=submit]:not(:disabled):hover{background:#1864ab} +.drpp-shell input:not([type=checkbox]):not([type=range]),.drpp-shell select{border-color:#858990} +.drpp-shell input[type=checkbox]{accent-color:#78aef1;width:16px;height:16px;vertical-align:-3px;margin:0 7px 0 0;flex:none} +.drpp-shell input[type=range]{width:min(180px,100%);accent-color:#8ab4f8} +.drpp-shell select{min-width:150px}.drpp-shell input[type=url],.drpp-shell input[type=text],.drpp-shell input[type=password],.drpp-shell input:not([type]){width:300px} +.drpp-shell input[type=search]{width:100%}.drpp-shell input::placeholder{color:#a8a9ae} +.drpp-shell :focus-visible{outline:2px solid #8ab4f8;outline-offset:3px} +.drpp-config a{color:#8fc4ff}.drpp-shell .ok{color:#8fc4ff}.drpp-shell .bad{color:#ffbb82}.drpp-shell .warn{color:#e0d070} +.drpp-config .drpp-setup{width:100%;background:#2d2922;border-color:#8e795a;border-radius:6px;margin:0 0 20px} +.drpp-setup a{width:fit-content;min-width:120px} +.drpp-shell #scan-help{font-size:12px;color:#b4b7bf;line-height:1.7;margin:0 0 16px} +.drpp-shell #discovered-list li,.drpp-shell #servers-list li{padding:12px 0;gap:12px;border-color:#474a51} +.drpp-shell #manual-add{margin-bottom:0}.drpp-shell #manual-add>summary{font-size:13px;color:#8fc4ff} +.drpp-shell #manual-add[open]>summary{margin-bottom:16px} +.drpp-shell #services-section h3{font-size:14px;font-weight:600}.drpp-shell #services-section>p{font-size:13px;color:#b4b7bf} +.drpp-shell #services-section>div{padding-top:12px;border-color:#474a51} +.drpp-shell #card-form .preview{margin:0 0 22px;padding:24px 16px;display:flex;flex-direction:column;align-items:center;background:#191a1c;border:1px solid #474a51;border-radius:4px;min-height:180px} +.drpp-shell .preview-label{align-self:flex-start;text-transform:uppercase;letter-spacing:.09em;font-size:10px;color:#adb0b8;margin:0 0 20px} +.drpp-shell .preview img{max-width:100%;height:auto}.drpp-shell .preview .hint{margin:12px 0 0} +.card-style-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin:0 0 20px} +.drpp-shell .card-style{padding:10px;text-align:left;display:flex;flex-direction:column;gap:8px;background:#202124;border:1px solid #696d77;min-width:0;border-radius:4px} +.drpp-shell .card-style[aria-pressed=true]{border:2px solid #8ab4f8;padding:9px;background:#283442} +.card-style img{display:block;width:100%;height:auto;aspect-ratio:280/140;object-fit:contain} +.card-style strong{font-size:12px;font-weight:600}.card-style small{color:#b4b7bf;font-size:11px;line-height:1.5;font-weight:400} +.drpp-shell .card-advanced{margin:20px 0;border-top:1px solid #474a51;padding-top:16px} +.drpp-shell .card-advanced>summary{color:#d4d6dc;font-size:13px;font-weight:500;margin-bottom:16px} +.drpp-shell .card-advanced[open]>summary{margin-bottom:22px} +.drpp-logs .drpp-panel-heading{padding:16px;align-content:flex-start;gap:10px} +.drpp-logs .drpp-panel-heading input{flex-basis:100%;order:4}.drpp-logs .drpp-panel-heading small{margin-left:auto} +.drpp-logs .drpp-divider{display:none}.drpp-logs .drpp-panel-heading label{font-size:11px} +.drpp-log-lines{font:12px/1.8 ui-monospace,SFMono-Regular,Consolas,monospace;padding:12px 0} +.drpp-log-line{border-left-width:2px;padding:2px 14px;background:transparent}.drpp-log-line .source{color:#b5bcc8} +#drpp-info{background:#24262a;border-color:#696d77;border-radius:6px} +@media(min-width:1500px){.drpp-config-scroll{padding:28px 40px}.drpp-config{width:62%}.drpp-logs{width:38%}} +@media(prefers-reduced-motion:reduce){.drpp-config-scroll{scroll-behavior:auto}} +@media(max-width:1100px){.drpp-config{width:66%}.drpp-logs{width:34%}.card-style-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.drpp-shell .drpp-panel-heading{padding:16px}.settings-nav{padding:12px 16px}.drpp-config-scroll{padding:16px}} +@media(max-width:900px){html.drpp-root{height:auto;overflow:auto}body.drpp-shell{height:auto;display:block;overflow:visible}.drpp-columns{height:auto;display:block}.drpp-config,.drpp-logs{width:100%}.drpp-config-scroll{max-height:none;overflow:visible}.drpp-shell .drpp-header{padding:16px}.drpp-logs{border-top:1px solid #474a51}.drpp-log-lines{min-height:220px;max-height:360px}.settings-nav{flex-wrap:wrap}.drpp-config>.drpp-panel-heading{flex-wrap:wrap}.drpp-header .drpp-divider{display:none}} +@media(max-width:520px){.drpp-config .drpp-accordion>summary,.drpp-config .drpp-accordion section{padding:16px}.drpp-shell .row label[for]{min-width:0;flex-basis:100%}.drpp-shell .row input:not([type=checkbox]):not([type=range]),.drpp-shell .row select{width:100%}.drpp-shell .drpp-heading{flex-wrap:wrap}.card-style-grid{gap:8px}.drpp-shell #card-form .preview{padding:20px 10px}} +`; diff --git a/src/settings-page-handler.js b/src/settings-page-handler.js index e7095920..4b554753 100644 --- a/src/settings-page-handler.js +++ b/src/settings-page-handler.js @@ -1,3 +1,6 @@ +import { cardPresets } from "./card-presets.js"; +import { renderCard } from "./card.js"; +import { SETTINGS_DESIGN_CSS } from "./settings-design.js"; // Local settings page (#253): Discord section (#272), including what // Discord shows when nothing is playing, and card appearance (#94). Served on loopback by // the running app next to the status page. Saves go to /api/settings as JSON; @@ -41,19 +44,20 @@ const buildPage = (startupCopy) => ` NowPlaying settings Skip to configuration
-

NowPlaying

Version: checking...
+

NowPlaying

Version: checking...

Configuration

Save each section below
+
-
Media servers${serverPanel()}
-
Connected services${servicePanel()}
-
Discord Settings +
Media serversConnect Plex, Jellyfin, Emby or Navidrome.${serverPanel()}
+
Connected servicesSpotify and your card hosting account.${servicePanel()}
+
Discord SettingsChoose what appears on your profile.

Discord

@@ -95,7 +99,7 @@ const buildPage = (startupCopy) => `
-
Privacy Settings +
Privacy SettingsControl what other people can see.
-
Card Settings +
Card SettingsChoose a style and make it your own.
-
YouTube Settings +
YouTube SettingsPair your browser extension.
-
Startup Settings +
Startup SettingsLaunch NowPlaying when you sign in.
-
Hosted Card Settings +
Hosted Card SettingsPut your card in a GitHub README.

Hosted card

-

Puts your card on nowplaying-hosted.vercel.app so a GitHub README can show it without opening your server to the internet. It sends only what your card shows: playing or paused, the title, artist and progress if the card shows them. Never your server address, user name, sign-in, artwork or Discord details. What leaves your PC

+

Puts your card on nowplaying-hosted.vercel.app so a GitHub README can show it without opening your server to the internet. It sends only what your card shows: playing or paused, the title, artist and progress if the card shows them. Never your server address, user name, sign-in or Discord details. Cover art is only uploaded if you enable it below. What leaves your PC

+

+

Adds album covers and posters to your public card. Images are resized and stripped of metadata on this PC; server addresses and artwork links are never sent. Hide artwork and Hide titles override this setting.

@@ -232,7 +240,7 @@ const buildPage = (startupCopy) => `
-
Hosted Card Devices +
Hosted Card DevicesManage the PCs updating your public card.