DrawFiddle▶ RunEdit in Fiddle
// ── Now Playing ──────────────────────────────────────────────────────────
// A real mobile product screen, not a control gallery: ambient blurred
// backdrop lifted from the cover, hero art, a scrubber you can drag, and two
// drawers — navigation from the left, the queue from the bottom — that slide
// over the player instead of stealing room from it. All on one canvas.

const string IconPlay = "M8 5v14l11-7z";
const string IconPause = "M6 5h3.5v14H6zM14.5 5H18v14h-3.5z";
const string IconPrev = "M6 6h2.5v12H6zM20 6v12l-9.5-6z";
const string IconNext = "M4 6l9.5 6L4 18zM15.5 6H18v12h-2.5z";
const string IconShuffle = "M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z";
const string IconRepeat = "M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z";
const string IconHeart = "M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z";
const string IconMenu = "M3 6h18v2H3zM3 11h18v2H3zM3 16h18v2H3z";
const string IconHome = "M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z";
const string IconSearch = "M15.5 14h-.79l-.28-.27A6.47 6.47 0 0016 9.5 6.5 6.5 0 109.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z";
const string IconQueue = "M15 6H3v2h12V6zm0 4H3v2h12v-2zM3 16h8v-2H3v2zM17 6v8.18c-.31-.11-.65-.18-1-.18-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3V8h3V6h-5z";
const string IconPerson = "M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z";
const string IconChevronUp = "M12 8.59l6 6L16.59 16 12 11.42 7.41 16 6 14.59z";
const string IconClose = "M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z";

var ink = Color.Parse("#0D0A12");
var accent = Color.Parse("#F58BA6");
var white = Color.Parse("#FFFFFF");
var dim = Color.Parse("#A79FB4");

// the hairline down the drawer's opening edge: colour here, opacity at its
// top and foot below (it brightens as it descends because the panel behind
// it darkens, otherwise the bottom half looks like it fades out)
var edgeColor = Color.Parse("#1F0C3A");
const float EdgeAlphaTop = 0.30f, EdgeAlphaFoot = 0.40f;

// Real cover art off the network. Unsplash serves these with
// Access-Control-Allow-Origin: *, which is what makes them loadable from
// WebAssembly at all — a host without CORS just yields a blank image here.
// The Tint colour stays as the placeholder shown while a sleeve downloads.
const string Art = "https://images.unsplash.com/photo-";
var tracks = new List<Track>
{
    new("Neon Pebbles", "Wave Atlas", 254, "#F58BA6", Art + "1470225620780-dba8ba36b745?w=500&h=500&fit=crop&q=70"),
    new("Slow Static", "Wave Atlas", 198, "#7FB6E8", Art + "1493225457124-a3eb161ffa5f?w=500&h=500&fit=crop&q=70"),
    new("Harbour Lights", "Mira Kess", 221, "#8FD3C1", Art + "1511671782779-c97d3d27a1d4?w=500&h=500&fit=crop&q=70"),
    new("Paper Planes", "The Long Wait", 176, "#E8C07F", Art + "1459749411175-04bf5292ceea?w=500&h=500&fit=crop&q=70"),
    new("Blue Hour", "Mira Kess", 243, "#B49BE8", Art + "1507838153414-b4b713384a76?w=500&h=500&fit=crop&q=70"),
};

var index = 0;
var playing = true;
var liked = true;
var shuffle = false;
var repeatAll = true;
double position = 42;

// both drawers start closed: the player owns the screen until you ask for more
var railOpen = false;
var queueOpen = false;
var tab = 2;
const double RailWide = 216;

// ── drawer swing, the knobs worth playing with ───────────────────────────
// Tilt: degrees of 3d rotation around the drawer-side edge.
//   positive = the far edge recedes away from you (current look)
//   negative = the far edge grows and leans TOWARDS you
//   0        = no 3d at all, the screen just slides sideways
// Bigger absolute values lean harder and push more of the far side off the
// canvas; -8 is a hint, -30 is dramatic.
const double DrawerTilt = 21;
// How far the camera sits from the page, as a MULTIPLE of the page width.
// This has to scale with the canvas: at a fixed distance a wider page pushes
// its far edge deeper in z, the projection shrinks it harder, and the stretch
// needed to reach the border runs away (a 1400pt canvas asked for 4.3x).
// Relative depth keeps the perspective looking identical at any size.
const double PerspectiveDepth = 1.6;
// how far the screen slides right, as a share of the drawer width: 1.0 puts
// its edge exactly against the drawer, less lets the drawer overlap it
const double DrawerShift = 1.0;
// corner radius the screen picks up while it is swung out
const double DrawerRound = 0;   // 0 = square corners; the far edge is a cut through the plane, not a card
// Horizontal factor (ScaleY stays 1, so the near edge keeps full height).
// Solved from the live canvas size every time it changes — see ObserveSelf.
// It is NOT always < 1: at a sharp tilt the projection shrinks the far edge to
// ~60% of the page, so reaching the right border needs the page STRETCHED
// horizontally, not squeezed. That stretch is the price of holding all three
// wishes at once: near edge full height, far edge on the border, sharp angle.
double drawerSqueeze = 1;
// nudge up off the floor, as a share of canvas height (0 = stands on it)
const double DrawerLift = 0;
// the floor mirror: degrees the reflected copy is tipped away from vertical.
// 0 = a plain mirror in the page's own plane, which reads as a WALL; 90 would
// lay it perfectly flat and it would vanish. Its foreshortening and its
// convergence both fall out of this one rotation, so they agree with the
// page's own projection instead of fighting it.
const double FloorAngle = 74;
// seconds for the whole swing
const double DrawerTime = 0.32;

