Casting is temporarily unavailable. It is switched off in shipped builds while a reported security issue is resolved. Local playback, downloads, and every server integration are unaffected, and your queue and settings are untouched. See Temporary containment below, and issues #572 and #575. The rest of this page describes how casting works and will work again once it is restored.
Linthra can hand a Jellyfin or Navidrome/Subsonic stream off to a Chromecast device (a Cast-enabled speaker, TV, or display) on your network. It uses a pure-Dart implementation of the Google Cast v2 protocol — no Google Play Services and no proprietary Cast SDK — so casting works the same on a sideloaded or F-Droid-style build.
- Be on the same Wi-Fi network as the Cast device.
- Start playing a streamed track (Jellyfin or Subsonic).
- Tap the cast icon in the Now Playing header to open the device sheet.
- Pick a device. Linthra resolves the stream at cast time, plays it on the receiver, and pauses local audio so you don't hear it twice.
- While connected, the sheet shows a Cast volume slider and mute that drive the device's own volume. Disconnecting (or the receiver dropping) resumes local playback, paused, at the receiver's last position — so it never surprise-starts the phone.
- On-device (local) files can't be cast — a receiver can't reach a
file://path on your phone, so only network streams hand off. The sheet says so plainly rather than failing silently. - Discovery uses mDNS, so a device only appears if it's reachable on your LAN (some guest/isolated networks block this).
- A device that reports a fixed volume shows an honest disabled state, and a failed volume command surfaces a calm notice without ever interrupting playback.
When Linthra hands a track to the default media receiver, it sends the track's metadata so the TV/speaker/display shows what's playing rather than a bare URL:
| Field | Sent? | Source |
|---|---|---|
| Title | ✅ | the track title |
| Artist | ✅ when known | the track artist |
| Album | ✅ when known | the track album |
| Duration | ✅ when known | the track's catalog duration (so the seek bar has a length immediately) |
| Artwork | ✅ Jellyfin · ❌ Subsonic | see below |
This metadata is built in one tested place — CastLoadMessage (in
lib/core/services/cast/) — from a CastMedia produced by the source's
CastMediaResolver. ChromecastCastTransport only serialises that message onto
the wire, so metadata formatting never scatters across the app or into a widget.
When the current track changes, the new track's metadata is sent automatically; a
re-emission of the same track is a no-op, so steady playback never reloads the
stream just to refresh metadata.
- Jellyfin cover art (
/Items/<id>/Images/Primary) needs no auth, so it is a safe, token-free URL the receiver can fetch directly — Linthra sends it. - Subsonic/Navidrome cover art (
getCoverArt) requires the salt+token in its query, so sending it would leak the credential to the receiver. Linthra omits Subsonic artwork rather than leak a token. (A token-free cover proxy is a possible follow-up.)
Artwork is only ever sent when it is a URL the receiver can actually reach without a credential; otherwise it is omitted.
The receiver shows "Default Media Receiver" (and your Cast device's name), not a Linthra name or logo. This is a limitation of the default Cast media receiver, not something Linthra can fix from the sender:
- The app name and logo shown on a Cast device are fixed by the receiver application that's running on the device, not by the sender or by any media metadata field. There is no Cast field that lets a sender set the receiver's displayed app name or logo.
- Linthra uses Google's published default media receiver (app id
CC1AD845) so casting needs no Google Play Services and no proprietary Cast SDK, which is what keeps it F-Droid/open-source friendly. That receiver renders the media metadata above, but always under its own generic branding. - Showing "Linthra" + the Linthra logo on the receiver would require shipping a custom Cast receiver application — a small hosted HTML/JS app registered in Google's Cast Developer Console under a Linthra-owned app id. That is a hosting + registration commitment (and arguably a Play-Services-adjacent dependency), so it is out of scope for now and tracked as a follow-up.
Linthra deliberately does not fake app branding it can't deliver: it improves exactly what the default receiver can show (title / artist / album / duration / artwork) and is honest about the rest.
The UI renders a CastState and drives discovery/connection through the
CastService interface, never a cast SDK directly — mirroring how the audio
engine is hidden behind PlaybackController.
DefaultCastServiceowns cast state and the playback handoff: it resolves the current track's stream URL at cast time, loads it on the receiver, pauses local audio, and resumes on disconnect. It delegates the wire protocol to a thinChromecastCastTransportover the pure-Dartcastpackage (Cast v2 over a TLS socket;bonsoirfor discovery). While containment is in place no build binds it: every platform getsUnavailableCastService.- The network-touching transport is isolated, so all of casting's decision-making
is unit-tested behind a fake
CastTransport; the only code that opens a socket is verified by analysis and on-device testing. - The single
ActivePlaybackControllerkeeps one source of truth: while casting, the now-playing screen / mini-player / lyrics follow the receiver's position/play-state, while the queue stays owned locally and track changes are mirrored onto the receiver. This is what fixes Cast desync. See architecture.md.
Casting is withheld from production builds by CastContainment while a reported
security issue is resolved. The report, its assessment, and the requirements a
restoration has to meet are held in the repository's private security advisory
and are not reproduced here, in the code, or in pull requests, until coordinated
disclosure. If you are picking this work up, ask a maintainer for advisory
access rather than inferring the details from the code.
It is enforced at three independent layers, because a containment one edit can undo is not a containment:
- Production wiring —
containedCastServiceOverridebindsUnavailableCastServiceon every platform. No live backend is constructed, so there is nothing to discover or connect with. - Transport —
ChromecastCastTransportrefuses discovery and connection outright. Re-wiring the service on its own, or injecting the transport from somewhere else, still cannot open a receiver socket. - Media handoff — the session handle refuses to hand media to a receiver, so a session obtained some other way is still given nothing.
CastContainment.isActive is a compile-time constant. There is deliberately no
setting, environment variable, or debug affordance that flips it: an unreviewed
runtime switch would be a second production path with none of the review the
restoration requires.
What demonstrates the containment is the test suite: the production-wiring tests
build the real override list per platform and drive the service, and the
transport tests call it directly. scripts/check_cast_containment.py, which also
runs in CI, is a much weaker tripwire on top of that — it matches source patterns
and only catches the obvious removals it knows about. Its limitations are written
down in the script itself; it is not evidence that casting cannot be reached.
Both of those look at the source tree. A release also has to answer a different
question — is the safeguard in the file people actually install? — so
scripts/verify_release_containment.py reads the compiled Dart inside the built
APK/AAB/tarball, and the release workflows run it before an artifact is uploaded
and again on the published assets. The shipped v0.2.6 artifacts and their
SHA-256 digests are recorded in
release-artifact-verification.md.
In the app, the cast button stays visible but muted and the sheet says casting is temporarily unavailable — it does not claim the platform is unsupported, which would be untrue on the phones this affects.
Restoration is a separate, reviewed change, not a revert. Its requirements are tracked publicly in #575, and in detail in the private advisory, which is where that work is reviewed before anything is restored. #576 is an additional layer, not a substitute for it.
The trust model, the contract already in the tree (CastReceiverAuthenticator
and the fail-closed TrustGatedCastTransport), the implementation options and
the test matrix are in
cast-receiver-trust.md, and the concrete design for
what a real receiver check would have to do (the Cast device-authentication
exchange, the pinned trust anchors, per-device pinning, and where the
cryptography would come from) is in
cast-hardened-design.md. What a handoff delegates to a
receiver, per server, is in cast-media-access.md.
The reviewed restoration updates CastContainment, the production wiring, the
transport guards, and scripts/check_cast_containment.py in one pull request.
While connected, the Cast sheet shows a clearly labelled Cast volume slider
plus mute, driving the device's own volume (not the phone's media volume) and
following the receiver's reported level live. It is all behind CastService
(setVolume / volumeUp / volumeDown / setMuted), with CastState exposing
volume / muted / supportsVolumeControl.
These describe the handoff as designed; while casting is contained, none of it runs, because no handoff happens at all.
The handoff resolves the current track's stream URL only at cast time
(Jellyfin's or Subsonic's authenticated URL, the credential woven in on demand)
and it is never logged or persisted. A track's stored reference stays the
token-free jellyfin:<id> / subsonic:<id>; the receiver is told to fetch a
freshly minted URL that never lands in Track, the catalog, a log, or app state.
- The token rides on exactly one field — the
contentId(the stream URL the receiver fetches). It must be there; the receiver pulls the bytes itself. - Nothing else carries it. The displayed metadata (title / artist / album /
artwork) never embeds the token;
CastMedia.toString()redacts the stream URL down to scheme/host/path; and the only diagnostics line emitted at cast time has no field for a token or full URL. - Artwork follows the same rule: a tokenised cover-art URL is never sent. Jellyfin's cover art is token-free (sent); Subsonic's needs the credential (omitted).
How much authority that one field carries is a property of the server, not a
choice: neither Jellyfin nor Subsonic issues a per-item capability, so the URL a
receiver is given is backed by an account credential. Each source declares that
in code (CastMediaAccess), so it can be stated rather than assumed — see
cast-media-access.md for the per-server matrix and what
would have to change.
- A dropped receiver returns playback to the device paused at the last position, with a friendly Cast/session notice — it never restarts unexpectedly.
- Local engine errors are ignored while casting (the engine is suspended), so a cast session never falls back to duplicate local playback. See streaming.md.
- Casting is off in shipped builds pending the security fix above. Everything below describes the feature as built, for when it returns.
- No Linthra app name/logo on the receiver with the default media receiver — it shows "Default Media Receiver". True branding needs a custom receiver app (see above). Tracked as a follow-up.
- Subsonic/Navidrome artwork is not shown on the receiver, because its cover-art URL would leak the credential. Jellyfin artwork is shown.
- On-device files can't be cast (no receiver-reachable URL).
- Receiver transport controls (volume aside) and local-file casting are follow-ups.
- Per-track content type is a best-effort
audio/mpeghint; an exact MIME / transcoded cast profile for exotic codecs is a follow-up. - mDNS discovery depends on a LAN that allows it.