5. The App's Floating Remote UI
5. The App’s Floating Remote UI
Section titled “5. The App’s Floating Remote UI”Where we left off
Section titled “Where we left off”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?
The requirement, stated precisely
Section titled “The requirement, stated precisely”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.
Why a normal screen/route can’t do this
Section titled “Why a normal screen/route can’t do this”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)The tool for this job: Overlay
Section titled “The tool for this job: Overlay”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 mini bar ↔ full remote transition
Section titled “The mini bar ↔ full remote transition”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 general principle worth taking away
Section titled “The general principle worth taking away”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.
Where this lives in the code
Section titled “Where this lives in the code”lib/main.dart—CastRemoteOverlayis wired in viaMaterialApp.router’sbuildercallback, the one hook that sits above the app’s ownNavigator.lib/presentation/widgets/player/cast/cast_remote_overlay.dart— theStack+ privateOverlaystructure described above, plus theAnimationControllerdriving expand/collapse and the drag-gesture handling.lib/presentation/widgets/player/cast/cast_mini_player.dartandcast_expanded_remote.dart— the two visual states that get cross-faded.
The takeaway (and the end of the series)
Section titled “The takeaway (and the end of the series)”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.