// ── 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);