SkiaLabel titleLabel = null, artistLabel = null, elapsedLabel = null, leftLabel = null;
SkiaLabel nextTitle = null, nextArtist = null;
SkiaSvg playIcon = null, heartIcon = null, shuffleIcon = null, repeatIcon = null;
SkiaImage nextArt = null, heroArt = null, ambient = null;
// Source is an ImageSource, not a string, so remember what is on screen to
// avoid re-fetching (and re-blurring the backdrop) on every clock tick
string shownArt = null, shownNext = null;
SkiaShape artCard = null, nextCard = null, sheet = null;
SkiaLayer progressFill = null, scrim = null;
SkiaShape stage = null;
SkiaLayout queue = null, rail = null;
SkiaLayer railDrawer = null;
SkiaShape edgeLine = null;
SkiaLayer menuButton = null, reflection = null;
var ground = new Sk3dView();   // reused, so the floor costs no allocation per frame
double reflect = 0;   // 0 = drawer shut, 1 = fully open, drives the floor mirror

string Svg(string path, string fill = "#FFFFFF") =>
    $"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><path d=\"{path}\" fill=\"{fill}\"/></svg>";

SkiaSvg Icon(string path, double size, Color tint, double opacity = 1) => new SkiaSvg
{
    UseCache = SkiaCacheType.Image,
    SvgString = Svg(path),
    WidthRequest = size,
    HeightRequest = size,
    Aspect = TransformAspect.AspectFit,
    TintColor = tint,
    Opacity = opacity,
    HorizontalOptions = LayoutOptions.Center,
    VerticalOptions = LayoutOptions.Center,
};

string Clock(double seconds)
{
    var total = (int)Math.Max(0, seconds);
    return $"{total / 60}:{total % 60:00}";
}

// One tap target for every icon button: keeps the hit area honest while the
// glyph inside stays small.
SkiaLayer Tapper(double size, SkiaControl content, Action action) => new SkiaLayer
{
    WidthRequest = size,
    HeightRequest = size,
    VerticalOptions = LayoutOptions.Center,
    Children = new List<SkiaControl> { content },
}.OnTapped(me => action());

// A cover thumbnail. The tint sits behind the image so a row still reads as
// itself while its sleeve is still coming down the wire.
SkiaShape Cover(double size, double radius, string tint, string art, out SkiaImage image) => new SkiaShape
{
    UseCache = SkiaCacheType.Image,
    Type = ShapeType.Rectangle,
    CornerRadius = radius,
    WidthRequest = size,
    HeightRequest = size,
    IsClippedToBounds = true,
    BackgroundColor = Color.Parse(tint),
    VerticalOptions = LayoutOptions.Center,
    Children = new List<SkiaControl>
    {
        new SkiaImage
        {
            Source = art,
            Aspect = TransformAspect.AspectCover,
            HorizontalOptions = LayoutOptions.Fill,
            VerticalOptions = LayoutOptions.Fill,
        }.Assign(out image),
    },
};


void Sync()
{
    var track = tracks[index];
    var next = tracks[(index + 1) % tracks.Count];
    titleLabel.Text = track.Title;
    artistLabel.Text = track.Artist;
    elapsedLabel.Text = Clock(position);
    leftLabel.Text = "-" + Clock(track.Seconds - position);
    progressFill.HorizontalFillRatio = Math.Clamp(position / track.Seconds, 0.004, 1);
    playIcon.SvgString = Svg(playing ? IconPause : IconPlay);
    heartIcon.TintColor = liked ? accent : dim;
    heartIcon.Opacity = liked ? 1 : 0.7;
    shuffleIcon.TintColor = shuffle ? accent : dim;
    repeatIcon.TintColor = repeatAll ? accent : dim;
    artCard.BackgroundColor = Color.Parse(track.Tint);
    nextTitle.Text = next.Title;
    nextArtist.Text = next.Artist;
    nextCard.BackgroundColor = Color.Parse(next.Tint);
    if (shownArt != track.Art)
    {
        shownArt = track.Art;
        heroArt.Source = track.Art;
        // the backdrop is this track's own sleeve, blown up and blurred: one
        // blur per track change, not per frame, since the layer stays cached
        ambient.Source = track.Art;
    }
    if (shownNext != next.Art)
    {
        shownNext = next.Art;
        nextArt.Source = next.Art;
    }
}

void Slide(SkiaControl drawer, bool horizontal, double to)
{
    var from = horizontal ? drawer.TranslationX : drawer.TranslationY;
    drawer.Animate(0.24, (me, animator, value, delta) =>
    {
        var at = from + (to - from) * value;
        if (horizontal) me.TranslationX = at; else me.TranslationY = at;
    }, easing: Easing.CubicOut);
}

// The sheet dims the player behind it, the nav drawer does not — but both
// need something to catch an outside tap, so the layer stays there either way
// and only its colour changes.
void Catcher()
{
    scrim.IsVisible = railOpen || queueOpen;
    scrim.BackgroundColor = queueOpen ? Color.Parse("#99000000") : Color.Parse("#01000000");
}

