UI/SkafinityMusicPanel.razor

A Razor UI panel component for the Skafinity music system. It displays transport controls, seek bar, seed input/copy, genre/shuffle/vibe controls, knobs for detailed configuration, and a playlist with save/jump actions; it finds or is assigned a SkafinityPlayer and drives it.

File AccessExternal Download
@using System
@using System.Collections.Generic
@using System.Linq
@using Sandbox
@using Sandbox.UI
@namespace Skafinity
@inherits PanelComponent
@* DontExecuteOnServer: this panel is pure human-client UI (like SkafinityPlayer). A dedicated
   server never renders it, so skip its lifecycle there. On a listen server the host is still a
   player, so the host-client keeps the panel — this only sheds the headless case. *@
@implements Component.DontExecuteOnServer

@*
	The optional drop-in settings board for the Skafinity music engine.

	Add this PanelComponent to a GameObject under a ScreenPanel (or WorldPanel). It finds a
	SkafinityPlayer in the scene (or set Player explicitly) and offers the whole transport as UI —
	you don't have to wire anything. The board's visibility is host-driven: set IsOpen (or call
	Toggle()) from your game — e.g. bind it to a hotkey or your own pause/menu UI. This component
	intentionally ships no launcher of its own, so it imposes nothing on the host's HUD — which also
	means a freshly-dropped panel shows nothing until you bind IsOpen. Run `skafinity_panel` in the
	console to see it before you have (SkafinityCommands.cs).

	The engine needs nothing from this: SkafinityPlayer plays on its own. This board is pure
	convenience for players who want to drive the station rather than tune it in the inspector.

	It is drawn against the WEB WIDGET as its design (web/skafinity-element.js) so the two are one
	product with one set of habits, and the wording and the derived decisions both sides need live in
	SkafinityBoard rather than in either drawing of it. What is deliberately NOT shared is layout:
	Razor and the DOM lay out differently enough that a common description of a row would be a lowest
	common denominator of both.

	Re-theming: the board derives its whole palette from one colour. Set SkafinityTheme.Accent from
	your game (e.g. to your own UI accent) and the board follows; leave it unset and it is neutral
	gray-on-black. SkafinityMusicPanel.razor.scss holds only the layout/type tokens.
*@

