Skip to content

5. The App's Floating Remote UI

Docs 1-4 covered casting itself: finding a device, talking to it, telling it what to play, and working around what it can’t fetch on its own. None of that is Flutter-specific — it would apply to an iOS app, an Android app, or a web app just the same. This doc is different: it’s about a genuinely Flutter-specific problem — how do you show a persistent “now casting” remote control that stays visible no matter what screen the user is on?

Once you’re casting, a small “now casting” bar should sit near the bottom of the screen — any screen: the home feed, search, a content detail page, anywhere in the app — and tapping it should expand into a full remote control (play/pause, seek, quality, subtitles). It has to survive the user navigating around the app entirely, because casting is a session, not a screen.

Flutter apps are built around routes — screens pushed onto a stack, one at a time (Navigator.push, or in this app, the AppRouter). A route is, by definition, tied to navigation: it appears when pushed, it disappears when popped, and only one “top” route is generally visible at once. A persistent remote-control bar is the opposite of that — it isn’t a screen, it needs to float above every screen simultaneously, unaffected by whatever the user navigates to underneath it.

❌ if the remote were "just a route":
Home ──push──► Content Detail ──push──► Search
│ │ │
▼ ▼ ▼
(remote (remote (remote
visible) now hidden, still hidden —
buried under pushing routes
the new route) just keeps
covering it up)

Flutter has a widget purpose-built for “float above everything, independent of navigation”: Overlay. In fact, you’ve already used it without necessarily thinking of it that way — tooltips, snackbars, and dropdown menus are all implemented using an Overlay, because they share the exact same requirement (visible on top of whatever screen is currently showing, not tied to a specific route).

An Overlay is a stack of OverlayEntry objects, each an independent piece of floating content, composited on top of one another. Every MaterialApp/WidgetsApp already creates one automatically, positioned just above its Navigator — which is precisely why a Navigator’s pushed routes can show a SnackBar or a tooltip on top of themselves: the app’s Overlay sits one layer above the entire routing system.

normal app layering, from bottom to top:
┌─────────────────────────────────────────────┐
│ MaterialApp's own Overlay │ ← tooltips, snackbars,
│ (created automatically) │ dropdown menus live here
├─────────────────────────────────────────────┤
│ Navigator (the route stack: Home, │
│ Detail, Search, ... — only the top │
│ route paints) │
└─────────────────────────────────────────────┘

This app needs the cast remote to sit even above that — including above whatever the app’s own Overlay might be showing — which is why CastRemoteOverlay is mounted from MaterialApp’s own builder callback, the one place in the widget tree that sits above the Navigator entirely:

this app's actual layering:
┌─────────────────────────────────────────────┐
│ CastRemoteOverlay's own private Overlay │ ← the cast remote bar
│ (mounted from MaterialApp.builder) │ lives here — always on
│ │ top, regardless of
│ │ navigation
├─────────────────────────────────────────────┤
│ MaterialApp's own Overlay │
├─────────────────────────────────────────────┤
│ Navigator (Home, Detail, Search, ...) │
└─────────────────────────────────────────────┘

Practically, this is built as a Stack with two children: the whole app (everything MaterialApp would normally show) as the bottom layer, and a Positioned.fill(child: Overlay(...)) hosting the remote as the top layer. Being above the Navigator is exactly what makes the remote survive every push/pop — it was never inside the thing being navigated in the first place.

The other notable UI choice: expanding from a small bottom bar to a full-screen remote is driven by a single AnimationController (0.0 = collapsed, 1.0 = expanded), not by pushing a new route. Both the mini bar and the expanded layout are built simultaneously and cross-faded based on that one animation value — which is what lets a drag gesture feel continuous under your finger (drag up, it expands proportionally; let go, it snaps to whichever end is closer) instead of jumping discretely the way a route push/pop would.

expansion = 0.0 expansion = 0.5 expansion = 1.0
(fully collapsed) (mid-drag) (fully expanded)
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ │ │ fading out ▲ │ │ │
│ │ │ mini bar │ │ full remote │
│ │ │ ┄┄┄┄┄┄┄┄┄┄┄┄ │ │ (play/pause,│
│ │ │ fading in ▼ │ │ seek, etc) │
├───────────────┤ │ expanded view │ │ │
│ ▶ Now Casting │ └───────────────┘ └───────────────┘
└───────────────┘ (both are actually built here,
layered with opacity — never a
route push, so it can reverse
mid-gesture without popping anything)

The one exception: screens that own everything

Section titled “The one exception: screens that own everything”

An overlay that draws over every route would sit on top of the video player itself, which is absurd — you don’t need a remote bar over the thing you’re already watching. So CastRemoteOverlay keeps a _hiddenRoutes set (VideoPlayerRoute, VideoPlayerEpisode, ShortsSingleRoute, SplashRoute) and draws nothing while one of those is on top.

This is worth knowing because it has a non-obvious consequence: the remote only appears once you have left the player. Casting arranges that itself — connecting pops the player screen (video_player_screen.dart, _onCastStateChanged), which is the move that makes the mini bar show up at all. AirPlay had to be taught the same trick before its remote would appear; doc 6 covers what it must not copy from that flow.

The specific detail (Overlay, AnimationController-driven cross-fade) matters less than the reasoning that led there: when a requirement doesn’t fit the “one screen at a time” model routing gives you for free, that’s a signal you need something living outside the navigation stack, not a screen inside it. Flutter’s Overlay exists specifically for that category of UI — anything that needs to be visible regardless of what route is currently showing, rather than as a route.

  • lib/main.dart — CastRemoteOverlay is wired in via MaterialApp.router’s builder callback, the one hook that sits above the app’s own Navigator.
  • lib/presentation/widgets/player/cast/cast_remote_overlay.dart — the Stack + private Overlay structure described above, plus the AnimationController driving expand/collapse and the drag-gesture handling.
  • lib/presentation/widgets/player/cast/cast_mini_player.dart and cast_expanded_remote.dart — the two visual states that get cross-faded.

Across these five docs, casting turns out to be built from a small number of ideas, layered: find a device (mDNS), open a channel to it and speak its language (CASTV2 over TLS), tell it a URL and watch it play (HLS + LOAD/MEDIA_STATUS), make sure that URL is one it can actually fetch (the LAN proxy), and give the user a way to control all of this without it getting in the way of the rest of the app (an Overlay, not a route). None of it is magic — it’s a sequence of ordinary, individually understandable problems, each solved with the smallest tool that actually fits.