void ShowRail(bool open)
{
    railOpen = open;
    Catcher();

    // one animator drives both sides: the drawer slides in from the left while
    // the stage translates, shrinks and rotates around its left edge, so the
    // screen reads as a page swinging open rather than a panel dropped on top
    var fromDrawer = railDrawer.TranslationX;
    var toDrawer = open ? 0 : -RailWide;
    var fromShift = stage.TranslationX;
    var toShift = open ? RailWide * DrawerShift : 0;
    var fromTilt = stage.RotationY;
    var toTilt = open ? DrawerTilt : 0.0;
    // the mirror needs the stage as a bitmap; outside the drawer it goes back
    // to plain rendering so a clock tick does not re-raster the whole screen
    stage.UseCache = SkiaCacheType.Image;
    var fromScale = stage.ScaleX;
    var toScale = open ? drawerSqueeze : 1.0;
    var fromLift = stage.TranslationY;
    var toLift = open ? -stage.Height * DrawerLift : 0.0;
    var fromReflect = reflect;
    var toReflect = open ? 1.0 : 0.0;
    var fromMenu = menuButton.Opacity;
    var toMenu = open ? 0.0 : 1.0;
    var fromRound = stage.CornerRadius.TopRight;
    var toRound = open ? DrawerRound : 0.0;

    railDrawer.Animate(DrawerTime, (me, animator, value, delta) =>
    {
        me.TranslationX = fromDrawer + (toDrawer - fromDrawer) * value;
        stage.TranslationX = fromShift + (toShift - fromShift) * value;
        // no scale at all: the hinge sits on the camera plane, so the near
        // edge keeps EXACTLY the full height and only the far edge changes
        stage.ScaleX = fromScale + (toScale - fromScale) * value;
        stage.TranslationY = fromLift + (toLift - fromLift) * value;
        stage.RotationY = fromTilt + (toTilt - fromTilt) * value;
        // only one hamburger on screen at a time
        menuButton.Opacity = fromMenu + (toMenu - fromMenu) * value;
        reflect = fromReflect + (toReflect - fromReflect) * value;
        reflection.Update();
        if (value >= 1 && !railOpen) stage.UseCache = SkiaCacheType.None;
        var round = fromRound + (toRound - fromRound) * value;
        stage.CornerRadius = new CornerRadius(0, round, 0, round);
    }, easing: Easing.CubicOut);
}

void ShowSheet(bool open)
{
    queueOpen = open;
    Catcher();
    Slide(sheet, false, open ? 0 : sheet.Height);
}

// Rebuilt whenever the track changes, so the drawer always lists what is
// actually coming — runtime mutation, hence ClearChildren/AddSubView.
void BuildQueue()
{
    if (queue == null) return;
    queue.ClearChildren();
    for (int step = 1; step < tracks.Count; step++)
    {
        var at = (index + step) % tracks.Count;
        var track = tracks[at];
        var target = at;
        SkiaImage thumb = null;
        queue.AddSubView(new SkiaLayer
        {
            UseCache = SkiaCacheType.Image,
            HeightRequest = 62,
            HorizontalOptions = LayoutOptions.Fill,
            Children = new List<SkiaControl>
            {
                new SkiaRow
                {
                    Spacing = 12,
                    VerticalOptions = LayoutOptions.Center,
                    HorizontalOptions = LayoutOptions.Fill,
                    Children = new List<SkiaControl>
                    {
                        Cover(44, 10, track.Tint, track.Art, out thumb),
                        new SkiaStack
                        {
                            Spacing = 3,
                            VerticalOptions = LayoutOptions.Center,
                            HorizontalOptions = LayoutOptions.Fill,
                            Children = new List<SkiaControl>
                            {
                                new SkiaLabel(track.Title)
                                {
                                    FontSize = 15,
                                    FontFamily = "FontTextTitle",
                                    TextColor = white,
                                    MaxLines = 1,
                                    LineBreakMode = LineBreakMode.TailTruncation,
                                },
                                new SkiaLabel(track.Artist) { FontSize = 12.5, TextColor = dim },
                            },
                        },
                        new SkiaLabel(Clock(track.Seconds))
                        {
                            FontSize = 12.5,
                            TextColor = dim,
                            VerticalOptions = LayoutOptions.Center,
                        },
                    },
                },
            },
        }.OnTapped(me =>
        {
            index = target;
            position = 0;
            playing = true;
            Sync();
            BuildQueue();
            ShowSheet(false);
        }));
    }
}

// Navigation drawer, closed by default. Icons plus labels, and the playlists
// a narrow rail would have no room for.
void BuildRail()
{
    if (rail == null) return;
    var items = new (string Icon, string Caption)[]
    {
        (IconHome, "Home"), (IconSearch, "Search"), (IconQueue, "Library"), (IconPerson, "You"),
    };
    rail.ClearChildren();
    for (int i = 0; i < items.Length; i++)
    {
        var at = i;
        var on = i == tab;
        rail.AddSubView(new SkiaRow
        {
            UseCache = SkiaCacheType.Image,
            Spacing = 18,
            Padding = new Thickness(20, 12, 12, 12),
            HorizontalOptions = LayoutOptions.Fill,
            Children = new List<SkiaControl>
            {
                Icon(items[i].Icon, 22, on ? white : dim),
                new SkiaLabel(items[i].Caption)
                {
                    FontSize = 13.5,
                    TextColor = on ? white : dim,
                    VerticalOptions = LayoutOptions.Center,
                    HorizontalOptions = LayoutOptions.Fill,
                },
            },
        }.OnTapped(me =>
        {
            tab = at;
            BuildRail();
        }));
    }

    rail.AddSubView(new SkiaShape
    {
        Type = ShapeType.Rectangle,
        HeightRequest = 1,
        Margin = new Thickness(20, 10, 20, 10),
        HorizontalOptions = LayoutOptions.Fill,
        BackgroundColor = Color.Parse("#22FFFFFF"),
    });
    rail.AddSubView(new SkiaLabel("PLAYLISTS")
    {
        FontSize = 10,
        CharacterSpacing = 1.4,
        TextColor = dim,
        Margin = new Thickness(20, 0, 0, 6),
    });
    foreach (var name in new[] { "Late Night Coding", "Deep Focus", "Sunday Drive" })
    {
        rail.AddSubView(new SkiaLabel(name)
        {
            FontSize = 13,
            TextColor = white,
            Opacity = 0.85,
            MaxLines = 1,
            LineBreakMode = LineBreakMode.TailTruncation,
            Margin = new Thickness(20, 0, 12, 0),
            Padding = new Thickness(0, 9, 0, 9),
        });
    }
}