<root class="@( IsOpen ? "open" : "" )">
	@if ( IsOpen )
	{
	@{
		var cfg = Player?.EffectiveConfig();
		int genre = cfg?.Genre ?? 0;
		var here = Player?.Playhead() ?? default;
		bool known = here.Duration > 0;
	}
	<div class="board" style="background-color:@SkafinityTheme.Bg;">
		<div class="header">
			<div class="title">MUSIC</div>
			<div class="close" onclick="@Toggle">✕</div>
		</div>

		@* ── Transport ─────────────────────────────────────────────────────────────────────
		   Two rows: the buttons, and the bar that says where in the song they are acting. *@
		<div class="transport">
			<div class="row">
				<div class="btn big" style="@BtnStyle" tooltip="@SkafinityBoard.Copy.PrevTitle"
					 onclick="@( () => Step( -1 ) )">@SkafinityBoard.Copy.Prev</div>
				<div class="btn big primary" style="@BtnOnStyle" tooltip="@SkafinityBoard.Copy.PlayTitle"
					 onclick="@TogglePlay">@( Playing ? SkafinityBoard.Copy.Pause : SkafinityBoard.Copy.Play )</div>
				<div class="btn big" style="@BtnStyle" tooltip="@SkafinityBoard.Copy.NextTitle"
					 onclick="@( () => Step( 1 ) )">@SkafinityBoard.Copy.Next</div>

				<div class="now" style="@LabelStyle">@SkafinityBoard.Copy.NowPlaying<b style="@TextStyle">@( Player?.N ?? 0 )</b></div>

				@* Playback stalled on the song you skipped to — as opposed to the silent background
				   look-ahead, which nobody needs telling about. *@
				@if ( Player?.IsBuffering == true )
				{
					<div class="bufstate" style="@AccentTextStyle">@SkafinityBoard.Copy.Generating( Player?.N ?? 0 )</div>
				}

				<div class="vol right" style="@LabelStyle">
					@SkafinityBoard.Copy.Volume
					<SkafinitySlider Min="@(0f)" Max="@(1.5f)" Step="@(0.01f)" FixedWidth="@(120f)"
									 Value="@( Player?.Volume ?? 1f )"
									 OnValueChanged="@( (float v) => SetVolume( v ) )"></SkafinitySlider>
				</div>
			</div>

			@* The seek bar. The whole song is already in memory, so a scrub is a stream restart on
			   PCM we are holding rather than a fetch — which is why this is a plain slider and not a
			   loading affordance. It goes inert, rather than drawing against a guess, until the song
			   has been rendered and has a length worth stating. *@
			<div class="seek">
				<div class="time" style="@LabelStyle">@SkafinityBoard.Time( ShownTime( here ), known )</div>
				<SkafinitySlider tooltip="@SkafinityBoard.Copy.SeekTitle" Disabled="@( !known )"
								 Min="@(0f)" Max="@(1000f)" Step="@(1f)"
								 Value="@( known ? ShownRatio( here ) * 1000f : 0f )"
								 OnValueChanged="@( (float v) => Scrub( v / 1000f, here.Duration ) )"></SkafinitySlider>
				<div class="time total" style="@LabelStyle">@SkafinityBoard.Time( here.Duration, known )</div>
			</div>
		</div>

		@* ── The seed ──────────────────────────────────────────────────────────────────────
		   TWO copy buttons, because "share this" means one of two things and neither can be
		   recovered from the other: the song, with everything it left to chance written down, or
		   the station as it stands, which keeps rolling for whoever is handed it. *@
		<div class="row seed-bar">
			<TextEntry @ref="_seedEntry" placeholder="@SkafinityBoard.Copy.SeedPlaceholder" class="grow seed-input" />
			<div class="btn primary" style="@BtnOnStyle" onclick="@PlayTyped">@SkafinityBoard.Copy.SeedGo</div>
			<div class="btn" style="@BtnStyle" tooltip="@SkafinityBoard.Copy.CopySongTitle"
				 onclick="@CopySong">@_copySongLabel</div>
			<div class="btn" style="@BtnStyle" tooltip="@SkafinityBoard.Copy.CopyStationTitle"
				 onclick="@CopyStation">@_copyStationLabel</div>
		</div>

		@* ── What plays ────────────────────────────────────────────────────────────────────
		   These are not knob controls — genre, reroll and shuffle change the seed, and tinker only
		   opens the box — so they live on the board itself. Putting them in the mixer made them look
		   like part of it AND hid them from everyone who never opened it. *@
		<div class="row what-plays">
			<div class="label" style="@LabelStyle">@SkafinityBoard.Copy.Genre</div>
			@* The dropdown IS the seed's genre part: "Random" takes it out of the string so every
			   song rolls its own again, and without that entry there is no way back out of a genre
			   once one has been chosen. It reads as selected when nothing is pinned. *@
			<DropDown class="genre" style="@BtnStyle" Value="@GenreValue" Options="@GenreOptions"
					  ValueChanged="@( (string v) => PickGenre( v ) )" />
			<div class="btn" style="@BtnStyle" tooltip="@SkafinityBoard.Copy.RerollTitle"
				 onclick="@RerollStation">@SkafinityBoard.Copy.Reroll</div>
			<div class="btn toggle @( Shuffling ? "on" : "" )" style="@( Shuffling ? BtnOnStyle : BtnStyle )"
				 tooltip="@SkafinityBoard.Copy.ShuffleTitle"
				 onclick="@ToggleShuffle">@( Shuffling ? SkafinityBoard.Copy.ShuffleOn : SkafinityBoard.Copy.ShuffleOff )</div>
			<div class="btn toggle right @( _tinkering ? "on" : "" )" style="@( _tinkering ? BtnOnStyle : BtnStyle )"
				 onclick="@ToggleTinker">@( _tinkering ? SkafinityBoard.Copy.TinkerOpen : SkafinityBoard.Copy.Tinker )</div>
		</div>

		@* ── The knobs ─────────────────────────────────────────────────────────────────────
		   Behind the tinker button. They are the deep end of the toy, and a wall of sliders is
		   otherwise the first thing anybody meets. *@
		@if ( _tinkering )
		{
			<div class="panel vibe" style="@PanelStyle">
				<div class="h2" style="@LabelStyle">@SkafinityBoard.Copy.VibeHeading</div>
				<div class="matrix">
					<div class="mrow mhead">
						<div class="mvoice"></div>
						@foreach ( var h in SkafinityBoard.ColumnHeaders )
						{
							<div class="mcell mhlabel" style="@LabelStyle">@h</div>
						}
					</div>
					@foreach ( var row in SkafinityBoard.Matrix( genre ) )
					{
						<div class="mrow">
							<div class="mvoice">@row.Voice</div>
							@for ( int col = 0; col < SkafinityBoard.ColumnHeaders.Length; col++ )
							{
								var f = row.Cells[col];
								<div class="mcell">
									@if ( f != null )
									{
										@Knob( f, cfg, SkafinityBoard.KnobLabel( f, col ) )
									}
								</div>
							}
						</div>
					}
				</div>

				@* Only when there IS a global knob. They have all been retired to reserved wire slots
				   (tempo to GenreProfile, width and reverb to house config), and a heading over an
				   empty grid reads as a panel that failed to draw something. *@
				@if ( SkafinityBoard.Globals( genre ).Count > 0 )
				{
					<div class="glabel" style="@LabelStyle">@SkafinityBoard.Copy.GlobalHeading</div>
					<div class="global-grid">
						@foreach ( var f in SkafinityBoard.Globals( genre ) )
						{
							<div class="knob-cell">@Knob( f, cfg, f.Name )</div>
						}
					</div>
				}

				@* The only two buttons that act on the sliders, and they are not two dice. 🎲 always
				   moves every knob, because it draws a fresh vibe and PINS it. ↺ is the way back out —
				   dragging a knob pins the whole vibe, so without it one accidental drag turns an
				   endless station into one song forever — and it is off when there is nothing pinned
				   rather than looking like a die that did nothing. *@
				<div class="row vibe-actions">
					<div class="btn" style="@BtnStyle" tooltip="@SkafinityBoard.Copy.VibeRollTitle"
						 onclick="@RerollVibe">@SkafinityBoard.Copy.VibeRoll</div>
					<div class="btn @( VibePinned ? "" : "off" )" style="@BtnStyle"
						 tooltip="@SkafinityBoard.Copy.VibeRandomTitle"
						 onclick="@RollVibe">@SkafinityBoard.Copy.VibeRandom</div>
				</div>
			</div>
		}

		@* ── The playlist ──────────────────────────────────────────────────────────────────
		   Past · now · up next. A row addresses its song by POSITION, which is its slot on the
		   timeline; the number it SHOWS is the song's index in its own station, and under shuffle
		   those are different things. *@
		<div class="panel playlist-panel" style="@PanelStyle">
			<div class="h2" style="@LabelStyle">@SkafinityBoard.Copy.PlaylistHeading</div>
			<div class="playlist">
				@foreach ( var e in Queue() )
				{
					var ee = e;
					<div class="plrow @( e.Current ? "now" : "" ) @( e.Cached ? "cached" : "" ) @( e.Progress >= 0 ? "gen" : "" )"
						 style="@RowStyle( e )">
						<div class="pllabel" onclick="@( () => Player?.SeekTo( ee.Position ) )">
							<div class="plcaret">@SkafinityBoard.RowCaret( e )</div>
							<div class="plhash">@SkafinityBoard.Copy.Hash</div>
							<div class="plnum">@e.N</div>
						</div>
						<div class="plgenre" style="@LabelStyle">@SkafinityBoard.GenreName( e.Genre )</div>
						<div class="plstatus" style="@LabelStyle">
							@if ( e.Progress >= 0 )
							{
								<div class="bar"><div class="bar-fill" style="@BarFillStyle( e.Progress )"></div></div>
							}
							else
							{
								@SkafinityBoard.RowStatus( e )
							}
						</div>
						<div class="pldl" style="@LabelStyle" tooltip="@SkafinityBoard.Copy.ExportTitle( e.N )"
							 onclick="@( () => Save( ee.Position ) )">⬇</div>
					</div>
				}
			</div>
			<div class="row jump" style="@LabelStyle">
				@SkafinityBoard.Copy.JumpTo
				<TextEntry @ref="_jumpEntry" Numeric="@true" class="jump-input" />
				<div class="btn" style="@BtnStyle" onclick="@JumpTo">@SkafinityBoard.Copy.JumpGo</div>
				<div class="btn right" style="@BtnStyle"
					 onclick="@( () => Save( Player?.Position ?? 0 ) )">@( _saving ? SkafinityBoard.Copy.ExportBusy : SkafinityBoard.Copy.Export )</div>
			</div>
		</div>

		@if ( _msg != null )
		{
			<div class="msg" style="@AccentTextStyle">@_msg</div>
		}
	</div>
	}