void Go(int delta)
{
    index = (index + delta + tracks.Count) % tracks.Count;
    position = 0;
    Sync();
    BuildQueue();
}

// Playback clock on real elapsed time, so it stays honest if a frame is late.
async void Play(SkiaControl root)
{
    var last = DateTime.Now;
    var shown = -1;
    while (!root.IsDisposed && !root.IsDisposing)
    {
        await System.Threading.Tasks.Task.Delay(250);
        var now = DateTime.Now;
        var elapsed = (now - last).TotalSeconds;
        last = now;
        if (!playing) continue;
        position += elapsed;
        if (position >= tracks[index].Seconds)
        {
            if (repeatAll) Go(1);
            else { position = tracks[index].Seconds; playing = false; }
        }
        // nothing on screen changes between whole seconds, so nothing is
        // redrawn between them either
        if ((int)position == shown) continue;
        shown = (int)position;
        Sync();
    }
}

var root = new SkiaLayer
{
    BackgroundColor = ink,
    HorizontalOptions = LayoutOptions.Fill,
    VerticalOptions = LayoutOptions.Fill,
    Children = new List<SkiaControl>
    {
        // ── the stage: everything the drawer pushes aside ──────────────
        // backdrop, player and the up-next card live in one container so the
        // drawer can swing the WHOLE screen away in 3D instead of covering it.
        // AnchorX = 0 puts the pivot on the left edge, so it hinges like a door.
        new SkiaShape
        {
            Type = ShapeType.Rectangle,
            HorizontalOptions = LayoutOptions.Fill,
            VerticalOptions = LayoutOptions.Fill,
            IsClippedToBounds = true,
            AnchorX = 0,
            // hinge on the left edge, pivot at half height: the projection then
            // shortens the far edge symmetrically, so its top corner drops below
            // the canvas top by as much as its bottom corner rises above the foot
            AnchorY = 0.5,
            BackgroundColor = ink,
            Children = new List<SkiaControl>
            {
            // ── layer 1: never changes ─────────────────────────────────────
            // The cover blown up and blurred, plus the scrim that keeps text
            // readable over it. Both are static, so they share ONE cached layer:
            // the blur runs once into that bitmap, every later frame is a blit.
            new SkiaLayer
            {
                HorizontalOptions = LayoutOptions.Fill,
                VerticalOptions = LayoutOptions.Fill,
                UseCache = SkiaCacheType.Image,
                Children = new List<SkiaControl>
                {
                    new SkiaImage
                    {
                        Source = tracks[0].Art,
                        Aspect = TransformAspect.AspectCover,
                        Blur = 34,
                        Opacity = 0.55,
                        HorizontalOptions = LayoutOptions.Fill,
                        VerticalOptions = LayoutOptions.Fill,
                    }.Assign(out ambient),
                    new SkiaLayer
                    {
                        HorizontalOptions = LayoutOptions.Fill,
                        VerticalOptions = LayoutOptions.Fill,
                        FillGradient = new SkiaGradient
                        {
                            Type = GradientType.Linear,
                            StartXRatio = 0, StartYRatio = 0,
                            EndXRatio = 0, EndYRatio = 1,
                            Colors = new List<Color> { Color.Parse("#2E0D0A12"), Color.Parse("#F20D0A12") },
                            ColorPositions = new List<double> { 0.0, 0.78 },
                        },
                    },
                },
            },

            // ── layer 2: the player, never scrolls ─────────────────────────
            new SkiaStack
            {
                Spacing = 0,
                Padding = new Thickness(22, 14, 22, 94),
                HorizontalOptions = LayoutOptions.Fill,
                VerticalOptions = LayoutOptions.Fill,
                Children = new List<SkiaControl>
                {
                    // ── header: hamburger + what is playing ────────────────
                    new SkiaRow
                    {
                        HorizontalOptions = LayoutOptions.Fill,
                        Children = new List<SkiaControl>
                        {
                            // opens the navigation drawer (layer 5); fades out
                            // as the drawer arrives, since the drawer carries
                            // its own hamburger
                            Tapper(40, Icon(IconMenu, 22, white, 0.9), () => ShowRail(true))
                                .Assign(out menuButton),

                            // playlist caption + name; the 40pt right margin
                            // balances the hamburger so the text stays centred
                            new SkiaStack
                            {
                                Spacing = 2,
                                HorizontalOptions = LayoutOptions.Fill,
                                VerticalOptions = LayoutOptions.Center,
                                Margin = new Thickness(0, 0, 40, 0),
                                Children = new List<SkiaControl>
                                {
                                    new SkiaLabel("PLAYING FROM PLAYLIST")
                                    {
                                        FontSize = 10,
                                        CharacterSpacing = 1.6,
                                        TextColor = dim,
                                        HorizontalTextAlignment = DrawTextAlignment.Center,
                                        HorizontalOptions = LayoutOptions.Fill,
                                    },
                                    new SkiaLabel("Late Night Coding")
                                    {
                                        FontSize = 13.5,
                                        FontFamily = "FontTextTitle",
                                        TextColor = white,
                                        HorizontalTextAlignment = DrawTextAlignment.Center,
                                        HorizontalOptions = LayoutOptions.Fill,
                                    },
                                },
                            },
                        },
                    },

                    // ── hero art: cover in a rounded card with a soft shadow ──
                    // side is set at runtime from the canvas height, see ObserveSelf
                    new SkiaShape
                    {
                        Type = ShapeType.Rectangle,
                        CornerRadius = 24,
                        WidthRequest = 232,
                        HeightRequest = 232,
                        IsClippedToBounds = true,
                        UseCache = SkiaCacheType.Image,
                        HorizontalOptions = LayoutOptions.Center,
                        Margin = new Thickness(0, 16, 0, 0),
                        BackgroundColor = accent,
                        Shadows = new List<SkiaShadow>
                        {
                            new SkiaShadow { Blur = 44, X = 0, Y = 26, Opacity = 0.55, Color = Color.Parse("#000000") },
                        },
                        Children = new List<SkiaControl>
                        {
                            new SkiaImage
                            {
                                Source = tracks[0].Art,
                                Aspect = TransformAspect.AspectCover,
                                HorizontalOptions = LayoutOptions.Fill,
                                VerticalOptions = LayoutOptions.Fill,
                            }.Assign(out heroArt),
                        },
                    }.Assign(out artCard),

                    // ── title + artist, like button on the right ───────────
                    new SkiaRow
                    {
                        Spacing = 12,
                        HorizontalOptions = LayoutOptions.Fill,
                        Margin = new Thickness(0, 20, 0, 0),
                        Children = new List<SkiaControl>
                        {
                            new SkiaStack
                            {
                                Spacing = 5,
                                HorizontalOptions = LayoutOptions.Fill,
                                VerticalOptions = LayoutOptions.Center,
                                Children = new List<SkiaControl>
                                {
                                    new SkiaLabel("Neon Pebbles")
                                    {
                                        FontSize = 25,
                                        FontFamily = "FontTextTitle",
                                        TextColor = white,
                                        MaxLines = 1,
                                        LineBreakMode = LineBreakMode.TailTruncation,
                                    }.Assign(out titleLabel),
                                    new SkiaLabel("Wave Atlas")
                                    {
                                        FontSize = 15,
                                        TextColor = dim,
                                        MaxLines = 1,
                                    }.Assign(out artistLabel),
                                },
                            },
                            Tapper(44, Icon(IconHeart, 24, accent).Assign(out heartIcon), () =>
                            {
                                liked = !liked;
                                Sync();
                            }),
                        },
                    },

                    // ── scrubber ──────────────────────────────────────────
                    // the whole 30pt strip takes the gestures so the 4pt bar is
                    // easy to grab; Sync() drives the played part below
                    new SkiaLayer
                    {
                        HeightRequest = 30,
                        HorizontalOptions = LayoutOptions.Fill,
                        Margin = new Thickness(0, 16, 0, 0),
                        Children = new List<SkiaControl>
                        {
                            // unplayed track
                            new SkiaShape
                            {
                                Type = ShapeType.Rectangle,
                                CornerRadius = 2,
                                HeightRequest = 4,
                                HorizontalOptions = LayoutOptions.Fill,
                                VerticalOptions = LayoutOptions.Center,
                                BackgroundColor = Color.Parse("#33FFFFFF"),
                            },
                            // played part, width = HorizontalFillRatio, with the
                            // thumb riding its right edge
                            new SkiaLayer
                            {
                                HeightRequest = 28,
                                HorizontalOptions = LayoutOptions.Fill,
                                HorizontalFillRatio = 0.2,
                                Children = new List<SkiaControl>
                                {
                                    new SkiaShape
                                    {
                                        Type = ShapeType.Rectangle,
                                        CornerRadius = 2,
                                        HeightRequest = 4,
                                        HorizontalOptions = LayoutOptions.Fill,
                                        VerticalOptions = LayoutOptions.Center,
                                        BackgroundColor = white,
                                    },
                                    new SkiaShape
                                    {
                                        Type = ShapeType.Circle,
                                        WidthRequest = 13,
                                        HeightRequest = 13,
                                        HorizontalOptions = LayoutOptions.End,
                                        VerticalOptions = LayoutOptions.Center,
                                        TranslationX = 6,
                                        BackgroundColor = white,
                                    },
                                },
                            }.Assign(out progressFill),
                        },
                    }
                    .WithGestures((me, args, apply) =>
                    {
                        if (args.Type == TouchActionResult.Down || args.Type == TouchActionResult.Panning)
                        {
                            var inside = me.GetOffsetInsideControlInPoints(args.Event.Location, apply.ChildOffset);
                            position = Math.Clamp(inside.X / me.Width, 0, 1) * tracks[index].Seconds;
                            Sync();
                            return me;
                        }
                        return null;
                    }),

                    // ── elapsed / remaining ───────────────────────────────
                    new SkiaRow
                    {
                        HorizontalOptions = LayoutOptions.Fill,
                        Margin = new Thickness(2, 2, 2, 0),
                        Children = new List<SkiaControl>
                        {
                            new SkiaLabel("0:42") { FontSize = 11.5, TextColor = dim }.Assign(out elapsedLabel),
                            new SkiaLabel("-3:32")
                            {
                                FontSize = 11.5,
                                TextColor = dim,
                                HorizontalTextAlignment = DrawTextAlignment.End,
                                HorizontalOptions = LayoutOptions.Fill,
                            }.Assign(out leftLabel),
                        },
                    },

                    // ── transport: shuffle, prev, play/pause, next, repeat ──
                    // fixed widths and a centred group: a SkiaRow does NOT split
                    // width between Fill children, that threw the play button
                    // off centre
                    new SkiaRow
                    {
                        Spacing = 14,
                        HorizontalOptions = LayoutOptions.Center,
                        Margin = new Thickness(0, 10, 0, 0),
                        UseCache = SkiaCacheType.Operations,
                        Children = new List<SkiaControl>
                        {
                            Tapper(46, Icon(IconShuffle, 20, dim).Assign(out shuffleIcon), () =>
                            {
                                shuffle = !shuffle;
                                Sync();
                            }),
                            Tapper(56, Icon(IconPrev, 30, white), () => Go(-1)),
                            new SkiaShape
                            {
                                Type = ShapeType.Circle,
                                WidthRequest = 64,
                                HeightRequest = 64,
                                BackgroundColor = white,
                                VerticalOptions = LayoutOptions.Center,
                                Shadows = new List<SkiaShadow>
                                {
                                    new SkiaShadow { Blur = 22, X = 0, Y = 8, Opacity = 0.35, Color = Color.Parse("#000000") },
                                },
                                Children = new List<SkiaControl>
                                {
                                    Icon(IconPause, 28, ink).Assign(out playIcon),
                                },
                            }.OnTapped(me =>
                            {
                                playing = !playing;
                                Sync();
                            }),
                            Tapper(56, Icon(IconNext, 30, white), () => Go(1)),
                            Tapper(46, Icon(IconRepeat, 20, accent).Assign(out repeatIcon), () =>
                            {
                                repeatAll = !repeatAll;
                                Sync();
                            }),
                        },
                    },
                },
            },

            // ── up next: one track inline, the rest behind the button ─────
            // pinned to the bottom of the canvas rather than flowing after the
            // transport, so a short screen cannot push it off
            new SkiaShape
            {
                Type = ShapeType.Rectangle,
                CornerRadius = 18,
                HorizontalOptions = LayoutOptions.Fill,
                VerticalOptions = LayoutOptions.End,
                Margin = new Thickness(22, 0, 22, 16),
                Padding = new Thickness(12, 8, 8, 8),
                // a dimmed pane lifts the strip off the photo behind it
                BackgroundColor = Color.Parse("#59130E1E"),
                StrokeColor = Color.Parse("#18FFFFFF"),
                StrokeWidth = 1,
                Children = new List<SkiaControl>
                {
                    new SkiaRow
                    {
                        Spacing = 12,
                        HorizontalOptions = LayoutOptions.Fill,
                        Children = new List<SkiaControl>
                        {
                            // sleeve of the next track, recoloured by Sync()
                            Cover(46, 10, "#7FB6E8", tracks[1].Art, out nextArt).Assign(out nextCard),
                            new SkiaStack
                            {
                                Spacing = 3,
                                VerticalOptions = LayoutOptions.Center,
                                HorizontalOptions = LayoutOptions.Fill,
                                Children = new List<SkiaControl>
                                {
                                    new SkiaLabel("UP NEXT")
                                    {
                                        FontSize = 9.5,
                                        CharacterSpacing = 1.4,
                                        TextColor = dim,
                                    },
                                    new SkiaLabel("Slow Static")
                                    {
                                        FontSize = 14.5,
                                        FontFamily = "FontTextTitle",
                                        TextColor = white,
                                        MaxLines = 1,
                                        LineBreakMode = LineBreakMode.TailTruncation,
                                    }.Assign(out nextTitle),
                                    new SkiaLabel("Wave Atlas")
                                    {
                                        FontSize = 12,
                                        TextColor = dim,
                                        MaxLines = 1,
                                    }.Assign(out nextArtist),
                                },
                            },
                            // raises the queue sheet (layer 4): a chevron UP,
                            // so it is obvious the panel comes from below
                            Tapper(44, Icon(IconChevronUp, 24, white, 0.9), () => ShowSheet(true)),
                        },
                    },
                },
            }
            // tapping the strip plays that track; the list button beside it
            // takes its own tap, so it still opens the queue
            .OnTapped(me =>
            {
                Go(1);
                playing = true;
                Sync();
            }),
            },
        }.Assign(out stage),

        // ── the floor: the swung screen mirrored below its own bottom edge ──
        // A receding page leaves dead black around it. Rather than paint that
        // black, reflect the page into it: the stage is cached as an image
        // while the drawer is open, so this is a second blit of a bitmap that
        // already exists, not a second render of the tree.
        new SkiaLayer
        {
            HorizontalOptions = LayoutOptions.Fill,
            VerticalOptions = LayoutOptions.Fill,
            // an empty layer has nothing to draw and can be skipped entirely,
            // and then WhenPaint never runs: one alpha step keeps it painting
            BackgroundColor = Color.Parse("#01000000"),
        }
        .Assign(out reflection)
        .WhenPaint((me, ctx) =>
        {
            if (reflect <= 0.01) return;
            var cache = stage.RenderObject;
            if (cache == null) return;

            var canvas = ctx.Context.Canvas;
            var rect = stage.DrawingRect;
            canvas.Save();

            // Same 3d transform as the page, then lay the copy DOWN on the
            // ground. The tip-over uses SK3dView — the same camera the page's
            // own RotationY goes through — so the floor's vanishing point
            // agrees with the page's. Hand-rolled Persp terms do not: they
            // converge on their own axis and the floor ends up tilted the
            // opposite way to the wall it belongs to.
            canvas.Concat(stage.RenderTransformMatrix);
            var pivotX = rect.MidX;
            canvas.Translate(pivotX, rect.Bottom);
            ground.Reset();
            ground.RotateXDegrees((float)FloorAngle);
            canvas.Concat(ground.Matrix);
            canvas.Scale(1, -1);
            canvas.Translate(-pivotX, -rect.Bottom);

            using (var paint = new SKPaint { Color = SKColors.White.WithAlpha((byte)(105 * reflect)) })
            {
                // a real floor scatters what it reflects. The blur is an image
                // filter over a full-screen bitmap, so it only runs once the
                // swing has SETTLED — during the animation this stays a plain
                // blit and the frames stay cheap.
                if (reflect > 0.999)
                {
                    paint.ImageFilter = SKImageFilter.CreateBlur(3.5f, 5.5f);
                }
                cache.Draw(canvas, rect, paint, FilterQuality.Medium);
                paint.ImageFilter?.Dispose();
            }

            // fade it out with distance from the floor line (rect.Bottom here,
            // since we are drawing flipped)
            using (var fade = new SKPaint())
            {
                fade.Shader = SKShader.CreateLinearGradient(
                    new SKPoint(0, rect.Top),
                    new SKPoint(0, rect.Bottom),
                    new[] { new SKColor(13, 10, 18, 255), new SKColor(13, 10, 18, 0) },
                    new[] { 0f, 1f },
                    SKShaderTileMode.Clamp);
                canvas.DrawRect(rect, fade);
            }

            // light pooling where the page meets the floor: a short additive
            // band hugging the bottom edge. Drawn in the same flipped space, so
            // it follows the perspective for free.
            var pool = new SKRect(rect.Left, rect.Bottom - rect.Height * 0.16f, rect.Right, rect.Bottom);
            using (var sheen = new SKPaint { BlendMode = SKBlendMode.Plus })
            {
                sheen.Shader = SKShader.CreateLinearGradient(
                    new SKPoint(0, pool.Top),
                    new SKPoint(0, pool.Bottom),
                    new[]
                    {
                        new SKColor(255, 255, 255, 0),
                        new SKColor(150, 140, 170, (byte)(38 * reflect)),
                    },
                    new[] { 0f, 1f },
                    SKShaderTileMode.Clamp);
                canvas.DrawRect(pool, sheen);
            }

            canvas.Restore();
        }),

        // ── layer 3: dim behind an open drawer, and close it on a tap ───
        new SkiaLayer
        {
            UseCache = SkiaCacheType.Operations,
            HorizontalOptions = LayoutOptions.Fill,
            VerticalOptions = LayoutOptions.Fill,
            BackgroundColor = Color.Parse("#99000000"),
            IsVisible = false,
        }
        .Assign(out scrim)
        .OnTapped(me =>
        {
            if (railOpen) ShowRail(false);
            if (queueOpen) ShowSheet(false);
        }),

        // ── layer 4: the queue, over the player rather than beside it ──
        new SkiaShape
        {
            UseCache = SkiaCacheType.Image,
            Type = ShapeType.Rectangle,
            CornerRadius = 22,
            HorizontalOptions = LayoutOptions.Fill,
            VerticalOptions = LayoutOptions.End,
            HeightRequest = 320,
            TranslationY = 320,
            BackgroundColor = Color.Parse("#F21A1424"),
            StrokeColor = Color.Parse("#22FFFFFF"),
            StrokeWidth = 1,
            Children = new List<SkiaControl>
            {
                new SkiaStack
                {
                    Spacing = 0,
                    Padding = new Thickness(20, 16, 20, 16),
                    HorizontalOptions = LayoutOptions.Fill,
                    VerticalOptions = LayoutOptions.Fill,
                    Children = new List<SkiaControl>
                    {
                        // sheet header: title + close
                        new SkiaRow
                        {
                            HorizontalOptions = LayoutOptions.Fill,
                            Children = new List<SkiaControl>
                            {
                                new SkiaLabel("Up next")
                                {
                                    FontSize = 17,
                                    FontFamily = "FontTextTitle",
                                    TextColor = white,
                                    VerticalOptions = LayoutOptions.Center,
                                    HorizontalOptions = LayoutOptions.Fill,
                                },
                                Tapper(36, Icon(IconClose, 20, dim), () => ShowSheet(false)),
                            },
                        },
                        // the only scrolling part of the whole screen,
                        // rows come from BuildQueue()
                        new SkiaScroll
                        {
                            HorizontalOptions = LayoutOptions.Fill,
                            VerticalOptions = LayoutOptions.Fill,
                            Margin = new Thickness(0, 8, 0, 0),
                            Content = new SkiaStack
                            {
                                Spacing = 4,
                                HorizontalOptions = LayoutOptions.Fill,
                            }.Assign(out queue),
                        },
                    },
                },
            },
        }.Assign(out sheet),

        // ── layer 5: navigation, closed by default ─────────────────────
        new SkiaLayer
        {
            UseCache = SkiaCacheType.Image,
            WidthRequest = RailWide,
            HorizontalOptions = LayoutOptions.Start,
            VerticalOptions = LayoutOptions.Fill,
            TranslationX = -RailWide,
            // near-black, but not FLAT black: a long vertical gradient warmed
            // by the cover's accent, so the panel has some depth of its own.
            // Opaque on purpose — at any alpha the pink backdrop bleeds through
            // and the whole thing reads as a colour cast.
            BackgroundColor = Color.Parse("#0F0F0F"),
            FillGradient = new SkiaGradient
            {
                Type = GradientType.Linear,
                StartXRatio = 0, StartYRatio = 0,
                EndXRatio = 0.4f, EndYRatio = 1,
                Colors = new List<Color>
                {
                    Color.Parse("#1E1622"),   // faint warmth up by the header
                    Color.Parse("#121016"),
                    Color.Parse("#0B0B0D"),   // near black at the foot
                },
                ColorPositions = new List<double> { 0.0, 0.45, 1.0 },
            },
            Children = new List<SkiaControl>
            {
                // the drawer is a layer, not a stack, so the edge hairline
                // below can sit over the whole height instead of queueing up
                // after the list
                new SkiaStack
                {
                    Spacing = 2,
                    Padding = new Thickness(0, 16, 0, 16),
                    HorizontalOptions = LayoutOptions.Fill,
                    VerticalOptions = LayoutOptions.Fill,
                    Children = new List<SkiaControl>
                    {
                // hamburger again, tapping it closes the drawer
                new SkiaStack
                {
                    Padding = new Thickness(18, 4, 0, 10),
                    HorizontalOptions = LayoutOptions.Fill,
                    Children = new List<SkiaControl>
                    {
                        Icon(IconMenu, 20, white, 0.9).WithHorizontalOptions(LayoutOptions.Start),
                    },
                }.OnTapped(me => ShowRail(false)),
                // entries + playlists, filled by BuildRail()
                new SkiaStack
                {
                    Spacing = 0,
                    UseCache = SkiaCacheType.Image,
                    HorizontalOptions = LayoutOptions.Fill,
                }.Assign(out rail),
                    },
                },

                // hairline down the opening edge, top to bottom: it catches the
                // light where the swung screen pulls away from the drawer
                new SkiaShape
                {
                    UseCache = SkiaCacheType.Image,
                    Type = ShapeType.Rectangle,
                    // one DEVICE pixel: WidthRequest is in points, so the real
                    // width is set from RenderingScale once the canvas is up
                    WidthRequest = 1,
                    HorizontalOptions = LayoutOptions.End,
                    VerticalOptions = LayoutOptions.Fill,
                    BackgroundColor = edgeColor.WithAlpha(EdgeAlphaTop),
                    FillGradient = new SkiaGradient
                    {
                        Type = GradientType.Linear,
                        StartXRatio = 0, StartYRatio = 0,
                        EndXRatio = 0, EndYRatio = 1,
                        Colors = new List<Color>
                        {
                            edgeColor.WithAlpha(EdgeAlphaTop),
                            edgeColor.WithAlpha((EdgeAlphaTop + EdgeAlphaFoot) / 2),
                            edgeColor.WithAlpha(EdgeAlphaFoot),
                        },
                        ColorPositions = new List<double> { 0.0, 0.5, 1.0 },
                    },
                }.Assign(out edgeLine),
            },
        }.Assign(out railDrawer),
    },
}
// The one bit of responsiveness a real screen needs: the hero art and the
// queue sheet take a share of whatever height they are given, so this same
// code fits a phone, a tablet and a short embedded canvas.
.ObserveSelf((me, property) =>
{
    // WIDTH matters as much as height here: the squeeze below is solved from
    // the canvas width, so watching only Height leaves a resized canvas fitted
    // to the old one
    if (property != "Height" && property != "Width") return;
    if (me.Height <= 0 || me.Width <= 0) return;
    // hairline stays exactly one device pixel whatever the display scale
    if (me.RenderingScale > 0) edgeLine.WidthRequest = 1.0 / me.RenderingScale;

    // Solve the horizontal factor that lands the far edge on the right border,
    // for THIS canvas. Sk3dView projects a point at local x to
    //     screen x = cos*x / ( sin*x/Z + 1 + cos/Z )
    // (see its CreateProjectionMatrix), and the camera distance Z comes off the
    // same object rather than being written down here, so the two cannot drift.
    if (me.Width > 0 && me.RenderingScale > 0)
    {
        var wide = me.Width * me.RenderingScale;

        // Push the camera back in proportion to the page, on BOTH the stage's
        // own 3d helper and the floor's, so page and reflection share one
        // projection. Helper3d is created lazily by the framework and only if
        // null, so this instance survives.
        var camera = (float)(wide * PerspectiveDepth);
        stage.Helper3d ??= new Sk3dView();
        stage.Helper3d.CameraDistance = camera;
        ground.CameraDistance = camera;

        var room = (me.Width - RailWide) * me.RenderingScale;
        var radians = DrawerTilt * Math.PI / 180.0;
        var flat = Math.Cos(radians);
        var deep = Math.Sin(radians);
        var divisor = wide * (flat - room * deep / camera);
        if (Math.Abs(divisor) > 1)
            drawerSqueeze = Math.Clamp(room * (1 + flat / camera) / divisor, 0.2, 2.0);
    }
    // and if the drawer is already open, re-fit it now instead of waiting for
    // the next open/close
    if (railOpen) stage.ScaleX = drawerSqueeze;

    var side = Math.Clamp(me.Height * 0.26, 120, 300);
    artCard.WidthRequest = side;
    artCard.HeightRequest = side;
    sheet.HeightRequest = Math.Clamp(me.Height * 0.62, 220, 460);
    if (!queueOpen) sheet.TranslationY = sheet.HeightRequest;
});

BuildRail();
BuildQueue();
Sync();
Play(root);

return root;

record Track(string Title, string Artist, int Seconds, string Tint, string Art);