</root>

@code
{
	/// <summary>The player this panel drives. Leave unset to auto-find a <see cref="SkafinityPlayer"/>
	/// in the scene on start.</summary>
	[Property] public SkafinityPlayer Player { get; set; }

	/// <summary>Whether the settings board is showing. Host-driven — set it from your game (or
	/// call <see cref="Toggle"/>) to wire the board to a hotkey / pause menu / your own button.
	/// This component ships no launcher of its own.</summary>
	/// <remarks>Deliberately NOT a <c>[Property]</c>: this is transient UI state. On a networked
	/// (<c>NetworkMode: Snapshot</c>) GameObject a serialized <c>[Property]</c> rides the late-join
	/// snapshot, so a client joining while the host has the board open would restore
	/// <c>IsOpen = true</c> — leaking the host's UI state and rendering the board open (and unstyled,
	/// since the panel is rebuilt mid-deserialize). Leaving it un-serialized keeps it host-driven
	/// from code while starting <c>false</c> on every client.</remarks>
	public bool IsOpen { get; set; }

	TextEntry _seedEntry;
	TextEntry _jumpEntry;
	bool _seedInit;
	// The station seed this panel last wrote into the box — what tells a stale box from a typed one.
	string _seedShown;
	// Both copy buttons keep their own label so pressing one doesn't report "copied!" on the other.
	string _copySongLabel = SkafinityBoard.Copy.CopySong;
	string _copyStationLabel = SkafinityBoard.Copy.CopyStation;
	string _msg;
	// The knob matrix is closed until asked for. Not a [Property] for the same reason IsOpen is not.
	bool _tinkering;
	bool _saving;

	// A DRAG, held. A slider reports every mouse-move and there is no "let go" event to wait for, so
	// seeking on each report would restart the stream at every pixel. The thumb is therefore followed
	// here and the transport is told once the drag has settled — which is the same one-seek-per-gesture
	// the web gets from listening to `change` rather than `input`.
	const float ScrubSettle = 0.2f;
	float _scrubTo = -1f;
	TimeSince _scrubSince;

	protected override void OnStart()
	{
		Player ??= Scene.GetAllComponents<SkafinityPlayer>().FirstOrDefault();
		if ( Player == null )
			Log.Warning( "SkafinityMusicPanel: no SkafinityPlayer found in the scene — add one (or set Player)." );
	}

	protected override void OnUpdate()
	{
		// The text entry follows the station as it stands — not once at open, but whenever the station
		// moves out from under it. Reroll, a pasted seed and a genre pin all change what the station
		// IS, and a box still showing the previous one reads as a reroll that did nothing.
		//
		// Only ever overwrites text this panel wrote: the moment somebody types, the box is theirs and
		// a background change to the station leaves it alone rather than eating what they were typing.
		if ( !IsOpen ) { _seedInit = false; return; }
		if ( _seedEntry != null )
		{
			var station = Player?.StationSeed ?? "";
			if ( !_seedInit || ( station != _seedShown && _seedEntry.Text == _seedShown ) )
			{
				_seedEntry.Text = station;
				_seedShown = station;
				_seedInit = true;
			}
		}

		// The held scrub, applied once the drag settles. See _scrubTo.
		if ( _scrubTo >= 0f && _scrubSince > ScrubSettle )
		{
			var d = Player?.Playhead().Duration ?? 0;
			if ( d > 0 ) Player?.SeekWithin( _scrubTo * d );
			_scrubTo = -1f;
		}
	}

	// ── Theme bindings ──
	// The palette is runtime (SkafinityTheme), so every fill and themed text colour is bound here as
	// an inline style rather than named in the .scss. The stylesheet owns every border in return —
	// see the header comment there for why the two must not overlap.
	//
	// Neither a rule nor an inline style reaches inside a control this library did not write, which
	// is why the board's slider is one it does (SkafinitySlider) — and why the accent reaches the
	// sliders at all.
	static string LabelStyle => $"color:{SkafinityTheme.TextDim};";
	static string TextStyle => $"color:{SkafinityTheme.Text};";
	static string AccentTextStyle => $"color:{SkafinityTheme.AccentCss};";
	static string BtnStyle => $"background-color:{SkafinityTheme.Cell}; color:{SkafinityTheme.Text};";
	static string BtnOnStyle => $"background-color:{SkafinityTheme.AccentBg}; color:{SkafinityTheme.Text};";
	static string PanelStyle => $"background-color:{SkafinityTheme.Cell};";
	static string BarFillStyle( float p ) => $"width:{SkafinityBoard.Percent( p )}; background-color:{SkafinityTheme.AccentCss};";

	// A playlist row reads as one of three states, brightest first: the song playing now, a song
	// already rendered and waiting, anything else.
	static string RowStyle( SkafinityPlayer.QueueEntry e ) =>
		e.Current ? $"background-color:{SkafinityTheme.AccentBg};"
		: e.Cached ? $"background-color:{SkafinityTheme.CellFillSoft};"
		: "";

	bool Playing => Player != null && !Player.IsPaused;
	bool Shuffling => Player?.Shuffle ?? false;
	bool VibePinned => Player?.VibePinned ?? false;
	bool GenreRolling => Player == null || !Player.GenrePinned;

	/// <summary>Open/close the settings board. Convenience for hosts that want to bind a single
	/// action; you can also set <see cref="IsOpen"/> directly.</summary>
	public void Toggle()
	{
		IsOpen = !IsOpen;
		if ( !IsOpen ) _seedInit = false;
	}

	// ── The playhead ──
	// Mid-drag the thumb the user is holding wins over the clock; every other moment reads the
	// transport. Both halves have to agree or the label counts up while the thumb sits still.
	float ShownRatio( SkafinityPlayer.SongPosition p ) => _scrubTo >= 0f ? _scrubTo : p.Ratio;
	double ShownTime( SkafinityPlayer.SongPosition p ) => _scrubTo >= 0f ? _scrubTo * p.Duration : p.Time;

	void Scrub( float ratio, double duration )
	{
		if ( duration <= 0 ) return;
		_scrubTo = Math.Clamp( ratio, 0f, 1f );
		_scrubSince = 0;
	}

	void TogglePlay() { Player?.TogglePlay(); _msg = null; }
	void Step( int d ) { Player?.StepN( d ); _msg = null; }
	void SetVolume( float v ) { if ( Player != null ) Player.Volume = v; }

	// ── The seed ──
	void PlayTyped()
	{
		if ( Player == null ) { _msg = null; return; }
		// A seed that will not parse leaves playback exactly where it was and says why, rather than
		// starting something adjacent to what was typed.
		_msg = Player.PlaySeed( _seedEntry?.Text, out var error )
			? SkafinityBoard.Copy.Playing( Player.CurrentSeed ) : error;
	}

	// This song, with everything it left to chance written down — whoever is handed it hears THIS,
	// not whatever their own station rolls at that index.
	void CopySong()
	{
		try { Clipboard.SetText( Player?.CurrentSeed ?? "" ); _copySongLabel = SkafinityBoard.Copy.Copied; }
		catch { _copySongLabel = "—"; }
	}

	// The seed as it stands: whatever this player left rolling keeps rolling for them too.
	void CopyStation()
	{
		try { Clipboard.SetText( Player?.StationSeed ?? "" ); _copyStationLabel = SkafinityBoard.Copy.Copied; }
		catch { _copyStationLabel = "—"; }
	}

	// ── What plays ──
	// "" is the Random entry — the genre is not in the seed, so every song rolls its own.
	static readonly List<Option> GenreOptions = BuildGenreOptions();
	static List<Option> BuildGenreOptions()
	{
		var list = new List<Option> { new( SkafinityBoard.Copy.GenreRandom, "" ) };
		for ( int g = 0; g < VibeCodec.GenreCount; g++ )
			list.Add( new Option( VibeCodec.Genres[g], g.ToString() ) );
		return list;
	}
	string GenreValue => GenreRolling ? "" : ( Player?.EffectiveConfig()?.Genre ?? 0 ).ToString();

	void PickGenre( string v )
	{
		if ( Player == null ) return;
		if ( string.IsNullOrEmpty( v ) ) { Player.RollGenre(); _msg = SkafinityBoard.Copy.GenreUnpinned; return; }
		if ( int.TryParse( v, out var g ) ) { Player.SetGenre( g ); _msg = null; }
	}

	// A different SONG, not a different taste: a fresh station at song 0, with anything pinned left
	// pinned.
	void RerollStation() { Player?.RerollStation(); _msg = SkafinityBoard.Copy.NewStation; }

	void ToggleShuffle() { if ( Player != null ) Player.SetShuffle( !Player.Shuffle ); _msg = null; }

	void ToggleTinker() { _tinkering = !_tinkering; }

	// ── The knobs ──
	// One knob: a name/value header over a real slider (or a dropdown, where the field is a choice).
	// The whole layout comes from the library's field metadata for the current genre, so a new genre
	// — or a new knob — is a pure engine change and there is no field table here.
	RenderFragment Knob( VibeCodec.Field f, MusicGen.Config cfg, string label )
	{
		int genre = cfg?.Genre ?? 0;
		int idx = SkafinityBoard.FieldIndex( genre, f );
		float norm = cfg != null ? f.GetNorm( cfg ) : 0f;
		return @<text>
		<div class="knob">
			<div class="knob-head">
				<div class="knob-name" style="@LabelStyle">@label</div>
				<div class="knob-val" style="@AccentTextStyle">@( cfg != null ? f.Display( cfg ) : "" )</div>
			</div>
			@if ( f.Choices != null )
			{
				<DropDown class="knob-select" style="@BtnStyle" Value="@SkafinityBoard.ChoiceIndex( f, norm ).ToString()"
						  Options="@ChoiceOptions( f )"
						  ValueChanged="@( (string v) => SetChoice( idx, f, v ) )" />
			}
			else
			{
				@* Snapped to the same discrete grid the seed encodes (one level per base-36 char), so
				   the slider can only land on values the vibe can actually represent. *@
				<SkafinitySlider Min="@(0f)" Max="@( (float)(VibeCodec.Levels - 1) )" Step="@(1f)"
								 Value="@( MathF.Round( norm * (VibeCodec.Levels - 1) ) )"
								 OnValueChanged="@( (float v) => SetVibe( idx, v / (VibeCodec.Levels - 1) ) )"></SkafinitySlider>
			}
		</div>
	</text>;
	}

	static List<Option> ChoiceOptions( VibeCodec.Field f )
	{
		var list = new List<Option>( f.Choices.Length );
		for ( int k = 0; k < f.Choices.Length; k++ ) list.Add( new Option( f.Choices[k], k.ToString() ) );
		return list;
	}

	void SetChoice( int idx, VibeCodec.Field f, string v )
	{
		if ( int.TryParse( v, out var k ) ) SetVibe( idx, SkafinityBoard.ChoiceNorm( f, k ) );
	}

	void SetVibe( int index, float norm )
	{
		if ( index < 0 ) return;
		Player?.SetVibe( index, norm );
		_msg = null;
	}

	// RerollVibe()'s defaults: the genre and the per-instrument volumes stay — the die over the
	// mixer re-voices the band, it does not swap the band or upend the mix you set.
	void RerollVibe() { Player?.RerollVibe(); _msg = SkafinityBoard.Copy.VibeRolled; }

	void RollVibe()
	{
		if ( Player == null || !Player.VibePinned ) return;   // nothing pinned — nothing to hand back
		Player.RollVibe();
		_msg = SkafinityBoard.Copy.VibeUnpinned;
	}

	// ── The playlist ──
	// How many history / look-ahead entries to show either side of the current song.
	static int QueueBack => 3;
	static int QueueFwd => 5;
	IEnumerable<SkafinityPlayer.QueueEntry> Queue() =>
		Player?.Timeline( QueueBack, QueueFwd ) ?? Enumerable.Empty<SkafinityPlayer.QueueEntry>();

	void JumpTo()
	{
		if ( Player == null ) return;
		if ( int.TryParse( _jumpEntry?.Text, out var p ) ) Player.SeekTo( p );
	}

	// Rendering a song outside the cache takes seconds, so the button says so — a save that looks
	// like it did nothing is a save people press again.
	async void Save( int position )
	{
		if ( Player == null || _saving ) return;
		_saving = true;
		try
		{
			var name = await Player.SaveToFileAsync( position );
			_msg = string.IsNullOrEmpty( name ) ? SkafinityBoard.Copy.SaveFailed : SkafinityBoard.Copy.Saved( name );
		}
		finally { _saving = false; }
	}

	protected override int BuildHash()
	{
		// Fold in the playhead and the queue's cached/generating state so the board animates as the
		// song runs and as songs render. Both are QUANTISED: the seek bar wants to move, but a panel
		// that rebuilds every frame to advance a bar by a pixel costs more than it shows. A fifth of
		// a second is smooth to look at and cheap to draw.
		var q = new HashCode();
		q.Add( IsOpen ); q.Add( Player?.CurrentSeed ); q.Add( Player?.CurrentVibe );
		q.Add( Player?.Enabled ?? true ); q.Add( Player?.Volume ?? 1f );
		q.Add( Player?.GenrePinned ?? false ); q.Add( Player?.VibePinned ?? false );
		q.Add( Player?.Shuffle ?? false ); q.Add( Player?.IsPaused ?? false );
		q.Add( _tinkering ); q.Add( Player?.IsBuffering ?? false ); q.Add( _saving );
		q.Add( _msg ); q.Add( _copySongLabel ); q.Add( _copyStationLabel );
		q.Add( _scrubTo );
		// The palette rides in inline style= values, so the board has to rebuild when the host
		// retints it — nothing else in this hash moves when only SkafinityTheme.Accent changes.
		q.Add( SkafinityTheme.Accent );
		if ( IsOpen )
		{
			var here = Player?.Playhead() ?? default;
			q.Add( (int)(here.Time * 5) ); q.Add( (int)(here.Duration * 5) );
			foreach ( var e in Queue() )
			{
				q.Add( e.N ); q.Add( e.Position ); q.Add( e.Cached ); q.Add( e.Current ); q.Add( e.Genre );
				q.Add( e.Progress >= 0 ? (int)MathF.Round( e.Progress * 20 ) : -1 );
			}
		}
		return q.ToHashCode();
	}
}