69 results

@using Sandbox;
@using Sandbox.UI;
@using System.Threading.Tasks;
@using System.Collections.Generic;
@using System;
@inherits PanelComponent

<root class="@(IsFadingOut ? "fade-out" : "fade-in")" style="background-image: @(!string.IsNullOrEmpty(BackgroundImage) ? $"url({BackgroundImage})" : "none");">
    
    <div class="content">
        @* Logo Image (.png / .jpg) *@
        @if (!string.IsNullOrEmpty(LogoImage))
        {
            <img class="logo" src="@LogoImage" />
        }
        
        @* Text Lines *@
        @if (TextLines != null && TextLines.Count > 0)
        {
            <div class="text-container">
                @foreach (var line in TextLines)
                {
                    <label style="color: @line.TextColor.Hex; font-size: @(line.FontSize)px;">
                        @line.Text
                    </label>
                }
            </div>
        }
    </div>

</root>

@code {
    // === CUSTOM DATA CLASS FOR TEXT LINES ===
    
    public class SplashTextLine
    {
        [Property, Description("The text to display.")] 
        public string Text { get; set; } = "NEW LINE";

        [Property, Description("Text color for this specific line.")] 
        public Color TextColor { get; set; } = Color.White;

        [Property, Description("Font size for this specific line.")] 
        public float FontSize { get; set; } = 80f;
    }


    // === IMAGE SETTINGS ===
    
    [Property, ImageAssetPath, Group("Images"), Description("Supports .png and .jpg. If empty, the background will be black.")] 
    public string BackgroundImage { get; set; }

    [Property, ImageAssetPath, Group("Images"), Description("Main logo image (.png / .jpg). Appears above the text if both are set.")] 
    public string LogoImage { get; set; }


    // === TEXT SETTINGS ===

    [Property, Group("Text"), Description("Add text lines with individual settings (color, size).")]
    public List<SplashTextLine> TextLines { get; set; } = new();


    // === AUDIO & SCENE SETTINGS ===

    [Property, Group("Audio"), Description("Select a Sound Event (.sound) that contains your .mp3 or .ogg file.")] 
    public SoundEvent SplashSound { get; set; }

    [Property, Group("Scene"), Description("The scene to load after the splash screen finishes.")] 
    public SceneFile NextScene { get; set; }


    // === LOGIC ===
    
    public bool IsFadingOut { get; set; } = false;

    protected override void OnStart()
    {
        base.OnStart();
        
        // Start the asynchronous sequence
        _ = RunSplashSequence();
    }

    private async Task RunSplashSequence()
    {
        // 1. Wait half a second before starting to avoid stuttering during load
        await Task.Delay(500);

        // 2. Play the assigned sound (.mp3 / .ogg via Sound Event)
        if (SplashSound != null)
        {
            Sound.Play(SplashSound);
        }

        // 3. Wait while the logo/text is visible on the screen (3 seconds)
        await Task.Delay(3000);

        // 4. Trigger the fade-out animation
        IsFadingOut = true;
        StateHasChanged(); // Notify the UI to update CSS classes

        // 5. Wait for the fade-out animation to finish (matches the CSS transition time)
        await Task.Delay(2000);

        // 6. Load the next scene
        if (NextScene != null)
        {
            Scene.Load(NextScene);
        }
        else
        {
            Log.Warning("Next Scene is not assigned in the Splash Screen component!");
            GameObject.Destroy(); // Destroy the component if no scene is assigned
        }
    }
}
@using Sandbox
@using Sandbox.UI
@using System
@using System.Collections.Generic
@namespace FieldGuide.DayNight
@inherits PanelComponent
@attribute [StyleSheet]

@*
	The demo's on-screen key card, up from the first frame. A scene with no visible instructions reads as
	a broken scene: you press nothing, nothing happens, you close it. So this says what the demo is and
	which keys do what, before you have touched anything.

	It also carries the one thing the demo could not otherwise show. The kit ships no sky shader and no
	sky art on purpose; the sky is a SEAM, four normalized crossfade weights per hour. Those weights have
	no picture, so the card prints them live and they visibly hand off from one slot to the next as the
	clock runs. That is the seam doing its job, on screen, with no art involved.

	Rows render in MAIN markup via @foreach per the fragment-undermeasure gotcha. H hides the card (a
	letter, never an F key, which the editor eats in play). No ESC anywhere: house law.

	Look and layout follow the Field Kit UI system: docs/design/ui-system/daynight-kit.dc.html for this
	screen, tokens.dc.html for the values. Font sizes come from the {12, 13, 14, 16} panel scale and there
	is no letter-spacing; the stylesheet head lists the rest of the engine-legality rules.

	Not part of the kit's runtime surface: delete Code/Demo when you drop the kit into your own project.
*@

<root>
@* DemoActive is the inert-by-construction gate (library law 11): only DayNightDemoBootstrap sets it, so
   this card cannot appear in a consumer's game even if Code/Demo was left in the project. *@
@if ( CardOpen && DayNightDemoBootstrap.DemoActive )
{
	<div class="dh-card">
		<div class="dh-hdr">
			<span class="dh-title">DAY / NIGHT KIT DEMO</span>
			<div class="dh-x" onclick=@(() => CardOpen = false)>×</div>
		</div>

		<div class="dh-lede">One directional light, one skybox, one clock. Watch the sun sweep and the colour grade follow it, or open the time panel and drive the cycle yourself.</div>

		<div class="dh-rows">
			@foreach ( var r in Keys )
			{
				string key = r.key;     // plain locals before interpolating: an inline tuple read can render blank
				string what = r.what;
				<div class="dh-row">
					<span class="dh-key">@key</span>
					<span class="dh-what">@what</span>
				</div>
			}
		</div>

		<div class="dh-live">
			@foreach ( var w in Weights )
			{
				string run = w;
				<span class="dh-lk">@run</span>
			}
		</div>

		<div class="dh-foot">Those four are the sky seam. The kit ships no sky shader and no sky art; you crossfade your own sky from these weights.</div>
	</div>
}
</root>

@code
{
	static bool _open = true;

	/// <summary>Console fallback: `daynight_hint 1` / `daynight_hint 0` shows or hides the card (H also
	/// toggles). Starts SHOWN, unlike the time panel, because it is the thing that tells you the time panel
	/// exists.</summary>
	[ConVar( "daynight_hint", Help = "Show or hide the demo scene's key card (same as the H key)" )]
	public static bool CardOpen { get => _open; set => _open = value; }

	static readonly List<(string key, string what)> Keys = new()
	{
		( "N", "Open the time panel: scrub the clock, change the pace, pin the weather" ),
		( "H", "Hide this card" ),
	};

	DayNightClock _clock;

	DayNightClock Clock
	{
		get
		{
			if ( _clock.IsValid() ) return _clock;
			_clock = DayNightClock.For( Scene );
			return _clock;
		}
	}

	/// <summary>The live sky weights as four short atomic runs. Split into separate spans rather than one
	/// sentence so a wrap breaks BETWEEN runs; a single long run wraps mid-word, which is a live bug class
	/// in this engine's text layout.</summary>
	List<string> Weights
	{
		get
		{
			var c = Clock;
			var cfg = c?.Config ?? DayNightConfig.Default;
			var w = SkyWeights.WeightsFor( c?.GetTimeHours() ?? 0f, cfg );
			return new List<string>
			{
				$"MORNING {w.x:0.00}",
				$"NOON {w.y:0.00}",
				$"EVENING {w.z:0.00}",
				$"NIGHT {w.w:0.00}",
			};
		}
	}

	protected override void OnUpdate()
	{
		if ( Input.Keyboard.Pressed( "H" ) )
			CardOpen = !CardOpen;
	}

	// Fold the card state and every printed weight (to the two decimals shown), or the strip freezes at
	// whatever it read on the first frame while the sun keeps moving.
	protected override int BuildHash()
	{
		var c = Clock;
		var cfg = c?.Config ?? DayNightConfig.Default;
		var w = SkyWeights.WeightsFor( c?.GetTimeHours() ?? 0f, cfg );
		return HashCode.Combine( CardOpen, DayNightDemoBootstrap.DemoActive,
			(int)MathF.Round( w.x * 100f ),
			(int)MathF.Round( w.y * 100f ),
			(int)MathF.Round( w.z * 100f ),
			(int)MathF.Round( w.w * 100f ) );
	}
}
@using System
@using System.Collections.Generic
@using System.Linq
@using Sandbox
@using Sandbox.UI
@namespace AdminSuite.UI
@inherits Panel

<root class="tab ranks-tab">
	@if ( Sys is null )
	{
		<label class="empty">Admin system not available.</label>
	}
	else
	{
		<div class="column list">
			<div class="section-title"><i>military_tech</i><label>Ranks (@Sys.ClientRanks.Count)</label></div>
			<div class="scroll">
				@foreach ( var rank in Sys.ClientRanks.OrderByDescending( r => r.Priority ) )
				{
					<div class="row rank @(SelectedId == rank.Id ? "selected" : "")" onclick=@( () => Select( rank ) )>
						<div class="dot" style="background-color: @rank.Color"></div>
						<div class="meta">
							<label class="name">@rank.Name</label>
							<label class="sub">@rank.Id · priority @rank.Priority@(rank.BuiltIn ? " · built-in" : "")</label>
						</div>
					</div>
				}
			</div>

			@if ( Sys.LocalHas( AdminPerm.RanksManage ) )
			{
				<div class="create-row">
					<TextEntry class="input grow" Value:bind=@NewRankName Placeholder="New rank name" />
					<div class="btn accent" onclick=@( () => Create() )><i>add</i><label>Create</label></div>
				</div>
			}
		</div>

		<div class="column detail">
			@{ var rank = Sys.ClientRanks.FirstOrDefault( r => r.Id == SelectedId ); }
			@if ( rank is null )
			{
				<div class="placeholder"><i>touch_app</i><label>Select a rank to view or edit it</label></div>
			}
			else
			{
				var canEdit = Sys.LocalCanEditRank( rank );

				<div class="detail-header">
					<div class="dot big" style="background-color: @rank.Color"></div>
					<div class="meta">
						<label class="name">@rank.Name</label>
						<label class="sub">@rank.Id · @(rank.BuiltIn ? "built-in rank" : "custom rank")</label>
					</div>
					<div class="badge" style="background-color: @rank.Color">priority @rank.Priority</div>
				</div>

				if ( !canEdit )
				{
					<div class="notice"><i>lock</i><label>You can only edit ranks below your own rank.</label></div>
				}
				else
				{
					if ( rank.BuiltIn )
					{
						<div class="notice"><i>info</i><label>Built-in ranks keep their name and priority — only the colour and permissions can be changed.</label></div>
					}

					<div class="form">
						@if ( !rank.BuiltIn )
						{
							<div class="field">
								<label class="key">Name</label>
								<TextEntry class="input" Value:bind=@EditName />
							</div>
							<div class="field">
								<label class="key">Priority (1–99, must stay below your own rank)</label>
								<TextEntry class="input" Value:bind=@EditPriority />
							</div>
						}
						<div class="field">
							<label class="key">Colour (hex, e.g. #b28dff)</label>
							<div class="colour-row">
								<TextEntry class="input grow" Value:bind=@EditColor />
								<div class="swatch" style="background-color: @EditColor"></div>
							</div>
						</div>
						<div class="form-actions">
							<div class="btn accent" onclick=@( () => Apply( rank ) )><i>save</i><label>Apply</label></div>
							@if ( !rank.BuiltIn )
							{
								<div class="btn danger" onclick=@( () => Delete( rank ) )>
									<i>delete</i><label>@(ConfirmingDelete ? "Confirm delete?" : "Delete Rank")</label>
								</div>
							}
						</div>
					</div>

					<label class="explain">Permissions for this rank are edited in the Permissions tab. Players holding a deleted rank fall back to the server's default rank.</label>
				}
			}
		</div>
	}
</root>

@code
{
	private AdminSystem Sys => AdminSystem.Instance;

	public string SelectedId { get; set; } = "";
	public string NewRankName { get; set; } = "";
	public string EditName { get; set; } = "";
	public string EditColor { get; set; } = "";
	public string EditPriority { get; set; } = "";

	private float _confirmUntil;
	private bool ConfirmingDelete => RealTime.Now < _confirmUntil;

	private void Select( AdminRank rank )
	{
		SelectedId = rank.Id;
		EditName = rank.Name;
		EditColor = rank.Color;
		EditPriority = rank.Priority.ToString();
		_confirmUntil = 0f;
		StateHasChanged();
	}

	private void Create()
	{
		if ( Sys is null ) return;
		if ( string.IsNullOrWhiteSpace( NewRankName ) )
		{
			AdminMenu.Instance?.Toast( "Enter a name for the new rank first.", 2 );
			return;
		}

		Sys.RequestCreateRank( NewRankName.Trim() );
		NewRankName = "";
	}

	private void Apply( AdminRank rank )
	{
		if ( Sys is null || !Sys.LocalCanEditRank( rank ) ) return;

		var priority = int.TryParse( EditPriority, out var p ) ? p : rank.Priority;
		var name = string.IsNullOrWhiteSpace( EditName ) ? rank.Name : EditName.Trim();

		Sys.RequestUpdateRank( rank.Id, name, EditColor?.Trim() ?? "", priority );
	}

	private void Delete( AdminRank rank )
	{
		if ( Sys is null || !Sys.LocalCanEditRank( rank ) || rank.BuiltIn ) return;

		if ( !ConfirmingDelete )
		{
			_confirmUntil = RealTime.Now + 3f;
			return;
		}

		_confirmUntil = 0f;
		Sys.RequestDeleteRank( rank.Id );
		SelectedId = "";
	}

	protected override int BuildHash() =>
		HashCode.Combine( Sys?.StateHash ?? 0, SelectedId, ConfirmingDelete, (int)RealTime.Now );
}
@using System
@using System.Collections.Generic
@using System.Linq
@using Sandbox
@using Sandbox.UI
@namespace AdminSuite.UI
@inherits Panel

<root class="tab permissions-tab">
	@if ( Sys is null )
	{
		<label class="empty">Admin system not available.</label>
	}
	else
	{
		<div class="section-title"><i>key</i><label>Rank Permissions</label></div>

		<div class="rank-pills">
			@foreach ( var rank in Sys.ClientRanks.OrderByDescending( r => r.Priority ) )
			{
				<div class="pill @(SelectedId == rank.Id ? "selected" : "")" onclick=@( () => Select( rank.Id ) )>
					<div class="dot" style="background-color: @rank.Color"></div>
					<label>@rank.Name</label>
				</div>
			}
		</div>

		var selected = Sys.ClientRanks.FirstOrDefault( r => r.Id == SelectedId );
		if ( selected is null )
		{
			<div class="placeholder"><i>touch_app</i><label>Pick a rank above to view or edit its permissions</label></div>
		}
		else
		{
			var locked = !Sys.LocalHas( AdminPerm.RanksManage )
				|| !Sys.LocalCanEditRank( selected )
				|| selected.Id == AdminDefaults.SuperAdminId;

			if ( selected.Id == AdminDefaults.SuperAdminId )
			{
				<div class="notice"><i>verified_user</i><label>Super Admin always has every permission — this can't be changed.</label></div>
			}
			else if ( locked )
			{
				<div class="notice"><i>lock</i><label>You can't edit this rank's permissions.</label></div>
			}

			if ( Sys.LocalHasAll && selected.Id != AdminDefaults.SuperAdminId && !locked )
			{
				<div class="perm-row master">
					<div class="meta">
						<label class="name">All Permissions (*)</label>
						<label class="sub">Grants every current and future permission — effectively a second Super Admin tier.</label>
					</div>
					<div class="toggle @(selected.HasAll ? "on" : "")" onclick=@( () => ToggleAll( selected ) )>
						<i>@(selected.HasAll ? "check" : "")</i>
					</div>
				</div>
			}

			<div class="scroll perms">
				@foreach ( var group in AdminPerm.ByCategory )
				{
					<div class="section-title"><label>@group.Key</label></div>
					foreach ( var perm in group )
					{
						var granted = selected.Has( perm.Id );
						<div class="perm-row @(selected.HasAll ? "implied" : "")">
							<div class="meta">
								<label class="name">@perm.Name</label>
								<label class="sub">@perm.Description</label>
							</div>
							<div class="toggle @(granted ? "on" : "") @(locked || selected.HasAll ? "locked" : "")"
								onclick=@( () => { if ( !locked && !selected.HasAll ) Toggle( selected, perm.Id ); } )>
								<i>@(granted ? "check" : "")</i>
							</div>
						</div>
					}
				}
			</div>
		}
	}
</root>

@code
{
	private AdminSystem Sys => AdminSystem.Instance;

	public string SelectedId { get; set; } = "";

	private void Select( string rankId )
	{
		SelectedId = rankId;
		StateHasChanged();
	}

	private void Toggle( AdminRank rank, string permId )
	{
		if ( Sys is null ) return;

		var perms = new List<string>( rank.Permissions );
		if ( !perms.Remove( permId ) ) perms.Add( permId );

		Sys.RequestSetRankPermissions( rank.Id, Json.Serialize( perms ) );
	}

	private void ToggleAll( AdminRank rank )
	{
		if ( Sys is null ) return;

		var perms = new List<string>( rank.Permissions );
		if ( !perms.Remove( AdminPerm.All ) ) perms.Add( AdminPerm.All );

		Sys.RequestSetRankPermissions( rank.Id, Json.Serialize( perms ) );
	}

	protected override int BuildHash() =>
		HashCode.Combine( Sys?.StateHash ?? 0, SelectedId );
}
@using System
@using System.Collections.Generic
@using System.Linq
@using Sandbox
@using Sandbox.UI
@namespace AdminSuite.UI
@inherits PanelComponent

<root class="adminsuite @(IsOpen ? "open" : "")">

	<div class="toasts">
		@foreach ( var toast in Toasts )
		{
			<div class="toast [email protected]">
				<i>@ToastIcon( toast.Level )</i>
				<label>@toast.Message</label>
			</div>
		}
	</div>

	@if ( IsOpen && Sys is not null )
	{
		var me = Connection.Local;
		var myRank = me is not null ? Sys.GetDisplayRank( me.SteamId ) : null;

		<div class="window">
			<div class="titlebar">
				<div class="brand">
					<i>local_police</i>
					<label class="name">ADMIN SUITE</label>
				</div>
				@if ( me is not null && myRank is not null )
				{
					<div class="whoami">
						<label>@me.DisplayName</label>
						<div class="badge" style="background-color: @myRank.Color">@myRank.Name</div>
					</div>
				}
				<div class="close" onclick=@( () => Close() )><i>close</i></div>
			</div>

			<div class="main">
				<div class="nav">
					@foreach ( var item in NavItems() )
					{
						<div class="nav-item @(Current == item.Tab ? "active" : "")" onclick=@( () => SetTab( item.Tab ) )>
							<i>@item.Icon</i>
							<label>@item.Label</label>
						</div>
					}
				</div>

				<div class="content">
					@if ( Current == Tab.Players ) { <PlayersTab /> }
					else if ( Current == Tab.Ranks ) { <RanksTab /> }
					else if ( Current == Tab.Permissions ) { <PermissionsTab /> }
					else if ( Current == Tab.Server ) { <ServerTab /> }
					else if ( Current == Tab.Settings ) { <SettingsTab /> }
				</div>
			</div>

			<div class="statusbar">
				<label>@Connection.All.Count player(s) online</label>
				<label class="hint">Toggle key: @AdminClientPrefs.Current.MenuKey.ToUpperInvariant() · console: adminsuite_menu</label>
			</div>
		</div>
	}
</root>

@code
{
	public static AdminMenu Instance { get; private set; }

	public enum Tab { Players, Ranks, Permissions, Server, Settings }

	public bool IsOpen { get; private set; }
	public Tab Current { get; private set; } = Tab.Players;

	public record struct ToastItem( string Message, int Level, float ExpiresAt );
	public List<ToastItem> Toasts { get; } = new();

	private bool _keyWasDown;

	private AdminSystem Sys => AdminSystem.Instance;

	protected override void OnStart()
	{
		Instance = this;
	}

	protected override void OnDestroy()
	{
		if ( Instance == this ) Instance = null;
	}

	protected override void OnUpdate()
	{
		// Raw-key toggle — edge-detected so holding the key doesn't flicker the menu.
		var key = AdminClientPrefs.Current.MenuKey;
		var isDown = !string.IsNullOrWhiteSpace( key ) && Input.Keyboard.Down( key );
		if ( isDown && !_keyWasDown ) Toggle();
		_keyWasDown = isDown;

		if ( IsOpen )
		{
			Mouse.Visible = true;

			if ( Input.EscapePressed )
			{
				Input.EscapePressed = false;
				Close();
			}

			// If our access was revoked (or the system vanished) while open, close.
			if ( Sys is null || !Sys.LocalHas( AdminPerm.MenuAccess ) )
				Close();
			else if ( NavItems().All( n => n.Tab != Current ) )
				Current = Tab.Players;
		}

		Toasts.RemoveAll( t => t.ExpiresAt < RealTime.Now );
	}

	public void Toggle()
	{
		if ( IsOpen ) Close();
		else Open();
	}

	public void Open()
	{
		if ( Sys is null )
		{
			Toast( "Admin system is not running on this server.", 2 );
			return;
		}

		if ( !Sys.LocalHas( AdminPerm.MenuAccess ) )
		{
			Toast( "You don't have access to the admin menu.", 2 );
			return;
		}

		IsOpen = true;
	}

	public void Close()
	{
		IsOpen = false;
		Mouse.Visible = false;
	}

	public void SetTab( Tab tab ) => Current = tab;

	public void Toast( string message, int level = 0 )
	{
		if ( string.IsNullOrEmpty( message ) ) return;

		Toasts.Add( new ToastItem( message, level, RealTime.Now + 6f ) );
		while ( Toasts.Count > 6 ) Toasts.RemoveAt( 0 );
		StateHasChanged();
	}

	private static string ToastIcon( int level ) => level switch
	{
		1 => "check_circle",
		2 => "error",
		3 => "campaign",
		_ => "info"
	};

	private record struct NavItem( Tab Tab, string Icon, string Label );

	private IEnumerable<NavItem> NavItems()
	{
		yield return new NavItem( Tab.Players, "group", "Players" );

		if ( Sys?.LocalHas( AdminPerm.RanksManage ) == true )
		{
			yield return new NavItem( Tab.Ranks, "military_tech", "Ranks" );
			yield return new NavItem( Tab.Permissions, "key", "Permissions" );
		}

		if ( ShowServerTab )
			yield return new NavItem( Tab.Server, "dns", "Server" );

		yield return new NavItem( Tab.Settings, "settings", "Settings" );
	}

	private bool ShowServerTab =>
		Sys is not null &&
		(Sys.LocalHas( AdminPerm.PropsCleanAll )
		 || Sys.LocalHas( AdminPerm.PropsCleanDisconnected )
		 || Sys.LocalHas( AdminPerm.ServerBroadcast )
		 || Sys.LocalHas( AdminPerm.PlayersBan )
		 || Sys.LocalHas( AdminPerm.ServerSettings ));

	protected override int BuildHash() =>
		HashCode.Combine( IsOpen, Current, Toasts.Count, Sys?.StateHash ?? 0, (int)RealTime.Now );
}
@using System
@using System.Collections.Generic
@using System.Linq
@using Sandbox
@using Sandbox.UI
@namespace AdminSuite.UI
@inherits Panel

<root class="tab server-tab">
	@if ( Sys is null )
	{
		<label class="empty">Admin system not available.</label>
	}
	else
	{
		<div class="scroll">
			@if ( Sys.LocalHas( AdminPerm.PropsCleanAll ) || Sys.LocalHas( AdminPerm.PropsCleanDisconnected ) )
			{
				<div class="card">
					<div class="section-title"><i>cleaning_services</i><label>Cleanup</label></div>
					<div class="action-row">
						@if ( Sys.LocalHas( AdminPerm.PropsCleanDisconnected ) )
						{
							<div class="btn accent" onclick=@( () => Sys.RequestCleanDisconnectedProps() )>
								<i>person_off</i><label>Clean Disconnected Players' Props</label>
							</div>
						}
						@if ( Sys.LocalHas( AdminPerm.PropsCleanAll ) )
						{
							<div class="btn danger" onclick=@( () => CleanAll() )>
								<i>delete_sweep</i><label>@(ConfirmingCleanAll ? "Confirm — remove ALL props?" : "Clean ALL Props")</label>
							</div>
						}
					</div>
					<label class="explain">Cleanup only removes networked spawned objects — the map itself is never touched. Per-player cleanup lives in the Players tab.</label>
				</div>
			}

			@if ( Sys.LocalHas( AdminPerm.ServerBroadcast ) )
			{
				<div class="card">
					<div class="section-title"><i>campaign</i><label>Broadcast</label></div>
					<div class="action-row">
						<TextEntry class="input grow" Value:bind=@BroadcastText Placeholder="Message to every player..." />
						<div class="btn accent" onclick=@( () => Send() )><i>send</i><label>Send</label></div>
					</div>
				</div>
			}

			@if ( Sys.LocalHas( AdminPerm.PlayersBan ) )
			{
				<div class="card">
					<div class="section-title"><i>block</i><label>Active Bans (@Sys.ClientBans.Count)</label></div>
					@if ( Sys.ClientBans.Count == 0 )
					{
						<label class="explain">No active bans.</label>
					}
					@foreach ( var ban in Sys.ClientBans )
					{
						<div class="row ban">
							<div class="meta">
								<label class="name">@ban.Name</label>
								<label class="sub">@ban.SteamId · "@ban.Reason" — by @ban.BannedByName · @Expiry( ban )</label>
							</div>
							<div class="btn ghost" onclick=@( () => Sys.RequestUnban( ban.SteamId ) )>
								<i>undo</i><label>Unban</label>
							</div>
						</div>
					}
				</div>
			}
		</div>
	}
</root>

@code
{
	private AdminSystem Sys => AdminSystem.Instance;

	public string BroadcastText { get; set; } = "";

	private float _confirmUntil;
	private bool ConfirmingCleanAll => RealTime.Now < _confirmUntil;

	private void CleanAll()
	{
		if ( Sys is null ) return;

		if ( !ConfirmingCleanAll )
		{
			_confirmUntil = RealTime.Now + 3f;
			return;
		}

		_confirmUntil = 0f;
		Sys.RequestCleanAllProps();
	}

	private void Send()
	{
		if ( Sys is null || string.IsNullOrWhiteSpace( BroadcastText ) ) return;
		Sys.RequestBroadcast( BroadcastText.Trim() );
		BroadcastText = "";
	}

	private static string Expiry( BanRecord ban ) =>
		ban.ExpiresUtc is null ? "permanent" : $"until {ban.ExpiresUtc.Value.ToLocalTime():g}";

	protected override int BuildHash() =>
		HashCode.Combine( Sys?.StateHash ?? 0, ConfirmingCleanAll, (int)RealTime.Now );
}
@using System
@using System.Collections.Generic
@using System.Linq
@using Sandbox
@using Sandbox.UI
@namespace AdminSuite.UI
@inherits Panel

<root class="tab ranks-tab">
	@if ( Sys is null )
	{
		<label class="empty">Admin system not available.</label>
	}
	else
	{
		<div class="column list">
			<div class="section-title"><i>military_tech</i><label>Ranks (@Sys.ClientRanks.Count)</label></div>
			<div class="scroll">
				@foreach ( var rank in Sys.ClientRanks.OrderByDescending( r => r.Priority ) )
				{
					<div class="row rank @(SelectedId == rank.Id ? "selected" : "")" onclick=@( () => Select( rank ) )>
						<div class="dot" style="background-color: @rank.Color"></div>
						<div class="meta">
							<label class="name">@rank.Name</label>
							<label class="sub">@rank.Id · priority @rank.Priority@(rank.BuiltIn ? " · built-in" : "")</label>
						</div>
					</div>
				}
			</div>

			@if ( Sys.LocalHas( AdminPerm.RanksManage ) )
			{
				<div class="create-row">
					<TextEntry class="input grow" Value:bind=@NewRankName Placeholder="New rank name" />
					<div class="btn accent" onclick=@( () => Create() )><i>add</i><label>Create</label></div>
				</div>
			}
		</div>

		<div class="column detail">
			@{ var rank = Sys.ClientRanks.FirstOrDefault( r => r.Id == SelectedId ); }
			@if ( rank is null )
			{
				<div class="placeholder"><i>touch_app</i><label>Select a rank to view or edit it</label></div>
			}
			else
			{
				var canEdit = Sys.LocalCanEditRank( rank );

				<div class="detail-header">
					<div class="dot big" style="background-color: @rank.Color"></div>
					<div class="meta">
						<label class="name">@rank.Name</label>
						<label class="sub">@rank.Id · @(rank.BuiltIn ? "built-in rank" : "custom rank")</label>
					</div>
					<div class="badge" style="background-color: @rank.Color">priority @rank.Priority</div>
				</div>

				if ( !canEdit )
				{
					<div class="notice"><i>lock</i><label>You can only edit ranks below your own rank.</label></div>
				}
				else
				{
					if ( rank.BuiltIn )
					{
						<div class="notice"><i>info</i><label>Built-in ranks keep their name and priority — only the colour and permissions can be changed.</label></div>
					}

					<div class="form">
						@if ( !rank.BuiltIn )
						{
							<div class="field">
								<label class="key">Name</label>
								<TextEntry class="input" Value:bind=@EditName />
							</div>
							<div class="field">
								<label class="key">Priority (1–99, must stay below your own rank)</label>
								<TextEntry class="input" Value:bind=@EditPriority />
							</div>
						}
						<div class="field">
							<label class="key">Colour (hex, e.g. #b28dff)</label>
							<div class="colour-row">
								<TextEntry class="input grow" Value:bind=@EditColor />
								<div class="swatch" style="background-color: @EditColor"></div>
							</div>
						</div>
						<div class="form-actions">
							<div class="btn accent" onclick=@( () => Apply( rank ) )><i>save</i><label>Apply</label></div>
							@if ( !rank.BuiltIn )
							{
								<div class="btn danger" onclick=@( () => Delete( rank ) )>
									<i>delete</i><label>@(ConfirmingDelete ? "Confirm delete?" : "Delete Rank")</label>
								</div>
							}
						</div>
					</div>

					<label class="explain">Permissions for this rank are edited in the Permissions tab. Players holding a deleted rank fall back to the server's default rank.</label>
				}
			}
		</div>
	}
</root>

@code
{
	private AdminSystem Sys => AdminSystem.Instance;

	public string SelectedId { get; set; } = "";
	public string NewRankName { get; set; } = "";
	public string EditName { get; set; } = "";
	public string EditColor { get; set; } = "";
	public string EditPriority { get; set; } = "";

	private float _confirmUntil;
	private bool ConfirmingDelete => RealTime.Now < _confirmUntil;

	private void Select( AdminRank rank )
	{
		SelectedId = rank.Id;
		EditName = rank.Name;
		EditColor = rank.Color;
		EditPriority = rank.Priority.ToString();
		_confirmUntil = 0f;
		StateHasChanged();
	}

	private void Create()
	{
		if ( Sys is null ) return;
		if ( string.IsNullOrWhiteSpace( NewRankName ) )
		{
			AdminMenu.Instance?.Toast( "Enter a name for the new rank first.", 2 );
			return;
		}

		Sys.RequestCreateRank( NewRankName.Trim() );
		NewRankName = "";
	}

	private void Apply( AdminRank rank )
	{
		if ( Sys is null || !Sys.LocalCanEditRank( rank ) ) return;

		var priority = int.TryParse( EditPriority, out var p ) ? p : rank.Priority;
		var name = string.IsNullOrWhiteSpace( EditName ) ? rank.Name : EditName.Trim();

		Sys.RequestUpdateRank( rank.Id, name, EditColor?.Trim() ?? "", priority );
	}

	private void Delete( AdminRank rank )
	{
		if ( Sys is null || !Sys.LocalCanEditRank( rank ) || rank.BuiltIn ) return;

		if ( !ConfirmingDelete )
		{
			_confirmUntil = RealTime.Now + 3f;
			return;
		}

		_confirmUntil = 0f;
		Sys.RequestDeleteRank( rank.Id );
		SelectedId = "";
	}

	protected override int BuildHash() =>
		HashCode.Combine( Sys?.StateHash ?? 0, SelectedId, ConfirmingDelete, (int)RealTime.Now );
}
@using System
@using System.Linq
@using Sandbox
@using Sandbox.UI
@namespace Bimp
@inherits PanelComponent
@implements Component.ISceneStage

@*
	The full controls, shown on the local player's screen when they USE a media player.
	Created on demand by MediaRemote.Open, never networked.
*@

<root class="mediaremote @(PadMode ? "pad" : "")">

	@if ( Target is not null )
	{
		<div class="window">

			<div class="header">
				<i>@(Target.AudioOnlyPlayer ? "speaker" : "smart_display")</i>
				<div class="title">Media Player</div>
				<div class="close nav" onclick="@Close"><i>close</i></div>
			</div>

			<div class="now">
				@if ( Target.HasMedia )
				{
					<div class="label">Now playing</div>
					<div class="track">@Target.Title</div>
					<div class="sub">
						@if ( !string.IsNullOrWhiteSpace( Target.RequestedBy ) )
						{
							<span>Requested by @Target.RequestedBy</span>
						}
						<span class="time">@TimeText</span>
					</div>
				}
				else
				{
					<div class="track dim">Nothing playing</div>
				}

				@if ( !string.IsNullOrWhiteSpace( Target.Status ) )
				{
					<div class="status">@Target.Status</div>
				}
				@if ( !string.IsNullOrWhiteSpace( Target.LocalNotice ) )
				{
					<div class="notice">@Target.LocalNotice</div>
				}
			</div>

			@if ( Target.HasMedia && !Target.IsLive && Target.MediaDuration > 0 )
			{
				<div class="seek @(Target.CanLocalControl ? "nav" : "disabled")">
					<SliderControl Min="@(0f)" Max="@Target.MediaDuration" Step="@(1f)" ShowValueTooltip="@false" Value="@SeekValue" OnValueChanged="@OnSeekChanged"></SliderControl>
				</div>
			}

			@if ( Target.CanLocalControl && Target.HasMedia )
			{
				<div class="transport">
					@if ( !Target.IsLive )
					{
						<div class="btn nav" onclick="@( () => Target.RequestSeek( Target.CurrentTime - 10 ) )"><i>replay_10</i></div>
						<div class="btn big nav nav-default" onclick="@( () => Target.RequestSetPaused( !Target.Paused ) )"><i>@(Target.Paused ? "play_arrow" : "pause")</i></div>
						<div class="btn nav" onclick="@( () => Target.RequestSeek( Target.CurrentTime + 10 ) )"><i>forward_10</i></div>
					}
					<div class="btn nav" onclick="@Target.RequestSkip"><i>skip_next</i></div>
					<div class="btn nav" onclick="@Target.RequestStop"><i>stop</i></div>
				</div>
			}

			@if ( Target.HasMedia && (Target.AvailableQualities.Count > 0 || Target.AvailableAudioTracks.Count > 0 || Target.AvailableCaptionTracks.Count > 0) )
			{
				<div class="stream-options">
					@if ( Target.AvailableQualities.Count > 0 )
					{
						<div class="option">
							<i>high_quality</i>
							<DropDown class="quality nav" Value="@QualityValue" ValueChanged="@OnQualityChanged" BuildOptions="@BuildQualityOptions"></DropDown>
						</div>
					}
					@if ( Target.AvailableAudioTracks.Count > 0 )
					{
						<div class="option">
							<i>translate</i>
							<DropDown class="audio-track nav" Value="@AudioTrackValue" ValueChanged="@OnAudioTrackChanged" BuildOptions="@BuildAudioTrackOptions"></DropDown>
						</div>
					}
					@if ( Target.AvailableCaptionTracks.Count > 0 )
					{
						<div class="option">
							<i>closed_caption</i>
							<DropDown class="audio-track nav" Value="@CaptionValue" ValueChanged="@OnCaptionChanged" BuildOptions="@BuildCaptionOptions"></DropDown>
						</div>
					}
					<div class="playing-as">@PlayingAsText</div>
				</div>
			}

			@if ( (Target.CanLocalControl || Target.CanLocalQueue) && PadMode )
			{
				@* a pad can't type a link *@
				<div class="pad-note"><i>keyboard</i> Use a keyboard to add a link</div>
			}
			else if ( Target.CanLocalControl || Target.CanLocalQueue )
			{
				<div class="entry">
					<TextEntry @ref="UrlEntry" Placeholder="Paste a YouTube link or a media url (mp4, webm, mp3...)" onsubmit="@OnSubmit"></TextEntry>
					@if ( Target.CanLocalControl )
					{
						<div class="action primary" onclick="@PlayNow"><i>play_arrow</i> Play now</div>
					}
					@if ( Target.CanLocalQueue )
					{
						<div class="action" onclick="@AddToQueue"><i>playlist_add</i> Queue</div>
					}
				</div>
			}
			else
			{
				<div class="locked"><i>lock</i> You can't control this player.</div>
			}

			<div class="queue">
				<div class="queue-header">
					<div class="tab nav @(ShowRecent ? "" : "active")" onclick="@( () => ShowRecent = false )">Up next <span class="count">@Target.Queue.Count / @Target.MaxQueue</span></div>
					<div class="tab nav @(ShowRecent ? "active" : "")" onclick="@( () => ShowRecent = true )">Recent <span class="count">@RecentLinks.All.Count</span></div>
					<div class="spacer"></div>
					@if ( !ShowRecent && Target.CanLocalControl && Target.Queue.Count > 0 )
					{
						<div class="small-btn nav" onclick="@Target.RequestClearQueue">Clear</div>
					}
				</div>

				<div class="queue-list">
					@if ( ShowRecent )
					{
						@if ( RecentLinks.All.Count == 0 )
						{
							<div class="empty">Links you play show up here.</div>
						}

						@foreach ( var recent in RecentLinks.All )
						{
							var url = recent.Url;
							<div class="queue-item recent">
								<div class="info">
									<div class="name">@recent.Title</div>
									<div class="by">@recent.Url</div>
								</div>
								<div class="dur">@(recent.IsLive ? "Live" : recent.Duration > 0 ? MediaSource.FormatTime( recent.Duration ) : "")</div>
								@if ( Target.CanLocalControl )
								{
									<div class="small-btn go nav" onclick="@( () => Target.RequestPlay( url ) )"><i>play_arrow</i></div>
								}
								@if ( Target.CanLocalQueue )
								{
									<div class="small-btn go nav" onclick="@( () => Target.RequestEnqueue( url ) )"><i>playlist_add</i></div>
								}
								<div class="small-btn nav" onclick="@( () => RecentLinks.Remove( url ) )"><i>close</i></div>
							</div>
						}
					}
					else if ( Target.Queue.Count == 0 )
					{
						<div class="empty">The queue is empty.</div>
					}

					@for ( int i = 0; !ShowRecent && i < Target.Queue.Count; i++ )
					{
						var index = i;
						var item = Target.Queue[i];
						<div class="queue-item">
							<div class="num">@(index + 1)</div>
							<div class="info">
								<div class="name">@item.Title</div>
								<div class="by">@item.RequestedBy</div>
							</div>
							<div class="dur">@(item.IsLive ? "Live" : item.Duration > 0 ? MediaSource.FormatTime( item.Duration ) : "")</div>
							@if ( Target.CanLocalControl )
							{
								<div class="small-btn nav" onclick="@( () => Target.RequestRemove( index ) )"><i>close</i></div>
							}
						</div>
					}
				</div>
			</div>

			<div class="footer">
				<div class="btn small nav" onclick="@ToggleMute"><i>@(MediaSettings.Mute ? "volume_off" : "volume_up")</i></div>
				<SliderControl class="volume nav" Min="@(0f)" Max="@(1f)" Step="@(0.01f)" ShowValueTooltip="@false" Value="@MediaSettings.Volume" OnValueChanged="@OnVolumeChanged"></SliderControl>
				<div class="btn small nav audio-mode @(MediaSettings.SpatialAudio ? "" : "flat")" onclick="@ToggleSpatial" tooltip="@(MediaSettings.SpatialAudio ? "3D audio: sound comes from the screen. Click for 2D" : "2D audio: no direction, still quieter further away. Click for 3D")"><i>@(MediaSettings.SpatialAudio ? "surround_sound" : "headphones")</i><span>@(MediaSettings.SpatialAudio ? "3D" : "2D")</span></div>
				@if ( PadMode )
				{
					<div class="hint">
						<InputGlyph Analog="LeftStickY" Size="@InputGlyphSize.Small"></InputGlyph><span>Move</span>
						@if ( Pad.SelectAction is not null )
						{
							<InputGlyph Action="@Pad.SelectAction" Size="@InputGlyphSize.Small"></InputGlyph><span>Select</span>
						}
						<InputGlyph Action="@(Pad.BackAction ?? "use")" Size="@InputGlyphSize.Small"></InputGlyph><span>Close</span>
					</div>
				}
				else
				{
					<div class="hint"><span>Your audio (all players) ·</span><InputGlyph Action="use" Size="@InputGlyphSize.Small"></InputGlyph><span>or</span><InputGlyph Key="escape" Size="@InputGlyphSize.Small"></InputGlyph><span>to close</span></div>
				}
			</div>

		</div>
	}

</root>

@code
{
	/// <summary>
	/// The player being controlled.
	/// </summary>
	public MediaPlayer Target { get; set; }

	/// <summary>
	/// Close automatically when the camera gets further than this from the player.
	/// </summary>
	public float MaxDistance { get; set; } = 1500.0f;

	TextEntry UrlEntry { get; set; }

	/// <summary> The list shows the links this client has played (<see cref="RecentLinks"/>) instead of the queue. </summary>
	bool ShowRecent { get; set; }

	RealTimeSince sinceOpened = 0;
	float? pendingSeek;
	RealTimeSince sincePendingSeek;

	float SeekValue => pendingSeek ?? Target?.CurrentTime ?? 0;

	string TimeText
	{
		get
		{
			if ( Target is null || !Target.HasMedia ) return "";
			if ( Target.IsLive ) return "Live";
			var cur = MediaSource.FormatTime( Target.CurrentTime );
			var s = Target.MediaDuration > 0 ? $"{cur} / {MediaSource.FormatTime( Target.MediaDuration )}" : cur;
			return Target.Paused ? $"{s} (paused)" : s;
		}
	}

	/// <summary>
	/// Open the remote for this player, or close it if it's already open for it.
	/// </summary>
	public static void Toggle( MediaPlayer target )
	{
		if ( !target.IsValid() || Application.IsDedicatedServer ) return;

		var existing = target.Scene.GetAllComponents<MediaRemote>().FirstOrDefault();
		if ( existing.IsValid() )
		{
			if ( existing.Target == target )
			{
				existing.Close();
				return;
			}

			existing.Target = target;
			existing.sinceOpened = 0;
			existing.StateHasChanged();
			return;
		}

		var go = target.Scene.CreateObject();
		go.Name = "bimp remote";
		go.NetworkMode = NetworkMode.Never;
		go.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.Hidden;

		var screen = go.Components.Create<ScreenPanel>();
		screen.ZIndex = 200;

		var remote = go.Components.Create<MediaRemote>();
		remote.Target = target;
	}

	/// <summary>
	/// Is a remote open right now? Games can use this to e.g. hide their crosshair.
	/// </summary>
	public static bool IsOpen( Scene scene ) => scene?.GetAllComponents<MediaRemote>().Any() ?? false;

	public void Close()
	{
		GameObject.Destroy();
	}

	protected override void OnTreeFirstBuilt()
	{
		base.OnTreeFirstBuilt();
		UrlEntry?.Focus();
	}

	//
	// Controller: the D-pad / left stick moves a highlight between the controls (the "nav" ones), A presses,
	// left/right adjusts sliders and choices, B closes. See PadNavigation.
	//

	/// <summary> Played with a controller right now (the engine switches as soon as they touch the keyboard or pad). </summary>
	bool PadMode => Input.UsingController;

	readonly PadNavigation Pad = new();

	/// <summary> The engine's virtual cursor (left stick moves a pointer) is off while we navigate; this was its setting. </summary>
	bool? virtualCursorWas;

	/// <summary>
	/// The very first thing in the frame, before anything (fixed update included) reads input: navigate, then suppress the
	/// frame's input, so the pad doesn't also move, jump or duck the player behind the remote.
	/// </summary>
	void Component.ISceneStage.Start()
	{
		if ( !PadMode || !Target.IsValid() || Panel is null )
		{
			RestoreVirtualCursor();
			return;
		}

		virtualCursorWas ??= Input.EnableVirtualCursor;
		Input.EnableVirtualCursor = false;

		Pad.Press ??= PressControl;
		Pad.Adjust ??= AdjustControl;
		Pad.Update( Panel );

		// B closes (unless it just closed a drop down's menu), and USE again, as on keyboard
		var close = sinceOpened > 0.2f && (Pad.BackPressed || Input.Pressed( "use" ));
		Input.Suppressed = true;
		if ( close ) Close();
	}

	/// <summary> The window's width in the stylesheet (.window), at 1080p. </summary>
	const float WindowWidth = 760;

	/// <summary>
	/// The screen panel already scales the remote with the screen height (as if every screen were 1080 high); this sizes
	/// it up from there by bimp_ui_scale - at its design size it was small at every resolution - but never wider than
	/// 90% of the screen.
	/// </summary>
	void FitToScreen()
	{
		if ( Components.Get<ScreenPanel>() is not { } screen ) return;

		var heightScale = Screen.Size.y / 1080.0f;
		var widest = heightScale > 0 ? Screen.Size.x * 0.9f / (WindowWidth * heightScale) : 1;
		var scale = Math.Clamp( MediaSettings.RemoteScale, 0.5f, Math.Max( 0.5f, widest ) );
		if ( MathF.Abs( screen.Scale - scale ) > 0.001f ) screen.Scale = scale;
	}

	/// <summary> For probes: each option of an open menu, its classes, and * on the pad's selection. </summary>
	IEnumerable<string> MenuOptions()
	{
		var top = Panel;
		while ( top?.Parent is not null ) top = top.Parent;
		var menu = top?.Descendants.OfType<Popup>().FirstOrDefault();
		return menu is null ? Enumerable.Empty<string>() : menu.Children.Select( c => $"{(c as Button)?.Text}:{c.Classes}{(c == menu.SelectedChild ? "*" : "")}" );
	}

	/// <summary> For probes: the controller state and what's highlighted. </summary>
	internal string DescribePad() => $"usingController={Input.UsingController} controllers={Input.ControllerCount} select={Pad.SelectAction ?? "-"} back={Pad.BackAction ?? "-"} virtualCursor={Input.EnableVirtualCursor} screen={Screen.Size} desktopScale={Screen.DesktopScale} uiScale={Panel?.ScaleToScreen} window={Panel?.Descendants.FirstOrDefault( p => p.HasClass( "window" ) )?.Box.Rect} focused=[{Pad.Focused?.Classes}] menu=[{string.Join( " | ", MenuOptions() )}]" + '\n' +
		string.Join( '\n', Panel.Descendants.Where( p => p.HasClass( "nav" ) ).Select( p => $"  [{p.Classes}] visible={p.IsVisible} rect={p.Box.Rect}" ) );

	/// <summary> For probes: move the highlight as the pad would (up, down, left, right, or "a" to press it), and say where it went. </summary>
	internal string ProbeMove( string direction )
	{
		if ( direction == "a" )
		{
			if ( Pad.Focused is { } focused ) PressControl( focused );
			return $"a on [{Pad.Focused?.Classes}]";
		}

		var d = direction switch { "up" => new Vector2( 0, -1 ), "down" => new Vector2( 0, 1 ), "left" => new Vector2( -1, 0 ), _ => new Vector2( 1, 0 ) };
		Pad.ProbeStep( Panel, d );
		return $"{direction} -> [{Pad.Focused?.Classes}] volume={MediaSettings.Volume:0.00}";
	}

	void RestoreVirtualCursor()
	{
		if ( virtualCursorWas is not { } was ) return;
		Input.EnableVirtualCursor = was;
		virtualCursorWas = null;
	}

	protected override void OnDestroy()
	{
		RestoreVirtualCursor();
		base.OnDestroy();
	}

	/// <summary> A on a control: a drop down opens its menu (navigated by PadNavigation), a button is clicked. Sliders only take left/right. </summary>
	void PressControl( Panel control )
	{
		if ( control.HasClass( "seek" ) || control.HasClass( "volume" ) ) return;

		if ( control is DropDown dropDown )
		{
			dropDown.Open();
			return;
		}

		control.CreateEvent( new MousePanelEvent( "onclick", control, "mouseleft" ) );
	}

	/// <summary> Left/right on a slider: seek by 10 s, volume by 5%. Anything else moves left/right. </summary>
	bool AdjustControl( Panel control, int step )
	{
		if ( control.HasClass( "seek" ) )
		{
			OnSeekChanged( Math.Clamp( SeekValue + step * 10, 0, Target.MediaDuration ) );
			return true;
		}

		if ( control.HasClass( "volume" ) )
		{
			OnVolumeChanged( MathF.Round( (MediaSettings.Volume + step * 0.05f) * 20 ) / 20 );
			return true;
		}

		return false;
	}

	protected override void OnUpdate()
	{
		if ( !Target.IsValid() )
		{
			Close();
			return;
		}

		MenuStyle.Apply( Panel );
		FitToScreen();

		if ( Input.EscapePressed )
		{
			Input.EscapePressed = false;
			Close();
			return;
		}

		// USE again closes it (not while typing - a focused text entry takes the keys)
		if ( sinceOpened > 0.2f && Input.Pressed( "use" ) && UseHandled.TryConsume() )
		{
			Close();
			return;
		}

		if ( Scene.Camera is not null && Scene.Camera.WorldPosition.Distance( Target.WorldPosition ) > MaxDistance )
		{
			Close();
			return;
		}

		// Seek once the slider has settled, rather than spamming the host while dragging
		if ( pendingSeek.HasValue && sincePendingSeek > 0.3f )
		{
			Target.RequestSeek( pendingSeek.Value );
			pendingSeek = null;
		}
	}

	void OnSeekChanged( float value )
	{
		if ( Target is null || !Target.CanLocalControl ) return;
		if ( MathF.Abs( value - Target.CurrentTime ) < 1.5f && !pendingSeek.HasValue ) return; // our own refresh

		pendingSeek = value;
		sincePendingSeek = 0;
	}

	//
	// Resolution / audio track. These are this player's own preferences (saved convars), applied to every
	// media player - picking one reloads the local stream at the same synced time.
	//

	string QualityValue => MediaSettings.Quality <= 0 ? "0" : Target.SelectedQuality.ToString();

	List<Option> BuildQualityOptions()
	{
		var auto = $"Auto (up to {MediaSettings.MaxVideoHeight}p)";
		var list = new List<Option> { new Option( auto, "0" ) };
		foreach ( var h in Target.AvailableQualities )
			list.Add( new Option( MediaStreamOptions.QualityLabel( h ), h.ToString() ) );
		return list;
	}

	void OnQualityChanged( string value )
	{
		if ( !int.TryParse( value, out var h ) ) return;
		if ( h == (MediaSettings.Quality <= 0 ? 0 : Target.SelectedQuality) ) return;
		MediaSettings.Quality = h;
	}

	string AudioTrackValue
	{
		get
		{
			var selected = Target.SelectedAudioTrack ?? Target.AvailableAudioTracks.FirstOrDefault( t => t.IsDefault );
			return selected?.Id ?? "";
		}
	}

	List<Option> BuildAudioTrackOptions()
	{
		return Target.AvailableAudioTracks
			.Select( t => new Option( t.IsDefault && !t.Name.Contains( "original", StringComparison.OrdinalIgnoreCase ) ? $"{t.Name} (original)" : t.Name, t.Id ) )
			.ToList();
	}

	void OnAudioTrackChanged( string id )
	{
		if ( id == AudioTrackValue ) return;
		var track = Target.AvailableAudioTracks.FirstOrDefault( t => t.Id == id );
		// picking the original clears the preference, so dubbed videos default to original again
		MediaSettings.AudioLanguage = track is null || track.IsDefault ? "" : track.Id;
	}

	//
	// Captions: off, or a language. The choice is this player's own (saved convars) and applies to every media player.
	// Auto-generated tracks are only offered for languages without a written one - that's what picking a language gets.
	//

	IEnumerable<MediaCaptionTrack> ListedCaptionTracks
		=> Target.AvailableCaptionTracks.Where( t => !t.IsAuto || !Target.AvailableCaptionTracks.Any( w => !w.IsAuto && w.Lang == t.Lang ) );

	string CaptionValue => Target.SelectedCaptionTrack?.Id ?? "";

	List<Option> BuildCaptionOptions()
	{
		var list = new List<Option> { new Option( "Captions off", "" ) };
		foreach ( var t in ListedCaptionTracks )
			list.Add( new Option( t.IsAuto && !t.Name.Contains( "auto", StringComparison.OrdinalIgnoreCase ) ? $"{t.Name} (auto-generated)" : t.Name, t.Id ) );
		return list;
	}

	void OnCaptionChanged( string id )
	{
		if ( id == CaptionValue ) return;

		var track = Target.AvailableCaptionTracks.FirstOrDefault( t => t.Id == id );
		MediaSettings.Subtitles = track is not null;
		if ( track is not null ) MediaSettings.SubtitleLanguage = track.Lang;
	}

	string PlayingAsText
	{
		get
		{
			var b = Target.Backend;
			if ( b is null || !b.Loaded ) return "Loading...";
			return b.HasVideo ? $"Playing {b.Height}p" : "";
		}
	}

	void OnVolumeChanged( float value )
	{
		MediaSettings.Volume = value.Clamp( 0, 1 );
		if ( value > 0 ) MediaSettings.Mute = false;
	}

	void ToggleMute()
	{
		MediaSettings.Mute = !MediaSettings.Mute;
	}

	/// <summary> 3D audio from every media player, or flat 2D for this player (bimp_spatial). </summary>
	void ToggleSpatial()
	{
		MediaSettings.SpatialAudio = !MediaSettings.SpatialAudio;
	}

	string TakeUrl()
	{
		var text = UrlEntry?.Text?.Trim();
		if ( UrlEntry is not null ) UrlEntry.Text = "";
		return string.IsNullOrWhiteSpace( text ) ? null : text;
	}

	void OnSubmit()
	{
		// Enter plays right away if we can, otherwise queues
		if ( Target.CanLocalControl && !Target.HasMedia ) PlayNow();
		else if ( Target.CanLocalQueue ) AddToQueue();
		else PlayNow();
	}

	void PlayNow()
	{
		var url = TakeUrl();
		if ( url is null ) return;
		Target.RequestPlay( url );
	}

	void AddToQueue()
	{
		var url = TakeUrl();
		if ( url is null ) return;
		Target.RequestEnqueue( url );
	}

	protected override int BuildHash()
	{
		if ( Target is null ) return 0;

		return HashCode.Combine(
			HashCode.Combine( Target.PlayId, Target.Title, Target.Paused, Target.Status, Target.LocalNotice, (int)Target.CurrentTime, Target.MediaDuration ),
			HashCode.Combine( QueueHash(), MediaSettings.Mute, MediaSettings.Volume, MediaSettings.SpatialAudio, Target.CanLocalControl, pendingSeek, PadMode ),
			HashCode.Combine( Target.Qualities, Target.AudioTracks, MediaSettings.Quality, MediaSettings.AudioLanguage, PlayingAsText, ShowRecent, RecentLinks.Version ),
			HashCode.Combine( Target.CaptionTracks, MediaSettings.Subtitles, MediaSettings.SubtitleLanguage )
		);
	}

	int QueueHash()
	{
		var h = new HashCode();
		h.Add( Target.Queue.Count );
		foreach ( var item in Target.Queue ) h.Add( item?.Url );
		return h.ToHashCode();
	}
}
@using System
@using Sandbox
@using Sandbox.UI
@namespace Bimp
@inherits PanelComponent
@implements Component.ExecuteInEditor

@*
	A small display for audio-only players (speakers, radios, jukeboxes).
	Put this on a GameObject with a WorldPanel (PanelSize ~800x400).
*@

<root class="mediaspeaker @(Player?.HasMedia == true ? "playing" : "idle")">

	<div class="header">
		<i>@(Player?.HasMedia == true ? "graphic_eq" : "speaker")</i>
		@if ( ShowUseHint )
		{
			<div class="title hint"><span>Press</span><InputGlyph Action="use"></InputGlyph><span>to play something</span></div>
		}
		else
		{
			<ScrollingText class="title" Text="@TitleText"></ScrollingText>
		}
	</div>

	<SpectrumBars Player="@Player"></SpectrumBars>

	<div class="footer">
		<div class="time">@TimeText</div>
		@if ( Player is not null && Player.Queue.Count > 0 )
		{
			<div class="queue"><i>queue_music</i> @Player.Queue.Count</div>
		}
	</div>

	@if ( !string.IsNullOrWhiteSpace( Player?.LocalNotice ) )
	{
		<div class="notice">@Player.LocalNotice</div>
	}

</root>

@code
{
	/// <summary>
	/// The player this display shows. Found automatically in this object or its parents if unset.
	/// </summary>
	[Property] public MediaPlayer Player { get; set; }

	/// <summary>
	/// Scale the UI to fit the WorldPanel's PanelSize instead of getting cut off. Sets the WorldPanel's RenderScale.
	/// </summary>
	[Property, Group( "Layout" )] public bool FitToPanel { get; set; } = true;

	/// <summary>
	/// The panel size the UI is designed for.
	/// </summary>
	[Property, Group( "Layout" ), ShowIf( nameof( FitToPanel ), true )] public Vector2 DesignSize { get; set; } = new Vector2( 560, 280 );

	string TitleText
	{
		get
		{
			if ( Player is null ) return "";
			if ( Player.HasMedia ) return Player.Title;
			if ( !string.IsNullOrWhiteSpace( Player.Status ) ) return Player.Status;
			return "";
		}
	}

	/// <summary> Nothing playing or happening: show how to start something, with the USE button's glyph. </summary>
	bool ShowUseHint => Player is not null && !Player.HasMedia && string.IsNullOrWhiteSpace( Player.Status );

	string TimeText
	{
		get
		{
			if ( Player is null || !Player.HasMedia ) return "";
			if ( Player.IsLive ) return "Live";
			var cur = MediaSource.FormatTime( Player.CurrentTime );
			var s = Player.MediaDuration > 0 ? $"{cur} / {MediaSource.FormatTime( Player.MediaDuration )}" : cur;
			return Player.Paused ? $"{s}  (paused)" : s;
		}
	}

	protected override void OnUpdate()
	{
		if ( FitToPanel ) PanelFit.Apply( this, DesignSize );
		if ( Scene.IsEditor ) return;

		Player ??= GetComponentInParent<MediaPlayer>();
	}

	protected override int BuildHash()
	{
		if ( Player is null ) return 0;
		return HashCode.Combine( Player.PlayId, Player.Title, Player.Status, Player.Paused, (int)Player.CurrentTime, Player.Queue.Count, Player.LocalNotice );
	}
}
@using System
@using Sandbox
@using Sandbox.UI
@namespace Bimp
@inherits PanelComponent
@implements Component.ExecuteInEditor

@*
	A small display for audio-only players (speakers, radios, jukeboxes).
	Put this on a GameObject with a WorldPanel (PanelSize ~800x400).
*@

<root class="mediaspeaker @(Player?.HasMedia == true ? "playing" : "idle")">

	<div class="header">
		<i>@(Player?.HasMedia == true ? "graphic_eq" : "speaker")</i>
		@if ( ShowUseHint )
		{
			<div class="title hint"><span>Press</span><InputGlyph Action="use"></InputGlyph><span>to play something</span></div>
		}
		else
		{
			<ScrollingText class="title" Text="@TitleText"></ScrollingText>
		}
	</div>

	<SpectrumBars Player="@Player"></SpectrumBars>

	<div class="footer">
		<div class="time">@TimeText</div>
		@if ( Player is not null && Player.Queue.Count > 0 )
		{
			<div class="queue"><i>queue_music</i> @Player.Queue.Count</div>
		}
	</div>

	@if ( !string.IsNullOrWhiteSpace( Player?.LocalNotice ) )
	{
		<div class="notice">@Player.LocalNotice</div>
	}

</root>

@code
{
	/// <summary>
	/// The player this display shows. Found automatically in this object or its parents if unset.
	/// </summary>
	[Property] public MediaPlayer Player { get; set; }

	/// <summary>
	/// Scale the UI to fit the WorldPanel's PanelSize instead of getting cut off. Sets the WorldPanel's RenderScale.
	/// </summary>
	[Property, Group( "Layout" )] public bool FitToPanel { get; set; } = true;

	/// <summary>
	/// The panel size the UI is designed for.
	/// </summary>
	[Property, Group( "Layout" ), ShowIf( nameof( FitToPanel ), true )] public Vector2 DesignSize { get; set; } = new Vector2( 560, 280 );

	string TitleText
	{
		get
		{
			if ( Player is null ) return "";
			if ( Player.HasMedia ) return Player.Title;
			if ( !string.IsNullOrWhiteSpace( Player.Status ) ) return Player.Status;
			return "";
		}
	}

	/// <summary> Nothing playing or happening: show how to start something, with the USE button's glyph. </summary>
	bool ShowUseHint => Player is not null && !Player.HasMedia && string.IsNullOrWhiteSpace( Player.Status );

	string TimeText
	{
		get
		{
			if ( Player is null || !Player.HasMedia ) return "";
			if ( Player.IsLive ) return "Live";
			var cur = MediaSource.FormatTime( Player.CurrentTime );
			var s = Player.MediaDuration > 0 ? $"{cur} / {MediaSource.FormatTime( Player.MediaDuration )}" : cur;
			return Player.Paused ? $"{s}  (paused)" : s;
		}
	}

	protected override void OnUpdate()
	{
		if ( FitToPanel ) PanelFit.Apply( this, DesignSize );
		if ( Scene.IsEditor ) return;

		Player ??= GetComponentInParent<MediaPlayer>();
	}

	protected override int BuildHash()
	{
		if ( Player is null ) return 0;
		return HashCode.Combine( Player.PlayId, Player.Title, Player.Status, Player.Paused, (int)Player.CurrentTime, Player.Queue.Count, Player.LocalNotice );
	}
}
@using System;
@using Sandbox;
@using Sandbox.UI;
@inherits PanelComponent

<root>
    <div class="crosshair" style="
		position: absolute;
		left: @( IsPercentage ? $"{Position.x}%"  : Position.x );
		top: @( IsPercentage ? $"{Position.y}%" : Position.y );
		transform: translate(-50%, -50%);
	">
		<div class="center-dot-border-wrapper" style="
			display: @(CenterDot ? "flex" : "none");
			border: @(Outline ? $"{OutlineThickness}px solid rgba(0, 0, 0, {OutlineOpacity})" : "none");
			z-index: 100;
		">
			<div class="center-dot" style="
				background-color: rgba(@(Color.r * 255), @(Color.g * 255), @(Color.b * 255), @(CenterDotOpacity));
				padding: @(CenterDotThickness)px;
			"></div>
		</div>
		<div class="inner-top-border-wrapper" style="
			position: absolute;
    		top: -@(InnerLinesOffset)px;
    		left: 50%;
    		transform: translateX(-50%);
			display:@(ShowInnerLines ? "flex" : "none");
			border: @(Outline ? $"{OutlineThickness}px solid rgba(0, 0, 0, {OutlineOpacity})" : "none");
		">
			<div class="inner-line top" style="
				background-color: rgba(@(Color.r * 255), @(Color.g * 255), @(Color.b * 255), @(InnerLineOpacity));
				padding-left: @(InnerLineThickness)px;
    			padding-top: @(InnerLineLenght)px;
			"></div>
		</div>
		<div class="inner-bottom-border-wrapper" style="
			position: absolute;
    		bottom: -@(InnerLinesOffset)px;
    		left: 50%;
    		transform: translateX(-50%);
			display:@(ShowInnerLines ? "flex" : "none");
			border: @(Outline ? $"{OutlineThickness}px solid rgba(0, 0, 0, {OutlineOpacity})" : "none");
		">
			<div class="inner-line bottom" style="
				background-color: rgba(@(Color.r * 255), @(Color.g * 255), @(Color.b * 255), @(InnerLineOpacity));
				padding-left: @(InnerLineThickness)px;
    			padding-bottom: @(InnerLineLenght)px;
			"></div>
		</div>
		<div class="inner-left-border-wrapper" style="
			position: absolute;
			left: -@(InnerLinesOffset)px;
			top: 50%;
			transform: translateY(-50%);
			display:@(ShowInnerLines ? "flex" : "none");
			border: @(Outline ? $"{OutlineThickness}px solid rgba(0, 0, 0, {OutlineOpacity})" : "none");
		">
			<div class="inner-line left" style="
				background-color: rgba(@(Color.r * 255), @(Color.g * 255), @(Color.b * 255), @(InnerLineOpacity));
				padding-top: @(InnerLineThickness)px;
				padding-left: @(InnerLineLenght)px;
			"></div>
		</div>
		<div class="inner-right-border-wrapper" style="
			position: absolute;
			right: -@(InnerLinesOffset)px;
			top: 50%;
			transform: translateY(-50%);
			display: @(ShowInnerLines ? "flex" : "none");
			border: @(Outline ? $"{OutlineThickness}px solid rgba(0, 0, 0, {OutlineOpacity})" : "none");
		">
			<div class="inner-line right" style="
				background-color: rgba(@(Color.r * 255), @(Color.g * 255), @(Color.b * 255), @(InnerLineOpacity));
				padding-top: @(InnerLineThickness)px;
				padding-right: @(InnerLineLenght)px;
			"></div>
		</div>
		<div class="outer-top-border-wrapper" style="
			position: absolute;
			top: -@(OuterLineOffset)px;
			left: 50%;
			transform: translateX(-50%);
			display: @(ShowOuterLines ? "flex" : "none");
			border: @(Outline ? $"{OutlineThickness}px solid rgba(0, 0, 0, {OutlineOpacity})" : "none");
		">
			<div class="outer-line top" style="
				background-color: rgba(@(Color.r * 255), @(Color.g * 255), @(Color.b * 255), @(OuterLineOpacity));
				padding-left: @(OuterLineThickness)px;
				padding-top: @(OuterLineLenght)px;
			"></div>
		</div>
		<div class="outer-bottom-border-wrapper" style="
			position: absolute;
			bottom: -@(OuterLineOffset)px;
			left: 50%;
			transform: translateX(-50%);
			display: @(ShowOuterLines ? "flex" : "none");
			border: @(Outline ? $"{OutlineThickness}px solid rgba(0, 0, 0, {OutlineOpacity})" : "none");
		">
			<div class="outer-line bottom" style="
				background-color: rgba(@(Color.r * 255), @(Color.g * 255), @(Color.b * 255), @(OuterLineOpacity));
				padding-left: @(OuterLineThickness)px;
				padding-bottom: @(OuterLineLenght)px;
			"></div>
		</div>
		<div class="outer-left-border-wrapper" style="
			position: absolute;
			left: -@(OuterLineOffset)px;
			top: 50%;
			transform: translateY(-50%);
			display: @(ShowOuterLines ? "flex" : "none");
			border: @(Outline ? $"{OutlineThickness}px solid rgba(0, 0, 0, {OutlineOpacity})" : "none");
		">
			<div class="outer-line left" style="
				background-color: rgba(@(Color.r * 255), @(Color.g * 255), @(Color.b * 255), @(OuterLineOpacity));
				padding-top: @(OuterLineThickness)px;
				padding-left: @(OuterLineLenght)px;
			"></div>
		</div>
		<div class="outer-right-border-wrapper" style="
			position: absolute;
			right: -@(OuterLineOffset)px;
			top: 50%;
			transform: translateY(-50%);
			display: @(ShowOuterLines ? "flex" : "none");
			border: @(Outline ? $"{OutlineThickness}px solid rgba(0, 0, 0, {OutlineOpacity})" : "none");
		">
			<div class="outer-line right" style="
				background-color: rgba(@(Color.r * 255), @(Color.g * 255), @(Color.b * 255), @(OuterLineOpacity));
				padding-top: @(OuterLineThickness)px;
				padding-right: @(OuterLineLenght)px;
			"></div>
		</div>
    </div>
</root>

@code
{
	// Position properties
	[Property] 
	[Category("Position")] public bool IsPercentage { get; set; } = true;
	[Property]
	[Category("Position")] public Vector2 Position { get; set; } = new Vector2(50, 50);

	// Crosshair properties
	[Property]
	[Category("Crosshair")] public Color Color { get; set; } = Color.FromRgb(0x2EFF00);
	[Property]
	[Category("Crosshair")] public bool Outline { get; set; } = true;
	[Property]
	[Range(0f, 1f, 0.001f)]
	[Category("Crosshair")] public float OutlineOpacity { get; set; } = 1f;
	[Property]
	[Range(1, 10, 1)]
	[Category("Crosshair")] public int OutlineThickness { get; set; } = 2;
	[Property]
	[Category("Crosshair")] public bool CenterDot { get; set; } = true;
	[Property]
	[Range(0f, 1f, 0.001f)]
	[Category("Crosshair")] public float CenterDotOpacity { get; set; } = 1f;
	[Property]
	[Range(1, 10, 1)]
	[Category("Crosshair")] public int CenterDotThickness { get; set; } = 1;

	// Inner Lines properties
	[Property]
	[Category("Inner Lines")] public bool ShowInnerLines { get; set; } = true;
	[Property]
	[Range(0f, 1f, 0.001f)]
	[Category("Inner Lines")] public float InnerLineOpacity { get; set; } = 1f;
	[Property]
	[Range(0, 20, 1)]
	[Category("Inner Lines")] public int InnerLineLenght { get; set; } = 10;
	[Property]
	[Range(0, 10, 1)]
	[Category("Inner Lines")] public int InnerLineThickness { get; set; } = 1;
	[Property]
	[Range(0, 20, 1)]
	[Category("Inner Lines")] public int InnerLinesOffset { get; set; } = 2;

	// Outer Lines properties
	[Property]
	[Category("Outer Lines")] public bool ShowOuterLines { get; set; } = false;
	[Property]
	[Range(0f, 1f, 0.001f)]
	[Category("Outer Lines")] public float OuterLineOpacity { get; set; } = 0.5f;
	[Property]
	[Range(0, 20, 1)]
	[Category("Outer Lines")] public int OuterLineLenght { get; set; }
	[Property]
	[Range(0, 10, 1)]
	[Category("Outer Lines")] public int OuterLineThickness { get; set; }
	[Property]
	[Range(0, 20, 1)]
	[Category("Outer Lines")] public int OuterLineOffset { get; set; }

	// Code properties
	[Property]
	[Category("Code")] public string Code { get; set; }

	// Member variables
	private string _cachedCode = null;

	public string EncodeCrosshairParameters()
	{
		// Convert boolean values to 1 or 0
		string EncodeBool(bool value) => value ? "1" : "0";
		// Convert float values to a shortened string representation (up to 2 decimal places)
		string EncodeFloat(float value) => Math.Round(value, 2).ToString("0.##");
		// Convert integers directly to string
		string EncodeInt(int value) => value.ToString();

		// Concatenate all parameters into a shortened format
		var parameters = $"{EncodeBool(IsPercentage)}," +
							$"{EncodeFloat(Position.x)},{EncodeFloat(Position.y)}," +
							$"{EncodeFloat(Color.r)},{EncodeFloat(Color.g)},{EncodeFloat(Color.b)}," +
							$"{EncodeBool(Outline)},{EncodeFloat(OutlineOpacity)},{EncodeInt(OutlineThickness)}," +
							$"{EncodeBool(CenterDot)},{EncodeFloat(CenterDotOpacity)},{EncodeInt(CenterDotThickness)}," +
							$"{EncodeBool(ShowInnerLines)},{EncodeFloat(InnerLineOpacity)},{EncodeInt(InnerLineLenght)}," +
							$"{EncodeInt(InnerLineThickness)},{EncodeInt(InnerLinesOffset)}," +
							$"{EncodeBool(ShowOuterLines)},{EncodeFloat(OuterLineOpacity)},{EncodeInt(OuterLineLenght)}," +
							$"{EncodeInt(OuterLineThickness)},{EncodeInt(OuterLineOffset)}";
		return parameters;
	}

	public void DecodeCrosshairParameters(string encodedString)
	{
		var parameters = encodedString.Split(',');

		// Convert "1" or "0" back to boolean
		bool DecodeBool(string value) => value == "1";
		// Convert string back to float
		float DecodeFloat(string value) => float.Parse(value);
		// Convert string back to int
		int DecodeInt(string value) => int.Parse(value);

		// Assign the decoded values back to the properties
		IsPercentage = DecodeBool(parameters[0]);
		Position = new Vector2(DecodeFloat(parameters[1]), DecodeFloat(parameters[2]));
		Color = new Color(DecodeFloat(parameters[3]), DecodeFloat(parameters[4]), DecodeFloat(parameters[5]));
		Outline = DecodeBool(parameters[6]);
		OutlineOpacity = DecodeFloat(parameters[7]);
		OutlineThickness = DecodeInt(parameters[8]);
		CenterDot = DecodeBool(parameters[9]);
		CenterDotOpacity = DecodeFloat(parameters[10]);
		CenterDotThickness = DecodeInt(parameters[11]);
		ShowInnerLines = DecodeBool(parameters[12]);
		InnerLineOpacity = DecodeFloat(parameters[13]);
		InnerLineLenght = DecodeInt(parameters[14]);
		InnerLineThickness = DecodeInt(parameters[15]);
		InnerLinesOffset = DecodeInt(parameters[16]);
		ShowOuterLines = DecodeBool(parameters[17]);
		OuterLineOpacity = DecodeFloat(parameters[18]);
		OuterLineLenght = DecodeInt(parameters[19]);
		OuterLineThickness = DecodeInt(parameters[20]);
		OuterLineOffset = DecodeInt(parameters[21]);
	}

	protected override void OnStart()
	{
		if (Code == null)
		{
			Code = EncodeCrosshairParameters();
		}
		DecodeCrosshairParameters(Code);
		_cachedCode = Code;
	}

	protected override void OnUpdate()
	{
		if (Code != _cachedCode)
		{
			DecodeCrosshairParameters(Code);
		}
		Code = EncodeCrosshairParameters();
		_cachedCode = Code;
	}

	/// <summary>
	/// the hash determines if the system should be rebuilt. If it changes, it will be rebuilt
	/// </summary>
	protected override int BuildHash()
	{
		var positionHash = System.HashCode.Combine( 
			IsPercentage,
			Position
		);

		var crosshairHash = System.HashCode.Combine( 
			Color,
			Outline,
			OutlineOpacity,
			OutlineThickness,
			CenterDot,
			CenterDotOpacity,
			CenterDotThickness
		);

		var innerLinesHash = System.HashCode.Combine(
			ShowInnerLines,
			InnerLineOpacity,
			InnerLineLenght,
			InnerLineThickness,
			InnerLinesOffset
		);

		var outerLinesHash = System.HashCode.Combine(
			ShowOuterLines,
			OuterLineOpacity,
			OuterLineLenght,
			OuterLineThickness,
			OuterLineOffset
		);

		var codeHash = Code.GetHashCode();
		
		return System.HashCode.Combine(
			positionHash,
			crosshairHash,
			innerLinesHash,
			outerLinesHash,
			codeHash
		);
	}
}
@namespace FieldGuide.VehiclePhysics
@inherits PanelComponent

@* Kit-native live tuning lab (demo layer). Toggled by the Tune action, bound to the car the chase
   camera is following. Writes tuning changes straight onto the running car through the same paths a
   consumer would use: it mutates the active CarDefinition (read live by the drivetrain and the brake
   model) and pushes suspension/tire values onto the live wheels. This is a demo-scale lab, not the
   full game panel: it exposes the highest-feel dials so the demo reads as a physics lab in the first
   minute. Replace it with your own UI; it lives only in the demo scene.

   Look and layout follow the Field Kit UI system: docs/design/ui-system/vehicle-physics.dc.html
   screen 03.A for this panel, tokens.dc.html for the values. The stylesheet carries this kit's own
   copy of those tokens (kits cannot import each other) and lists the engine-legality rules at its
   head. *@

<root>
	@if ( Car.IsValid() )
	{
		@* Key legend, top-right: how to hop between the demo cars. Always visible; the tuning
		   chip/panel top-left covers the T binding. *@
		<div class="legend">
			<div class="mono kbd">[ ]</div>
			<div class="title">switch car</div>
		</div>
	}
	@if ( !IsOpen && Car.IsValid() )
	{
		@* Collapsed legend chip: sits exactly where the expanded panel's top-left corner lands, so
		   pressing T reads as the chip expanding into the lab. Mouse-look stays with the camera. *@
		<div class="panel chip" onclick=@ToggleOpen>
			<div class="head">
				<div class="mono kbd">@ToggleKeyLabel</div>
				<div class="title">tuning</div>
			</div>
		</div>
	}
	@if ( IsOpen && Car.IsValid() )
	{
		<div class="panel">
			<div class="head">
				<div class="title">Tuning lab · @CarName</div>
				@* Key chip plus the 42px close x. The x runs the SAME ToggleOpen path the T key and
				   the collapsed chip use, so cursor visibility stays decided in one place. *@
				<div class="hr">
					<div class="mono kbd">@ToggleKeyLabel</div>
					<div class="x" onclick=@ToggleOpen>×</div>
				</div>
			</div>

			<div class="hint">Tuning the car you are driving. Changes apply live.</div>

			<div class="dials">
				@foreach ( var d in SliderDials )
				{
					var dd = d;
					<div class="dial">
						<div class="drow">
							<div class="dname">@d.Name</div>
							<div class="mono dval">@d.Display()</div>
						</div>
						<div class="dctl">
							<div class="mono step" onclick=@( () => Step( dd, -1 ) )>-</div>
							<div class="track"
								onmousedown=@( e => Scrub( dd, e, true ) )
								onmousemove=@( e => Scrub( dd, e, false ) )>
								<div class="fill" style="width: @WidthPct( dd )%"></div>
							</div>
							<div class="mono step" onclick=@( () => Step( dd, +1 ) )>+</div>
						</div>
					</div>
				}

				<div class="dial">
					<div class="drow">
						<div class="dname">Assists</div>
						<div class="mono cycle" onclick=@CycleAssists>@AssistLabel</div>
					</div>
				</div>

				<div class="dial">
					<div class="drow">
						<div class="dname">Tires</div>
						<div class="mono cycle" onclick=@CycleTires>@TireLabel</div>
					</div>
				</div>
			</div>

			<div class="foot">
				<div class="btn" onclick=@ResetToStock>Reset to stock</div>
			</div>
		</div>
	}
</root>

@code {
	/// <summary>The car this panel tunes: the one the chase camera follows. Set by DemoBootstrap. On
	/// change the panel snapshots the car's pristine (authored) values so Reset-to-stock can restore
	/// them and the grip/torque multipliers rebase to the new car.</summary>
	VehicleController _car;
	public VehicleController Car
	{
		get => _car;
		set
		{
			if ( ReferenceEquals( _car, value ) )
				return;
			_car = value;
			Snapshot();
		}
	}

	/// <summary>Open state. Static so the kit's chase camera can read it through the
	/// <see cref="VehicleCamera.CursorModalOpen"/> seam (DemoBootstrap wires that) without holding a
	/// reference to this panel. The demo runs exactly one panel, so a single flag is enough.</summary>
	public static bool IsOpen;

	// Toggle input action. Documented in the README input table; the host ProjectSettings/Input.config
	// ships it. Never Escape or an F-key.
	const string ToggleAction = "Tune";
	string ToggleKeyLabel => "T";

	string CarName => Car?.Definition?.Name ?? "Car";

	// Live multipliers over the pristine base. Grip scales the (preset-selected) tire curves; torque
	// scales the authored peak engine torque.
	float _gripScale = 1f;
	float _torqueScale = 1f;
	int _tirePreset; // 0 Stock, 1 Street, 2 Sport, 3 Offroad

	// Pristine snapshot, captured value-by-value when the car is bound (its definition is untouched at
	// that point). Value types only (floats, TireCurve struct, enum), so later live tuning of the
	// definition can never corrupt these; Reset restores from here.
	bool _snapped;
	float _stockPeakTorque, _stockSpring, _stockDamper, _stockTravel, _stockBrake;
	TireCurve _stockLat, _stockLong;
	AssistLevel _stockAssists;

	// Current UNSCALED tire base (Stock or a named preset). Grip multiplies these to get the live curves.
	TireCurve _baseLat, _baseLong;

	void Snapshot()
	{
		var def = _car?.Definition;
		_dials = null;
		if ( def is null )
		{
			_snapped = false;
			return;
		}

		_stockPeakTorque = def.PeakTorque;
		_stockSpring = def.SpringRate;
		_stockDamper = def.DamperRate;
		_stockTravel = def.SuspensionTravel;
		_stockBrake = def.BrakeTorque;
		_stockLat = def.LateralCurve;
		_stockLong = def.LongitudinalCurve;
		// Stock assist = the authored definition default. Read it off the definition, not the
		// controller: the controller adopts DefaultAssists in its own OnStart, which may run a frame
		// or two after this bind, so its live Assists is not reliable yet.
		_stockAssists = def.DefaultAssists;

		_baseLat = _stockLat;
		_baseLong = _stockLong;
		_gripScale = 1f;
		_torqueScale = 1f;
		_tirePreset = 0;
		_snapped = true;
		StateHasChanged();
	}

	// ---- dial model ----
	class Dial
	{
		public string Name;
		public float Min, Max, Step;
		public Func<float> Get;
		public Action<float> Set;
		public Func<string> Fmt;
		public string Display() => Fmt();
	}

	List<Dial> _dials;
	List<Dial> SliderDials => _dials ??= BuildDials();

	List<Dial> BuildDials()
	{
		if ( !Car.IsValid() )
			return new List<Dial>();

		var def = Car.Definition;
		return new List<Dial>
		{
			new()
			{
				Name = "Grip", Min = 0.6f, Max = 2.2f, Step = 0.05f,
				Get = () => _gripScale, Set = SetGrip,
				Fmt = () => _gripScale.ToString( "0.00" ) + "x",
			},
			new()
			{
				Name = "Drive torque", Min = 0.5f, Max = 2.0f, Step = 0.05f,
				Get = () => _torqueScale, Set = SetTorqueScale,
				Fmt = () => _torqueScale.ToString( "0.00" ) + "x",
			},
			new()
			{
				Name = "Suspension stiffness", Min = 15000f, Max = 60000f, Step = 2000f,
				Get = () => def.SpringRate, Set = v => { def.SpringRate = v; ApplyWheels(); },
				Fmt = () => def.SpringRate.ToString( "0" ) + " N/m",
			},
			new()
			{
				Name = "Suspension damping", Min = 800f, Max = 6000f, Step = 200f,
				Get = () => def.DamperRate, Set = v => { def.DamperRate = v; ApplyWheels(); },
				Fmt = () => def.DamperRate.ToString( "0" ) + " Ns/m",
			},
			new()
			{
				Name = "Suspension travel", Min = 0.10f, Max = 0.35f, Step = 0.01f,
				Get = () => def.SuspensionTravel, Set = v => { def.SuspensionTravel = v; ApplyWheels(); },
				Fmt = () => (def.SuspensionTravel * 100f).ToString( "0" ) + " cm",
			},
			new()
			{
				Name = "Brake force", Min = 1500f, Max = 8000f, Step = 200f,
				Get = () => def.BrakeTorque, Set = v => def.BrakeTorque = v,
				Fmt = () => def.BrakeTorque.ToString( "0" ) + " Nm",
			},
		};
	}

	float Frac( Dial d )
	{
		if ( d.Max <= d.Min )
			return 0f;
		return Math.Clamp( (d.Get() - d.Min) / (d.Max - d.Min), 0f, 1f );
	}

	string WidthPct( Dial d ) =>
		(Frac( d ) * 100f).ToString( "0.#", System.Globalization.CultureInfo.InvariantCulture );

	void Step( Dial d, int clicks )
	{
		float v = Math.Clamp( d.Get() + clicks * d.Step, d.Min, d.Max );
		d.Set( v );
		StateHasChanged();
	}

	// Click-to-jump + drag-to-scrub on the dial track (mirrors the game panel's proven pattern). jump
	// is true on mousedown (a bare click sets the value at the cursor) and false on mousemove (scrub
	// only while the track owns the press). Value = mouse local x over track width, snapped to the
	// dial's step, pushed through the SAME Set path as the +/- steps.
	void Scrub( Dial d, Sandbox.UI.PanelEvent ev, bool jump )
	{
		if ( ev is not Sandbox.UI.MousePanelEvent e )
			return;

		var track = e.This;
		if ( track is null )
			return;

		// mousemove fires whether or not the button is held; only scrub while the track owns the press.
		if ( !jump && !track.PseudoClass.HasFlag( Sandbox.UI.PseudoClass.Active ) )
			return;

		float w = track.Box.Rect.Width;
		if ( w <= 0f )
			return;

		float frac = Math.Clamp( e.LocalPosition.x / w, 0f, 1f );
		float v = d.Min + frac * (d.Max - d.Min);
		if ( d.Step > 0f )
			v = MathF.Round( v / d.Step ) * d.Step;
		v = Math.Clamp( v, d.Min, d.Max );
		d.Set( v );
		StateHasChanged();
	}

	// ---- apply paths (same seams a consumer would use) ----
	static TireCurve Scaled( TireCurve c, float k ) =>
		new( c.PeakSlip, c.PeakGrip * k, c.TailSlip, c.TailGrip * k );

	// Push the definition's suspension + tire values onto the live wheels. The factory copies these at
	// spawn; the wheel re-reads them every substep, so writing them here is the live-apply path.
	void ApplyWheels()
	{
		var def = Car.Definition;
		foreach ( var w in Car.Wheels )
		{
			w.SpringRate = def.SpringRate;
			w.DamperRate = def.DamperRate;
			w.SuspensionTravel = def.SuspensionTravel;
			w.LateralCurve = def.LateralCurve;
			w.LongitudinalCurve = def.LongitudinalCurve;
		}
	}

	void SetGrip( float k )
	{
		_gripScale = k;
		var def = Car.Definition;
		def.LateralCurve = Scaled( _baseLat, k );
		def.LongitudinalCurve = Scaled( _baseLong, k );
		ApplyWheels();
	}

	// Drive torque scale multiplies the authored peak torque. Drivetrain reads def.PeakTorque live
	// (it holds the same definition instance), so no drivetrain touch is needed.
	void SetTorqueScale( float k )
	{
		_torqueScale = k;
		Car.Definition.PeakTorque = _stockPeakTorque * k;
	}

	void CycleAssists()
	{
		Car.Assists = Car.Assists switch
		{
			AssistLevel.Casual => AssistLevel.Sport,
			AssistLevel.Sport => AssistLevel.Sim,
			_ => AssistLevel.Casual,
		};
		StateHasChanged();
	}

	string AssistLabel => Car.IsValid() ? Car.Assists.ToString() : "";

	// Tire preset swaps the UNSCALED base curves, then re-applies the current grip multiplier so the
	// grip dial and the preset compose. Stock restores the car's own authored curves.
	void CycleTires()
	{
		_tirePreset = (_tirePreset + 1) % 4;
		(_baseLat, _baseLong) = _tirePreset switch
		{
			1 => (TireCurve.Street, TireCurve.Street),
			2 => (TireCurve.Sport, TireCurve.Sport),
			3 => (TireCurve.Offroad, TireCurve.Offroad),
			_ => (_stockLat, _stockLong),
		};
		SetGrip( _gripScale );
		StateHasChanged();
	}

	string TireLabel => _tirePreset switch
	{
		1 => "Street",
		2 => "Sport",
		3 => "Offroad",
		_ => "Stock",
	};

	// Re-apply the car's pristine authored values (captured at bind). Definitions in the demo roster
	// are fresh per car, so "stock" is unambiguous: the values this car spawned with.
	void ResetToStock()
	{
		if ( !Car.IsValid() || !_snapped )
			return;

		var def = Car.Definition;
		def.PeakTorque = _stockPeakTorque;
		def.SpringRate = _stockSpring;
		def.DamperRate = _stockDamper;
		def.SuspensionTravel = _stockTravel;
		def.BrakeTorque = _stockBrake;
		def.LateralCurve = _stockLat;
		def.LongitudinalCurve = _stockLong;

		_gripScale = 1f;
		_torqueScale = 1f;
		_tirePreset = 0;
		_baseLat = _stockLat;
		_baseLong = _stockLong;

		Car.Assists = _stockAssists;
		ApplyWheels();
		StateHasChanged();
	}

	protected override void OnEnabled()
	{
		// Start COLLAPSED: the chip legend keeps the keybind discoverable while mouse-look stays
		// with the camera (an open lab on spawn captured the cursor before players ever drove,
		// owner call 2026-07-19). Static flag, so reset it here each session.
		IsOpen = false;
	}

	void ToggleOpen()
	{
		IsOpen = !IsOpen;
		Mouse.Visibility = IsOpen ? MouseVisibility.Visible : MouseVisibility.Hidden;
		StateHasChanged();
	}

	protected override void OnUpdate()
	{
		if ( Input.Pressed( ToggleAction ) )
		{
			IsOpen = !IsOpen;
			Mouse.Visibility = IsOpen ? MouseVisibility.Visible : MouseVisibility.Hidden;
			StateHasChanged();
		}

		// The chase camera re-hides the cursor every frame while it owns it; hold it visible while open.
		if ( IsOpen )
			Mouse.Visibility = MouseVisibility.Visible;
	}

	protected override int BuildHash()
	{
		// Closed still renders the chip legend, and the chip waits on the car binding, so the
		// hash must move when the car arrives or the first build would stick on the empty tree.
		if ( !IsOpen )
			return Car.IsValid() ? 1 : 0;

		var h = new HashCode();
		h.Add( _gripScale );
		h.Add( _torqueScale );
		h.Add( _tirePreset );
		if ( Car.IsValid() )
		{
			var def = Car.Definition;
			h.Add( def.SpringRate );
			h.Add( def.DamperRate );
			h.Add( def.SuspensionTravel );
			h.Add( def.BrakeTorque );
			h.Add( (int)Car.Assists );
		}
		return h.ToHashCode();
	}
}
@using System
@using System.Threading.Tasks
@using Sandbox
@using Sandbox.UI
@inherits WebPanel

<style>
	/* .scss files don't work in libraries. https://github.com/Facepunch/sbox-issues/issues/4813 */
	prompt {
		width: 100%;
		pointer-events: all;
	}
</style>

@code {
	public readonly TaskCompletionSource<bool> Purchased;
	
	public Prompt( string URL )
	{
		Url = URL;
		Purchased = new TaskCompletionSource<bool>();

		if ( !Game.ActiveScene.Components.TryGet( out ScreenPanel panel ) )
		{
			panel = Game.ActiveScene.Components.Create<ScreenPanel>();
			panel.GetPanel().ElementName = "monetization";
			panel.ZIndex = int.MaxValue;
		}

		Parent = panel.GetPanel();
	}
	
	public override void OnDeleted()
	{
		if ( !Game.ActiveScene.Components.TryGet( out ScreenPanel panel ) )
		{
			return;
		}
		
		if ( panel.GetPanel().HasChildren )
		{
			return;
		}
		
		panel.Destroy();
	}

	protected override void OnAfterTreeRender( bool firstTime )
	{
		// This was the only way I could think of to get the
		// Steam browser to send data back data to the game.
		// If you have a better way let me know.
		
		switch ( Surface.PageTitle )
		{
			case "finished": Delete();
				break;
			case "purchased": Purchased.TrySetResult( true );
				break;
			case "cancelled": Purchased.TrySetResult( false );
				break;
		}
	}
	
	protected override int BuildHash() => HashCode.Combine( Surface.PageTitle );
}
@using Sandbox;
@using Sandbox.Audio;
@using Sandbox.UI;
@using Sandbox.Services;
@using System;
@using System.Collections.Generic;
@using System.Linq;
@inherits PanelComponent

<root class="menu">
    <div class="container">
        <div class="title">
            <span>Options</span>
        </div>
        @if (activeTab != null)
        {
            <div class="content tabs-container">
                <div class="tabs-group">
                    @foreach (var tab in tabs)
                    {
                        if (tab == null)
                            continue;

                        <div class="button red @(tab == activeTab ? "active" : "inactive")" onclick="@(() => activeTab = tab)">
                            @tab.tabName
                        </div>
                    }
                </div>
            </div>
            <div class="content tab-content" CanDragScroll="false">
                <div class="table">
                    @foreach (var group in activeTab.groups)
                    {
                        <div class="row title">
                            <div class="label">@group.groupName</div>
                        </div>

                        foreach (var element in group.elements)
                        {
                            var elementType = element.GetType();

                            <div class="row setting">
                                <div class="column key">
                                    <div class="label">@element.displayName:</div>
                                </div>

                                @if (element is UIToggle)
                                {
                                    var toggleElement = (UIToggle)element;
                                    <div class="column value">
                                        <div class="buttons">
                                            <div class="button @GetToggleColor(true, toggleElement.getter())" onclick="@(() => toggleElement.setter(false))">Off</div>
                                            <div class="button @GetToggleColor(false, toggleElement.getter())" onclick="@(() => toggleElement.setter(true))">On</div>
                                        </div>
                                    </div>
                                }
                                @if (element is UISlider)
                                {
                                    var sliderElement = (UISlider)element;
                                    <div class="column value">
                                        <div class="slider">
                                            <SliderControl Value="@(sliderElement.getter())" OnValueChanged="@(sliderElement.setter)" Min="@(sliderElement.min)" Max="@(sliderElement.max)" Step="@(sliderElement.step)"></SliderControl>
                                        </div>
                                    </div>
                                }
                                @if (element is UICyclerBase)
                                {
                                    var cyclerElement = (UICyclerBase)element;
                                    <div class="column value">
                                        <div class="cycling-selector">
                                            <div class="cycling-controls">
                                                <button class="arrow left" onclick="@(() => cyclerElement.CycleLeft())">&#8249;</button>
                                                <span class="value" id="current-value">@cyclerElement.onGet()</span>
                                                <button class="arrow right" onclick="@(() => cyclerElement.CycleRight())">&#8250;</button>
                                            </div>
                                        </div>
                                    </div>
                                }
                            </div>
                        }
                    }
                </div>
            </div>
        }
        <div class="menu buttons">
            <div class="button red" onclick="@(() => ButtonClose())">
                Close
            </div>
        </div>
    </div>
</root>

@code
{
    public static string GetToggleColor(bool isOff, bool value)
    {
        if (isOff)
        {
            return value ? "gray" : "red";
        }
        return value ? "green" : "gray";
    }


    public List<UITab> tabs { get; set; } = new List<UITab>();
    public UITab activeTab { get; set; }

    protected override void OnAwake()
    {
        base.OnAwake();

        var sortedValues = EasySaveNonGenericBase.typeToInst.Values.OrderBy(v => (v.uiTab != null) ? v.uiTab.order : 99999).ToList();

        foreach (var sortedValue in sortedValues)
        {
            if (sortedValue == null)
                continue;

            tabs.Add(sortedValue.uiTab);
        }

        if (tabs != null && tabs.Count() > 0)
        {
            activeTab = tabs[0];
        }
    }

    protected override void OnUpdate()
    {
        base.OnUpdate();

        if (Input.EscapePressed)
        {
            Input.EscapePressed = false;
            ButtonClose();
        }
    }

    [ConCmd("open_options")]
    public static void OpenOptions()
    {
        var inst = Game.ActiveScene.Components.Get<OptionsScreen>(true);
        if (inst != null)
        {
            inst.Enabled = true;
        }
        else
        {
            var optionsScreenGO = Game.ActiveScene.CreateObject();
            var screenPanel = optionsScreenGO.Components.Create<ScreenPanel>();
            var optionsScreen = optionsScreenGO.Components.Create<OptionsScreen>();
        }
    }

    void ButtonClose()
    {
        var soundHandle = Sound.Play("ui.navigate.back");
        soundHandle.TargetMixer = Mixer.FindMixerByName("UI");
        Close();
    }

    public void Close()
    {
        this.Enabled = false;
    }

    public bool wasMouseVisible { get; set; } = false;
    protected override void OnEnabled()
    {
        wasMouseVisible = Mouse.Visible;
        Mouse.Visible = true;
    }

    protected override void OnDisabled()
    {
        EasySaveNonGenericBase.SaveAll();
        Mouse.Visible = wasMouseVisible;
    }

    /// <summary>
    /// update every second
    /// </summary>
    protected override int BuildHash() => System.HashCode.Combine(RealTime.Now.CeilToInt());
}
@using Sandbox;
@inherits PanelComponent

@if (InSequence)
{
	<root>
		<div class="Main">
			<div style="opacity: @Opacity">
				<img src="@CurrentEntry.TexturePath" style="transform: scale(@Scale);">
			</div>
		</div>
	</root>
}
@using Sandbox.UI;
@namespace Duccsoft
@inherits Panel

<root class=@OverlayClass>
    <image id="spinner" src="ui/img/BufferingCircle.png"/>
    <div id="bottomBar">
        <div class="button symbol" onclick=@TogglePause>@PlayButtonIcon</div>
        <div id="timecodeArea" class=@TimecodeAreaClass>
            <div class="progress time">@ProgressText</div>
            <div class="progress center">/</div>
            <div class="progress time right">@DurationText</div>
        </div>
        <SliderControl id="progressBar" Min=@(0) Max=@DurationSeconds 
            Value:bind=@PlaybackTime OnValueChanged=@ProgressBarChanged ShowValueTooltip=@false/>
        <div class="button symbol" onclick=@ToggleMute>@VolumeButtonIcon</div>
    </div>
</root>
@using Sandbox
@using Sandbox.UI
@using LobbySystem
@inherits PanelComponent
@namespace LobbySystem.Examples

<root>
	@if ( Dir is null )
	{
		<text></text>
	}
	else if ( Dir.MenuOpen || Dir.SuggestMenuOpen )
	{
		<div class="menu">
			<div class="title">MULTIPLAYER LOBBY</div>
			<div class="sub">@(Dir.MenuOpen ? "Choose a game mode" : "Suggest a mode to the host")</div>
			<div class="row">
				@for ( int i = 0; i < Dir.Modes.Count; i++ )
				{
					var idx = i;
					<button class="mode" onclick=@(() => Dir.PickMode( idx ))>
						<div class="k">@(idx + 1)</div>
						<div class="n">@Dir.Modes[idx].DisplayName</div>
					</button>
				}
			</div>
			<button class="back" onclick=@(() => Dir.RequestCloseMenu())>Back to lobby  [E]</button>
		</div>
	}
	else
	{
		<div class="top">
			@if ( Dir.RoundLive )
			{
				<div class="mode">@(Dir.ActiveMode?.DisplayName ?? "")</div>
				<div class="timer @(Dir.TimeLeftSeconds < 15 ? "urgent" : "")">@TimerText</div>
			}
			else
			{
				<div class="lobby">LOBBY</div>
			}
		</div>

		@if ( !Dir.RoundLive && !string.IsNullOrEmpty( Dir.StatusMessage ) )
		{
			<div class="status">@Dir.StatusMessage</div>
		}
		@if ( Dir.ChatVisible )
		{
			<div class="chat">@Dir.ChatLine</div>
		}

		<div class="hints">
			<span>[WASD] Move</span>
			<span>[Space] Jump</span>
			@if ( !Dir.RoundLive )
			{
				<span>Walk to the pad and press [E] to start a round</span>
			}
		</div>
	}
</root>

@code
{
	LobbyDirector Dir => LobbyDirector.Current;

	string TimerText
	{
		get
		{
			int t = Dir?.TimeLeftSeconds ?? 0;
			if ( t < 0 ) t = 0;
			return $"{t / 60:D2}:{t % 60:D2}";
		}
	}

	protected override int BuildHash() => System.HashCode.Combine(
		Dir?.State, Dir?.MenuOpen, Dir?.SuggestMenuOpen, Dir?.RoundLive,
		Dir?.TimeLeftSeconds, Dir?.StatusMessage, Dir?.ChatLine, Dir?.ChatVisible );
}
@using Sandbox
@using Sandbox.UI

@namespace PanelRenderTarget
@inherits ScreenPanel

<div class="screen">
	<div class="title">Example</div>

	<div class="debug" @onclick=@Click @onmouseover=@OnDebugMouseOver @onmouseout=@OnDebugMouseOut>
		<div class="test-hover">Hover: @IsHovering</div>
		<div>Pressed: @IsPressed</div>
		<div>Clicks: @ClickCount</div>
		<div>Mouse: @MousePosition</div>

		<TextEntry @onclick=@TestClick Value:Bind=@test placeholder="Quantitée" />
	</div>

	

	<div class="cursor" style="left:@CursorLeft; top:@CursorTop;"></div>
	
</div>

@code
{
	public Vector2 MousePosition { get; private set; }
	public bool IsHovering { get; private set; }
	public bool IsPressed { get; private set; }
	public int ClickCount { get; private set; }

	private string CursorLeft => $"{MousePosition.x}px";
	private string CursorTop => $"{MousePosition.y}px";

	public string test { get; set; } = "teste";

	protected override void OnMouseMove(MousePanelEvent e)
	{
		base.OnMouseMove(e);
		var root = FindRootPanel() as TargetRootPanel;
		//MousePosition = root.MousePosition;
	}

	public void OnDebugMouseOver(PanelEvent e)
	{
		IsHovering = true;
	}

	public void OnDebugMouseOut(PanelEvent e)
	{
		IsHovering = false;
	}

	public void TestClick(PanelEvent e)
	{
		Log.Info("Clicked on text entry");
	}

	protected override void OnMouseDown(MousePanelEvent e)
	{
		base.OnMouseDown(e);
		IsPressed = true;
	}

	protected override void OnMouseUp(MousePanelEvent e)
	{
		base.OnMouseUp(e);
		IsPressed = false;
	}

	public void Click(PanelEvent e)
	{
		ClickCount++;
	}

	protected override int BuildHash()
	{
		StateHasChanged();
		var hash = 17;
		hash = hash * 31 + MousePosition.x.GetHashCode();
		hash = hash * 31 + MousePosition.y.GetHashCode();
		hash = hash * 31 + IsHovering.GetHashCode();
		hash = hash * 31 + IsPressed.GetHashCode();
		hash = hash * 31 + ClickCount.GetHashCode();
		return hash;
	}
}

<style>
	.screen {
		cursor: crosshair;
		pointer-events: auto;
		position: absolute;
		left: 0;
		top: 0;
		width: 100%;
		height: 100%;
		background-color: #06111f;
		color: white;
		font-size: 32px;
		font-weight: bold;
	}

	.title {
		position: absolute;
		left: 40px;
		top: 40px;
		color: white;
		font-size: 64px;
	}

	.debug {
		position: absolute;
		left: 40px;
		top: 140px;
		font-size: 28px;
		color: #9fd4ff;
		flex-direction: column;
		width:400px;
	}

		.debug:hover {
			background-color: aqua;
		}

		.debug:active {
			background-color: red;
		}

	.cursor {
		position: absolute;
		width: 24px;
		height: 24px;
		background-color: red;
		border-radius: 50%;
		transform: translate(-50% -50%);
	}

	TextEntry {
		background-color:green;
	}

	TextEntry:focus {
		border-color: red;
	}
</style>
@using Sandbox
@using Sandbox.UI

@namespace PanelRenderTarget
@inherits ScreenPanel

<div class="screen">
	<div class="title">Example</div>

	<div class="debug" @onclick=@Click @onmouseover=@OnDebugMouseOver @onmouseout=@OnDebugMouseOut>
		<div class="test-hover">Hover: @IsHovering</div>
		<div>Pressed: @IsPressed</div>
		<div>Clicks: @ClickCount</div>
		<div>Mouse: @MousePosition</div>

		<TextEntry @onclick=@TestClick Value:Bind=@test placeholder="Quantitée" />
	</div>

	

	<div class="cursor" style="left:@CursorLeft; top:@CursorTop;"></div>
	
</div>

@code
{
	public Vector2 MousePosition { get; private set; }
	public bool IsHovering { get; private set; }
	public bool IsPressed { get; private set; }
	public int ClickCount { get; private set; }

	private string CursorLeft => $"{MousePosition.x}px";
	private string CursorTop => $"{MousePosition.y}px";

	public string test { get; set; } = "teste";

	protected override void OnMouseMove(MousePanelEvent e)
	{
		base.OnMouseMove(e);
		var root = FindRootPanel() as TargetRootPanel;
		//MousePosition = root.MousePosition;
	}

	public void OnDebugMouseOver(PanelEvent e)
	{
		IsHovering = true;
	}

	public void OnDebugMouseOut(PanelEvent e)
	{
		IsHovering = false;
	}

	public void TestClick(PanelEvent e)
	{
		Log.Info("Clicked on text entry");
	}

	protected override void OnMouseDown(MousePanelEvent e)
	{
		base.OnMouseDown(e);
		IsPressed = true;
	}

	protected override void OnMouseUp(MousePanelEvent e)
	{
		base.OnMouseUp(e);
		IsPressed = false;
	}

	public void Click(PanelEvent e)
	{
		ClickCount++;
	}

	protected override int BuildHash()
	{
		StateHasChanged();
		var hash = 17;
		hash = hash * 31 + MousePosition.x.GetHashCode();
		hash = hash * 31 + MousePosition.y.GetHashCode();
		hash = hash * 31 + IsHovering.GetHashCode();
		hash = hash * 31 + IsPressed.GetHashCode();
		hash = hash * 31 + ClickCount.GetHashCode();
		return hash;
	}
}

<style>
	.screen {
		cursor: crosshair;
		pointer-events: auto;
		position: absolute;
		left: 0;
		top: 0;
		width: 100%;
		height: 100%;
		background-color: #06111f;
		color: white;
		font-size: 32px;
		font-weight: bold;
	}

	.title {
		position: absolute;
		left: 40px;
		top: 40px;
		color: white;
		font-size: 64px;
	}

	.debug {
		position: absolute;
		left: 40px;
		top: 140px;
		font-size: 28px;
		color: #9fd4ff;
		flex-direction: column;
		width:400px;
	}

		.debug:hover {
			background-color: aqua;
		}

		.debug:active {
			background-color: red;
		}

	.cursor {
		position: absolute;
		width: 24px;
		height: 24px;
		background-color: red;
		border-radius: 50%;
		transform: translate(-50% -50%);
	}

	TextEntry {
		background-color:green;
	}

	TextEntry:focus {
		border-color: red;
	}
</style>
@using Sandbox
@using Sandbox.UI
@using System
@using System.Collections.Generic
@using System.Linq
@namespace FieldGuide.Tips
@inherits PanelComponent
@attribute [StyleSheet]

@*
	TIPS STUDIO - author a tip inside the running game and watch the real card change as you type.

	One modal, three columns, over a dim scrim (the approved Field Kits layout, docs/design/ui-system).
	LEFT is the merged catalog with the source each id resolved from, which is also how a tip left behind
	by another scene gives itself away. MIDDLE is the editor: wording, order, prerequisites, and the two
	trigger pickers, whose action list comes from the project's own input actions. RIGHT is the payoff:
	the same card the player sees, rendered twice side by side so the keyboard and controller wordings
	read together, and under it the bake-out (Copy to clipboard, or stage it for the editor menu action
	that writes Assets/tips).

	The lower-left card is still the REAL one: "show it" pushes the draft into the coach and TipsDisplay
	draws it with the shipped stylesheet, and the device chips pin TipsCoach.PreviewDevice. The two cards
	in the rail are the same component restated inside this panel, because a preview you have to look
	away from is not a preview.

	Toggle: `fg_tips_studio 1` in the console, or press T (a plain letter; the editor eats F1-F12 in
	play-in-editor). The key OPENS only, never closes, so pressing T inside a text box types a T and
	nothing else. Close with the x in the header or `fg_tips_studio 0`.

	Panel rules this file follows, each of which has cost someone a session: rows come from @foreach in
	main markup, never a RenderFragment; the root takes no pointer events and the scrim and modal take
	all of them; both scroll regions are a FIXED pixel height, never a percentage; no field being TYPED
	into is folded into BuildHash, because a rebuild takes the cursor out of the box.
*@

<root class="ts-root">
@if ( TipsStudio.Open )
{
	<div class="ts-scrim">
		<div class="ts-modal">

			@* ================= header ================= *@
			<div class="ts-hdr">
				<div class="ts-hdr-left">
					<div class="ts-title">TIPS STUDIO</div>
					<div class="ts-hdr-meta">@HeaderMeta</div>
				</div>
				<div class="ts-x" onclick=@Close>×</div>
			</div>

			<div class="ts-cols">

				@* ================= left: the merged catalog ================= *@
				<div class="ts-left">
					<div class="ts-list-hdr">
						<div class="ts-list-t">Tips</div>
						<div class="ts-list-m">by priority</div>
					</div>

					<div class="ts-list" @ref="ListBody">
						@if ( Entries.Count == 0 )
						{
							<div class="ts-empty">
								<div class="ts-empty-t">No tips yet</div>
								<div class="ts-empty-l">Press New tip to write one.</div>
							</div>
						}
						@foreach ( var e in Entries )
						{
							var entry = e;
							<div class="ts-row @(entry.Selected ? "on" : "")" onclick=@(() => OpenTip( entry.Id ))>
								<div class="ts-row-id">@entry.Id</div>
								@* State REPLACES the source rather than sitting beside it: the row is 248px wide
									and a third thing in it squeezes the id until it blanks out. *@
								<div class="ts-row-meta">
									@if ( entry.State is null )
									{
										<div class="ts-row-src">@entry.Source</div>
									}
									else
									{
										<div class="ts-row-state">@entry.State</div>
									}
								</div>
							</div>
						}
					</div>

					<div class="ts-left-btns">
						<div class="ts-btn pri grow gap" onclick=@NewTip>New tip</div>
						<div class="ts-btn" onclick=@Rescan>Rescan</div>
					</div>
				</div>

				@* ================= middle: the draft ================= *@
				<div class="ts-mid" @ref="MidBody">

					@if ( TipsStudio.DroppedPredicates )
					{
						<div class="ts-warn">
							<div class="ts-warn-b">!</div>
							<div class="ts-warn-t">
								<div class="ts-warn-l">This tip carries a code predicate.</div>
								<div class="ts-warn-l">A .tip file cannot hold one, so baking</div>
								<div class="ts-warn-l">keeps the triggers and drops the predicate.</div>
							</div>
						</div>
					}

					<div class="ts-frow">
						<div class="ts-field w200">
							<div class="ts-lab">Id</div>
							<TextEntry class="ts-in" Value=@DraftId
								OnTextEdited=@((string v) => { TipsStudio.Draft.Id = v; }) onsubmit=@Commit />
						</div>
						<div class="ts-field w150">
							<div class="ts-lab">Priority</div>
							<div class="ts-step">
								<div class="ts-stp" onclick=@(() => BumpPriority( -10 ))>−</div>
								<div class="ts-val">@DraftPriority</div>
								<div class="ts-stp" onclick=@(() => BumpPriority( 10 ))>+</div>
							</div>
						</div>
						<div class="ts-field grow last">
							<div class="ts-lab">Prerequisites</div>
							<div class="ts-drop">
								<div class="ts-drop-face @(IsOpen( PrereqDrop ) ? "open" : "")"
									onclick=@(() => ToggleDrop( PrereqDrop ))>
									<div class="ts-drop-val">@PrereqFace</div>
									<div class="ts-chev">expand_more</div>
								</div>
								@if ( IsOpen( PrereqDrop ) )
								{
									<div class="ts-drop-list @(Tall( AvailablePrerequisites.Count ))">
										@if ( AvailablePrerequisites.Count == 0 )
										{
											<div class="ts-drop-opt">no other tip ids yet</div>
										}
										@foreach ( var p in AvailablePrerequisites )
										{
											var prereq = p;
											<div class="ts-drop-opt" onclick=@(() => AddPrerequisite( prereq ))>@prereq</div>
										}
									</div>
								}
							</div>
						</div>
					</div>

					@* Its own block under the field, with its own bottom margin: this row used to collapse
						into the wording section and paint its chips over the Text label. *@
					@if ( PrerequisiteList.Count > 0 )
					{
						<div class="ts-chips block">
							@foreach ( var p in PrerequisiteList )
							{
								var prereq = p;
								<div class="ts-chip on mono"
									onclick=@(() => RemovePrerequisite( prereq ))>@($"{prereq} ×")</div>
							}
						</div>
					}

					<div class="ts-frow">
						<div class="ts-field grow last">
							<div class="ts-lab-row">
								<div class="ts-lab">Text</div>
								<div class="ts-cap">*Space* keycap · `A` pad button</div>
							</div>
							<TextEntry class="ts-in" Value=@DraftText
								OnTextEdited=@((string v) => { TipsStudio.Draft.Text = v; Refresh(); }) onsubmit=@Commit />
						</div>
					</div>

					<div class="ts-frow">
						<div class="ts-field grow">
							<div class="ts-lab">Pad text · optional</div>
							<TextEntry class="ts-in" Value=@DraftTextPad
								OnTextEdited=@((string v) => { TipsStudio.Draft.TextPad = v; Refresh(); }) onsubmit=@Commit />
						</div>
						<div class="ts-field w120 last">
							<div class="ts-lab">Icon</div>
							<TextEntry class="ts-in" Value=@DraftIcon
								OnTextEdited=@((string v) => { TipsStudio.Draft.Icon = v; Refresh(); }) onsubmit=@Commit />
						</div>
					</div>

					@* ---- the two trigger pickers, from one block of markup ---- *@
					@foreach ( var s in TriggerSlots )
					{
						var slot = s;
						var trig = slot.Trigger;
						var actionDrop = slot.Key;

						<div class="ts-sec">
							<div class="ts-sec-hd">
								<div class="ts-sec-t">@slot.Title</div>
								<div class="ts-cap flat">@slot.Blurb</div>
							</div>

							<div class="ts-chips">
								@foreach ( var k in TipStudioTrigger.AllKinds )
								{
									var kind = k;
									<div class="ts-chip @(trig.Kind == kind ? "on" : "")"
										onclick=@(() => SetKind( trig, kind ))>@TipStudioTrigger.KindName( kind )</div>
								}
							</div>

							<div class="ts-frow">
								@if ( trig.Kind == TipTriggerKind.InputAction )
								{
									<div class="ts-field w200">
										<div class="ts-lab">Input action</div>
										<div class="ts-drop">
											<div class="ts-drop-face @(IsOpen( actionDrop ) ? "open" : "")"
												onclick=@(() => ToggleDrop( actionDrop ))>
												<div class="ts-drop-val">@ActionFace( trig )</div>
												<div class="ts-chev">expand_more</div>
											</div>
											@if ( IsOpen( actionDrop ) )
											{
												<div class="ts-drop-list @(Tall( ActionNames.Count ))">
													@if ( ActionNames.Count == 0 )
													{
														<div class="ts-drop-opt">no input actions bound</div>
													}
													@foreach ( var a in ActionNames )
													{
														var action = a;
														<div class="ts-drop-opt @(trig.Action == action ? "on" : "")"
															onclick=@(() => PickAction( trig, action ))>@action</div>
													}
												</div>
											}
										</div>
										<div class="ts-cap">from Input.config</div>
									</div>
								}

								@if ( trig.Kind == TipTriggerKind.Key )
								{
									<div class="ts-field w200">
										<div class="ts-lab">Key</div>
										<TextEntry class="ts-in" Value=@TrigKey( trig )
											OnTextEdited=@((string v) => { trig.Key = v; }) onsubmit=@Commit />
										<div class="ts-cap">space, w, mouse1</div>
									</div>
								}

								@if ( slot.NeedsName )
								{
									<div class="ts-field w200">
										<div class="ts-lab">Name</div>
										<TextEntry class="ts-in" Value=@TrigName( trig )
											OnTextEdited=@((string v) => { trig.Name = v; }) onsubmit=@Commit />
										<div class="ts-cap">@slot.NameHint</div>
									</div>
								}

								@if ( trig.Kind == TipTriggerKind.AtLeast )
								{
									<div class="ts-field w150">
										<div class="ts-lab">At least</div>
										<div class="ts-step">
											<div class="ts-stp" onclick=@(() => BumpThreshold( trig, -1f ))>−</div>
											<div class="ts-val">@TrigThreshold( trig )</div>
											<div class="ts-stp" onclick=@(() => BumpThreshold( trig, 1f ))>+</div>
										</div>
									</div>
								}

								@if ( trig.Kind == TipTriggerKind.Timer )
								{
									<div class="ts-field w150">
										<div class="ts-lab">Seconds</div>
										<div class="ts-step">
											<div class="ts-stp" onclick=@(() => BumpSeconds( trig, -1f ))>−</div>
											<div class="ts-val">@TrigSeconds( trig )</div>
											<div class="ts-stp" onclick=@(() => BumpSeconds( trig, 1f ))>+</div>
										</div>
									</div>
								}

								@if ( trig.Kind == TipTriggerKind.AnalogAxis )
								{
									<div class="ts-field w150">
										<div class="ts-lab">Magnitude</div>
										<div class="ts-step">
											<div class="ts-stp" onclick=@(() => BumpMagnitude( trig, -0.1f ))>−</div>
											<div class="ts-val">@TrigMagnitude( trig )</div>
											<div class="ts-stp" onclick=@(() => BumpMagnitude( trig, 0.1f ))>+</div>
										</div>
										<div class="ts-cap">0 to 1</div>
									</div>
								}

								@if ( slot.IsCompletion )
								{
									<div class="ts-field grow last">
										<div class="ts-lab">Max show seconds</div>
										<div class="ts-step">
											<div class="ts-stp" onclick=@(() => BumpMaxShow( -1f ))>−</div>
											<div class="ts-val w80">@DraftMaxShow</div>
											<div class="ts-stp" onclick=@(() => BumpMaxShow( 1f ))>+</div>
										</div>
										<div class="ts-cap">0 = never auto-complete</div>
									</div>
								}
							</div>

							@if ( trig.Kind == TipTriggerKind.AnalogAxis )
							{
								<div class="ts-chips">
									@foreach ( var src in TipStudioTrigger.AllAnalogSources )
									{
										var source = src;
										<div class="ts-chip @(trig.AnalogSource == source ? "on" : "")"
											onclick=@(() => SetSource( trig, source ))>@TipStudioTrigger.SourceName( source )</div>
									}
								</div>
							}

							@if ( slot.IsComposite )
							{
								<div class="ts-chips">
									<div class="ts-chip" onclick=@(() => AddChild( trig ))>add one</div>
									@if ( trig.Children.Count == 0 )
									{
										<div class="ts-cap flat">@slot.EmptyCompositeHint</div>
									}
								</div>

								@foreach ( var c in trig.Children.ToList() )
								{
									var child = c;
									<div class="ts-child">
										<div class="ts-chips">
											@foreach ( var k in TipStudioTrigger.AllKinds )
											{
												var kind = k;
												<div class="ts-chip small @(child.Kind == kind ? "on" : "")"
													onclick=@(() => SetKind( child, kind ))>@TipStudioTrigger.KindName( kind )</div>
											}
											<div class="ts-chip small drop" onclick=@(() => RemoveChild( trig, child ))>remove</div>
										</div>
										<div class="ts-frow">
											<div class="ts-field grow last">
												<div class="ts-lab-row">
													<div class="ts-lab">Value</div>
													<div class="ts-cap flat">@ChildHint( child )</div>
												</div>
												<TextEntry class="ts-in" Value=@ChildValue( child )
													OnTextEdited=@((string v) => SetChildValue( child, v )) onsubmit=@Commit />
											</div>
										</div>
									</div>
								}
							}
						</div>
					}

					@* ---- what the draft would do wrong ---- *@
					@if ( TipsStudio.ShadowedBy is not null )
					{
						<div class="ts-warn">
							<div class="ts-warn-b">!</div>
							<div class="ts-warn-t">
								<div class="ts-warn-l">A code tip already owns this id.</div>
								<div class="ts-warn-l">Your file sits behind it in the catalog.</div>
							</div>
						</div>
					}

					@foreach ( var n in NoteBlocks )
					{
						var note = n;
						<div class="ts-warn">
							<div class="ts-warn-b">!</div>
							<div class="ts-warn-t">
								@foreach ( var l in note.Lines )
								{
									var line = l;
									<div class="ts-warn-l">@line</div>
								}
							</div>
						</div>
					}
				</div>

				@* ================= right: preview and bake ================= *@
				<div class="ts-rail">
					<div class="ts-rail-hdr">
						<div class="ts-kicker">LIVE PREVIEW</div>
						<div class="ts-btn-row">
							<div class="ts-btn small gap" onclick=@CompleteNow>Complete it</div>
							<div class="ts-btn pri small" onclick=@TestFire>Test fire</div>
						</div>
					</div>

					@* Pinned above the scrolling preview: these four pick what the REAL lower-left card
						shows, and a control that scrolls out of sight is a control nobody finds. *@
					<div class="ts-pv-row">
						<div class="ts-pv-key">Live card</div>
						<div class="ts-chip @(TipsStudio.PreviewOn ? "on" : "")"
							onclick=@TogglePreview>@(TipsStudio.PreviewOn ? "showing" : "show it")</div>
						<div class="ts-chip @(TipsStudio.PinnedDevice == TipDevice.KeyboardMouse ? "on" : "")"
							onclick=@(() => PinDevice( TipDevice.KeyboardMouse ))>keyboard</div>
						<div class="ts-chip @(TipsStudio.PinnedDevice == TipDevice.Gamepad ? "on" : "")"
							onclick=@(() => PinDevice( TipDevice.Gamepad ))>pad</div>
						<div class="ts-chip @(TipsStudio.PinnedDevice is null ? "on" : "")"
							onclick=@(() => PinDevice( null ))>live</div>
					</div>

					<div class="ts-rail-body" @ref="RailBody">
					<div class="ts-well">
						@foreach ( var p in PreviewCards )
						{
							var card = p;
							<div class="ts-pv @(card.Last ? "last" : "")">
								<div class="ts-pv-lab">@card.Label</div>
								<div class="tsp-card">
									<div class="tsp-stripe"></div>
									<div class="tsp-in">
										@if ( !string.IsNullOrEmpty( card.Icon ) )
										{
											<div class="tsp-glyph">@card.Icon</div>
										}
										<div class="tsp-body">
											<div class="tsp-kicker">GUIDE</div>
											<div class="tsp-text">
												@foreach ( var seg in card.Segments )
												{
													var run = seg;
													if ( run.Kind == TipSegmentKind.Key )
													{
														<span class="tsp-key">@run.Text</span>
													}
													else if ( run.Kind == TipSegmentKind.GamepadButton )
													{
														<span class="tsp-pad">@run.Text</span>
													}
													else
													{
														<span>@run.Text</span>
													}
												}
											</div>
										</div>
										<div class="tsp-x">×</div>
									</div>
								</div>
							</div>
						}
						<div class="ts-cap well">renders exactly what the coach will show</div>
					</div>
					</div>

					<div class="ts-out">
						<div class="ts-btn-row">
							<div class="ts-btn grow gap" onclick=@CopyJson>@_copyLabel</div>
							<div class="ts-btn pri grow" onclick=@Stage>Write to project</div>
						</div>
						<div class="ts-out-line">@BakeTarget</div>
						<div class="ts-btn-row pad">
							<div class="ts-btn small" onclick=@ClearStaged>Clear staged</div>
							<div class="ts-out-line inline">@StatusLine</div>
						</div>
					</div>
				</div>

			</div>
		</div>
	</div>
}
</root>

@code
{
	// ---- mounting ----

	/// <summary>The raw key that OPENS the Studio. A plain letter on purpose: the editor eats F1 to F12 in
	/// play-in-editor. It only opens, never closes, so pressing it inside a text box just types the letter.
	/// Close with the header's × or <c>fg_tips_studio 0</c>.</summary>
	[Property] public string OpenKey { get; set; } = "T";

	/// <summary>Whether the Studio starts open. Off by default: an authoring panel that appears unbidden over
	/// a game is a bug. This, and only this, decides the boot state; the persisted convar never does.</summary>
	[Property] public bool OpenOnStart { get; set; }

	/// <summary>Force the Studio shut anywhere but the editor. On by default: it is an authoring tool, and a
	/// published build has nothing to author. Turn it off if you want it in your own standalone dev build.</summary>
	[Property] public bool EditorOnly { get; set; } = true;

	// @ref binds to an auto-PROPERTY. On a bare private field it silently never assigns, and the
	// CanDragScroll fix below would quietly do nothing.
	Sandbox.UI.Panel ListBody { get; set; }
	Sandbox.UI.Panel MidBody { get; set; }
	Sandbox.UI.Panel RailBody { get; set; }

	bool _booted;
	bool _wasOpen;
	int _revision;
	string _copyLabel = "Copy .tip JSON";
	string _stageLine = "";

	/// <summary>Which dropdown is showing its options, or null. One at a time: the lists sit in flow under
	/// their field, so two open at once would push the column around for no reason.</summary>
	string _openDrop;

	/// <summary>The prerequisite picker's key. The trigger pickers key off their slot name.</summary>
	const string PrereqDrop = "prereq";

	bool IsOpen( string key ) => _openDrop == key;

	void ToggleDrop( string key )
	{
		_openDrop = _openDrop == key ? null : key;
		Refresh();
	}

	// ---- the catalog list ----

	/// <summary>One row of the tip list. A struct of finished strings so the markup interpolates single
	/// identifiers only, never a chained member read (which renders blank in several razor cases).</summary>
	public struct Entry
	{
		public string Id;
		public string Source;
		public string State;
		public bool Selected;
	}

	/// <summary>Every tip in the merged catalog, read fresh (so an edited .tip appears the moment the
	/// catalog rebuilds), labelled with the source it resolved from and ordered the way the coach picks
	/// them: highest priority first.</summary>
	List<Entry> Entries
	{
		get
		{
			var list = new List<Entry>();
			var view = TipsCatalog.View;
			var activeId = TipsCoach.ActiveTip?.Id;
			var opened = TipsStudio.OpenedFrom;

			foreach ( var def in view.Tips.OrderByDescending( t => t.Priority ).ThenBy( t => t.Id, StringComparer.Ordinal ) )
			{
				var source = view.SourceById.TryGetValue( def.Id, out var s ) ? s : "unknown";

				list.Add( new Entry
				{
					Id = def.Id,
					Source = source,
					State = def.Id == activeId ? "on screen" : ( TipsCoach.IsCompleted( def.Id ) ? "done" : null ),
					Selected = def.Id == opened,
				} );
			}

			return list;
		}
	}

	/// <summary>The header's one line: the package, the catalog, and what the middle column is editing.
	/// One interpolated string rather than three text nodes, because the in-editor codegen drops the
	/// whitespace between a literal and an expression.</summary>
	string HeaderMeta => $"fieldguide.tips · {CatalogSummary} · {DraftOrigin}";

	/// <summary>How many tips and where they came from. A source you did not expect is a tip left over
	/// from somewhere else.</summary>
	string CatalogSummary
	{
		get
		{
			var view = TipsCatalog.View;
			if ( view.Tips.Count == 0 )
				return "no tips yet";

			var counts = new Dictionary<string, int>();
			foreach ( var kv in view.SourceById )
				counts[kv.Value] = counts.TryGetValue( kv.Value, out var n ) ? n + 1 : 1;

			var parts = counts.OrderBy( kv => kv.Key, StringComparer.Ordinal ).Select( kv => $"{kv.Value} {kv.Key}" );
			return $"{view.Tips.Count} tips · {string.Join( ", ", parts )}";
		}
	}

	void OpenTip( string id )
	{
		TipsStudio.OpenTip( id );
		ResetLabels();
		_openDrop = null;
		Refresh();
	}

	void NewTip()
	{
		TipsStudio.NewDraft();
		ResetLabels();
		_openDrop = null;
		Refresh();
	}

	void Rescan()
	{
		TipsCatalog.NoteAssetsChanged();
		Refresh();
	}

	// ---- draft editing ----

	string DraftOrigin => string.IsNullOrEmpty( TipsStudio.OpenedFrom )
		? "new tip"
		: $"editing {TipsStudio.OpenedFrom}";

	// Single-identifier reads for the markup. A razor interpolation of a CHAINED member read
	// (TipsStudio.Draft.Priority) renders blank in several cases; a plain property or a method call does not.
	string DraftId => TipsStudio.Draft.Id;
	string DraftText => TipsStudio.Draft.Text;
	string DraftTextPad => TipsStudio.Draft.TextPad;
	string DraftIcon => TipsStudio.Draft.Icon;
	int DraftPriority => TipsStudio.Draft.Priority;
	string DraftMaxShow => Show( TipsStudio.Draft.MaxShowSeconds );

	static string TrigKey( TipStudioTrigger t ) => t.Key;
	static string TrigName( TipStudioTrigger t ) => t.Name;
	static string TrigThreshold( TipStudioTrigger t ) => Show( t.Threshold );
	static string TrigSeconds( TipStudioTrigger t ) => Show( t.Seconds );
	static string TrigMagnitude( TipStudioTrigger t ) => Show( t.Magnitude );

	static string Show( float value ) => value.ToString( "0.##" );

	void BumpPriority( int delta )
	{
		TipsStudio.Draft.Priority += delta;
		Refresh();
	}

	void BumpMaxShow( float delta )
	{
		TipsStudio.Draft.MaxShowSeconds = MathF.Max( 0f, TipsStudio.Draft.MaxShowSeconds + delta );
		Refresh();
	}

	List<string> PrerequisiteList => TipsStudio.Draft.PrerequisiteTipIds ?? new List<string>();

	/// <summary>What the prerequisite field reads at rest. The list underneath ADDS one; the chips below the
	/// row remove them, which is the only honest shape for a field that holds several values.</summary>
	string PrereqFace
	{
		get
		{
			var have = PrerequisiteList;
			if ( have.Count == 0 )
				return "none";

			return have.Count == 1 ? have[0] : $"{have.Count} tips";
		}
	}

	/// <summary>Catalog ids this tip could wait on: everything except itself, the preview id, and the ones it
	/// already waits on.</summary>
	List<string> AvailablePrerequisites
	{
		get
		{
			var have = new HashSet<string>( PrerequisiteList, StringComparer.Ordinal );
			var mine = TipsStudio.Draft.Id ?? "";
			return TipsCatalog.Active
				.Select( t => t.Id )
				.Where( id => id != mine && id != TipsStudio.PreviewId && !have.Contains( id ) )
				.OrderBy( id => id, StringComparer.Ordinal )
				.ToList();
		}
	}

	void AddPrerequisite( string id )
	{
		TipsStudio.Draft.PrerequisiteTipIds.Add( id );
		_openDrop = null;
		Refresh();
	}

	void RemovePrerequisite( string id )
	{
		TipsStudio.Draft.PrerequisiteTipIds.Remove( id );
		Refresh();
	}

	// ---- the two trigger pickers ----

	/// <summary>The Completion and Relevance pickers as data, so ONE block of markup renders both. A
	/// RenderFragment would be the other way to share it, and RenderFragments under-measure here.</summary>
	public struct TriggerSlot
	{
		public string Key;
		public string Title;
		public string Blurb;
		public TipStudioTrigger Trigger;
		public bool IsCompletion;
		public bool NeedsName;
		public string NameHint;
		public bool IsComposite;
		public string EmptyCompositeHint;
	}

	List<TriggerSlot> TriggerSlots
	{
		get
		{
			var completion = TipsStudio.Draft.Completion ??= new TipStudioTrigger();
			var relevance = TipsStudio.Draft.Relevance ??= new TipStudioTrigger();

			return new List<TriggerSlot>
			{
				Slot( "completion", "COMPLETION TRIGGER", "what retires this tip", completion, true ),
				Slot( "relevance", "RELEVANCE TRIGGER", "an extra gate before it shows", relevance, false ),
			};
		}
	}

	static TriggerSlot Slot( string key, string title, string blurb, TipStudioTrigger trigger, bool isCompletion )
	{
		var kind = trigger.Kind;
		var needsName = kind == TipTriggerKind.Signal || kind == TipTriggerKind.Ever
			|| kind == TipTriggerKind.Flag || kind == TipTriggerKind.AtLeast;

		var hint = kind switch
		{
			TipTriggerKind.Signal => "Signal(...) string",
			TipTriggerKind.Ever => "ctx.Ever(...)",
			TipTriggerKind.Flag => "ctx.SetFlag(...)",
			_ => "ctx.SetNumber(...)",
		};

		return new TriggerSlot
		{
			Key = key,
			Title = title,
			Blurb = blurb,
			Trigger = trigger,
			IsCompletion = isCompletion,
			NeedsName = needsName,
			NameHint = hint,
			IsComposite = kind == TipTriggerKind.AnyOf || kind == TipTriggerKind.AllOf,
			EmptyCompositeHint = isCompletion && kind == TipTriggerKind.AllOf
				? "empty: the shape a TipTriggerObject retires"
				: "empty, so it never fires",
		};
	}

	void SetKind( TipStudioTrigger trigger, TipTriggerKind kind )
	{
		trigger.Kind = kind;
		_openDrop = null;
		Refresh();
	}

	static string ActionFace( TipStudioTrigger trigger )
		=> string.IsNullOrEmpty( trigger.Action ) ? "pick an action" : trigger.Action;

	void PickAction( TipStudioTrigger trigger, string action )
	{
		trigger.Action = action;
		_openDrop = null;
		Refresh();
	}

	void SetSource( TipStudioTrigger trigger, TipTriggerAnalogSource source )
	{
		trigger.AnalogSource = source;
		Refresh();
	}

	void BumpThreshold( TipStudioTrigger trigger, float delta )
	{
		trigger.Threshold = MathF.Max( 0f, trigger.Threshold + delta );
		Refresh();
	}

	void BumpSeconds( TipStudioTrigger trigger, float delta )
	{
		trigger.Seconds = MathF.Max( 0f, trigger.Seconds + delta );
		Refresh();
	}

	void BumpMagnitude( TipStudioTrigger trigger, float delta )
	{
		trigger.Magnitude = Math.Clamp( trigger.Magnitude + delta, 0f, 1f );
		Refresh();
	}

	void AddChild( TipStudioTrigger parent )
	{
		parent.Children.Add( new TipStudioTrigger { Kind = TipTriggerKind.Key } );
		Refresh();
	}

	void RemoveChild( TipStudioTrigger parent, TipStudioTrigger child )
	{
		parent.Children.Remove( child );
		Refresh();
	}

	/// <summary>A composed child edits its one parameter through a single box, whichever box its kind reads.
	/// Nesting a full picker per child would triple the panel for a case the format barely uses.</summary>
	static string ChildValue( TipStudioTrigger child ) => child.Kind switch
	{
		TipTriggerKind.InputAction => child.Action,
		TipTriggerKind.Key => child.Key,
		TipTriggerKind.Signal or TipTriggerKind.Ever or TipTriggerKind.Flag or TipTriggerKind.AtLeast => child.Name,
		TipTriggerKind.Timer => child.Seconds.ToString( "0.##" ),
		TipTriggerKind.AnalogAxis => child.Magnitude.ToString( "0.##" ),
		_ => "",
	};

	static void SetChildValue( TipStudioTrigger child, string value )
	{
		switch ( child.Kind )
		{
			case TipTriggerKind.InputAction: child.Action = value; break;
			case TipTriggerKind.Key: child.Key = value; break;
			case TipTriggerKind.Signal:
			case TipTriggerKind.Ever:
			case TipTriggerKind.Flag:
			case TipTriggerKind.AtLeast: child.Name = value; break;
			case TipTriggerKind.Timer:
				if ( float.TryParse( value, out var seconds ) ) child.Seconds = MathF.Max( 0f, seconds );
				break;
			case TipTriggerKind.AnalogAxis:
				if ( float.TryParse( value, out var magnitude ) ) child.Magnitude = Math.Clamp( magnitude, 0f, 1f );
				break;
		}
	}

	static string ChildHint( TipStudioTrigger child ) => TipStudioTrigger.FieldFor( child.Kind ) switch
	{
		"action" => "an input action name",
		"key" => "a raw key name",
		"name" => "the named condition",
		"name+threshold" => "the named number",
		"seconds" => "seconds",
		"stick+magnitude" => "magnitude, 0 to 1",
		"children" => "nest one level only",
		_ => "this kind takes no value",
	};

	// ---- the two preview cards ----

	/// <summary>One rendered card in the rail: the label above it and the runs inside it. Finished data, so
	/// the markup walks a list rather than calling into the parser mid-tree.</summary>
	public struct PreviewCard
	{
		public string Label;
		public string Icon;
		public List<TipSegment> Segments;
		public bool Last;
	}

	/// <summary>The draft as the player will read it on each device, side by side. The pad card runs the same
	/// keycap remap the shipped display does (TipsCoach.PadLabelFor), so a chip with no controller equivalent
	/// disappears here exactly as it would in the game.</summary>
	List<PreviewCard> PreviewCards
	{
		get
		{
			var text = TipsStudio.Draft.Text ?? "";
			var pad = TipsStudio.Draft.TextPad ?? "";
			var icon = TipsStudio.Draft.Icon ?? "";

			return new List<PreviewCard>
			{
				new PreviewCard
				{
					Label = "KEYBOARD",
					Icon = icon,
					Segments = TipSegment.Parse( text ).ToList(),
				},
				new PreviewCard
				{
					Label = "CONTROLLER",
					Icon = icon,
					Segments = PadRuns( text, pad ),
					Last = true,
				},
			};
		}
	}

	/// <summary>The pad-mode runs for a wording: its pad text when authored, then every keycap put through
	/// the game's pad label map. A mapped label reads as a controller chip; an unmappable one is dropped, the
	/// same two rules the shipped card follows.</summary>
	static List<TipSegment> PadRuns( string text, string textPad )
	{
		var runs = new List<TipSegment>();

		foreach ( var seg in TipSegment.Parse( TipDeviceText.PadTextOr( text, textPad ) ) )
		{
			if ( seg.Kind != TipSegmentKind.Key )
			{
				runs.Add( seg );
				continue;
			}

			var mapped = TipDeviceText.PadCap( seg.Text, TipsCoach.PadLabelFor );
			if ( string.IsNullOrEmpty( mapped ) )
				continue;

			runs.Add( new TipSegment( mapped, mapped == seg.Text ? TipSegmentKind.Key : TipSegmentKind.GamepadButton ) );
		}

		return runs;
	}

	// ---- preview, test fire ----

	List<string> ActionNames => TipsStudio.ActionNames.ToList();

	/// <summary>One authoring note, already broken into lines that fit.</summary>
	public struct NoteBlock
	{
		public List<string> Lines;
	}

	List<NoteBlock> NoteBlocks => TipsStudio.Notes
		.Select( n => new NoteBlock { Lines = Lines( n ) } )
		.ToList();

	/// <summary>An option list longer than this scrolls at a fixed height instead of growing the column.</summary>
	static string Tall( int count ) => count > 6 ? "tall" : "";

	/// <summary>
	/// Chunk a sentence into lines short enough to lay out as text. A run that overflows its box does not
	/// wrap here: the style engine rasterizes it as a solid grey block, or drops it to an empty box. 46
	/// characters is one comfortable line in the widest box this panel has, and it is the same ceiling
	/// TipStudioText warns tip authors about.
	/// </summary>
	static List<string> Lines( string text )
	{
		var lines = new List<string>();
		if ( string.IsNullOrWhiteSpace( text ) )
			return lines;

		var line = "";

		foreach ( var word in text.Split( ' ' ) )
		{
			if ( string.IsNullOrEmpty( word ) )
				continue;

			if ( line.Length == 0 )
				line = word;
			else if ( line.Length + 1 + word.Length > 46 )
			{
				lines.Add( line );
				line = word;
			}
			else
				line = line + " " + word;
		}

		if ( line.Length > 0 )
			lines.Add( line );

		return lines;
	}

	void TogglePreview()
	{
		if ( TipsStudio.PreviewOn )
			TipsStudio.StopPreview( Scene );
		else
			TipsStudio.PushPreview( Scene );

		Refresh();
	}

	void PinDevice( TipDevice? device )
	{
		TipsStudio.PinnedDevice = device;
		Refresh();
	}

	void TestFire()
	{
		TipsStudio.TestFire( Scene );
		Refresh();
	}

	void CompleteNow()
	{
		TipsStudio.CompleteNow();
		Refresh();
	}

	/// <summary>The one-line status under the bake buttons: whatever the last bake action said, or what
	/// is waiting in the staging folder when it has said nothing yet.</summary>
	string StatusLine
	{
		get
		{
			var text = string.IsNullOrEmpty( _stageLine ) ? StagedLine : _stageLine;
			var lines = Lines( text );
			return lines.Count == 0 ? "" : lines[0];
		}
	}

	// ---- bake ----

	string BakeTarget
	{
		get
		{
			var file = TipStudioJson.FileNameFor( TipsStudio.Draft.Id );
			return file is null
				? "Give the tip an id: the file takes its name."
				: $"writes Assets/tips/{file}";
		}
	}

	string StagedLine
	{
		get
		{
			var count = TipsStudio.StagedCount;
			return count == 0 ? "nothing staged" : $"{count} staged for the editor";
		}
	}

	void CopyJson()
	{
		TipsStudio.CopyJson();
		_copyLabel = "Copied!";
		Refresh();
	}

	void Stage()
	{
		_stageLine = TipsStudio.Stage();
		Refresh();
	}

	void ClearStaged()
	{
		_stageLine = TipsStudio.ClearStaged();
		Refresh();
	}

	void ResetLabels()
	{
		_copyLabel = "Copy .tip JSON";
		_stageLine = "";
	}

	// ---- open / close, boot, cursor ----

	/// <summary>Bump the panel's own revision so the next frame rebuilds it. Every click calls this; typing
	/// into the id box does NOT, because a rebuild would take the cursor out of the box you are typing in.</summary>
	void Refresh() => _revision++;

	/// <summary>Enter in a text box: push the wording at the preview card and refresh everything derived
	/// from it.</summary>
	void Commit()
	{
		if ( TipsStudio.PreviewOn )
			TipsStudio.PushPreview( Scene );

		Refresh();
	}

	void Close()
	{
		TipsStudio.StopPreview( Scene );
		TipsStudio.Open = false;
		_openDrop = null;
		ResetLabels();
	}

	protected override void OnTreeBuilt()
	{
		// A background press-drag over a scrolling region must not pan the content or eat a button click; the
		// wheel and the scrollbar still scroll.
		if ( ListBody is not null )
			ListBody.CanDragScroll = false;

		if ( MidBody is not null )
			MidBody.CanDragScroll = false;

		if ( RailBody is not null )
			RailBody.CanDragScroll = false;
	}

	protected override void OnUpdate()
	{
		// BOOT. `fg_tips_studio` is a convar and s&box persists convars across sessions, so a session could
		// otherwise come up with an authoring panel open from a value set weeks ago. This component's own
		// OpenOnStart decides the boot state and the persisted value never does. Deliberately in the FIRST
		// UPDATE, not OnStart: a panel created in code is configured by whatever created it, and OnStart would
		// race that assignment.
		if ( !_booted )
		{
			_booted = true;
			TipsStudio.Open = OpenOnStart;
		}

		// The Studio is an authoring tool; a published build has nothing to author with it.
		if ( EditorOnly && !Application.IsEditor )
		{
			TipsStudio.Open = false;
			return;
		}

		// Opens only. Closing is the header × or the convar, so this key can never fight a text box.
		if ( !TipsStudio.Open && !string.IsNullOrEmpty( OpenKey ) && Input.Keyboard.Pressed( OpenKey ) )
		{
			TipsStudio.Open = true;
			Refresh();
		}

		if ( TipsStudio.Open )
		{
			Mouse.Visibility = MouseVisibility.Visible;
			_wasOpen = true;
		}
		else if ( _wasOpen )
		{
			_wasOpen = false;
			ResetLabels();
			TipsStudio.StopPreview( Scene );
		}
	}

	protected override void OnDestroy()
	{
		// Everything the Studio pins is static and would otherwise follow the developer into the next scene:
		// the preview draft, a test-fire draft, and the pinned preview device.
		TipsStudio.Shutdown();
	}

	// Fold the things a CLICK changes, and nothing anyone TYPES into. A rebuild rehomes every TextEntry, which
	// takes the cursor out of the box mid-word, so Id and a trigger's Key and Name are deliberately absent:
	// the panel repaints when you press Enter or click, via _revision.
	protected override int BuildHash()
	{
		var hc = new HashCode();
		hc.Add( TipsStudio.Open );
		hc.Add( TipsStudio.OpenedFrom );
		hc.Add( TipsStudio.PreviewOn );
		hc.Add( TipsStudio.PinnedDevice );
		hc.Add( TipsCoach.ActiveTip?.Id );
		hc.Add( _copyLabel );
		hc.Add( _stageLine );
		hc.Add( _openDrop );
		hc.Add( _revision );
		hc.Add( TipsStudio.Draft.Priority );
		hc.Add( TipsStudio.Draft.MaxShowSeconds );
		hc.Add( PrerequisiteList.Count );

		foreach ( var slot in TriggerSlots )
		{
			hc.Add( slot.Trigger.Kind );
			hc.Add( slot.Trigger.Action );
			hc.Add( slot.Trigger.Threshold );
			hc.Add( slot.Trigger.Seconds );
			hc.Add( slot.Trigger.AnalogSource );
			hc.Add( slot.Trigger.Magnitude );
			hc.Add( slot.Trigger.Children.Count );
			foreach ( var child in slot.Trigger.Children )
				hc.Add( child.Kind );
		}

		return hc.ToHashCode();
	}
}
@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();
	}
}
@using System
@using System.Collections.Generic
@using System.Linq
@using Sandbox
@using Sandbox.UI
@namespace AdminSuite.UI
@inherits Panel

<root class="tab server-tab">
	@if ( Sys is null )
	{
		<label class="empty">Admin system not available.</label>
	}
	else
	{
		<div class="scroll">
			@if ( Sys.LocalHas( AdminPerm.PropsCleanAll ) || Sys.LocalHas( AdminPerm.PropsCleanDisconnected ) )
			{
				<div class="card">
					<div class="section-title"><i>cleaning_services</i><label>Cleanup</label></div>
					<div class="action-row">
						@if ( Sys.LocalHas( AdminPerm.PropsCleanDisconnected ) )
						{
							<div class="btn accent" onclick=@( () => Sys.RequestCleanDisconnectedProps() )>
								<i>person_off</i><label>Clean Disconnected Players' Props</label>
							</div>
						}
						@if ( Sys.LocalHas( AdminPerm.PropsCleanAll ) )
						{
							<div class="btn danger" onclick=@( () => CleanAll() )>
								<i>delete_sweep</i><label>@(ConfirmingCleanAll ? "Confirm — remove ALL props?" : "Clean ALL Props")</label>
							</div>
						}
					</div>
					<label class="explain">Cleanup only removes networked spawned objects — the map itself is never touched. Per-player cleanup lives in the Players tab.</label>
				</div>
			}

			@if ( Sys.LocalHas( AdminPerm.ServerBroadcast ) )
			{
				<div class="card">
					<div class="section-title"><i>campaign</i><label>Broadcast</label></div>
					<div class="action-row">
						<TextEntry class="input grow" Value:bind=@BroadcastText Placeholder="Message to every player..." />
						<div class="btn accent" onclick=@( () => Send() )><i>send</i><label>Send</label></div>
					</div>
				</div>
			}

			@if ( Sys.LocalHas( AdminPerm.PlayersBan ) )
			{
				<div class="card">
					<div class="section-title"><i>block</i><label>Active Bans (@Sys.ClientBans.Count)</label></div>
					@if ( Sys.ClientBans.Count == 0 )
					{
						<label class="explain">No active bans.</label>
					}
					@foreach ( var ban in Sys.ClientBans )
					{
						<div class="row ban">
							<div class="meta">
								<label class="name">@ban.Name</label>
								<label class="sub">@ban.SteamId · "@ban.Reason" — by @ban.BannedByName · @Expiry( ban )</label>
							</div>
							<div class="btn ghost" onclick=@( () => Sys.RequestUnban( ban.SteamId ) )>
								<i>undo</i><label>Unban</label>
							</div>
						</div>
					}
				</div>
			}
		</div>
	}
</root>

@code
{
	private AdminSystem Sys => AdminSystem.Instance;

	public string BroadcastText { get; set; } = "";

	private float _confirmUntil;
	private bool ConfirmingCleanAll => RealTime.Now < _confirmUntil;

	private void CleanAll()
	{
		if ( Sys is null ) return;

		if ( !ConfirmingCleanAll )
		{
			_confirmUntil = RealTime.Now + 3f;
			return;
		}

		_confirmUntil = 0f;
		Sys.RequestCleanAllProps();
	}

	private void Send()
	{
		if ( Sys is null || string.IsNullOrWhiteSpace( BroadcastText ) ) return;
		Sys.RequestBroadcast( BroadcastText.Trim() );
		BroadcastText = "";
	}

	private static string Expiry( BanRecord ban ) =>
		ban.ExpiresUtc is null ? "permanent" : $"until {ban.ExpiresUtc.Value.ToLocalTime():g}";

	protected override int BuildHash() =>
		HashCode.Combine( Sys?.StateHash ?? 0, ConfirmingCleanAll, (int)RealTime.Now );
}
@using System
@using System.Linq
@using Sandbox
@using Sandbox.UI
@namespace Bimp
@inherits PanelComponent
@implements Component.ISceneStage

@*
	The full controls, shown on the local player's screen when they USE a media player.
	Created on demand by MediaRemote.Open, never networked.
*@

<root class="mediaremote @(PadMode ? "pad" : "")">

	@if ( Target is not null )
	{
		<div class="window">

			<div class="header">
				<i>@(Target.AudioOnlyPlayer ? "speaker" : "smart_display")</i>
				<div class="title">Media Player</div>
				<div class="close nav" onclick="@Close"><i>close</i></div>
			</div>

			<div class="now">
				@if ( Target.HasMedia )
				{
					<div class="label">Now playing</div>
					<div class="track">@Target.Title</div>
					<div class="sub">
						@if ( !string.IsNullOrWhiteSpace( Target.RequestedBy ) )
						{
							<span>Requested by @Target.RequestedBy</span>
						}
						<span class="time">@TimeText</span>
					</div>
				}
				else
				{
					<div class="track dim">Nothing playing</div>
				}

				@if ( !string.IsNullOrWhiteSpace( Target.Status ) )
				{
					<div class="status">@Target.Status</div>
				}
				@if ( !string.IsNullOrWhiteSpace( Target.LocalNotice ) )
				{
					<div class="notice">@Target.LocalNotice</div>
				}
			</div>

			@if ( Target.HasMedia && !Target.IsLive && Target.MediaDuration > 0 )
			{
				<div class="seek @(Target.CanLocalControl ? "nav" : "disabled")">
					<SliderControl Min="@(0f)" Max="@Target.MediaDuration" Step="@(1f)" ShowValueTooltip="@false" Value="@SeekValue" OnValueChanged="@OnSeekChanged"></SliderControl>
				</div>
			}

			@if ( Target.CanLocalControl && Target.HasMedia )
			{
				<div class="transport">
					@if ( !Target.IsLive )
					{
						<div class="btn nav" onclick="@( () => Target.RequestSeek( Target.CurrentTime - 10 ) )"><i>replay_10</i></div>
						<div class="btn big nav nav-default" onclick="@( () => Target.RequestSetPaused( !Target.Paused ) )"><i>@(Target.Paused ? "play_arrow" : "pause")</i></div>
						<div class="btn nav" onclick="@( () => Target.RequestSeek( Target.CurrentTime + 10 ) )"><i>forward_10</i></div>
					}
					<div class="btn nav" onclick="@Target.RequestSkip"><i>skip_next</i></div>
					<div class="btn nav" onclick="@Target.RequestStop"><i>stop</i></div>
				</div>
			}

			@if ( Target.HasMedia && (Target.AvailableQualities.Count > 0 || Target.AvailableAudioTracks.Count > 0 || Target.AvailableCaptionTracks.Count > 0) )
			{
				<div class="stream-options">
					@if ( Target.AvailableQualities.Count > 0 )
					{
						<div class="option">
							<i>high_quality</i>
							<DropDown class="quality nav" Value="@QualityValue" ValueChanged="@OnQualityChanged" BuildOptions="@BuildQualityOptions"></DropDown>
						</div>
					}
					@if ( Target.AvailableAudioTracks.Count > 0 )
					{
						<div class="option">
							<i>translate</i>
							<DropDown class="audio-track nav" Value="@AudioTrackValue" ValueChanged="@OnAudioTrackChanged" BuildOptions="@BuildAudioTrackOptions"></DropDown>
						</div>
					}
					@if ( Target.AvailableCaptionTracks.Count > 0 )
					{
						<div class="option">
							<i>closed_caption</i>
							<DropDown class="audio-track nav" Value="@CaptionValue" ValueChanged="@OnCaptionChanged" BuildOptions="@BuildCaptionOptions"></DropDown>
						</div>
					}
					<div class="playing-as">@PlayingAsText</div>
				</div>
			}

			@if ( (Target.CanLocalControl || Target.CanLocalQueue) && PadMode )
			{
				@* a pad can't type a link *@
				<div class="pad-note"><i>keyboard</i> Use a keyboard to add a link</div>
			}
			else if ( Target.CanLocalControl || Target.CanLocalQueue )
			{
				<div class="entry">
					<TextEntry @ref="UrlEntry" Placeholder="Paste a YouTube link or a media url (mp4, webm, mp3...)" onsubmit="@OnSubmit"></TextEntry>
					@if ( Target.CanLocalControl )
					{
						<div class="action primary" onclick="@PlayNow"><i>play_arrow</i> Play now</div>
					}
					@if ( Target.CanLocalQueue )
					{
						<div class="action" onclick="@AddToQueue"><i>playlist_add</i> Queue</div>
					}
				</div>
			}
			else
			{
				<div class="locked"><i>lock</i> You can't control this player.</div>
			}

			<div class="queue">
				<div class="queue-header">
					<div class="tab nav @(ShowRecent ? "" : "active")" onclick="@( () => ShowRecent = false )">Up next <span class="count">@Target.Queue.Count / @Target.MaxQueue</span></div>
					<div class="tab nav @(ShowRecent ? "active" : "")" onclick="@( () => ShowRecent = true )">Recent <span class="count">@RecentLinks.All.Count</span></div>
					<div class="spacer"></div>
					@if ( !ShowRecent && Target.CanLocalControl && Target.Queue.Count > 0 )
					{
						<div class="small-btn nav" onclick="@Target.RequestClearQueue">Clear</div>
					}
				</div>

				<div class="queue-list">
					@if ( ShowRecent )
					{
						@if ( RecentLinks.All.Count == 0 )
						{
							<div class="empty">Links you play show up here.</div>
						}

						@foreach ( var recent in RecentLinks.All )
						{
							var url = recent.Url;
							<div class="queue-item recent">
								<div class="info">
									<div class="name">@recent.Title</div>
									<div class="by">@recent.Url</div>
								</div>
								<div class="dur">@(recent.IsLive ? "Live" : recent.Duration > 0 ? MediaSource.FormatTime( recent.Duration ) : "")</div>
								@if ( Target.CanLocalControl )
								{
									<div class="small-btn go nav" onclick="@( () => Target.RequestPlay( url ) )"><i>play_arrow</i></div>
								}
								@if ( Target.CanLocalQueue )
								{
									<div class="small-btn go nav" onclick="@( () => Target.RequestEnqueue( url ) )"><i>playlist_add</i></div>
								}
								<div class="small-btn nav" onclick="@( () => RecentLinks.Remove( url ) )"><i>close</i></div>
							</div>
						}
					}
					else if ( Target.Queue.Count == 0 )
					{
						<div class="empty">The queue is empty.</div>
					}

					@for ( int i = 0; !ShowRecent && i < Target.Queue.Count; i++ )
					{
						var index = i;
						var item = Target.Queue[i];
						<div class="queue-item">
							<div class="num">@(index + 1)</div>
							<div class="info">
								<div class="name">@item.Title</div>
								<div class="by">@item.RequestedBy</div>
							</div>
							<div class="dur">@(item.IsLive ? "Live" : item.Duration > 0 ? MediaSource.FormatTime( item.Duration ) : "")</div>
							@if ( Target.CanLocalControl )
							{
								<div class="small-btn nav" onclick="@( () => Target.RequestRemove( index ) )"><i>close</i></div>
							}
						</div>
					}
				</div>
			</div>

			<div class="footer">
				<div class="btn small nav" onclick="@ToggleMute"><i>@(MediaSettings.Mute ? "volume_off" : "volume_up")</i></div>
				<SliderControl class="volume nav" Min="@(0f)" Max="@(1f)" Step="@(0.01f)" ShowValueTooltip="@false" Value="@MediaSettings.Volume" OnValueChanged="@OnVolumeChanged"></SliderControl>
				<div class="btn small nav audio-mode @(MediaSettings.SpatialAudio ? "" : "flat")" onclick="@ToggleSpatial" tooltip="@(MediaSettings.SpatialAudio ? "3D audio: sound comes from the screen. Click for 2D" : "2D audio: no direction, still quieter further away. Click for 3D")"><i>@(MediaSettings.SpatialAudio ? "surround_sound" : "headphones")</i><span>@(MediaSettings.SpatialAudio ? "3D" : "2D")</span></div>
				@if ( PadMode )
				{
					<div class="hint">
						<InputGlyph Analog="LeftStickY" Size="@InputGlyphSize.Small"></InputGlyph><span>Move</span>
						@if ( Pad.SelectAction is not null )
						{
							<InputGlyph Action="@Pad.SelectAction" Size="@InputGlyphSize.Small"></InputGlyph><span>Select</span>
						}
						<InputGlyph Action="@(Pad.BackAction ?? "use")" Size="@InputGlyphSize.Small"></InputGlyph><span>Close</span>
					</div>
				}
				else
				{
					<div class="hint"><span>Your audio (all players) ·</span><InputGlyph Action="use" Size="@InputGlyphSize.Small"></InputGlyph><span>or</span><InputGlyph Key="escape" Size="@InputGlyphSize.Small"></InputGlyph><span>to close</span></div>
				}
			</div>

		</div>
	}

</root>

@code
{
	/// <summary>
	/// The player being controlled.
	/// </summary>
	public MediaPlayer Target { get; set; }

	/// <summary>
	/// Close automatically when the camera gets further than this from the player.
	/// </summary>
	public float MaxDistance { get; set; } = 1500.0f;

	TextEntry UrlEntry { get; set; }

	/// <summary> The list shows the links this client has played (<see cref="RecentLinks"/>) instead of the queue. </summary>
	bool ShowRecent { get; set; }

	RealTimeSince sinceOpened = 0;
	float? pendingSeek;
	RealTimeSince sincePendingSeek;

	float SeekValue => pendingSeek ?? Target?.CurrentTime ?? 0;

	string TimeText
	{
		get
		{
			if ( Target is null || !Target.HasMedia ) return "";
			if ( Target.IsLive ) return "Live";
			var cur = MediaSource.FormatTime( Target.CurrentTime );
			var s = Target.MediaDuration > 0 ? $"{cur} / {MediaSource.FormatTime( Target.MediaDuration )}" : cur;
			return Target.Paused ? $"{s} (paused)" : s;
		}
	}

	/// <summary>
	/// Open the remote for this player, or close it if it's already open for it.
	/// </summary>
	public static void Toggle( MediaPlayer target )
	{
		if ( !target.IsValid() || Application.IsDedicatedServer ) return;

		var existing = target.Scene.GetAllComponents<MediaRemote>().FirstOrDefault();
		if ( existing.IsValid() )
		{
			if ( existing.Target == target )
			{
				existing.Close();
				return;
			}

			existing.Target = target;
			existing.sinceOpened = 0;
			existing.StateHasChanged();
			return;
		}

		var go = target.Scene.CreateObject();
		go.Name = "bimp remote";
		go.NetworkMode = NetworkMode.Never;
		go.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.Hidden;

		var screen = go.Components.Create<ScreenPanel>();
		screen.ZIndex = 200;

		var remote = go.Components.Create<MediaRemote>();
		remote.Target = target;
	}

	/// <summary>
	/// Is a remote open right now? Games can use this to e.g. hide their crosshair.
	/// </summary>
	public static bool IsOpen( Scene scene ) => scene?.GetAllComponents<MediaRemote>().Any() ?? false;

	public void Close()
	{
		GameObject.Destroy();
	}

	protected override void OnTreeFirstBuilt()
	{
		base.OnTreeFirstBuilt();
		UrlEntry?.Focus();
	}

	//
	// Controller: the D-pad / left stick moves a highlight between the controls (the "nav" ones), A presses,
	// left/right adjusts sliders and choices, B closes. See PadNavigation.
	//

	/// <summary> Played with a controller right now (the engine switches as soon as they touch the keyboard or pad). </summary>
	bool PadMode => Input.UsingController;

	readonly PadNavigation Pad = new();

	/// <summary> The engine's virtual cursor (left stick moves a pointer) is off while we navigate; this was its setting. </summary>
	bool? virtualCursorWas;

	/// <summary>
	/// The very first thing in the frame, before anything (fixed update included) reads input: navigate, then suppress the
	/// frame's input, so the pad doesn't also move, jump or duck the player behind the remote.
	/// </summary>
	void Component.ISceneStage.Start()
	{
		if ( !PadMode || !Target.IsValid() || Panel is null )
		{
			RestoreVirtualCursor();
			return;
		}

		virtualCursorWas ??= Input.EnableVirtualCursor;
		Input.EnableVirtualCursor = false;

		Pad.Press ??= PressControl;
		Pad.Adjust ??= AdjustControl;
		Pad.Update( Panel );

		// B closes (unless it just closed a drop down's menu), and USE again, as on keyboard
		var close = sinceOpened > 0.2f && (Pad.BackPressed || Input.Pressed( "use" ));
		Input.Suppressed = true;
		if ( close ) Close();
	}

	/// <summary> The window's width in the stylesheet (.window), at 1080p. </summary>
	const float WindowWidth = 760;

	/// <summary>
	/// The screen panel already scales the remote with the screen height (as if every screen were 1080 high); this sizes
	/// it up from there by bimp_ui_scale - at its design size it was small at every resolution - but never wider than
	/// 90% of the screen.
	/// </summary>
	void FitToScreen()
	{
		if ( Components.Get<ScreenPanel>() is not { } screen ) return;

		var heightScale = Screen.Size.y / 1080.0f;
		var widest = heightScale > 0 ? Screen.Size.x * 0.9f / (WindowWidth * heightScale) : 1;
		var scale = Math.Clamp( MediaSettings.RemoteScale, 0.5f, Math.Max( 0.5f, widest ) );
		if ( MathF.Abs( screen.Scale - scale ) > 0.001f ) screen.Scale = scale;
	}

	/// <summary> For probes: each option of an open menu, its classes, and * on the pad's selection. </summary>
	IEnumerable<string> MenuOptions()
	{
		var top = Panel;
		while ( top?.Parent is not null ) top = top.Parent;
		var menu = top?.Descendants.OfType<Popup>().FirstOrDefault();
		return menu is null ? Enumerable.Empty<string>() : menu.Children.Select( c => $"{(c as Button)?.Text}:{c.Classes}{(c == menu.SelectedChild ? "*" : "")}" );
	}

	/// <summary> For probes: the controller state and what's highlighted. </summary>
	internal string DescribePad() => $"usingController={Input.UsingController} controllers={Input.ControllerCount} select={Pad.SelectAction ?? "-"} back={Pad.BackAction ?? "-"} virtualCursor={Input.EnableVirtualCursor} screen={Screen.Size} desktopScale={Screen.DesktopScale} uiScale={Panel?.ScaleToScreen} window={Panel?.Descendants.FirstOrDefault( p => p.HasClass( "window" ) )?.Box.Rect} focused=[{Pad.Focused?.Classes}] menu=[{string.Join( " | ", MenuOptions() )}]" + '\n' +
		string.Join( '\n', Panel.Descendants.Where( p => p.HasClass( "nav" ) ).Select( p => $"  [{p.Classes}] visible={p.IsVisible} rect={p.Box.Rect}" ) );

	/// <summary> For probes: move the highlight as the pad would (up, down, left, right, or "a" to press it), and say where it went. </summary>
	internal string ProbeMove( string direction )
	{
		if ( direction == "a" )
		{
			if ( Pad.Focused is { } focused ) PressControl( focused );
			return $"a on [{Pad.Focused?.Classes}]";
		}

		var d = direction switch { "up" => new Vector2( 0, -1 ), "down" => new Vector2( 0, 1 ), "left" => new Vector2( -1, 0 ), _ => new Vector2( 1, 0 ) };
		Pad.ProbeStep( Panel, d );
		return $"{direction} -> [{Pad.Focused?.Classes}] volume={MediaSettings.Volume:0.00}";
	}

	void RestoreVirtualCursor()
	{
		if ( virtualCursorWas is not { } was ) return;
		Input.EnableVirtualCursor = was;
		virtualCursorWas = null;
	}

	protected override void OnDestroy()
	{
		RestoreVirtualCursor();
		base.OnDestroy();
	}

	/// <summary> A on a control: a drop down opens its menu (navigated by PadNavigation), a button is clicked. Sliders only take left/right. </summary>
	void PressControl( Panel control )
	{
		if ( control.HasClass( "seek" ) || control.HasClass( "volume" ) ) return;

		if ( control is DropDown dropDown )
		{
			dropDown.Open();
			return;
		}

		control.CreateEvent( new MousePanelEvent( "onclick", control, "mouseleft" ) );
	}

	/// <summary> Left/right on a slider: seek by 10 s, volume by 5%. Anything else moves left/right. </summary>
	bool AdjustControl( Panel control, int step )
	{
		if ( control.HasClass( "seek" ) )
		{
			OnSeekChanged( Math.Clamp( SeekValue + step * 10, 0, Target.MediaDuration ) );
			return true;
		}

		if ( control.HasClass( "volume" ) )
		{
			OnVolumeChanged( MathF.Round( (MediaSettings.Volume + step * 0.05f) * 20 ) / 20 );
			return true;
		}

		return false;
	}

	protected override void OnUpdate()
	{
		if ( !Target.IsValid() )
		{
			Close();
			return;
		}

		MenuStyle.Apply( Panel );
		FitToScreen();

		if ( Input.EscapePressed )
		{
			Input.EscapePressed = false;
			Close();
			return;
		}

		// USE again closes it (not while typing - a focused text entry takes the keys)
		if ( sinceOpened > 0.2f && Input.Pressed( "use" ) && UseHandled.TryConsume() )
		{
			Close();
			return;
		}

		if ( Scene.Camera is not null && Scene.Camera.WorldPosition.Distance( Target.WorldPosition ) > MaxDistance )
		{
			Close();
			return;
		}

		// Seek once the slider has settled, rather than spamming the host while dragging
		if ( pendingSeek.HasValue && sincePendingSeek > 0.3f )
		{
			Target.RequestSeek( pendingSeek.Value );
			pendingSeek = null;
		}
	}

	void OnSeekChanged( float value )
	{
		if ( Target is null || !Target.CanLocalControl ) return;
		if ( MathF.Abs( value - Target.CurrentTime ) < 1.5f && !pendingSeek.HasValue ) return; // our own refresh

		pendingSeek = value;
		sincePendingSeek = 0;
	}

	//
	// Resolution / audio track. These are this player's own preferences (saved convars), applied to every
	// media player - picking one reloads the local stream at the same synced time.
	//

	string QualityValue => MediaSettings.Quality <= 0 ? "0" : Target.SelectedQuality.ToString();

	List<Option> BuildQualityOptions()
	{
		var auto = $"Auto (up to {MediaSettings.MaxVideoHeight}p)";
		var list = new List<Option> { new Option( auto, "0" ) };
		foreach ( var h in Target.AvailableQualities )
			list.Add( new Option( MediaStreamOptions.QualityLabel( h ), h.ToString() ) );
		return list;
	}

	void OnQualityChanged( string value )
	{
		if ( !int.TryParse( value, out var h ) ) return;
		if ( h == (MediaSettings.Quality <= 0 ? 0 : Target.SelectedQuality) ) return;
		MediaSettings.Quality = h;
	}

	string AudioTrackValue
	{
		get
		{
			var selected = Target.SelectedAudioTrack ?? Target.AvailableAudioTracks.FirstOrDefault( t => t.IsDefault );
			return selected?.Id ?? "";
		}
	}

	List<Option> BuildAudioTrackOptions()
	{
		return Target.AvailableAudioTracks
			.Select( t => new Option( t.IsDefault && !t.Name.Contains( "original", StringComparison.OrdinalIgnoreCase ) ? $"{t.Name} (original)" : t.Name, t.Id ) )
			.ToList();
	}

	void OnAudioTrackChanged( string id )
	{
		if ( id == AudioTrackValue ) return;
		var track = Target.AvailableAudioTracks.FirstOrDefault( t => t.Id == id );
		// picking the original clears the preference, so dubbed videos default to original again
		MediaSettings.AudioLanguage = track is null || track.IsDefault ? "" : track.Id;
	}

	//
	// Captions: off, or a language. The choice is this player's own (saved convars) and applies to every media player.
	// Auto-generated tracks are only offered for languages without a written one - that's what picking a language gets.
	//

	IEnumerable<MediaCaptionTrack> ListedCaptionTracks
		=> Target.AvailableCaptionTracks.Where( t => !t.IsAuto || !Target.AvailableCaptionTracks.Any( w => !w.IsAuto && w.Lang == t.Lang ) );

	string CaptionValue => Target.SelectedCaptionTrack?.Id ?? "";

	List<Option> BuildCaptionOptions()
	{
		var list = new List<Option> { new Option( "Captions off", "" ) };
		foreach ( var t in ListedCaptionTracks )
			list.Add( new Option( t.IsAuto && !t.Name.Contains( "auto", StringComparison.OrdinalIgnoreCase ) ? $"{t.Name} (auto-generated)" : t.Name, t.Id ) );
		return list;
	}

	void OnCaptionChanged( string id )
	{
		if ( id == CaptionValue ) return;

		var track = Target.AvailableCaptionTracks.FirstOrDefault( t => t.Id == id );
		MediaSettings.Subtitles = track is not null;
		if ( track is not null ) MediaSettings.SubtitleLanguage = track.Lang;
	}

	string PlayingAsText
	{
		get
		{
			var b = Target.Backend;
			if ( b is null || !b.Loaded ) return "Loading...";
			return b.HasVideo ? $"Playing {b.Height}p" : "";
		}
	}

	void OnVolumeChanged( float value )
	{
		MediaSettings.Volume = value.Clamp( 0, 1 );
		if ( value > 0 ) MediaSettings.Mute = false;
	}

	void ToggleMute()
	{
		MediaSettings.Mute = !MediaSettings.Mute;
	}

	/// <summary> 3D audio from every media player, or flat 2D for this player (bimp_spatial). </summary>
	void ToggleSpatial()
	{
		MediaSettings.SpatialAudio = !MediaSettings.SpatialAudio;
	}

	string TakeUrl()
	{
		var text = UrlEntry?.Text?.Trim();
		if ( UrlEntry is not null ) UrlEntry.Text = "";
		return string.IsNullOrWhiteSpace( text ) ? null : text;
	}

	void OnSubmit()
	{
		// Enter plays right away if we can, otherwise queues
		if ( Target.CanLocalControl && !Target.HasMedia ) PlayNow();
		else if ( Target.CanLocalQueue ) AddToQueue();
		else PlayNow();
	}

	void PlayNow()
	{
		var url = TakeUrl();
		if ( url is null ) return;
		Target.RequestPlay( url );
	}

	void AddToQueue()
	{
		var url = TakeUrl();
		if ( url is null ) return;
		Target.RequestEnqueue( url );
	}

	protected override int BuildHash()
	{
		if ( Target is null ) return 0;

		return HashCode.Combine(
			HashCode.Combine( Target.PlayId, Target.Title, Target.Paused, Target.Status, Target.LocalNotice, (int)Target.CurrentTime, Target.MediaDuration ),
			HashCode.Combine( QueueHash(), MediaSettings.Mute, MediaSettings.Volume, MediaSettings.SpatialAudio, Target.CanLocalControl, pendingSeek, PadMode ),
			HashCode.Combine( Target.Qualities, Target.AudioTracks, MediaSettings.Quality, MediaSettings.AudioLanguage, PlayingAsText, ShowRecent, RecentLinks.Version ),
			HashCode.Combine( Target.CaptionTracks, MediaSettings.Subtitles, MediaSettings.SubtitleLanguage )
		);
	}

	int QueueHash()
	{
		var h = new HashCode();
		h.Add( Target.Queue.Count );
		foreach ( var item in Target.Queue ) h.Add( item?.Url );
		return h.ToHashCode();
	}
}
@using System.Collections.Generic;
@using System.Linq;
@using Sandbox;
@using Sandbox.UI;
@namespace TwitchAPI.Examples

<root>
    <div class="username">
        <div class="badges">
            @foreach (var badge in ChatMessage.User.Badges)
            {
                <div class="badge @badge" />
            }
        </div>
        @if (HasAvatarImages)
        {
            <img class="avatar" [email protected]() />
        }
        <label class="name" style="color: @ChatMessage.User.Color.Hex">@ChatMessage.Username</label>
        <label>:</label>
    </div>
    <div class="message">
        @foreach (var fragment in Fragments)
        {
            if (fragment.Emote is not null)
            {
                <img class="emote" src="@fragment.Emote" />
            }
            else
            {
                <label>@fragment.Text</label>
            }
        }
    </div>
</root>

@code
{
    public TwitchChatMessage ChatMessage { get; set; }
    public float Lifetime { get; set; } = 10f;
    public float FadeTime { get; set; } = 0.5f;
    public bool HasAvatarImages { get; set; } = false;
    List<MessageFragment> Fragments { get; set; } = new();

    TimeSince TimeSinceCreated = 0f;

    record MessageFragment(string Text, string Emote);

    protected override void OnParametersSet()
    {
        base.OnParametersSet();

        Fragments.Clear();
        var text = ChatMessage.Message;
        var emotes = ChatMessage.Emotes;
        var currentEmote = emotes.FirstOrDefault();
        var currentIndex = 0;
        while (currentEmote is not null)
        {
            var textPrior = text.Substring(currentIndex, currentEmote.StartingCharacter - currentIndex);
            if (!string.IsNullOrWhiteSpace(textPrior))
            {
                foreach (var word in textPrior.Split(' '))
                {
                    if (string.IsNullOrWhiteSpace(word)) continue;
                    Fragments.Add(new MessageFragment(word, null));
                }
            }

            var emoteText = text.Substring(currentEmote.StartingCharacter, currentEmote.EndingCharacter - currentEmote.StartingCharacter + 1);
            Fragments.Add(new MessageFragment(emoteText, currentEmote.GetImageUrl()));

            currentIndex = currentEmote.EndingCharacter + 1;
            currentEmote = emotes.FirstOrDefault(e => e.StartingCharacter >= currentIndex);
        }

        var textAfter = text.Substring(currentIndex);
        if (!string.IsNullOrWhiteSpace(textAfter))
        {
            foreach (var word in textAfter.Split(' '))
            {
                if (string.IsNullOrWhiteSpace(word)) continue;
                Fragments.Add(new MessageFragment(word, null));
            }
        }
    }

    public override void Tick()
    {
        if (TimeSinceCreated >= Lifetime)
        {
            var opacity = 1f - ((TimeSinceCreated - Lifetime) / FadeTime);
            if (opacity <= 0f)
            {
                Delete();
            }
            else
            {
                Style.Opacity = opacity;
            }
        }
    }
}
@namespace FieldGuide.VehiclePhysics
@inherits PanelComponent

@* Kit-native live tuning lab (demo layer). Toggled by the Tune action, bound to the car the chase
   camera is following. Writes tuning changes straight onto the running car through the same paths a
   consumer would use: it mutates the active CarDefinition (read live by the drivetrain and the brake
   model) and pushes suspension/tire values onto the live wheels. This is a demo-scale lab, not the
   full game panel: it exposes the highest-feel dials so the demo reads as a physics lab in the first
   minute. Replace it with your own UI; it lives only in the demo scene.

   Look and layout follow the Field Kit UI system: docs/design/ui-system/vehicle-physics.dc.html
   screen 03.A for this panel, tokens.dc.html for the values. The stylesheet carries this kit's own
   copy of those tokens (kits cannot import each other) and lists the engine-legality rules at its
   head. *@

<root>
	@if ( Car.IsValid() )
	{
		@* Key legend, top-right: how to hop between the demo cars. Always visible; the tuning
		   chip/panel top-left covers the T binding. *@
		<div class="legend">
			<div class="mono kbd">[ ]</div>
			<div class="title">switch car</div>
		</div>
	}
	@if ( !IsOpen && Car.IsValid() )
	{
		@* Collapsed legend chip: sits exactly where the expanded panel's top-left corner lands, so
		   pressing T reads as the chip expanding into the lab. Mouse-look stays with the camera. *@
		<div class="panel chip" onclick=@ToggleOpen>
			<div class="head">
				<div class="mono kbd">@ToggleKeyLabel</div>
				<div class="title">tuning</div>
			</div>
		</div>
	}
	@if ( IsOpen && Car.IsValid() )
	{
		<div class="panel">
			<div class="head">
				<div class="title">Tuning lab · @CarName</div>
				@* Key chip plus the 42px close x. The x runs the SAME ToggleOpen path the T key and
				   the collapsed chip use, so cursor visibility stays decided in one place. *@
				<div class="hr">
					<div class="mono kbd">@ToggleKeyLabel</div>
					<div class="x" onclick=@ToggleOpen>×</div>
				</div>
			</div>

			<div class="hint">Tuning the car you are driving. Changes apply live.</div>

			<div class="dials">
				@foreach ( var d in SliderDials )
				{
					var dd = d;
					<div class="dial">
						<div class="drow">
							<div class="dname">@d.Name</div>
							<div class="mono dval">@d.Display()</div>
						</div>
						<div class="dctl">
							<div class="mono step" onclick=@( () => Step( dd, -1 ) )>-</div>
							<div class="track"
								onmousedown=@( e => Scrub( dd, e, true ) )
								onmousemove=@( e => Scrub( dd, e, false ) )>
								<div class="fill" style="width: @WidthPct( dd )%"></div>
							</div>
							<div class="mono step" onclick=@( () => Step( dd, +1 ) )>+</div>
						</div>
					</div>
				}

				<div class="dial">
					<div class="drow">
						<div class="dname">Assists</div>
						<div class="mono cycle" onclick=@CycleAssists>@AssistLabel</div>
					</div>
				</div>

				<div class="dial">
					<div class="drow">
						<div class="dname">Tires</div>
						<div class="mono cycle" onclick=@CycleTires>@TireLabel</div>
					</div>
				</div>
			</div>

			<div class="foot">
				<div class="btn" onclick=@ResetToStock>Reset to stock</div>
			</div>
		</div>
	}
</root>

@code {
	/// <summary>The car this panel tunes: the one the chase camera follows. Set by DemoBootstrap. On
	/// change the panel snapshots the car's pristine (authored) values so Reset-to-stock can restore
	/// them and the grip/torque multipliers rebase to the new car.</summary>
	VehicleController _car;
	public VehicleController Car
	{
		get => _car;
		set
		{
			if ( ReferenceEquals( _car, value ) )
				return;
			_car = value;
			Snapshot();
		}
	}

	/// <summary>Open state. Static so the kit's chase camera can read it through the
	/// <see cref="VehicleCamera.CursorModalOpen"/> seam (DemoBootstrap wires that) without holding a
	/// reference to this panel. The demo runs exactly one panel, so a single flag is enough.</summary>
	public static bool IsOpen;

	// Toggle input action. Documented in the README input table; the host ProjectSettings/Input.config
	// ships it. Never Escape or an F-key.
	const string ToggleAction = "Tune";
	string ToggleKeyLabel => "T";

	string CarName => Car?.Definition?.Name ?? "Car";

	// Live multipliers over the pristine base. Grip scales the (preset-selected) tire curves; torque
	// scales the authored peak engine torque.
	float _gripScale = 1f;
	float _torqueScale = 1f;
	int _tirePreset; // 0 Stock, 1 Street, 2 Sport, 3 Offroad

	// Pristine snapshot, captured value-by-value when the car is bound (its definition is untouched at
	// that point). Value types only (floats, TireCurve struct, enum), so later live tuning of the
	// definition can never corrupt these; Reset restores from here.
	bool _snapped;
	float _stockPeakTorque, _stockSpring, _stockDamper, _stockTravel, _stockBrake;
	TireCurve _stockLat, _stockLong;
	AssistLevel _stockAssists;

	// Current UNSCALED tire base (Stock or a named preset). Grip multiplies these to get the live curves.
	TireCurve _baseLat, _baseLong;

	void Snapshot()
	{
		var def = _car?.Definition;
		_dials = null;
		if ( def is null )
		{
			_snapped = false;
			return;
		}

		_stockPeakTorque = def.PeakTorque;
		_stockSpring = def.SpringRate;
		_stockDamper = def.DamperRate;
		_stockTravel = def.SuspensionTravel;
		_stockBrake = def.BrakeTorque;
		_stockLat = def.LateralCurve;
		_stockLong = def.LongitudinalCurve;
		// Stock assist = the authored definition default. Read it off the definition, not the
		// controller: the controller adopts DefaultAssists in its own OnStart, which may run a frame
		// or two after this bind, so its live Assists is not reliable yet.
		_stockAssists = def.DefaultAssists;

		_baseLat = _stockLat;
		_baseLong = _stockLong;
		_gripScale = 1f;
		_torqueScale = 1f;
		_tirePreset = 0;
		_snapped = true;
		StateHasChanged();
	}

	// ---- dial model ----
	class Dial
	{
		public string Name;
		public float Min, Max, Step;
		public Func<float> Get;
		public Action<float> Set;
		public Func<string> Fmt;
		public string Display() => Fmt();
	}

	List<Dial> _dials;
	List<Dial> SliderDials => _dials ??= BuildDials();

	List<Dial> BuildDials()
	{
		if ( !Car.IsValid() )
			return new List<Dial>();

		var def = Car.Definition;
		return new List<Dial>
		{
			new()
			{
				Name = "Grip", Min = 0.6f, Max = 2.2f, Step = 0.05f,
				Get = () => _gripScale, Set = SetGrip,
				Fmt = () => _gripScale.ToString( "0.00" ) + "x",
			},
			new()
			{
				Name = "Drive torque", Min = 0.5f, Max = 2.0f, Step = 0.05f,
				Get = () => _torqueScale, Set = SetTorqueScale,
				Fmt = () => _torqueScale.ToString( "0.00" ) + "x",
			},
			new()
			{
				Name = "Suspension stiffness", Min = 15000f, Max = 60000f, Step = 2000f,
				Get = () => def.SpringRate, Set = v => { def.SpringRate = v; ApplyWheels(); },
				Fmt = () => def.SpringRate.ToString( "0" ) + " N/m",
			},
			new()
			{
				Name = "Suspension damping", Min = 800f, Max = 6000f, Step = 200f,
				Get = () => def.DamperRate, Set = v => { def.DamperRate = v; ApplyWheels(); },
				Fmt = () => def.DamperRate.ToString( "0" ) + " Ns/m",
			},
			new()
			{
				Name = "Suspension travel", Min = 0.10f, Max = 0.35f, Step = 0.01f,
				Get = () => def.SuspensionTravel, Set = v => { def.SuspensionTravel = v; ApplyWheels(); },
				Fmt = () => (def.SuspensionTravel * 100f).ToString( "0" ) + " cm",
			},
			new()
			{
				Name = "Brake force", Min = 1500f, Max = 8000f, Step = 200f,
				Get = () => def.BrakeTorque, Set = v => def.BrakeTorque = v,
				Fmt = () => def.BrakeTorque.ToString( "0" ) + " Nm",
			},
		};
	}

	float Frac( Dial d )
	{
		if ( d.Max <= d.Min )
			return 0f;
		return Math.Clamp( (d.Get() - d.Min) / (d.Max - d.Min), 0f, 1f );
	}

	string WidthPct( Dial d ) =>
		(Frac( d ) * 100f).ToString( "0.#", System.Globalization.CultureInfo.InvariantCulture );

	void Step( Dial d, int clicks )
	{
		float v = Math.Clamp( d.Get() + clicks * d.Step, d.Min, d.Max );
		d.Set( v );
		StateHasChanged();
	}

	// Click-to-jump + drag-to-scrub on the dial track (mirrors the game panel's proven pattern). jump
	// is true on mousedown (a bare click sets the value at the cursor) and false on mousemove (scrub
	// only while the track owns the press). Value = mouse local x over track width, snapped to the
	// dial's step, pushed through the SAME Set path as the +/- steps.
	void Scrub( Dial d, Sandbox.UI.PanelEvent ev, bool jump )
	{
		if ( ev is not Sandbox.UI.MousePanelEvent e )
			return;

		var track = e.This;
		if ( track is null )
			return;

		// mousemove fires whether or not the button is held; only scrub while the track owns the press.
		if ( !jump && !track.PseudoClass.HasFlag( Sandbox.UI.PseudoClass.Active ) )
			return;

		float w = track.Box.Rect.Width;
		if ( w <= 0f )
			return;

		float frac = Math.Clamp( e.LocalPosition.x / w, 0f, 1f );
		float v = d.Min + frac * (d.Max - d.Min);
		if ( d.Step > 0f )
			v = MathF.Round( v / d.Step ) * d.Step;
		v = Math.Clamp( v, d.Min, d.Max );
		d.Set( v );
		StateHasChanged();
	}

	// ---- apply paths (same seams a consumer would use) ----
	static TireCurve Scaled( TireCurve c, float k ) =>
		new( c.PeakSlip, c.PeakGrip * k, c.TailSlip, c.TailGrip * k );

	// Push the definition's suspension + tire values onto the live wheels. The factory copies these at
	// spawn; the wheel re-reads them every substep, so writing them here is the live-apply path.
	void ApplyWheels()
	{
		var def = Car.Definition;
		foreach ( var w in Car.Wheels )
		{
			w.SpringRate = def.SpringRate;
			w.DamperRate = def.DamperRate;
			w.SuspensionTravel = def.SuspensionTravel;
			w.LateralCurve = def.LateralCurve;
			w.LongitudinalCurve = def.LongitudinalCurve;
		}
	}

	void SetGrip( float k )
	{
		_gripScale = k;
		var def = Car.Definition;
		def.LateralCurve = Scaled( _baseLat, k );
		def.LongitudinalCurve = Scaled( _baseLong, k );
		ApplyWheels();
	}

	// Drive torque scale multiplies the authored peak torque. Drivetrain reads def.PeakTorque live
	// (it holds the same definition instance), so no drivetrain touch is needed.
	void SetTorqueScale( float k )
	{
		_torqueScale = k;
		Car.Definition.PeakTorque = _stockPeakTorque * k;
	}

	void CycleAssists()
	{
		Car.Assists = Car.Assists switch
		{
			AssistLevel.Casual => AssistLevel.Sport,
			AssistLevel.Sport => AssistLevel.Sim,
			_ => AssistLevel.Casual,
		};
		StateHasChanged();
	}

	string AssistLabel => Car.IsValid() ? Car.Assists.ToString() : "";

	// Tire preset swaps the UNSCALED base curves, then re-applies the current grip multiplier so the
	// grip dial and the preset compose. Stock restores the car's own authored curves.
	void CycleTires()
	{
		_tirePreset = (_tirePreset + 1) % 4;
		(_baseLat, _baseLong) = _tirePreset switch
		{
			1 => (TireCurve.Street, TireCurve.Street),
			2 => (TireCurve.Sport, TireCurve.Sport),
			3 => (TireCurve.Offroad, TireCurve.Offroad),
			_ => (_stockLat, _stockLong),
		};
		SetGrip( _gripScale );
		StateHasChanged();
	}

	string TireLabel => _tirePreset switch
	{
		1 => "Street",
		2 => "Sport",
		3 => "Offroad",
		_ => "Stock",
	};

	// Re-apply the car's pristine authored values (captured at bind). Definitions in the demo roster
	// are fresh per car, so "stock" is unambiguous: the values this car spawned with.
	void ResetToStock()
	{
		if ( !Car.IsValid() || !_snapped )
			return;

		var def = Car.Definition;
		def.PeakTorque = _stockPeakTorque;
		def.SpringRate = _stockSpring;
		def.DamperRate = _stockDamper;
		def.SuspensionTravel = _stockTravel;
		def.BrakeTorque = _stockBrake;
		def.LateralCurve = _stockLat;
		def.LongitudinalCurve = _stockLong;

		_gripScale = 1f;
		_torqueScale = 1f;
		_tirePreset = 0;
		_baseLat = _stockLat;
		_baseLong = _stockLong;

		Car.Assists = _stockAssists;
		ApplyWheels();
		StateHasChanged();
	}

	protected override void OnEnabled()
	{
		// Start COLLAPSED: the chip legend keeps the keybind discoverable while mouse-look stays
		// with the camera (an open lab on spawn captured the cursor before players ever drove,
		// owner call 2026-07-19). Static flag, so reset it here each session.
		IsOpen = false;
	}

	void ToggleOpen()
	{
		IsOpen = !IsOpen;
		Mouse.Visibility = IsOpen ? MouseVisibility.Visible : MouseVisibility.Hidden;
		StateHasChanged();
	}

	protected override void OnUpdate()
	{
		if ( Input.Pressed( ToggleAction ) )
		{
			IsOpen = !IsOpen;
			Mouse.Visibility = IsOpen ? MouseVisibility.Visible : MouseVisibility.Hidden;
			StateHasChanged();
		}

		// The chase camera re-hides the cursor every frame while it owns it; hold it visible while open.
		if ( IsOpen )
			Mouse.Visibility = MouseVisibility.Visible;
	}

	protected override int BuildHash()
	{
		// Closed still renders the chip legend, and the chip waits on the car binding, so the
		// hash must move when the car arrives or the first build would stick on the empty tree.
		if ( !IsOpen )
			return Car.IsValid() ? 1 : 0;

		var h = new HashCode();
		h.Add( _gripScale );
		h.Add( _torqueScale );
		h.Add( _tirePreset );
		if ( Car.IsValid() )
		{
			var def = Car.Definition;
			h.Add( def.SpringRate );
			h.Add( def.DamperRate );
			h.Add( def.SuspensionTravel );
			h.Add( def.BrakeTorque );
			h.Add( (int)Car.Assists );
		}
		return h.ToHashCode();
	}
}
@using System
@using Sandbox
@using Sandbox.UI
@namespace Bimp
@inherits PanelComponent
@implements Component.ExecuteInEditor

@*
	The screen itself. Put this on a GameObject with a WorldPanel (PanelSize 1920x1080 works well).
	Shows the video, and point-and-click controls when a player looks at it. The controls hide after a
	few seconds and stay hidden for as long as you keep looking, however you move - they only come back
	when you look away and back again. Clicking needs a WorldInput on the camera (added automatically).

	USE (E) while aiming at a visible button presses it; USE anywhere else opens the full remote
	(MediaInteract asks TryPressAimedControl first).
*@

<root class="@RootClass">

	<div class="video" @ref="VideoSurface"></div>

	@if ( Player is null || !Player.HasMedia )
	{
		<div class="splash">
			<i>smart_display</i>
			<div class="title">@(string.IsNullOrWhiteSpace( IdleText ) ? "Media Player" : IdleText)</div>
			@if ( !string.IsNullOrWhiteSpace( Player?.Status ) )
			{
				<div class="status">@Player.Status</div>
			}
			else
			{
				@* separate pieces in a row: text either side of an element lost its spaces ("PressUSEto open") *@
				<div class="hint"><span>Press</span><InputGlyph Action="@UseAction"></InputGlyph><span>to open the controls</span></div>
			}
		</div>
	}
	else if ( ShowAudioVisual )
	{
		<div class="audio">
			<i>graphic_eq</i>
			<ScrollingText class="title" Text="@Player.Title"></ScrollingText>
			<SpectrumBars Player="@Player"></SpectrumBars>
		</div>
	}

	@if ( Player is not null && Player.HasMedia )
	{
		@if ( !string.IsNullOrEmpty( CaptionText ) )
		{
			<div class="subs">
				@foreach ( var line in CaptionText.Split( '\n' ) )
				{
					<div class="subs-line">@line</div>
				}
			</div>
		}

		@if ( Player.Backend is not null && !Player.Backend.Loaded && Player.Backend.Error is null )
		{
			<div class="loading"><i>hourglass_top</i> Loading...</div>
		}

		@if ( FlashIcon is not null )
		{
			<div class="flash">
				<i>@FlashIcon</i>
				@if ( !string.IsNullOrEmpty( FlashText ) )
				{
					<div class="flash-text">@FlashText</div>
				}
			</div>
		}

		<div class="osd">
			<div class="meta">
				<ScrollingText class="title" Text="@Player.Title"></ScrollingText>
				<div class="time">@TimeText</div>
			</div>

			@if ( !Player.IsLive )
			{
				<div class="progress @AimedClass( "progress" )" @ref="SeekBar" onclick=@((Action<PanelEvent>)OnSeekBarClick)>
					<div class="fill" @ref="SeekFill"></div>
				</div>
			}
			else
			{
				<div class="live"><i>sensors</i> LIVE</div>
			}

			<div class="controls">
				@if ( Player.CanLocalControl )
				{
					@if ( !Player.IsLive )
					{
						<div class="btn back @AimedClass( "back" )" onclick="@SeekBack"><i>replay_10</i></div>
						<div class="btn playpause @AimedClass( "playpause" )" onclick="@TogglePause"><i>@(Player.Paused ? "play_arrow" : "pause")</i></div>
						<div class="btn forward @AimedClass( "forward" )" onclick="@SeekForward"><i>forward_10</i></div>
					}
					<div class="btn next @AimedClass( "next" )" onclick="@Next"><i>skip_next</i></div>
					<div class="btn stop @AimedClass( "stop" )" onclick="@Stop"><i>stop</i></div>
				}
				<div class="spacer"></div>
				@if ( Player.Queue.Count > 0 )
				{
					<div class="queue-count"><i>queue_music</i> @Player.Queue.Count</div>
				}
				@if ( !string.IsNullOrWhiteSpace( Player.RequestedBy ) )
				{
					<div class="by">Requested by @Player.RequestedBy</div>
				}
			</div>
		</div>
	}

	@if ( !string.IsNullOrWhiteSpace( Player?.LocalNotice ) )
	{
		<div class="notice">@Player.LocalNotice</div>
	}

	@* "Press E" dot: the USE button's glyph. Always in the tree (last, so it draws on top); UpdatePressDot shows
	   it and moves it onto the aimed control every frame. *@
	<div class="press-dot" @ref="PressDot"><InputGlyph Action="@UseAction"></InputGlyph></div>

</root>

@code
{
	/// <summary>
	/// The player this screen shows. Found automatically in this object or its parents if unset.
	/// </summary>
	[Property] public MediaPlayer Player { get; set; }

	/// <summary>
	/// Text shown when nothing is playing.
	/// </summary>
	[Property] public string IdleText { get; set; } = "Media Player";

	/// <summary>
	/// Scale the UI to fit the WorldPanel's PanelSize, so it looks the same (just smaller or bigger) at any
	/// screen size instead of getting cut off. Sets the WorldPanel's RenderScale.
	/// </summary>
	[Property, Group( "Layout" )] public bool FitToPanel { get; set; } = true;

	/// <summary>
	/// The panel size the UI is designed for. With FitToPanel, the UI is laid out at this size and scaled.
	/// </summary>
	[Property, Group( "Layout" ), ShowIf( nameof( FitToPanel ), true )] public Vector2 DesignSize { get; set; } = new Vector2( 1920, 1080 );

	/// <summary>
	/// Size the screen (the WorldPanel's PanelSize) to the screen's model - the frame / TV you see -
	/// so resizing or swapping the model resizes the picture and UI with it. Updates live in the editor.
	/// </summary>
	[Property, Group( "Layout" )] public bool FitToObject { get; set; } = true;

	/// <summary>
	/// The model to fit to. Defaults to the first ModelRenderer on this object or its children.
	/// </summary>
	[Property, Group( "Layout" ), ShowIf( nameof( FitToObject ), true )] public ModelRenderer SizeFrom { get; set; }

	/// <summary>
	/// Border left around the picture on each side, in world units (the frame's bezel).
	/// </summary>
	[Property, Group( "Layout" ), ShowIf( nameof( FitToObject ), true ), Range( 0, 20 )] public float Bezel { get; set; } = 2.0f;

	/// <summary>
	/// Also fit this object's BoxCollider to the model, so USE / looking works over the whole screen.
	/// </summary>
	[Property, Group( "Layout" ), ShowIf( nameof( FitToObject ), true )] public bool FitCollider { get; set; } = true;

	bool warnedOffCenter;

	void FitToScreenObject()
	{
		var model = SizeFrom.IsValid() ? SizeFrom
			: Components.GetAll<ModelRenderer>( FindMode.EnabledInSelfAndDescendants ).FirstOrDefault( m => m.Model is not null );
		if ( model is null ) return;

		var bounds = PanelFit.FitToModel( this, model, Bezel, FitCollider );

		// The panel is always centered on this object - tell the mapper if their model isn't
		if ( bounds is BBox b && !warnedOffCenter && new Vector2( b.Center.y, b.Center.z ).Length > MathF.Max( 2, b.Size.y * 0.05f ) )
		{
			warnedOffCenter = true;
			Log.Warning( $"[bimp] {GameObject.Name}: the screen model isn't centered on the Media Screen object, the picture will be offset. Move the model so its face is centered on the object." );
		}
	}

	/// <summary>
	/// Show the title/progress bar all the time.
	/// </summary>
	[Property, Group( "Controls" )] public bool AlwaysShowInfo { get; set; } = false;

	/// <summary>
	/// Hide the controls this many seconds after you start looking at the screen, last press a button, or
	/// move your aim off a button (they never time out while you're aiming at one). Once hidden they stay
	/// hidden while you keep looking, and come back when you look away and back. 0 = never hide.
	/// </summary>
	[Property, Group( "Controls" ), Range( 0, 30 )] public float HideControlsAfter { get; set; } = 3.0f;

	/// <summary>
	/// How long you have to look away before looking back shows the controls again. Stops them flickering
	/// back when your aim just grazes the edge of the screen.
	/// </summary>
	[Property, Group( "Controls" ), Range( 0, 2 )] public float LookAwayTime { get; set; } = 0.3f;

	/// <summary>
	/// Keep the controls up while paused (like most video players).
	/// </summary>
	[Property, Group( "Controls" )] public bool ShowControlsWhilePaused { get; set; } = true;

	/// <summary>
	/// How far away you can be for looking at the screen to count. Uses the WorldPanel's interaction range if 0.
	/// </summary>
	[Property, Group( "Controls" )] public float LookRange { get; set; } = 0;

	/// <summary>
	/// Don't count looking at the screen through walls.
	/// </summary>
	[Property, Group( "Controls" )] public bool CheckLineOfSight { get; set; } = true;

	/// <summary>
	/// Add a WorldInput to the camera if the scene doesn't have one, so the buttons can be clicked.
	/// </summary>
	[Property, Group( "Controls" )] public bool AddWorldInput { get; set; } = true;

	/// <summary>
	/// The input action that presses the aimed button, or opens the full remote. Works from anywhere you can
	/// point at the screen (LookRange), not just the PlayerController's short reach.
	/// </summary>
	[Property, Group( "Controls" ), InputAction] public string UseAction { get; set; } = "use";

	/// <summary> Seconds jumped by the back / forward buttons. </summary>
	[Property, Group( "Controls" ), Range( 1, 60 )] public float SeekStep { get; set; } = 10;

	/// <summary>
	/// Is the local player looking at (or pointing the mouse at) this screen right now?
	/// </summary>
	public bool IsLookedAt { get; private set; }

	/// <summary>
	/// Are the controls showing right now?
	/// </summary>
	public bool ControlsVisible { get; private set; }

	Panel VideoSurface { get; set; }
	Panel SeekBar { get; set; }
	Panel SeekFill { get; set; }

	RealTimeSince sinceChanged = 0;
	RealTimeSince sinceActivity = 100;
	RealTimeSince sinceLookedAway = 100;
	string lastTitle;

	string FlashIcon;
	string FlashText;
	int flashId;
	RealTimeSince sinceFlash = 100;

	bool ShowAudioVisual => Player.AudioOnly || (Player.Backend is not null && Player.Backend.Loaded && !Player.Backend.HasVideo);

	string RootClass
	{
		get
		{
			var c = "mediascreen";
			if ( Player is null || !Player.HasMedia ) c += " idle";
			if ( Player?.Paused ?? false ) c += " paused";
			if ( ControlsVisible ) c += " controls-visible";
			if ( AlwaysShowInfo ) c += " always-info";
			return c;
		}
	}

	string TimeText
	{
		get
		{
			if ( Player.IsLive ) return "Live";
			var cur = MediaSource.FormatTime( Player.CurrentTime );
			return Player.MediaDuration > 0 ? $"{cur} / {MediaSource.FormatTime( Player.MediaDuration )}" : cur;
		}
	}

	protected override void OnStart()
	{
		base.OnStart();
		Player ??= GetComponentInParent<MediaPlayer>();
	}

	protected override void OnUpdate()
	{
		if ( FitToObject ) FitToScreenObject();
		if ( FitToPanel ) PanelFit.Apply( this, DesignSize );

		// In the editor we only keep the layout fitted, so the scene view shows the real result
		if ( Scene.IsEditor ) return;

		Player ??= GetComponentInParent<MediaPlayer>();

		if ( Player?.Title != lastTitle )
		{
			lastTitle = Player?.Title;
			sinceChanged = 0;
		}

		if ( VideoSurface.IsValid() )
		{
			var backend = Player?.Backend;
			var tex = backend is not null && backend.HasVideo && !Player.AudioOnly ? backend.Texture : null;
			VideoSurface.Style.SetBackgroundImage( tex );
		}

		if ( SeekFill.IsValid() && Player is not null && Player.MediaDuration > 0 )
		{
			SeekFill.Style.Width = Length.Percent( (Player.CurrentTime / Player.MediaDuration * 100.0f).Clamp( 0, 100 ) );
		}

		EnsureWorldInput();
		UpdateLook();
		UpdateVisibility();
		UpdateUse();
		UpdatePressDot();

		if ( FlashIcon is not null && sinceFlash > 0.8f )
			FlashIcon = null;
	}

	#region Looking at the screen

	void UpdateLook()
	{
		var wasLooking = IsLookedAt;
		IsLookedAt = TryGetLookPoint( out lookPoint );

		// Which button the crosshair is on - highlighted, and what USE presses
		Panel aimed = null;
		var previous = AimedControl;
		AimedControl = IsLookedAt && ControlsVisible ? FindControlAt( lookPoint, out aimed ) : null;
		aimedPanel = aimed;

		if ( MediaSettings.Debug && (AimedControl != previous || wasLooking != IsLookedAt) )
		{
			var layout = Panel.IsValid() ? LayoutPos( lookPoint ) : default;
			Log.Info( $"[bimp] {GameObject.Name}: looking={IsLookedAt} controls={ControlsVisible} aim={lookPoint:0} (layout {layout:0}, root {Panel?.Box.Rect}) -> {AimedControl ?? "nothing"}" +
				(aimed.IsValid() ? $" rect {aimed.Box.Rect}" : "") );
		}

		if ( AimedControl == "progress" && aimed.IsValid() && aimed.Box.Rect.Width > 0 )
			AimedFraction = ((LayoutPos( lookPoint ).x - aimed.Box.Rect.Left) / aimed.Box.Rect.Width).Clamp( 0, 1 );

		// No timeout while hovering a button - the countdown only starts once you move off it
		if ( AimedControl is not null )
			Activity();

		if ( !IsLookedAt )
		{
			if ( wasLooking ) sinceLookedAway = 0;
			return;
		}

		// Only (re)starting to look shows the controls. Moving around, or moving your aim across the
		// screen, while you keep looking doesn't - once they've timed out they stay hidden until you
		// look away (for at least LookAwayTime) and come back.
		if ( !wasLooking && sinceLookedAway >= LookAwayTime )
			Activity();
	}

	/// <summary>
	/// Where on the panel (in panel pixels, 0,0 top left) the local camera or mouse cursor points, if it's on the screen.
	/// </summary>
	bool TryGetLookPoint( out Vector2 point )
	{
		point = default;

		var camera = Scene.Camera;
		var worldPanel = Components.Get<Sandbox.WorldPanel>();
		if ( camera is null || worldPanel is null || !worldPanel.Enabled ) return false;
		if ( MediaRemote.IsOpen( Scene ) ) return false;

		var ray = Mouse.Active ? camera.ScreenPixelToRay( Mouse.Position ) : new Ray( camera.WorldPosition, camera.WorldRotation.Forward );
		var range = LookRange > 0 ? LookRange : worldPanel.InteractionRange;

		var plane = new Plane( WorldPosition, WorldRotation.Forward );
		if ( plane.Trace( ray, true, range ) is not Vector3 hit ) return false;

		// The panel's x runs along our local +Y, its y down our local -Z, in ScreenToWorldScale units
		var local = WorldTransform.PointToLocal( hit );
		var scale = Sandbox.UI.WorldPanel.ScreenToWorldScale;
		var p = new Vector2( local.y / scale, -local.z / scale );

		var size = worldPanel.PanelSize;
		var origin = Vector2.Zero;
		if ( worldPanel.HorizontalAlign == Sandbox.WorldPanel.HAlignment.Center ) origin.x = -size.x * 0.5f;
		if ( worldPanel.HorizontalAlign == Sandbox.WorldPanel.HAlignment.Right ) origin.x = -size.x;
		if ( worldPanel.VerticalAlign == Sandbox.WorldPanel.VAlignment.Center ) origin.y = -size.y * 0.5f;
		if ( worldPanel.VerticalAlign == Sandbox.WorldPanel.VAlignment.Bottom ) origin.y = -size.y;

		p -= origin;
		if ( p.x < 0 || p.y < 0 || p.x > size.x || p.y > size.y ) return false;

		if ( CheckLineOfSight && IsBlocked( ray.Position, hit ) ) return false;

		point = p;
		return true;
	}

	bool IsBlocked( Vector3 from, Vector3 to )
	{
		var dir = (to - from).Normal;
		var trace = Scene.Trace.Ray( from, to - dir * 4.0f ).IgnoreGameObjectHierarchy( GameObject.Root );

		// third person cameras look through the local player's own body
		var local = Scene.GetAllComponents<PlayerController>().FirstOrDefault( p => !p.IsProxy );
		if ( local.IsValid() ) trace = trace.IgnoreGameObjectHierarchy( local.GameObject );

		return trace.Run().Hit;
	}

	/// <summary> For probes: why the controls are (or aren't) showing. </summary>
	internal string DescribeControls() => $"looked={IsLookedAt} visible={ControlsVisible} aimed={AimedControl ?? "-"} sinceActivity={(float)sinceActivity:0.0} sinceChanged={(float)sinceChanged:0.0} paused={Player?.Paused} mouseActive={Mouse.Active} mouse={Mouse.Position:0} controller={Input.UsingController} virtualCursor={Input.EnableVirtualCursor} root=[{Panel?.Classes}] osd=" +
		string.Join( ", ", Panel?.Descendants.Where( p => p.HasClass( "osd" ) || p.HasClass( "meta" ) || p.HasClass( "title" ) || p.HasClass( "time" ) ).Select( p => $"{p.ElementName}.{string.Join( ".", p.Class )} opacity {p.ComputedStyle?.Opacity}" ) ?? Enumerable.Empty<string>() );

	void Activity()
	{
		sinceActivity = 0;
	}

	//
	// Aiming at the on-screen buttons (for pressing them with USE)
	//

	Vector2 lookPoint;

	/// <summary>
	/// The control under the local player's crosshair, if the controls are showing:
	/// "back", "playpause", "forward", "next", "stop" or "progress". Null otherwise.
	/// </summary>
	public string AimedControl { get; private set; }

	static readonly string[] ControlNames = { "back", "playpause", "forward", "next", "stop", "progress" };

	string AimedClass( string name ) => AimedControl == name ? "aimed" : "";

	/// <summary>
	/// Where along the progress bar the crosshair is (0-1), when aiming at it.
	/// </summary>
	public float AimedFraction { get; private set; }

	Panel PressDot { get; set; }
	Panel aimedPanel;

	/// <summary>
	/// Show the "press E" dot on the aimed control: its top-right corner for buttons, above the aimed spot
	/// for the progress bar. Positioned from the control's layout rect (screen space) converted back to
	/// our CSS pixels, so it's right at any PanelSize / RenderScale.
	/// </summary>
	void UpdatePressDot()
	{
		if ( !PressDot.IsValid() ) return;

		var root = Panel;
		if ( AimedControl is null || !aimedPanel.IsValid() || !root.IsValid() )
		{
			PressDot.Style.Display = DisplayMode.None;
			return;
		}

		var r = aimedPanel.Box.Rect;
		var onBar = AimedControl == "progress";
		var anchor = onBar ? new Vector2( LayoutPos( lookPoint ).x, r.Top ) : new Vector2( r.Right, r.Top );

		var local = (anchor - root.Box.Rect.Position) * root.ScaleFromScreen;
		if ( onBar ) local.y -= 40; // sit above the bar

		PressDot.Style.Display = DisplayMode.Flex;
		PressDot.Style.Left = Length.Pixels( local.x );
		PressDot.Style.Top = Length.Pixels( local.y );
	}

	/// <summary>
	/// A human readable name for what pressing USE on the aimed control does, for tooltips.
	/// </summary>
	public string AimedActionName => AimedControl switch
	{
		"back" => $"Back {SeekStep:0}s",
		"playpause" => Player?.Paused == true ? "Play" : "Pause",
		"forward" => $"Forward {SeekStep:0}s",
		"next" => "Next",
		"stop" => "Stop",
		"progress" => "Seek here",
		_ => null,
	};

	/// <summary>
	/// Find the control at a point on the panel (in PanelSize pixels, see TryGetLookPoint).
	/// <paramref name="control"/> is the control's panel.
	/// </summary>
	string FindControlAt( Vector2 point, out Panel control )
	{
		control = null;
		var root = Panel;
		if ( !root.IsValid() ) return null;

		var pos = LayoutPos( point );

		foreach ( var p in root.Descendants )
		{
			if ( !p.HasClass( "btn" ) && !p.HasClass( "progress" ) ) continue;
			if ( !p.IsVisible || !p.IsInside( pos ) ) continue;

			var name = ControlNames.FirstOrDefault( p.HasClass );
			if ( name is null ) continue;

			control = p;
			return name;
		}

		return null;
	}

	/// <summary>
	/// USE while looking at the screen: press the aimed button, else open the full remote.
	/// </summary>
	void UpdateUse()
	{
		if ( !IsLookedAt || Player is null || string.IsNullOrWhiteSpace( UseAction ) ) return;
		if ( !Input.Pressed( UseAction ) ) return;

		// A PlayerController in reach may also press our MediaInteract this frame - only act once
		if ( !UseHandled.TryConsume() ) return;

		if ( !TryPressAimedControl() )
			MediaRemote.Toggle( Player );
	}

	/// <summary>
	/// Press whatever on-screen control the local player is aiming at. Returns false if they aren't
	/// aiming at one (or the controls are hidden) - the caller then opens the full remote instead.
	/// </summary>
	public bool TryPressAimedControl()
	{
		if ( Player is null || !Player.HasMedia || !ControlsVisible ) return false;
		if ( !TryGetLookPoint( out var point ) ) return false;

		var name = FindControlAt( point, out var control );
		if ( name is null ) return false;

		switch ( name )
		{
			case "back": SeekBack(); break;
			case "playpause": TogglePause(); break;
			case "forward": SeekForward(); break;
			case "next": Next(); break;
			case "stop": Stop(); break;
			case "progress":
				Activity();
				if ( Player.CanLocalControl && Player.MediaDuration > 0 )
				{
					var pos = LayoutPos( point );
					var frac = ((pos.x - control.Box.Rect.Left) / control.Box.Rect.Width).Clamp( 0, 1 );
					Flash( "fast_forward", MediaSource.FormatTime( frac * Player.MediaDuration ) );
					Player.RequestSeek( frac * Player.MediaDuration );
				}
				break;
		}

		return true;
	}

	/// <summary>
	/// PanelSize pixels -> the panel's layout coordinates. Going through the root panel's own rect keeps this
	/// right whatever the RenderScale (FitToPanel) or alignment is.
	/// </summary>
	Vector2 LayoutPos( Vector2 point )
	{
		var size = Components.Get<Sandbox.WorldPanel>()?.PanelSize ?? Vector2.One;
		var rect = Panel.Box.Rect;
		return rect.Position + new Vector2( point.x / size.x * rect.Width, point.y / size.y * rect.Height );
	}

	void UpdateVisibility()
	{
		var hasMedia = Player?.HasMedia ?? false;
		var recentlyActive = HideControlsAfter <= 0 ? IsLookedAt : sinceActivity < HideControlsAfter;

		ControlsVisible = hasMedia && (
			(IsLookedAt && recentlyActive) ||
			(ShowControlsWhilePaused && Player.Paused) ||
			sinceChanged < 4 );
	}

	// A click while the controls are up keeps them up for another HideControlsAfter. Clicks on a screen whose
	// controls have timed out don't bring them back (the hidden controls don't take clicks either).
	protected override void OnMouseDown( MousePanelEvent e )
	{
		if ( ControlsVisible ) Activity();
	}

	void EnsureWorldInput()
	{
		if ( !AddWorldInput || Scene.Camera is null ) return;
		if ( Scene.GetAllComponents<Sandbox.WorldInput>().Any() ) return;

		var input = Scene.Camera.GameObject.Components.Create<Sandbox.WorldInput>();
		input.Flags |= ComponentFlags.NotSaved | ComponentFlags.NotNetworked;
	}

	#endregion

	#region Controls

	bool CanAct()
	{
		Activity();
		if ( Player is null || !Player.HasMedia ) return false;
		if ( Player.CanLocalControl ) return true;

		Flash( "lock", "No permission" );
		return false;
	}

	void TogglePause()
	{
		if ( !CanAct() || Player.IsLive ) return;
		Flash( Player.Paused ? "play_arrow" : "pause" );
		Player.RequestSetPaused( !Player.Paused );
	}

	void SeekBack()
	{
		if ( !CanAct() || Player.IsLive ) return;
		Flash( "replay_10", $"-{SeekStep:0}s" );
		Player.RequestSeek( Player.CurrentTime - SeekStep );
	}

	void SeekForward()
	{
		if ( !CanAct() || Player.IsLive ) return;
		Flash( "forward_10", $"+{SeekStep:0}s" );
		Player.RequestSeek( Player.CurrentTime + SeekStep );
	}

	void Next()
	{
		if ( !CanAct() ) return;
		Flash( "skip_next", Player.Queue.Count > 0 ? "Next" : "End of queue" );
		Player.RequestSkip();
	}

	void Stop()
	{
		if ( !CanAct() ) return;
		Flash( "stop" );
		Player.RequestStop();
	}

	void Flash( string icon, string text = null )
	{
		FlashIcon = icon;
		FlashText = text;
		flashId++;
		sinceFlash = 0;
	}

	void OnSeekBarClick( PanelEvent e )
	{
		if ( !ControlsVisible ) return;
		Activity();
		if ( Player is null || !Player.CanLocalControl || Player.MediaDuration <= 0 ) return;
		if ( e is not MousePanelEvent me || !SeekBar.IsValid() ) return;

		var width = SeekBar.Box.Rect.Width;
		if ( width <= 0 ) return;

		var frac = (me.LocalPosition.x / width).Clamp( 0, 1 );
		Player.RequestSeek( frac * Player.MediaDuration );
	}

	#endregion

	/// <summary> The caption line(s) to show now, null for none (see <see cref="MediaPlayer.CurrentCaption"/>). </summary>
	string CaptionText => Player?.CurrentCaption;

	protected override int BuildHash()
	{
		if ( Player is null ) return 0;

		return HashCode.Combine(
			HashCode.Combine( Player.PlayId, Player.HasMedia, Player.Title, Player.Paused, Player.Status, Player.LocalNotice, Player.Queue.Count ),
			HashCode.Combine( (int)Player.CurrentTime, Player.MediaDuration, Player.Backend?.Loaded, Player.Backend?.HasVideo, Player.CanLocalControl, RootClass ),
			HashCode.Combine( FlashIcon, flashId, AimedControl, UseAction, CaptionText )
		);
	}
}
@using System;
@using Sandbox;
@using Sandbox.UI;
@inherits PanelComponent

<root>
    <div class="crosshair" style="
		position: absolute;
		left: @( IsPercentage ? $"{Position.x}%"  : Position.x );
		top: @( IsPercentage ? $"{Position.y}%" : Position.y );
		transform: translate(-50%, -50%);
	">
		<div class="center-dot-border-wrapper" style="
			display: @(CenterDot ? "flex" : "none");
			border: @(Outline ? $"{OutlineThickness}px solid rgba(0, 0, 0, {OutlineOpacity})" : "none");
			z-index: 100;
		">
			<div class="center-dot" style="
				background-color: rgba(@(Color.r * 255), @(Color.g * 255), @(Color.b * 255), @(CenterDotOpacity));
				padding: @(CenterDotThickness)px;
			"></div>
		</div>
		<div class="inner-top-border-wrapper" style="
			position: absolute;
    		top: -@(InnerLinesOffset)px;
    		left: 50%;
    		transform: translateX(-50%);
			display:@(ShowInnerLines ? "flex" : "none");
			border: @(Outline ? $"{OutlineThickness}px solid rgba(0, 0, 0, {OutlineOpacity})" : "none");
		">
			<div class="inner-line top" style="
				background-color: rgba(@(Color.r * 255), @(Color.g * 255), @(Color.b * 255), @(InnerLineOpacity));
				padding-left: @(InnerLineThickness)px;
    			padding-top: @(InnerLineLenght)px;
			"></div>
		</div>
		<div class="inner-bottom-border-wrapper" style="
			position: absolute;
    		bottom: -@(InnerLinesOffset)px;
    		left: 50%;
    		transform: translateX(-50%);
			display:@(ShowInnerLines ? "flex" : "none");
			border: @(Outline ? $"{OutlineThickness}px solid rgba(0, 0, 0, {OutlineOpacity})" : "none");
		">
			<div class="inner-line bottom" style="
				background-color: rgba(@(Color.r * 255), @(Color.g * 255), @(Color.b * 255), @(InnerLineOpacity));
				padding-left: @(InnerLineThickness)px;
    			padding-bottom: @(InnerLineLenght)px;
			"></div>
		</div>
		<div class="inner-left-border-wrapper" style="
			position: absolute;
			left: -@(InnerLinesOffset)px;
			top: 50%;
			transform: translateY(-50%);
			display:@(ShowInnerLines ? "flex" : "none");
			border: @(Outline ? $"{OutlineThickness}px solid rgba(0, 0, 0, {OutlineOpacity})" : "none");
		">
			<div class="inner-line left" style="
				background-color: rgba(@(Color.r * 255), @(Color.g * 255), @(Color.b * 255), @(InnerLineOpacity));
				padding-top: @(InnerLineThickness)px;
				padding-left: @(InnerLineLenght)px;
			"></div>
		</div>
		<div class="inner-right-border-wrapper" style="
			position: absolute;
			right: -@(InnerLinesOffset)px;
			top: 50%;
			transform: translateY(-50%);
			display: @(ShowInnerLines ? "flex" : "none");
			border: @(Outline ? $"{OutlineThickness}px solid rgba(0, 0, 0, {OutlineOpacity})" : "none");
		">
			<div class="inner-line right" style="
				background-color: rgba(@(Color.r * 255), @(Color.g * 255), @(Color.b * 255), @(InnerLineOpacity));
				padding-top: @(InnerLineThickness)px;
				padding-right: @(InnerLineLenght)px;
			"></div>
		</div>
		<div class="outer-top-border-wrapper" style="
			position: absolute;
			top: -@(OuterLineOffset)px;
			left: 50%;
			transform: translateX(-50%);
			display: @(ShowOuterLines ? "flex" : "none");
			border: @(Outline ? $"{OutlineThickness}px solid rgba(0, 0, 0, {OutlineOpacity})" : "none");
		">
			<div class="outer-line top" style="
				background-color: rgba(@(Color.r * 255), @(Color.g * 255), @(Color.b * 255), @(OuterLineOpacity));
				padding-left: @(OuterLineThickness)px;
				padding-top: @(OuterLineLenght)px;
			"></div>
		</div>
		<div class="outer-bottom-border-wrapper" style="
			position: absolute;
			bottom: -@(OuterLineOffset)px;
			left: 50%;
			transform: translateX(-50%);
			display: @(ShowOuterLines ? "flex" : "none");
			border: @(Outline ? $"{OutlineThickness}px solid rgba(0, 0, 0, {OutlineOpacity})" : "none");
		">
			<div class="outer-line bottom" style="
				background-color: rgba(@(Color.r * 255), @(Color.g * 255), @(Color.b * 255), @(OuterLineOpacity));
				padding-left: @(OuterLineThickness)px;
				padding-bottom: @(OuterLineLenght)px;
			"></div>
		</div>
		<div class="outer-left-border-wrapper" style="
			position: absolute;
			left: -@(OuterLineOffset)px;
			top: 50%;
			transform: translateY(-50%);
			display: @(ShowOuterLines ? "flex" : "none");
			border: @(Outline ? $"{OutlineThickness}px solid rgba(0, 0, 0, {OutlineOpacity})" : "none");
		">
			<div class="outer-line left" style="
				background-color: rgba(@(Color.r * 255), @(Color.g * 255), @(Color.b * 255), @(OuterLineOpacity));
				padding-top: @(OuterLineThickness)px;
				padding-left: @(OuterLineLenght)px;
			"></div>
		</div>
		<div class="outer-right-border-wrapper" style="
			position: absolute;
			right: -@(OuterLineOffset)px;
			top: 50%;
			transform: translateY(-50%);
			display: @(ShowOuterLines ? "flex" : "none");
			border: @(Outline ? $"{OutlineThickness}px solid rgba(0, 0, 0, {OutlineOpacity})" : "none");
		">
			<div class="outer-line right" style="
				background-color: rgba(@(Color.r * 255), @(Color.g * 255), @(Color.b * 255), @(OuterLineOpacity));
				padding-top: @(OuterLineThickness)px;
				padding-right: @(OuterLineLenght)px;
			"></div>
		</div>
    </div>
</root>

@code
{
	// Position properties
	[Property] 
	[Category("Position")] public bool IsPercentage { get; set; } = true;
	[Property]
	[Category("Position")] public Vector2 Position { get; set; } = new Vector2(50, 50);

	// Crosshair properties
	[Property]
	[Category("Crosshair")] public Color Color { get; set; } = Color.FromRgb(0x2EFF00);
	[Property]
	[Category("Crosshair")] public bool Outline { get; set; } = true;
	[Property]
	[Range(0f, 1f, 0.001f)]
	[Category("Crosshair")] public float OutlineOpacity { get; set; } = 1f;
	[Property]
	[Range(1, 10, 1)]
	[Category("Crosshair")] public int OutlineThickness { get; set; } = 2;
	[Property]
	[Category("Crosshair")] public bool CenterDot { get; set; } = true;
	[Property]
	[Range(0f, 1f, 0.001f)]
	[Category("Crosshair")] public float CenterDotOpacity { get; set; } = 1f;
	[Property]
	[Range(1, 10, 1)]
	[Category("Crosshair")] public int CenterDotThickness { get; set; } = 1;

	// Inner Lines properties
	[Property]
	[Category("Inner Lines")] public bool ShowInnerLines { get; set; } = true;
	[Property]
	[Range(0f, 1f, 0.001f)]
	[Category("Inner Lines")] public float InnerLineOpacity { get; set; } = 1f;
	[Property]
	[Range(0, 20, 1)]
	[Category("Inner Lines")] public int InnerLineLenght { get; set; } = 10;
	[Property]
	[Range(0, 10, 1)]
	[Category("Inner Lines")] public int InnerLineThickness { get; set; } = 1;
	[Property]
	[Range(0, 20, 1)]
	[Category("Inner Lines")] public int InnerLinesOffset { get; set; } = 2;

	// Outer Lines properties
	[Property]
	[Category("Outer Lines")] public bool ShowOuterLines { get; set; } = false;
	[Property]
	[Range(0f, 1f, 0.001f)]
	[Category("Outer Lines")] public float OuterLineOpacity { get; set; } = 0.5f;
	[Property]
	[Range(0, 20, 1)]
	[Category("Outer Lines")] public int OuterLineLenght { get; set; }
	[Property]
	[Range(0, 10, 1)]
	[Category("Outer Lines")] public int OuterLineThickness { get; set; }
	[Property]
	[Range(0, 20, 1)]
	[Category("Outer Lines")] public int OuterLineOffset { get; set; }

	// Code properties
	[Property]
	[Category("Code")] public string Code { get; set; }

	// Member variables
	private string _cachedCode = null;

	public string EncodeCrosshairParameters()
	{
		// Convert boolean values to 1 or 0
		string EncodeBool(bool value) => value ? "1" : "0";
		// Convert float values to a shortened string representation (up to 2 decimal places)
		string EncodeFloat(float value) => Math.Round(value, 2).ToString("0.##");
		// Convert integers directly to string
		string EncodeInt(int value) => value.ToString();

		// Concatenate all parameters into a shortened format
		var parameters = $"{EncodeBool(IsPercentage)}," +
							$"{EncodeFloat(Position.x)},{EncodeFloat(Position.y)}," +
							$"{EncodeFloat(Color.r)},{EncodeFloat(Color.g)},{EncodeFloat(Color.b)}," +
							$"{EncodeBool(Outline)},{EncodeFloat(OutlineOpacity)},{EncodeInt(OutlineThickness)}," +
							$"{EncodeBool(CenterDot)},{EncodeFloat(CenterDotOpacity)},{EncodeInt(CenterDotThickness)}," +
							$"{EncodeBool(ShowInnerLines)},{EncodeFloat(InnerLineOpacity)},{EncodeInt(InnerLineLenght)}," +
							$"{EncodeInt(InnerLineThickness)},{EncodeInt(InnerLinesOffset)}," +
							$"{EncodeBool(ShowOuterLines)},{EncodeFloat(OuterLineOpacity)},{EncodeInt(OuterLineLenght)}," +
							$"{EncodeInt(OuterLineThickness)},{EncodeInt(OuterLineOffset)}";
		return parameters;
	}

	public void DecodeCrosshairParameters(string encodedString)
	{
		var parameters = encodedString.Split(',');

		// Convert "1" or "0" back to boolean
		bool DecodeBool(string value) => value == "1";
		// Convert string back to float
		float DecodeFloat(string value) => float.Parse(value);
		// Convert string back to int
		int DecodeInt(string value) => int.Parse(value);

		// Assign the decoded values back to the properties
		IsPercentage = DecodeBool(parameters[0]);
		Position = new Vector2(DecodeFloat(parameters[1]), DecodeFloat(parameters[2]));
		Color = new Color(DecodeFloat(parameters[3]), DecodeFloat(parameters[4]), DecodeFloat(parameters[5]));
		Outline = DecodeBool(parameters[6]);
		OutlineOpacity = DecodeFloat(parameters[7]);
		OutlineThickness = DecodeInt(parameters[8]);
		CenterDot = DecodeBool(parameters[9]);
		CenterDotOpacity = DecodeFloat(parameters[10]);
		CenterDotThickness = DecodeInt(parameters[11]);
		ShowInnerLines = DecodeBool(parameters[12]);
		InnerLineOpacity = DecodeFloat(parameters[13]);
		InnerLineLenght = DecodeInt(parameters[14]);
		InnerLineThickness = DecodeInt(parameters[15]);
		InnerLinesOffset = DecodeInt(parameters[16]);
		ShowOuterLines = DecodeBool(parameters[17]);
		OuterLineOpacity = DecodeFloat(parameters[18]);
		OuterLineLenght = DecodeInt(parameters[19]);
		OuterLineThickness = DecodeInt(parameters[20]);
		OuterLineOffset = DecodeInt(parameters[21]);
	}

	protected override void OnStart()
	{
		if (Code == null)
		{
			Code = EncodeCrosshairParameters();
		}
		DecodeCrosshairParameters(Code);
		_cachedCode = Code;
	}

	protected override void OnUpdate()
	{
		if (Code != _cachedCode)
		{
			DecodeCrosshairParameters(Code);
		}
		Code = EncodeCrosshairParameters();
		_cachedCode = Code;
	}

	/// <summary>
	/// the hash determines if the system should be rebuilt. If it changes, it will be rebuilt
	/// </summary>
	protected override int BuildHash()
	{
		var positionHash = System.HashCode.Combine( 
			IsPercentage,
			Position
		);

		var crosshairHash = System.HashCode.Combine( 
			Color,
			Outline,
			OutlineOpacity,
			OutlineThickness,
			CenterDot,
			CenterDotOpacity,
			CenterDotThickness
		);

		var innerLinesHash = System.HashCode.Combine(
			ShowInnerLines,
			InnerLineOpacity,
			InnerLineLenght,
			InnerLineThickness,
			InnerLinesOffset
		);

		var outerLinesHash = System.HashCode.Combine(
			ShowOuterLines,
			OuterLineOpacity,
			OuterLineLenght,
			OuterLineThickness,
			OuterLineOffset
		);

		var codeHash = Code.GetHashCode();
		
		return System.HashCode.Combine(
			positionHash,
			crosshairHash,
			innerLinesHash,
			outerLinesHash,
			codeHash
		);
	}
}
@using System.Collections.Generic;
@using System.Linq;
@using Sandbox;
@using Sandbox.UI;
@namespace TwitchAPI.Examples

<root>
    <div class="username">
        <div class="badges">
            @foreach (var badge in ChatMessage.User.Badges)
            {
                <div class="badge @badge" />
            }
        </div>
        @if (HasAvatarImages)
        {
            <img class="avatar" [email protected]() />
        }
        <label class="name" style="color: @ChatMessage.User.Color.Hex">@ChatMessage.Username</label>
        <label>:</label>
    </div>
    <div class="message">
        @foreach (var fragment in Fragments)
        {
            if (fragment.Emote is not null)
            {
                <img class="emote" src="@fragment.Emote" />
            }
            else
            {
                <label>@fragment.Text</label>
            }
        }
    </div>
</root>

@code
{
    public TwitchChatMessage ChatMessage { get; set; }
    public float Lifetime { get; set; } = 10f;
    public float FadeTime { get; set; } = 0.5f;
    public bool HasAvatarImages { get; set; } = false;
    List<MessageFragment> Fragments { get; set; } = new();

    TimeSince TimeSinceCreated = 0f;

    record MessageFragment(string Text, string Emote);

    protected override void OnParametersSet()
    {
        base.OnParametersSet();

        Fragments.Clear();
        var text = ChatMessage.Message;
        var emotes = ChatMessage.Emotes;
        var currentEmote = emotes.FirstOrDefault();
        var currentIndex = 0;
        while (currentEmote is not null)
        {
            var textPrior = text.Substring(currentIndex, currentEmote.StartingCharacter - currentIndex);
            if (!string.IsNullOrWhiteSpace(textPrior))
            {
                foreach (var word in textPrior.Split(' '))
                {
                    if (string.IsNullOrWhiteSpace(word)) continue;
                    Fragments.Add(new MessageFragment(word, null));
                }
            }

            var emoteText = text.Substring(currentEmote.StartingCharacter, currentEmote.EndingCharacter - currentEmote.StartingCharacter + 1);
            Fragments.Add(new MessageFragment(emoteText, currentEmote.GetImageUrl()));

            currentIndex = currentEmote.EndingCharacter + 1;
            currentEmote = emotes.FirstOrDefault(e => e.StartingCharacter >= currentIndex);
        }

        var textAfter = text.Substring(currentIndex);
        if (!string.IsNullOrWhiteSpace(textAfter))
        {
            foreach (var word in textAfter.Split(' '))
            {
                if (string.IsNullOrWhiteSpace(word)) continue;
                Fragments.Add(new MessageFragment(word, null));
            }
        }
    }

    public override void Tick()
    {
        if (TimeSinceCreated >= Lifetime)
        {
            var opacity = 1f - ((TimeSinceCreated - Lifetime) / FadeTime);
            if (opacity <= 0f)
            {
                Delete();
            }
            else
            {
                Style.Opacity = opacity;
            }
        }
    }
}
@using Sandbox;
@inherits PanelComponent

@if (InSequence)
{
	<root>
		<div class="Main">
			<div style="opacity: @Opacity">
				<img src="@CurrentEntry.TexturePath" style="transform: scale(@Scale);">
			</div>
		</div>
	</root>
}
@using Sandbox;
@using Sandbox.UI;
@inherits PanelComponent
@namespace Sandbox

<root>
	<div class="title">@MyStringValue</div>
</root>

@code
{

	[Property, TextArea] public string MyStringValue { get; set; } = "Hello World!";
	protected override int BuildHash() => System.HashCode.Combine( MyStringValue );
}
@using System
@using System.Collections.Generic
@using System.Linq
@using Sandbox
@using Sandbox.UI
@namespace AdminSuite.UI
@inherits Panel

<root class="tab settings-tab">
	@if ( Sys is null )
	{
		<label class="empty">Admin system not available.</label>
	}
	else
	{
		<div class="scroll">
			<div class="card">
				<div class="section-title"><i>keyboard</i><label>Menu Key (this client only)</label></div>
				<div class="action-row">
					<TextEntry class="input" Value:bind=@KeyName />
					<div class="btn accent" onclick=@( () => ApplyKey() )><i>save</i><label>Apply</label></div>
				</div>
				<label class="explain">Raw key name, e.g. f4, f6, f7, insert, home. Function keys are recommended so typing in chat can't trigger the menu. Console alternative: adminsuite_key f7</label>
			</div>

			@if ( Sys.LocalHas( AdminPerm.ServerSettings ) )
			{
				<div class="card">
					<div class="section-title"><i>dns</i><label>Server Settings</label></div>

					<div class="field">
						<label class="key">Default rank for new players</label>
						<div class="rank-pills">
							@foreach ( var rank in Sys.ClientRanks.Where( r => !r.HasAll ).OrderBy( r => r.Priority ) )
							{
								<div class="pill @(DefaultRankId == rank.Id ? "selected" : "")" onclick=@( () => SetDefault( rank.Id ) )>
									<div class="dot" style="background-color: @rank.Color"></div>
									<label>@rank.Name</label>
								</div>
							}
						</div>
					</div>

					<div class="perm-row">
						<div class="meta">
							<label class="name">Announce admin actions to everyone</label>
							<label class="sub">When off, only staff with menu access see kick/ban/cleanup notices.</label>
						</div>
						<div class="toggle @(Announce ? "on" : "")" onclick=@( () => Announce = !Announce )>
							<i>@(Announce ? "check" : "")</i>
						</div>
					</div>

					<div class="perm-row">
						<div class="meta">
							<label class="name">Auto-grant listen host Super Admin</label>
							<label class="sub">The player hosting a listen server automatically becomes Super Admin.</label>
						</div>
						<div class="toggle @(AutoGrant ? "on" : "")" onclick=@( () => AutoGrant = !AutoGrant )>
							<i>@(AutoGrant ? "check" : "")</i>
						</div>
					</div>

					<div class="form-actions">
						<div class="btn accent" onclick=@( () => SaveServer() )><i>save</i><label>Save Server Settings</label></div>
					</div>

					<label class="explain">Owner SteamIDs (accounts that are always Super Admin) are deliberately only editable in the server's data file: adminsuite/config.json.</label>
				</div>
			}

			<div class="card">
				<div class="section-title"><i>info</i><label>About</label></div>
				<label class="explain">Admin Suite — a ULX/XGUI-inspired admin menu for s&box. All rank, ban and config data lives on the server and survives restarts. Every action is validated server-side; the client UI is presentation only.</label>
			</div>
		</div>
	}
</root>

@code
{
	private AdminSystem Sys => AdminSystem.Instance;

	public string KeyName { get; set; } = "";
	public string DefaultRankId { get; set; } = "";
	public bool Announce { get; set; } = true;
	public bool AutoGrant { get; set; } = true;

	protected override void OnAfterTreeRender( bool firstTime )
	{
		base.OnAfterTreeRender( firstTime );
		if ( firstTime ) Seed();
	}

	private void Seed()
	{
		KeyName = AdminClientPrefs.Current.MenuKey;

		if ( Sys is null ) return;
		var cfg = Sys.ClientConfig;
		DefaultRankId = cfg.DefaultRankId;
		Announce = cfg.AnnounceActions;
		AutoGrant = cfg.AutoGrantHostSuperAdmin;
	}

	private void SetDefault( string rankId )
	{
		DefaultRankId = rankId;
		StateHasChanged();
	}

	private void ApplyKey()
	{
		var key = KeyName?.Trim().ToLowerInvariant();
		if ( string.IsNullOrEmpty( key ) )
		{
			AdminMenu.Instance?.Toast( "Enter a key name first, e.g. f6.", 2 );
			return;
		}

		AdminClientPrefs.Current.MenuKey = key;
		AdminClientPrefs.Save();
		AdminMenu.Instance?.Toast( $"Menu key set to {key.ToUpperInvariant()}.", 1 );
	}

	private void SaveServer()
	{
		if ( Sys is null || string.IsNullOrEmpty( DefaultRankId ) ) return;
		Sys.RequestUpdateConfig( DefaultRankId, Announce, AutoGrant );
	}

	protected override int BuildHash() =>
		HashCode.Combine( Sys?.StateHash ?? 0, DefaultRankId, Announce, AutoGrant );
}
@using System
@using System.Collections.Generic
@using System.Linq
@using Sandbox
@using Sandbox.UI
@namespace AdminSuite.UI
@inherits Panel

<root class="tab players-tab">
	@if ( Sys is null )
	{
		<label class="empty">Admin system not available.</label>
	}
	else
	{
		<div class="column list">
			<div class="section-title"><i>group</i><label>Online Players (@Connection.All.Count)</label></div>
			<div class="scroll">
				@foreach ( var c in Connection.All.OrderByDescending( x => Sys.DisplayPriorityOf( x.SteamId ) ).ThenBy( x => x.DisplayName ) )
				{
					var rank = Sys.GetDisplayRank( c.SteamId );
					<div class="row player @(Selected == c.SteamId ? "selected" : "")" onclick=@( () => Select( c.SteamId ) )>
						<div class="avatar" style="background-color: @rank.Color"><label>@Initial( c.DisplayName )</label></div>
						<div class="meta">
							<label class="name">@c.DisplayName</label>
							<label class="sub">@c.SteamId</label>
						</div>
						<div class="badge" style="background-color: @rank.Color">@rank.Name</div>
					</div>
				}
			</div>
		</div>

		<div class="column detail">
			@{ var target = Connection.All.FirstOrDefault( x => x.SteamId == Selected ); }
			@if ( target is null )
			{
				<div class="placeholder"><i>touch_app</i><label>Select a player on the left</label></div>
			}
			else
			{
				var targetRank = Sys.GetDisplayRank( target.SteamId );
				var canTarget = Sys.LocalCanTarget( target.SteamId );

				<div class="detail-header">
					<div class="avatar big" style="background-color: @targetRank.Color"><label>@Initial( target.DisplayName )</label></div>
					<div class="meta">
						<label class="name">@target.DisplayName</label>
						<label class="sub">SteamID64: @target.SteamId</label>
					</div>
					<div class="badge" style="background-color: @targetRank.Color">@targetRank.Name</div>
				</div>

				if ( !canTarget )
				{
					<div class="notice"><i>lock</i><label>This player's rank is equal to or higher than yours — you can only view them.</label></div>
				}

				<div class="section-title"><i>military_tech</i><label>Rank</label></div>
				<div class="rank-buttons">
					@foreach ( var rank in Sys.ClientRanks.OrderByDescending( r => r.Priority ) )
					{
						var isCurrent = targetRank.Id == rank.Id;
						var canAssign = !isCurrent && Sys.LocalCanAssign( rank, target.SteamId );
						<div class="btn rank @(isCurrent ? "current" : "") @(canAssign || isCurrent ? "" : "disabled")"
							onclick=@( () => { if ( canAssign ) Sys.RequestSetRank( target.SteamId, rank.Id ); } )>
							<div class="dot" style="background-color: @rank.Color"></div>
							<label>@rank.Name</label>
							@if ( isCurrent ) { <i>check</i> }
						</div>
					}
				</div>

				<div class="section-title"><i>gavel</i><label>Actions</label></div>

				if ( Sys.LocalHas( AdminPerm.PropsCleanPlayer ) )
				{
					<div class="action-row">
						<div class="btn accent @(canTarget ? "" : "disabled")"
							onclick=@( () => { if ( canTarget ) Sys.RequestCleanPlayerProps( target.SteamId ); } )>
							<i>cleaning_services</i><label>Clean This Player's Props</label>
						</div>
					</div>
				}

				if ( Sys.LocalHas( AdminPerm.PlayersKick ) )
				{
					<div class="action-row">
						<TextEntry class="input grow" Value:bind=@KickReason Placeholder="Kick reason" />
						<div class="btn warn @(canTarget ? "" : "disabled")" onclick=@( () => DoKick( target ) )>
							<i>logout</i><label>Kick</label>
						</div>
					</div>
				}

				if ( Sys.LocalHas( AdminPerm.PlayersBan ) )
				{
					<div class="action-row">
						<TextEntry class="input grow" Value:bind=@BanReason Placeholder="Ban reason" />
						<TextEntry class="input minutes" Value:bind=@BanMinutes Placeholder="Min" />
						<div class="btn danger @(canTarget ? "" : "disabled")" onclick=@( () => DoBan( target ) )>
							<i>block</i><label>Ban</label>
						</div>
					</div>
					<label class="explain">Ban duration in minutes — 0 or empty means permanent.</label>
				}
			}
		</div>
	}
</root>

@code
{
	private AdminSystem Sys => AdminSystem.Instance;

	public ulong Selected { get; set; }
	public string KickReason { get; set; } = "";
	public string BanReason { get; set; } = "";
	public string BanMinutes { get; set; } = "0";

	private void Select( ulong steamId )
	{
		Selected = steamId;
		StateHasChanged();
	}

	private static string Initial( string name ) =>
		string.IsNullOrEmpty( name ) ? "?" : name[..1].ToUpperInvariant();

	private void DoKick( Connection target )
	{
		if ( target is null || Sys is null || !Sys.LocalCanTarget( target.SteamId ) ) return;
		Sys.RequestKick( target.SteamId, KickReason );
		KickReason = "";
	}

	private void DoBan( Connection target )
	{
		if ( target is null || Sys is null || !Sys.LocalCanTarget( target.SteamId ) ) return;
		var minutes = int.TryParse( BanMinutes, out var m ) ? Math.Max( 0, m ) : 0;
		Sys.RequestBan( target.SteamId, BanReason, minutes );
		BanReason = "";
	}

	protected override int BuildHash() =>
		HashCode.Combine( Sys?.StateHash ?? 0, Selected, Connection.All.Count, (int)RealTime.Now );
}
@using System
@using System.Collections.Generic
@using System.Linq
@using Sandbox
@using Sandbox.UI
@namespace AdminSuite.UI
@inherits Panel

<root class="tab permissions-tab">
	@if ( Sys is null )
	{
		<label class="empty">Admin system not available.</label>
	}
	else
	{
		<div class="section-title"><i>key</i><label>Rank Permissions</label></div>

		<div class="rank-pills">
			@foreach ( var rank in Sys.ClientRanks.OrderByDescending( r => r.Priority ) )
			{
				<div class="pill @(SelectedId == rank.Id ? "selected" : "")" onclick=@( () => Select( rank.Id ) )>
					<div class="dot" style="background-color: @rank.Color"></div>
					<label>@rank.Name</label>
				</div>
			}
		</div>

		var selected = Sys.ClientRanks.FirstOrDefault( r => r.Id == SelectedId );
		if ( selected is null )
		{
			<div class="placeholder"><i>touch_app</i><label>Pick a rank above to view or edit its permissions</label></div>
		}
		else
		{
			var locked = !Sys.LocalHas( AdminPerm.RanksManage )
				|| !Sys.LocalCanEditRank( selected )
				|| selected.Id == AdminDefaults.SuperAdminId;

			if ( selected.Id == AdminDefaults.SuperAdminId )
			{
				<div class="notice"><i>verified_user</i><label>Super Admin always has every permission — this can't be changed.</label></div>
			}
			else if ( locked )
			{
				<div class="notice"><i>lock</i><label>You can't edit this rank's permissions.</label></div>
			}

			if ( Sys.LocalHasAll && selected.Id != AdminDefaults.SuperAdminId && !locked )
			{
				<div class="perm-row master">
					<div class="meta">
						<label class="name">All Permissions (*)</label>
						<label class="sub">Grants every current and future permission — effectively a second Super Admin tier.</label>
					</div>
					<div class="toggle @(selected.HasAll ? "on" : "")" onclick=@( () => ToggleAll( selected ) )>
						<i>@(selected.HasAll ? "check" : "")</i>
					</div>
				</div>
			}

			<div class="scroll perms">
				@foreach ( var group in AdminPerm.ByCategory )
				{
					<div class="section-title"><label>@group.Key</label></div>
					foreach ( var perm in group )
					{
						var granted = selected.Has( perm.Id );
						<div class="perm-row @(selected.HasAll ? "implied" : "")">
							<div class="meta">
								<label class="name">@perm.Name</label>
								<label class="sub">@perm.Description</label>
							</div>
							<div class="toggle @(granted ? "on" : "") @(locked || selected.HasAll ? "locked" : "")"
								onclick=@( () => { if ( !locked && !selected.HasAll ) Toggle( selected, perm.Id ); } )>
								<i>@(granted ? "check" : "")</i>
							</div>
						</div>
					}
				}
			</div>
		}
	}
</root>

@code
{
	private AdminSystem Sys => AdminSystem.Instance;

	public string SelectedId { get; set; } = "";

	private void Select( string rankId )
	{
		SelectedId = rankId;
		StateHasChanged();
	}

	private void Toggle( AdminRank rank, string permId )
	{
		if ( Sys is null ) return;

		var perms = new List<string>( rank.Permissions );
		if ( !perms.Remove( permId ) ) perms.Add( permId );

		Sys.RequestSetRankPermissions( rank.Id, Json.Serialize( perms ) );
	}

	private void ToggleAll( AdminRank rank )
	{
		if ( Sys is null ) return;

		var perms = new List<string>( rank.Permissions );
		if ( !perms.Remove( AdminPerm.All ) ) perms.Add( AdminPerm.All );

		Sys.RequestSetRankPermissions( rank.Id, Json.Serialize( perms ) );
	}

	protected override int BuildHash() =>
		HashCode.Combine( Sys?.StateHash ?? 0, SelectedId );
}
@using System
@using System.Threading.Tasks
@using Sandbox
@using Sandbox.UI
@inherits WebPanel

<style>
	/* .scss files don't work in libraries. https://github.com/Facepunch/sbox-issues/issues/4813 */
	prompt {
		width: 100%;
		pointer-events: all;
	}
</style>

@code {
	public readonly TaskCompletionSource<bool> Purchased;
	
	public Prompt( string URL )
	{
		Url = URL;
		Purchased = new TaskCompletionSource<bool>();

		if ( !Game.ActiveScene.Components.TryGet( out ScreenPanel panel ) )
		{
			panel = Game.ActiveScene.Components.Create<ScreenPanel>();
			panel.GetPanel().ElementName = "monetization";
			panel.ZIndex = int.MaxValue;
		}

		Parent = panel.GetPanel();
	}
	
	public override void OnDeleted()
	{
		if ( !Game.ActiveScene.Components.TryGet( out ScreenPanel panel ) )
		{
			return;
		}
		
		if ( panel.GetPanel().HasChildren )
		{
			return;
		}
		
		panel.Destroy();
	}

	protected override void OnAfterTreeRender( bool firstTime )
	{
		// This was the only way I could think of to get the
		// Steam browser to send data back data to the game.
		// If you have a better way let me know.
		
		switch ( Surface.PageTitle )
		{
			case "finished": Delete();
				break;
			case "purchased": Purchased.TrySetResult( true );
				break;
			case "cancelled": Purchased.TrySetResult( false );
				break;
		}
	}
	
	protected override int BuildHash() => HashCode.Combine( Surface.PageTitle );
}
@using Sandbox.UI;
@namespace Duccsoft
@inherits Panel

<root class=@OverlayClass>
    <image id="spinner" src="ui/img/BufferingCircle.png"/>
    <div id="bottomBar">
        <div class="button symbol" onclick=@TogglePause>@PlayButtonIcon</div>
        <div id="timecodeArea" class=@TimecodeAreaClass>
            <div class="progress time">@ProgressText</div>
            <div class="progress center">/</div>
            <div class="progress time right">@DurationText</div>
        </div>
        <SliderControl id="progressBar" Min=@(0) Max=@DurationSeconds 
            Value:bind=@PlaybackTime OnValueChanged=@ProgressBarChanged ShowValueTooltip=@false/>
        <div class="button symbol" onclick=@ToggleMute>@VolumeButtonIcon</div>
    </div>
</root>
@using Sandbox
@using Sandbox.UI
@using System
@using System.Collections.Generic
@namespace FieldGuide.Placement
@inherits PanelComponent
@attribute [StyleSheet]

@*
	A live transform tuner for objects hanging off something else: an accessory on a character mount, a
	part on a vehicle, a prop in a scene. One tab per ITweakTarget supplied by the scene's TweakSession;
	the active tab edits that target GameObject's LocalPosition, LocalRotation (as pitch / yaw / roll) and
	uniform LocalScale, so what you drag is the OFFSET, which is the number your game code wants.

	Each row is a draggable slider (click-jump + drag-scrub, the engine SliderControl mechanic) with a
	small +/- stepper beside it for precision a drag can't hit; the three-state step toggle divides or
	multiplies that stepper by ten. Row ranges come from the target's TweakRanges, so an accessory gets
	character-scale sliders and a scene prop gets scene-scale ones.

	Copy puts the active target's paste-ready bake line on the clipboard (game-side Clipboard.SetText).
	Export writes every target and every placed object into FileSystem.Data as JSON plus a C# snippet.
	Reset restores the transform captured when the target was registered, never zero.

	Rows render in MAIN markup via @foreach (not a RenderFragment) per the fragment-undermeasure gotcha,
	and add shapes only (track/fill), keeping the text-run count low. Toggle with P (raw key, letter,
	never an F key), the 42px x in the header, or the `placement_panel` console convar. Starts closed
	unless OpenOnStart is set; see the boot block in OnUpdate.

	Look and layout follow the Field Kit UI system: docs/design/ui-system/placement-kit.dc.html for
	this screen, tokens.dc.html for the values. The stylesheet carries this kit's own copy of those
	tokens (kits cannot import each other) and lists the engine-legality translations at its head.
*@

<root>
@if ( PanelOpen )
{
	<div class="tp-card">
		<div class="tp-hdr">
			<span class="tp-title">TRANSFORM TWEAK</span>
			<div class="tp-hr">
				<span class="tp-key">P</span>
				<div class="tp-x" onclick=@ClosePanel>×</div>
			</div>
		</div>

		@if ( TargetList.Count == 0 )
		{
			<div class="tp-empty">No targets. Add GameObjects to a TweakSession (Objects list) or call TweakSession.Add(...).</div>
		}
		else
		{
			@* ---- one tab per target ---- *@
			<div class="tp-tabs">
				@for ( int i = 0; i < TargetList.Count; i++ )
				{
					var idx = i;
					string tab = TargetList[idx].DisplayName;
					<div class="tp-tab @(idx == ActiveIndex ? "on" : "")" onclick=@(() => SelectTab( idx ))>@tab</div>
				}
			</div>

			<div class="tp-sub">
				<span class="tp-item">@ActiveName</span>
				<span class="tp-note">@FrameNote</span>
			</div>

			@* ---- step-size toggle (segments live inside a pill group per the design system) ---- *@
			<div class="tp-steprow">
				<span class="tp-sl">Step</span>
				<div class="tp-seg-group">
					<div class="tp-seg @(_step == StepSize.XFine ? "on" : "")" onclick=@(() => _step = StepSize.XFine)>xfine ÷10</div>
					<div class="tp-seg @(_step == StepSize.Fine ? "on" : "")" onclick=@(() => _step = StepSize.Fine)>fine</div>
					<div class="tp-seg @(_step == StepSize.Coarse ? "on" : "")" onclick=@(() => _step = StepSize.Coarse)>coarse ×10</div>
				</div>
			</div>

			@* ---- editable rows (draggable slider + stepper) ---- *@
			<div class="tp-rows">
				@foreach ( var r in Rows )
				{
					var row = r;
					float cur = Get( ActiveTarget, row.field );
					string val = cur.ToString( row.fmt );
					string lab = row.label;   // plain local before interpolating: an inline field read can render blank
					int fillPct = (int)( Frac( cur, row ) * 100f );
					<div class="tp-row @(row.header ? "hdr" : "")">
						<div class="tp-rlab">
							<span class="tp-rl">@lab</span>
							<span class="tp-rv">@val</span>
						</div>
						<div class="tp-slider">
							<span class="tp-stp" onclick=@(() => Nudge( row.field, -row.step * StepMult ))>−</span>
							<div class="tp-track"
								onmousedown=@(e => TrackPointer( e, row, true ))
								onmousemove=@(e => TrackPointer( e, row, false ))>
								<div class="tp-fill" style="width: @(fillPct)%;"></div>
							</div>
							<span class="tp-stp" onclick=@(() => Nudge( row.field, row.step * StepMult ))>+</span>
						</div>
					</div>
				}
			</div>

			@* ---- actions ---- *@
			<div class="tp-btns">
				<div class="tp-btn" onclick=@ResetActive>Reset</div>
				<div class="tp-btn primary" onclick=@CopyActive>@_copyLabel</div>
			</div>
			<div class="tp-btns">
				<div class="tp-btn" onclick=@ExportNow>Export all to Data</div>
			</div>
		}
	</div>
}
</root>

@code
{
	// ---- toggle state (P raw key + `placement_panel` convar fallback) ----
	static bool _open;

	/// <summary>Console fallback: `placement_panel 1` / `placement_panel 0` toggles the panel (P also toggles).</summary>
	[ConVar( "placement_panel", Help = "Open or close the transform tweak panel (same as the P key)" )]
	public static bool PanelOpen { get => _open; set => _open = value; }

	/// <summary>
	/// Whether this panel starts open. Off by default: a tuning panel that appears unbidden over a
	/// consumer's game is a bug, not a feature. Turn it on for a scene whose whole point is the panel, the
	/// way the kit's own demo does.
	///
	/// This is what decides the panel's boot state, and it is the ONLY thing that decides it. See the boot
	/// block in OnUpdate for why that matters.
	/// </summary>
	[Property] public bool OpenOnStart { get; set; }

	int _activeIndex;
	string _copyLabel = "Copy";
	bool _wasOpen;
	bool _booted;

	/// <summary>Three-state stepper size (owner ruling, ported from the World Builder mount tuner): xfine for
	/// final seating, fine for normal work, coarse for getting into the neighbourhood.</summary>
	enum StepSize { XFine, Fine, Coarse }
	StepSize _step = StepSize.Fine;

	IReadOnlyList<ITweakTarget> TargetList => TweakSession.Instance?.Targets ?? System.Array.Empty<ITweakTarget>();

	int ActiveIndex
	{
		get => TargetList.Count == 0 ? 0 : Math.Clamp( _activeIndex, 0, TargetList.Count - 1 );
		set => _activeIndex = value;
	}

	// Named ActiveTarget (not Active): PanelComponent/Component already exposes an inherited
	// bool Active, and a razor-generated member named Active would hide it (CS0108).
	ITweakTarget ActiveTarget => TargetList.Count == 0 ? null : TargetList[ActiveIndex];

	/// <summary>The active target's label, resolved to a single-identifier property so the markup never
	/// interpolates a chained member read (which renders blank in several razor cases).</summary>
	string ActiveName => ActiveTarget?.DisplayName ?? "";

	/// <summary>The sub-line under the tabs: what the numbers below are measured against. This is the one
	/// piece of context that makes a baked offset readable later, so the panel shows it while you drag.</summary>
	string FrameNote
	{
		get
		{
			var frame = ActiveTarget?.FrameName;
			return string.IsNullOrEmpty( frame ) ? "local offset · edited live" : $"{frame} · edited live";
		}
	}

	void SelectTab( int idx )
	{
		ActiveIndex = idx;
		_copyLabel = "Copy";   // a stale "Copied!" on a different target reads as a lie
	}

	void ClosePanel()
	{
		PanelOpen = false;
		_copyLabel = "Copy";
	}

	// ---- field model ----
	public enum Field { PosX, PosY, PosZ, Pitch, Yaw, Roll, Scale }

	struct Row { public Field field; public string label; public string fmt; public float step; public float min; public float max; public bool header; }

	/// <summary>The seven rows for the ACTIVE target, bounded by that target's TweakRanges. Built per read
	/// rather than held in a static array, because an accessory and a scene prop want very different
	/// ranges out of the same three sliders.</summary>
	List<Row> Rows
	{
		get
		{
			var g = ActiveTarget?.Ranges ?? TweakRanges.Accessory;
			float p = MathF.Max( g.PositionRange, 0.001f );
			return new List<Row>
			{
				new Row { field = Field.PosX,  label = "Pos X",   fmt = "0.###", step = g.PositionStep, min = -p, max = p },
				new Row { field = Field.PosY,  label = "Pos Y",   fmt = "0.###", step = g.PositionStep, min = -p, max = p },
				new Row { field = Field.PosZ,  label = "Pos Z",   fmt = "0.###", step = g.PositionStep, min = -p, max = p },
				new Row { field = Field.Pitch, label = "Pitch °", fmt = "0.#",   step = g.RotationStep, min = -180f, max = 180f, header = true },
				new Row { field = Field.Yaw,   label = "Yaw °",   fmt = "0.#",   step = g.RotationStep, min = -180f, max = 180f },
				new Row { field = Field.Roll,  label = "Roll °",  fmt = "0.#",   step = g.RotationStep, min = -180f, max = 180f },
				new Row { field = Field.Scale, label = "Scale",   fmt = "0.###", step = g.ScaleStep,    min = g.ScaleMin, max = g.ScaleMax, header = true },
			};
		}
	}

	float StepMult => _step switch { StepSize.Coarse => 10f, StepSize.XFine => 0.1f, _ => 1f };

	static float Frac( float value, Row row )
		=> Math.Clamp( (value - row.min) / MathF.Max( row.max - row.min, 0.0001f ), 0f, 1f );

	// ---- read/write the active target's LOCAL transform ----
	static float Get( ITweakTarget t, Field f )
	{
		var go = t?.Target;
		if ( go is null || !go.IsValid() ) return 0f;
		var p = go.LocalPosition;
		var a = go.LocalRotation.Angles();
		return f switch
		{
			Field.PosX => p.x,
			Field.PosY => p.y,
			Field.PosZ => p.z,
			Field.Pitch => a.pitch,
			Field.Yaw => a.yaw,
			Field.Roll => a.roll,
			Field.Scale => go.LocalScale.x,
			_ => 0f,
		};
	}

	void Nudge( Field f, float delta )
	{
		var go = ActiveTarget?.Target;
		if ( go is null || !go.IsValid() ) return;
		var p = go.LocalPosition;
		var a = go.LocalRotation.Angles();
		switch ( f )
		{
			case Field.PosX: go.LocalPosition = p.WithX( p.x + delta ); break;
			case Field.PosY: go.LocalPosition = p.WithY( p.y + delta ); break;
			case Field.PosZ: go.LocalPosition = p.WithZ( p.z + delta ); break;
			case Field.Pitch: go.LocalRotation = new Angles( a.pitch + delta, a.yaw, a.roll ).ToRotation(); break;
			case Field.Yaw: go.LocalRotation = new Angles( a.pitch, a.yaw + delta, a.roll ).ToRotation(); break;
			case Field.Roll: go.LocalRotation = new Angles( a.pitch, a.yaw, a.roll + delta ).ToRotation(); break;
			case Field.Scale:
				float s = MathF.Max( 0.01f, go.LocalScale.x + delta );
				go.LocalScale = new Vector3( s, s, s );
				break;
		}
	}

	/// <summary>Draggable track (World Builder left-UI idiom): onmousedown JUMPS to the click, onmousemove
	/// SCRUBS while Active. Snaps to the row's fine step, applies as a DELTA through Nudge so the same
	/// write path runs whether you drag or step.</summary>
	void TrackPointer( PanelEvent ev, Row row, bool jump )
	{
		if ( ev is not MousePanelEvent e ) return;
		var track = e.This;
		if ( track is null ) return;
		if ( !jump && !track.PseudoClass.HasFlag( PseudoClass.Active ) ) return;

		float w = track.Box.Rect.Width;
		if ( w <= 0f ) return;
		float frac = Math.Clamp( e.LocalPosition.x / w, 0f, 1f );
		float target = row.min + frac * (row.max - row.min);
		if ( row.step > 0f ) target = MathF.Round( target / row.step ) * row.step;
		target = Math.Clamp( target, row.min, row.max );
		Nudge( row.field, target - Get( ActiveTarget, row.field ) );
	}

	/// <summary>Restore the transform the target was REGISTERED with, not zero. Zeroing a hand-mounted
	/// accessory collapses it into the wrist, which is never the thing you wanted back.</summary>
	void ResetActive()
	{
		var go = ActiveTarget?.Target;
		if ( go is null || !go.IsValid() ) return;
		if ( TweakSession.Instance?.ResetToSeed( go ) == true ) return;

		// Never registered here (a hand-built target list, say): identity is the only baseline we have.
		go.LocalPosition = Vector3.Zero;
		go.LocalRotation = Rotation.Identity;
		go.LocalScale = Vector3.One;
	}

	/// <summary>Copy the active target's paste-ready bake line to the system clipboard. Game-side
	/// Sandbox.UI.Clipboard.SetText, so this works in play without an editor round trip.</summary>
	void CopyActive()
	{
		var t = ActiveTarget;
		if ( t is null ) return;
		var line = PlacementExport.BakeLine( t );
		if ( string.IsNullOrEmpty( line ) ) return;
		Sandbox.UI.Clipboard.SetText( line );
		_copyLabel = "Copied!";
	}

	void ExportNow()
	{
		if ( Scene is not null )
			PlacementExport.WriteAll( Scene );
	}

	// ---- boot state, P toggle, cursor while open ----

	protected override void OnUpdate()
	{
		// BOOT. `placement_panel` is a convar and s&box PERSISTS convars across sessions, so a session can
		// otherwise come up with the panel logically open from whatever someone left set weeks ago. The rule
		// that prevents it: this component's own OpenOnStart decides the boot state, and the persisted value
		// never does. Default off means a consumer game still cannot be pre-opened by a stale convar; a scene
		// that wants the panel up says so explicitly.
		//
		// Deliberately in the FIRST UPDATE rather than OnStart. A panel built in code is configured by the
		// component that created it, and doing this in OnStart would race that assignment: whichever ran
		// first would win. The first update is after every OnStart in the frame, so the setting is always
		// read, never half-applied.
		if ( !_booted )
		{
			_booted = true;
			if ( PanelOpen && !OpenOnStart )
				Log.Info( "[placement] tweak panel was OPEN at session start (persisted convar), forcing closed" );
			PanelOpen = OpenOnStart;
		}

		if ( Input.Keyboard.Pressed( "P" ) )
			PanelOpen = !PanelOpen;

		if ( PanelOpen )
		{
			Mouse.Visibility = MouseVisibility.Visible;   // keep the cursor usable over the panel
			_wasOpen = true;
		}
		else if ( _wasOpen )
		{
			_wasOpen = false;
			_copyLabel = "Copy";   // closing clears the flash, so a reopen never claims a copy that was not made
		}
	}

	// Fold the toggle, active tab, step mode, the copy label, and every displayed value (rounded) so
	// readouts update the instant a value is nudged. Miss one and the number freezes on screen.
	protected override int BuildHash()
	{
		int h = HashCode.Combine( PanelOpen, ActiveIndex, (int)_step, _copyLabel, TargetList.Count );
		var a = ActiveTarget;
		if ( a is not null )
		{
			h = HashCode.Combine( h, a.FrameName );
			foreach ( var r in Rows )
				h = HashCode.Combine( h, (int)MathF.Round( Get( a, r.field ) * 1000f ) );
		}
		return h;
	}
}
@using System
@using System.Collections.Generic
@using System.Linq
@using Sandbox
@using Sandbox.UI
@namespace AdminSuite.UI
@inherits Panel

<root class="tab server-tab">
	@if ( Sys is null )
	{
		<label class="empty">Admin system not available.</label>
	}
	else
	{
		<div class="scroll">
			@if ( Sys.LocalHas( AdminPerm.PropsCleanAll ) || Sys.LocalHas( AdminPerm.PropsCleanDisconnected ) )
			{
				<div class="card">
					<div class="section-title"><i>cleaning_services</i><label>Cleanup</label></div>
					<div class="action-row">
						@if ( Sys.LocalHas( AdminPerm.PropsCleanDisconnected ) )
						{
							<div class="btn accent" onclick=@( () => Sys.RequestCleanDisconnectedProps() )>
								<i>person_off</i><label>Clean Disconnected Players' Props</label>
							</div>
						}
						@if ( Sys.LocalHas( AdminPerm.PropsCleanAll ) )
						{
							<div class="btn danger" onclick=@( () => CleanAll() )>
								<i>delete_sweep</i><label>@(ConfirmingCleanAll ? "Confirm — remove ALL props?" : "Clean ALL Props")</label>
							</div>
						}
					</div>
					<label class="explain">Cleanup only removes networked spawned objects — the map itself is never touched. Per-player cleanup lives in the Players tab.</label>
				</div>
			}

			@if ( Sys.LocalHas( AdminPerm.ServerBroadcast ) )
			{
				<div class="card">
					<div class="section-title"><i>campaign</i><label>Broadcast</label></div>
					<div class="action-row">
						<TextEntry class="input grow" Value:bind=@BroadcastText Placeholder="Message to every player..." />
						<div class="btn accent" onclick=@( () => Send() )><i>send</i><label>Send</label></div>
					</div>
				</div>
			}

			@if ( Sys.LocalHas( AdminPerm.PlayersBan ) )
			{
				<div class="card">
					<div class="section-title"><i>block</i><label>Active Bans (@Sys.ClientBans.Count)</label></div>
					@if ( Sys.ClientBans.Count == 0 )
					{
						<label class="explain">No active bans.</label>
					}
					@foreach ( var ban in Sys.ClientBans )
					{
						<div class="row ban">
							<div class="meta">
								<label class="name">@ban.Name</label>
								<label class="sub">@ban.SteamId · "@ban.Reason" — by @ban.BannedByName · @Expiry( ban )</label>
							</div>
							<div class="btn ghost" onclick=@( () => Sys.RequestUnban( ban.SteamId ) )>
								<i>undo</i><label>Unban</label>
							</div>
						</div>
					}
				</div>
			}
		</div>
	}
</root>

@code
{
	private AdminSystem Sys => AdminSystem.Instance;

	public string BroadcastText { get; set; } = "";

	private float _confirmUntil;
	private bool ConfirmingCleanAll => RealTime.Now < _confirmUntil;

	private void CleanAll()
	{
		if ( Sys is null ) return;

		if ( !ConfirmingCleanAll )
		{
			_confirmUntil = RealTime.Now + 3f;
			return;
		}

		_confirmUntil = 0f;
		Sys.RequestCleanAllProps();
	}

	private void Send()
	{
		if ( Sys is null || string.IsNullOrWhiteSpace( BroadcastText ) ) return;
		Sys.RequestBroadcast( BroadcastText.Trim() );
		BroadcastText = "";
	}

	private static string Expiry( BanRecord ban ) =>
		ban.ExpiresUtc is null ? "permanent" : $"until {ban.ExpiresUtc.Value.ToLocalTime():g}";

	protected override int BuildHash() =>
		HashCode.Combine( Sys?.StateHash ?? 0, ConfirmingCleanAll, (int)RealTime.Now );
}
@using System
@using System.Collections.Generic
@using System.Linq
@using Sandbox
@using Sandbox.UI
@namespace AdminSuite.UI
@inherits PanelComponent

<root class="adminsuite @(IsOpen ? "open" : "")">

	<div class="toasts">
		@foreach ( var toast in Toasts )
		{
			<div class="toast [email protected]">
				<i>@ToastIcon( toast.Level )</i>
				<label>@toast.Message</label>
			</div>
		}
	</div>

	@if ( IsOpen && Sys is not null )
	{
		var me = Connection.Local;
		var myRank = me is not null ? Sys.GetDisplayRank( me.SteamId ) : null;

		<div class="window">
			<div class="titlebar">
				<div class="brand">
					<i>local_police</i>
					<label class="name">ADMIN SUITE</label>
				</div>
				@if ( me is not null && myRank is not null )
				{
					<div class="whoami">
						<label>@me.DisplayName</label>
						<div class="badge" style="background-color: @myRank.Color">@myRank.Name</div>
					</div>
				}
				<div class="close" onclick=@( () => Close() )><i>close</i></div>
			</div>

			<div class="main">
				<div class="nav">
					@foreach ( var item in NavItems() )
					{
						<div class="nav-item @(Current == item.Tab ? "active" : "")" onclick=@( () => SetTab( item.Tab ) )>
							<i>@item.Icon</i>
							<label>@item.Label</label>
						</div>
					}
				</div>

				<div class="content">
					@if ( Current == Tab.Players ) { <PlayersTab /> }
					else if ( Current == Tab.Ranks ) { <RanksTab /> }
					else if ( Current == Tab.Permissions ) { <PermissionsTab /> }
					else if ( Current == Tab.Server ) { <ServerTab /> }
					else if ( Current == Tab.Settings ) { <SettingsTab /> }
				</div>
			</div>

			<div class="statusbar">
				<label>@Connection.All.Count player(s) online</label>
				<label class="hint">Toggle key: @AdminClientPrefs.Current.MenuKey.ToUpperInvariant() · console: adminsuite_menu</label>
			</div>
		</div>
	}
</root>

@code
{
	public static AdminMenu Instance { get; private set; }

	public enum Tab { Players, Ranks, Permissions, Server, Settings }

	public bool IsOpen { get; private set; }
	public Tab Current { get; private set; } = Tab.Players;

	public record struct ToastItem( string Message, int Level, float ExpiresAt );
	public List<ToastItem> Toasts { get; } = new();

	private bool _keyWasDown;

	private AdminSystem Sys => AdminSystem.Instance;

	protected override void OnStart()
	{
		Instance = this;
	}

	protected override void OnDestroy()
	{
		if ( Instance == this ) Instance = null;
	}

	protected override void OnUpdate()
	{
		// Raw-key toggle — edge-detected so holding the key doesn't flicker the menu.
		var key = AdminClientPrefs.Current.MenuKey;
		var isDown = !string.IsNullOrWhiteSpace( key ) && Input.Keyboard.Down( key );
		if ( isDown && !_keyWasDown ) Toggle();
		_keyWasDown = isDown;

		if ( IsOpen )
		{
			Mouse.Visible = true;

			if ( Input.EscapePressed )
			{
				Input.EscapePressed = false;
				Close();
			}

			// If our access was revoked (or the system vanished) while open, close.
			if ( Sys is null || !Sys.LocalHas( AdminPerm.MenuAccess ) )
				Close();
			else if ( NavItems().All( n => n.Tab != Current ) )
				Current = Tab.Players;
		}

		Toasts.RemoveAll( t => t.ExpiresAt < RealTime.Now );
	}

	public void Toggle()
	{
		if ( IsOpen ) Close();
		else Open();
	}

	public void Open()
	{
		if ( Sys is null )
		{
			Toast( "Admin system is not running on this server.", 2 );
			return;
		}

		if ( !Sys.LocalHas( AdminPerm.MenuAccess ) )
		{
			Toast( "You don't have access to the admin menu.", 2 );
			return;
		}

		IsOpen = true;
	}

	public void Close()
	{
		IsOpen = false;
		Mouse.Visible = false;
	}

	public void SetTab( Tab tab ) => Current = tab;

	public void Toast( string message, int level = 0 )
	{
		if ( string.IsNullOrEmpty( message ) ) return;

		Toasts.Add( new ToastItem( message, level, RealTime.Now + 6f ) );
		while ( Toasts.Count > 6 ) Toasts.RemoveAt( 0 );
		StateHasChanged();
	}

	private static string ToastIcon( int level ) => level switch
	{
		1 => "check_circle",
		2 => "error",
		3 => "campaign",
		_ => "info"
	};

	private record struct NavItem( Tab Tab, string Icon, string Label );

	private IEnumerable<NavItem> NavItems()
	{
		yield return new NavItem( Tab.Players, "group", "Players" );

		if ( Sys?.LocalHas( AdminPerm.RanksManage ) == true )
		{
			yield return new NavItem( Tab.Ranks, "military_tech", "Ranks" );
			yield return new NavItem( Tab.Permissions, "key", "Permissions" );
		}

		if ( ShowServerTab )
			yield return new NavItem( Tab.Server, "dns", "Server" );

		yield return new NavItem( Tab.Settings, "settings", "Settings" );
	}

	private bool ShowServerTab =>
		Sys is not null &&
		(Sys.LocalHas( AdminPerm.PropsCleanAll )
		 || Sys.LocalHas( AdminPerm.PropsCleanDisconnected )
		 || Sys.LocalHas( AdminPerm.ServerBroadcast )
		 || Sys.LocalHas( AdminPerm.PlayersBan )
		 || Sys.LocalHas( AdminPerm.ServerSettings ));

	protected override int BuildHash() =>
		HashCode.Combine( IsOpen, Current, Toasts.Count, Sys?.StateHash ?? 0, (int)RealTime.Now );
}
@using System
@using System.Collections.Generic
@using System.Linq
@using Sandbox
@using Sandbox.UI
@namespace AdminSuite.UI
@inherits Panel

<root class="tab server-tab">
	@if ( Sys is null )
	{
		<label class="empty">Admin system not available.</label>
	}
	else
	{
		<div class="scroll">
			@if ( Sys.LocalHas( AdminPerm.PropsCleanAll ) || Sys.LocalHas( AdminPerm.PropsCleanDisconnected ) )
			{
				<div class="card">
					<div class="section-title"><i>cleaning_services</i><label>Cleanup</label></div>
					<div class="action-row">
						@if ( Sys.LocalHas( AdminPerm.PropsCleanDisconnected ) )
						{
							<div class="btn accent" onclick=@( () => Sys.RequestCleanDisconnectedProps() )>
								<i>person_off</i><label>Clean Disconnected Players' Props</label>
							</div>
						}
						@if ( Sys.LocalHas( AdminPerm.PropsCleanAll ) )
						{
							<div class="btn danger" onclick=@( () => CleanAll() )>
								<i>delete_sweep</i><label>@(ConfirmingCleanAll ? "Confirm — remove ALL props?" : "Clean ALL Props")</label>
							</div>
						}
					</div>
					<label class="explain">Cleanup only removes networked spawned objects — the map itself is never touched. Per-player cleanup lives in the Players tab.</label>
				</div>
			}

			@if ( Sys.LocalHas( AdminPerm.ServerBroadcast ) )
			{
				<div class="card">
					<div class="section-title"><i>campaign</i><label>Broadcast</label></div>
					<div class="action-row">
						<TextEntry class="input grow" Value:bind=@BroadcastText Placeholder="Message to every player..." />
						<div class="btn accent" onclick=@( () => Send() )><i>send</i><label>Send</label></div>
					</div>
				</div>
			}

			@if ( Sys.LocalHas( AdminPerm.PlayersBan ) )
			{
				<div class="card">
					<div class="section-title"><i>block</i><label>Active Bans (@Sys.ClientBans.Count)</label></div>
					@if ( Sys.ClientBans.Count == 0 )
					{
						<label class="explain">No active bans.</label>
					}
					@foreach ( var ban in Sys.ClientBans )
					{
						<div class="row ban">
							<div class="meta">
								<label class="name">@ban.Name</label>
								<label class="sub">@ban.SteamId · "@ban.Reason" — by @ban.BannedByName · @Expiry( ban )</label>
							</div>
							<div class="btn ghost" onclick=@( () => Sys.RequestUnban( ban.SteamId ) )>
								<i>undo</i><label>Unban</label>
							</div>
						</div>
					}
				</div>
			}
		</div>
	}
</root>

@code
{
	private AdminSystem Sys => AdminSystem.Instance;

	public string BroadcastText { get; set; } = "";

	private float _confirmUntil;
	private bool ConfirmingCleanAll => RealTime.Now < _confirmUntil;

	private void CleanAll()
	{
		if ( Sys is null ) return;

		if ( !ConfirmingCleanAll )
		{
			_confirmUntil = RealTime.Now + 3f;
			return;
		}

		_confirmUntil = 0f;
		Sys.RequestCleanAllProps();
	}

	private void Send()
	{
		if ( Sys is null || string.IsNullOrWhiteSpace( BroadcastText ) ) return;
		Sys.RequestBroadcast( BroadcastText.Trim() );
		BroadcastText = "";
	}

	private static string Expiry( BanRecord ban ) =>
		ban.ExpiresUtc is null ? "permanent" : $"until {ban.ExpiresUtc.Value.ToLocalTime():g}";

	protected override int BuildHash() =>
		HashCode.Combine( Sys?.StateHash ?? 0, ConfirmingCleanAll, (int)RealTime.Now );
}
@using System
@using System.Collections.Generic
@using System.Linq
@using Sandbox
@using Sandbox.UI
@namespace AdminSuite.UI
@inherits Panel

<root class="tab players-tab">
	@if ( Sys is null )
	{
		<label class="empty">Admin system not available.</label>
	}
	else
	{
		<div class="column list">
			<div class="section-title"><i>group</i><label>Online Players (@Connection.All.Count)</label></div>
			<div class="scroll">
				@foreach ( var c in Connection.All.OrderByDescending( x => Sys.DisplayPriorityOf( x.SteamId ) ).ThenBy( x => x.DisplayName ) )
				{
					var rank = Sys.GetDisplayRank( c.SteamId );
					<div class="row player @(Selected == c.SteamId ? "selected" : "")" onclick=@( () => Select( c.SteamId ) )>
						<div class="avatar" style="background-color: @rank.Color"><label>@Initial( c.DisplayName )</label></div>
						<div class="meta">
							<label class="name">@c.DisplayName</label>
							<label class="sub">@c.SteamId</label>
						</div>
						<div class="badge" style="background-color: @rank.Color">@rank.Name</div>
					</div>
				}
			</div>
		</div>

		<div class="column detail">
			@{ var target = Connection.All.FirstOrDefault( x => x.SteamId == Selected ); }
			@if ( target is null )
			{
				<div class="placeholder"><i>touch_app</i><label>Select a player on the left</label></div>
			}
			else
			{
				var targetRank = Sys.GetDisplayRank( target.SteamId );
				var canTarget = Sys.LocalCanTarget( target.SteamId );

				<div class="detail-header">
					<div class="avatar big" style="background-color: @targetRank.Color"><label>@Initial( target.DisplayName )</label></div>
					<div class="meta">
						<label class="name">@target.DisplayName</label>
						<label class="sub">SteamID64: @target.SteamId</label>
					</div>
					<div class="badge" style="background-color: @targetRank.Color">@targetRank.Name</div>
				</div>

				if ( !canTarget )
				{
					<div class="notice"><i>lock</i><label>This player's rank is equal to or higher than yours — you can only view them.</label></div>
				}

				<div class="section-title"><i>military_tech</i><label>Rank</label></div>
				<div class="rank-buttons">
					@foreach ( var rank in Sys.ClientRanks.OrderByDescending( r => r.Priority ) )
					{
						var isCurrent = targetRank.Id == rank.Id;
						var canAssign = !isCurrent && Sys.LocalCanAssign( rank, target.SteamId );
						<div class="btn rank @(isCurrent ? "current" : "") @(canAssign || isCurrent ? "" : "disabled")"
							onclick=@( () => { if ( canAssign ) Sys.RequestSetRank( target.SteamId, rank.Id ); } )>
							<div class="dot" style="background-color: @rank.Color"></div>
							<label>@rank.Name</label>
							@if ( isCurrent ) { <i>check</i> }
						</div>
					}
				</div>

				<div class="section-title"><i>gavel</i><label>Actions</label></div>

				if ( Sys.LocalHas( AdminPerm.PropsCleanPlayer ) )
				{
					<div class="action-row">
						<div class="btn accent @(canTarget ? "" : "disabled")"
							onclick=@( () => { if ( canTarget ) Sys.RequestCleanPlayerProps( target.SteamId ); } )>
							<i>cleaning_services</i><label>Clean This Player's Props</label>
						</div>
					</div>
				}

				if ( Sys.LocalHas( AdminPerm.PlayersKick ) )
				{
					<div class="action-row">
						<TextEntry class="input grow" Value:bind=@KickReason Placeholder="Kick reason" />
						<div class="btn warn @(canTarget ? "" : "disabled")" onclick=@( () => DoKick( target ) )>
							<i>logout</i><label>Kick</label>
						</div>
					</div>
				}

				if ( Sys.LocalHas( AdminPerm.PlayersBan ) )
				{
					<div class="action-row">
						<TextEntry class="input grow" Value:bind=@BanReason Placeholder="Ban reason" />
						<TextEntry class="input minutes" Value:bind=@BanMinutes Placeholder="Min" />
						<div class="btn danger @(canTarget ? "" : "disabled")" onclick=@( () => DoBan( target ) )>
							<i>block</i><label>Ban</label>
						</div>
					</div>
					<label class="explain">Ban duration in minutes — 0 or empty means permanent.</label>
				}
			}
		</div>
	}
</root>

@code
{
	private AdminSystem Sys => AdminSystem.Instance;

	public ulong Selected { get; set; }
	public string KickReason { get; set; } = "";
	public string BanReason { get; set; } = "";
	public string BanMinutes { get; set; } = "0";

	private void Select( ulong steamId )
	{
		Selected = steamId;
		StateHasChanged();
	}

	private static string Initial( string name ) =>
		string.IsNullOrEmpty( name ) ? "?" : name[..1].ToUpperInvariant();

	private void DoKick( Connection target )
	{
		if ( target is null || Sys is null || !Sys.LocalCanTarget( target.SteamId ) ) return;
		Sys.RequestKick( target.SteamId, KickReason );
		KickReason = "";
	}

	private void DoBan( Connection target )
	{
		if ( target is null || Sys is null || !Sys.LocalCanTarget( target.SteamId ) ) return;
		var minutes = int.TryParse( BanMinutes, out var m ) ? Math.Max( 0, m ) : 0;
		Sys.RequestBan( target.SteamId, BanReason, minutes );
		BanReason = "";
	}

	protected override int BuildHash() =>
		HashCode.Combine( Sys?.StateHash ?? 0, Selected, Connection.All.Count, (int)RealTime.Now );
}
@using System
@using System.Collections.Generic
@using System.Linq
@using Sandbox
@using Sandbox.UI
@namespace AdminSuite.UI
@inherits Panel

<root class="tab ranks-tab">
	@if ( Sys is null )
	{
		<label class="empty">Admin system not available.</label>
	}
	else
	{
		<div class="column list">
			<div class="section-title"><i>military_tech</i><label>Ranks (@Sys.ClientRanks.Count)</label></div>
			<div class="scroll">
				@foreach ( var rank in Sys.ClientRanks.OrderByDescending( r => r.Priority ) )
				{
					<div class="row rank @(SelectedId == rank.Id ? "selected" : "")" onclick=@( () => Select( rank ) )>
						<div class="dot" style="background-color: @rank.Color"></div>
						<div class="meta">
							<label class="name">@rank.Name</label>
							<label class="sub">@rank.Id · priority @rank.Priority@(rank.BuiltIn ? " · built-in" : "")</label>
						</div>
					</div>
				}
			</div>

			@if ( Sys.LocalHas( AdminPerm.RanksManage ) )
			{
				<div class="create-row">
					<TextEntry class="input grow" Value:bind=@NewRankName Placeholder="New rank name" />
					<div class="btn accent" onclick=@( () => Create() )><i>add</i><label>Create</label></div>
				</div>
			}
		</div>

		<div class="column detail">
			@{ var rank = Sys.ClientRanks.FirstOrDefault( r => r.Id == SelectedId ); }
			@if ( rank is null )
			{
				<div class="placeholder"><i>touch_app</i><label>Select a rank to view or edit it</label></div>
			}
			else
			{
				var canEdit = Sys.LocalCanEditRank( rank );

				<div class="detail-header">
					<div class="dot big" style="background-color: @rank.Color"></div>
					<div class="meta">
						<label class="name">@rank.Name</label>
						<label class="sub">@rank.Id · @(rank.BuiltIn ? "built-in rank" : "custom rank")</label>
					</div>
					<div class="badge" style="background-color: @rank.Color">priority @rank.Priority</div>
				</div>

				if ( !canEdit )
				{
					<div class="notice"><i>lock</i><label>You can only edit ranks below your own rank.</label></div>
				}
				else
				{
					if ( rank.BuiltIn )
					{
						<div class="notice"><i>info</i><label>Built-in ranks keep their name and priority — only the colour and permissions can be changed.</label></div>
					}

					<div class="form">
						@if ( !rank.BuiltIn )
						{
							<div class="field">
								<label class="key">Name</label>
								<TextEntry class="input" Value:bind=@EditName />
							</div>
							<div class="field">
								<label class="key">Priority (1–99, must stay below your own rank)</label>
								<TextEntry class="input" Value:bind=@EditPriority />
							</div>
						}
						<div class="field">
							<label class="key">Colour (hex, e.g. #b28dff)</label>
							<div class="colour-row">
								<TextEntry class="input grow" Value:bind=@EditColor />
								<div class="swatch" style="background-color: @EditColor"></div>
							</div>
						</div>
						<div class="form-actions">
							<div class="btn accent" onclick=@( () => Apply( rank ) )><i>save</i><label>Apply</label></div>
							@if ( !rank.BuiltIn )
							{
								<div class="btn danger" onclick=@( () => Delete( rank ) )>
									<i>delete</i><label>@(ConfirmingDelete ? "Confirm delete?" : "Delete Rank")</label>
								</div>
							}
						</div>
					</div>

					<label class="explain">Permissions for this rank are edited in the Permissions tab. Players holding a deleted rank fall back to the server's default rank.</label>
				}
			}
		</div>
	}
</root>

@code
{
	private AdminSystem Sys => AdminSystem.Instance;

	public string SelectedId { get; set; } = "";
	public string NewRankName { get; set; } = "";
	public string EditName { get; set; } = "";
	public string EditColor { get; set; } = "";
	public string EditPriority { get; set; } = "";

	private float _confirmUntil;
	private bool ConfirmingDelete => RealTime.Now < _confirmUntil;

	private void Select( AdminRank rank )
	{
		SelectedId = rank.Id;
		EditName = rank.Name;
		EditColor = rank.Color;
		EditPriority = rank.Priority.ToString();
		_confirmUntil = 0f;
		StateHasChanged();
	}

	private void Create()
	{
		if ( Sys is null ) return;
		if ( string.IsNullOrWhiteSpace( NewRankName ) )
		{
			AdminMenu.Instance?.Toast( "Enter a name for the new rank first.", 2 );
			return;
		}

		Sys.RequestCreateRank( NewRankName.Trim() );
		NewRankName = "";
	}

	private void Apply( AdminRank rank )
	{
		if ( Sys is null || !Sys.LocalCanEditRank( rank ) ) return;

		var priority = int.TryParse( EditPriority, out var p ) ? p : rank.Priority;
		var name = string.IsNullOrWhiteSpace( EditName ) ? rank.Name : EditName.Trim();

		Sys.RequestUpdateRank( rank.Id, name, EditColor?.Trim() ?? "", priority );
	}

	private void Delete( AdminRank rank )
	{
		if ( Sys is null || !Sys.LocalCanEditRank( rank ) || rank.BuiltIn ) return;

		if ( !ConfirmingDelete )
		{
			_confirmUntil = RealTime.Now + 3f;
			return;
		}

		_confirmUntil = 0f;
		Sys.RequestDeleteRank( rank.Id );
		SelectedId = "";
	}

	protected override int BuildHash() =>
		HashCode.Combine( Sys?.StateHash ?? 0, SelectedId, ConfirmingDelete, (int)RealTime.Now );
}
@using Sandbox.UI;
@namespace Duccsoft
@inherits Panel

<root class="video-texture">
    @if ( ShowControls )
    {
        <VideoControlOverlay VideoPanel=@this AutoHide=@AutoHideControls AutoHideDelay=@AutoHideDelay/>
    }
</root>
@using Sandbox;
@using Sandbox.UI;
@inherits PanelComponent
@namespace Sandbox

<root>
	<div class="title">@MyStringValue</div>
</root>

@code
{

	[Property, TextArea] public string MyStringValue { get; set; } = "Hello World!";
	protected override int BuildHash() => System.HashCode.Combine( MyStringValue );
}
@using System
@using System.Collections.Generic
@using System.Linq
@using Sandbox
@using Sandbox.UI
@namespace AdminSuite.UI
@inherits Panel

<root class="tab settings-tab">
	@if ( Sys is null )
	{
		<label class="empty">Admin system not available.</label>
	}
	else
	{
		<div class="scroll">
			<div class="card">
				<div class="section-title"><i>keyboard</i><label>Menu Key (this client only)</label></div>
				<div class="action-row">
					<TextEntry class="input" Value:bind=@KeyName />
					<div class="btn accent" onclick=@( () => ApplyKey() )><i>save</i><label>Apply</label></div>
				</div>
				<label class="explain">Raw key name, e.g. f4, f6, f7, insert, home. Function keys are recommended so typing in chat can't trigger the menu. Console alternative: adminsuite_key f7</label>
			</div>

			@if ( Sys.LocalHas( AdminPerm.ServerSettings ) )
			{
				<div class="card">
					<div class="section-title"><i>dns</i><label>Server Settings</label></div>

					<div class="field">
						<label class="key">Default rank for new players</label>
						<div class="rank-pills">
							@foreach ( var rank in Sys.ClientRanks.Where( r => !r.HasAll ).OrderBy( r => r.Priority ) )
							{
								<div class="pill @(DefaultRankId == rank.Id ? "selected" : "")" onclick=@( () => SetDefault( rank.Id ) )>
									<div class="dot" style="background-color: @rank.Color"></div>
									<label>@rank.Name</label>
								</div>
							}
						</div>
					</div>

					<div class="perm-row">
						<div class="meta">
							<label class="name">Announce admin actions to everyone</label>
							<label class="sub">When off, only staff with menu access see kick/ban/cleanup notices.</label>
						</div>
						<div class="toggle @(Announce ? "on" : "")" onclick=@( () => Announce = !Announce )>
							<i>@(Announce ? "check" : "")</i>
						</div>
					</div>

					<div class="perm-row">
						<div class="meta">
							<label class="name">Auto-grant listen host Super Admin</label>
							<label class="sub">The player hosting a listen server automatically becomes Super Admin.</label>
						</div>
						<div class="toggle @(AutoGrant ? "on" : "")" onclick=@( () => AutoGrant = !AutoGrant )>
							<i>@(AutoGrant ? "check" : "")</i>
						</div>
					</div>

					<div class="form-actions">
						<div class="btn accent" onclick=@( () => SaveServer() )><i>save</i><label>Save Server Settings</label></div>
					</div>

					<label class="explain">Owner SteamIDs (accounts that are always Super Admin) are deliberately only editable in the server's data file: adminsuite/config.json.</label>
				</div>
			}

			<div class="card">
				<div class="section-title"><i>info</i><label>About</label></div>
				<label class="explain">Admin Suite — a ULX/XGUI-inspired admin menu for s&box. All rank, ban and config data lives on the server and survives restarts. Every action is validated server-side; the client UI is presentation only.</label>
			</div>
		</div>
	}
</root>

@code
{
	private AdminSystem Sys => AdminSystem.Instance;

	public string KeyName { get; set; } = "";
	public string DefaultRankId { get; set; } = "";
	public bool Announce { get; set; } = true;
	public bool AutoGrant { get; set; } = true;

	protected override void OnAfterTreeRender( bool firstTime )
	{
		base.OnAfterTreeRender( firstTime );
		if ( firstTime ) Seed();
	}

	private void Seed()
	{
		KeyName = AdminClientPrefs.Current.MenuKey;

		if ( Sys is null ) return;
		var cfg = Sys.ClientConfig;
		DefaultRankId = cfg.DefaultRankId;
		Announce = cfg.AnnounceActions;
		AutoGrant = cfg.AutoGrantHostSuperAdmin;
	}

	private void SetDefault( string rankId )
	{
		DefaultRankId = rankId;
		StateHasChanged();
	}

	private void ApplyKey()
	{
		var key = KeyName?.Trim().ToLowerInvariant();
		if ( string.IsNullOrEmpty( key ) )
		{
			AdminMenu.Instance?.Toast( "Enter a key name first, e.g. f6.", 2 );
			return;
		}

		AdminClientPrefs.Current.MenuKey = key;
		AdminClientPrefs.Save();
		AdminMenu.Instance?.Toast( $"Menu key set to {key.ToUpperInvariant()}.", 1 );
	}

	private void SaveServer()
	{
		if ( Sys is null || string.IsNullOrEmpty( DefaultRankId ) ) return;
		Sys.RequestUpdateConfig( DefaultRankId, Announce, AutoGrant );
	}

	protected override int BuildHash() =>
		HashCode.Combine( Sys?.StateHash ?? 0, DefaultRankId, Announce, AutoGrant );
}
@using System
@using Sandbox
@using Sandbox.UI
@namespace Bimp
@inherits PanelComponent
@implements Component.ExecuteInEditor

@*
	The screen itself. Put this on a GameObject with a WorldPanel (PanelSize 1920x1080 works well).
	Shows the video, and point-and-click controls when a player looks at it. The controls hide after a
	few seconds and stay hidden for as long as you keep looking, however you move - they only come back
	when you look away and back again. Clicking needs a WorldInput on the camera (added automatically).

	USE (E) while aiming at a visible button presses it; USE anywhere else opens the full remote
	(MediaInteract asks TryPressAimedControl first).
*@

<root class="@RootClass">

	<div class="video" @ref="VideoSurface"></div>

	@if ( Player is null || !Player.HasMedia )
	{
		<div class="splash">
			<i>smart_display</i>
			<div class="title">@(string.IsNullOrWhiteSpace( IdleText ) ? "Media Player" : IdleText)</div>
			@if ( !string.IsNullOrWhiteSpace( Player?.Status ) )
			{
				<div class="status">@Player.Status</div>
			}
			else
			{
				@* separate pieces in a row: text either side of an element lost its spaces ("PressUSEto open") *@
				<div class="hint"><span>Press</span><InputGlyph Action="@UseAction"></InputGlyph><span>to open the controls</span></div>
			}
		</div>
	}
	else if ( ShowAudioVisual )
	{
		<div class="audio">
			<i>graphic_eq</i>
			<ScrollingText class="title" Text="@Player.Title"></ScrollingText>
			<SpectrumBars Player="@Player"></SpectrumBars>
		</div>
	}

	@if ( Player is not null && Player.HasMedia )
	{
		@if ( !string.IsNullOrEmpty( CaptionText ) )
		{
			<div class="subs">
				@foreach ( var line in CaptionText.Split( '\n' ) )
				{
					<div class="subs-line">@line</div>
				}
			</div>
		}

		@if ( Player.Backend is not null && !Player.Backend.Loaded && Player.Backend.Error is null )
		{
			<div class="loading"><i>hourglass_top</i> Loading...</div>
		}

		@if ( FlashIcon is not null )
		{
			<div class="flash">
				<i>@FlashIcon</i>
				@if ( !string.IsNullOrEmpty( FlashText ) )
				{
					<div class="flash-text">@FlashText</div>
				}
			</div>
		}

		<div class="osd">
			<div class="meta">
				<ScrollingText class="title" Text="@Player.Title"></ScrollingText>
				<div class="time">@TimeText</div>
			</div>

			@if ( !Player.IsLive )
			{
				<div class="progress @AimedClass( "progress" )" @ref="SeekBar" onclick=@((Action<PanelEvent>)OnSeekBarClick)>
					<div class="fill" @ref="SeekFill"></div>
				</div>
			}
			else
			{
				<div class="live"><i>sensors</i> LIVE</div>
			}

			<div class="controls">
				@if ( Player.CanLocalControl )
				{
					@if ( !Player.IsLive )
					{
						<div class="btn back @AimedClass( "back" )" onclick="@SeekBack"><i>replay_10</i></div>
						<div class="btn playpause @AimedClass( "playpause" )" onclick="@TogglePause"><i>@(Player.Paused ? "play_arrow" : "pause")</i></div>
						<div class="btn forward @AimedClass( "forward" )" onclick="@SeekForward"><i>forward_10</i></div>
					}
					<div class="btn next @AimedClass( "next" )" onclick="@Next"><i>skip_next</i></div>
					<div class="btn stop @AimedClass( "stop" )" onclick="@Stop"><i>stop</i></div>
				}
				<div class="spacer"></div>
				@if ( Player.Queue.Count > 0 )
				{
					<div class="queue-count"><i>queue_music</i> @Player.Queue.Count</div>
				}
				@if ( !string.IsNullOrWhiteSpace( Player.RequestedBy ) )
				{
					<div class="by">Requested by @Player.RequestedBy</div>
				}
			</div>
		</div>
	}

	@if ( !string.IsNullOrWhiteSpace( Player?.LocalNotice ) )
	{
		<div class="notice">@Player.LocalNotice</div>
	}

	@* "Press E" dot: the USE button's glyph. Always in the tree (last, so it draws on top); UpdatePressDot shows
	   it and moves it onto the aimed control every frame. *@
	<div class="press-dot" @ref="PressDot"><InputGlyph Action="@UseAction"></InputGlyph></div>

</root>

@code
{
	/// <summary>
	/// The player this screen shows. Found automatically in this object or its parents if unset.
	/// </summary>
	[Property] public MediaPlayer Player { get; set; }

	/// <summary>
	/// Text shown when nothing is playing.
	/// </summary>
	[Property] public string IdleText { get; set; } = "Media Player";

	/// <summary>
	/// Scale the UI to fit the WorldPanel's PanelSize, so it looks the same (just smaller or bigger) at any
	/// screen size instead of getting cut off. Sets the WorldPanel's RenderScale.
	/// </summary>
	[Property, Group( "Layout" )] public bool FitToPanel { get; set; } = true;

	/// <summary>
	/// The panel size the UI is designed for. With FitToPanel, the UI is laid out at this size and scaled.
	/// </summary>
	[Property, Group( "Layout" ), ShowIf( nameof( FitToPanel ), true )] public Vector2 DesignSize { get; set; } = new Vector2( 1920, 1080 );

	/// <summary>
	/// Size the screen (the WorldPanel's PanelSize) to the screen's model - the frame / TV you see -
	/// so resizing or swapping the model resizes the picture and UI with it. Updates live in the editor.
	/// </summary>
	[Property, Group( "Layout" )] public bool FitToObject { get; set; } = true;

	/// <summary>
	/// The model to fit to. Defaults to the first ModelRenderer on this object or its children.
	/// </summary>
	[Property, Group( "Layout" ), ShowIf( nameof( FitToObject ), true )] public ModelRenderer SizeFrom { get; set; }

	/// <summary>
	/// Border left around the picture on each side, in world units (the frame's bezel).
	/// </summary>
	[Property, Group( "Layout" ), ShowIf( nameof( FitToObject ), true ), Range( 0, 20 )] public float Bezel { get; set; } = 2.0f;

	/// <summary>
	/// Also fit this object's BoxCollider to the model, so USE / looking works over the whole screen.
	/// </summary>
	[Property, Group( "Layout" ), ShowIf( nameof( FitToObject ), true )] public bool FitCollider { get; set; } = true;

	bool warnedOffCenter;

	void FitToScreenObject()
	{
		var model = SizeFrom.IsValid() ? SizeFrom
			: Components.GetAll<ModelRenderer>( FindMode.EnabledInSelfAndDescendants ).FirstOrDefault( m => m.Model is not null );
		if ( model is null ) return;

		var bounds = PanelFit.FitToModel( this, model, Bezel, FitCollider );

		// The panel is always centered on this object - tell the mapper if their model isn't
		if ( bounds is BBox b && !warnedOffCenter && new Vector2( b.Center.y, b.Center.z ).Length > MathF.Max( 2, b.Size.y * 0.05f ) )
		{
			warnedOffCenter = true;
			Log.Warning( $"[bimp] {GameObject.Name}: the screen model isn't centered on the Media Screen object, the picture will be offset. Move the model so its face is centered on the object." );
		}
	}

	/// <summary>
	/// Show the title/progress bar all the time.
	/// </summary>
	[Property, Group( "Controls" )] public bool AlwaysShowInfo { get; set; } = false;

	/// <summary>
	/// Hide the controls this many seconds after you start looking at the screen, last press a button, or
	/// move your aim off a button (they never time out while you're aiming at one). Once hidden they stay
	/// hidden while you keep looking, and come back when you look away and back. 0 = never hide.
	/// </summary>
	[Property, Group( "Controls" ), Range( 0, 30 )] public float HideControlsAfter { get; set; } = 3.0f;

	/// <summary>
	/// How long you have to look away before looking back shows the controls again. Stops them flickering
	/// back when your aim just grazes the edge of the screen.
	/// </summary>
	[Property, Group( "Controls" ), Range( 0, 2 )] public float LookAwayTime { get; set; } = 0.3f;

	/// <summary>
	/// Keep the controls up while paused (like most video players).
	/// </summary>
	[Property, Group( "Controls" )] public bool ShowControlsWhilePaused { get; set; } = true;

	/// <summary>
	/// How far away you can be for looking at the screen to count. Uses the WorldPanel's interaction range if 0.
	/// </summary>
	[Property, Group( "Controls" )] public float LookRange { get; set; } = 0;

	/// <summary>
	/// Don't count looking at the screen through walls.
	/// </summary>
	[Property, Group( "Controls" )] public bool CheckLineOfSight { get; set; } = true;

	/// <summary>
	/// Add a WorldInput to the camera if the scene doesn't have one, so the buttons can be clicked.
	/// </summary>
	[Property, Group( "Controls" )] public bool AddWorldInput { get; set; } = true;

	/// <summary>
	/// The input action that presses the aimed button, or opens the full remote. Works from anywhere you can
	/// point at the screen (LookRange), not just the PlayerController's short reach.
	/// </summary>
	[Property, Group( "Controls" ), InputAction] public string UseAction { get; set; } = "use";

	/// <summary> Seconds jumped by the back / forward buttons. </summary>
	[Property, Group( "Controls" ), Range( 1, 60 )] public float SeekStep { get; set; } = 10;

	/// <summary>
	/// Is the local player looking at (or pointing the mouse at) this screen right now?
	/// </summary>
	public bool IsLookedAt { get; private set; }

	/// <summary>
	/// Are the controls showing right now?
	/// </summary>
	public bool ControlsVisible { get; private set; }

	Panel VideoSurface { get; set; }
	Panel SeekBar { get; set; }
	Panel SeekFill { get; set; }

	RealTimeSince sinceChanged = 0;
	RealTimeSince sinceActivity = 100;
	RealTimeSince sinceLookedAway = 100;
	string lastTitle;

	string FlashIcon;
	string FlashText;
	int flashId;
	RealTimeSince sinceFlash = 100;

	bool ShowAudioVisual => Player.AudioOnly || (Player.Backend is not null && Player.Backend.Loaded && !Player.Backend.HasVideo);

	string RootClass
	{
		get
		{
			var c = "mediascreen";
			if ( Player is null || !Player.HasMedia ) c += " idle";
			if ( Player?.Paused ?? false ) c += " paused";
			if ( ControlsVisible ) c += " controls-visible";
			if ( AlwaysShowInfo ) c += " always-info";
			return c;
		}
	}

	string TimeText
	{
		get
		{
			if ( Player.IsLive ) return "Live";
			var cur = MediaSource.FormatTime( Player.CurrentTime );
			return Player.MediaDuration > 0 ? $"{cur} / {MediaSource.FormatTime( Player.MediaDuration )}" : cur;
		}
	}

	protected override void OnStart()
	{
		base.OnStart();
		Player ??= GetComponentInParent<MediaPlayer>();
	}

	protected override void OnUpdate()
	{
		if ( FitToObject ) FitToScreenObject();
		if ( FitToPanel ) PanelFit.Apply( this, DesignSize );

		// In the editor we only keep the layout fitted, so the scene view shows the real result
		if ( Scene.IsEditor ) return;

		Player ??= GetComponentInParent<MediaPlayer>();

		if ( Player?.Title != lastTitle )
		{
			lastTitle = Player?.Title;
			sinceChanged = 0;
		}

		if ( VideoSurface.IsValid() )
		{
			var backend = Player?.Backend;
			var tex = backend is not null && backend.HasVideo && !Player.AudioOnly ? backend.Texture : null;
			VideoSurface.Style.SetBackgroundImage( tex );
		}

		if ( SeekFill.IsValid() && Player is not null && Player.MediaDuration > 0 )
		{
			SeekFill.Style.Width = Length.Percent( (Player.CurrentTime / Player.MediaDuration * 100.0f).Clamp( 0, 100 ) );
		}

		EnsureWorldInput();
		UpdateLook();
		UpdateVisibility();
		UpdateUse();
		UpdatePressDot();

		if ( FlashIcon is not null && sinceFlash > 0.8f )
			FlashIcon = null;
	}

	#region Looking at the screen

	void UpdateLook()
	{
		var wasLooking = IsLookedAt;
		IsLookedAt = TryGetLookPoint( out lookPoint );

		// Which button the crosshair is on - highlighted, and what USE presses
		Panel aimed = null;
		var previous = AimedControl;
		AimedControl = IsLookedAt && ControlsVisible ? FindControlAt( lookPoint, out aimed ) : null;
		aimedPanel = aimed;

		if ( MediaSettings.Debug && (AimedControl != previous || wasLooking != IsLookedAt) )
		{
			var layout = Panel.IsValid() ? LayoutPos( lookPoint ) : default;
			Log.Info( $"[bimp] {GameObject.Name}: looking={IsLookedAt} controls={ControlsVisible} aim={lookPoint:0} (layout {layout:0}, root {Panel?.Box.Rect}) -> {AimedControl ?? "nothing"}" +
				(aimed.IsValid() ? $" rect {aimed.Box.Rect}" : "") );
		}

		if ( AimedControl == "progress" && aimed.IsValid() && aimed.Box.Rect.Width > 0 )
			AimedFraction = ((LayoutPos( lookPoint ).x - aimed.Box.Rect.Left) / aimed.Box.Rect.Width).Clamp( 0, 1 );

		// No timeout while hovering a button - the countdown only starts once you move off it
		if ( AimedControl is not null )
			Activity();

		if ( !IsLookedAt )
		{
			if ( wasLooking ) sinceLookedAway = 0;
			return;
		}

		// Only (re)starting to look shows the controls. Moving around, or moving your aim across the
		// screen, while you keep looking doesn't - once they've timed out they stay hidden until you
		// look away (for at least LookAwayTime) and come back.
		if ( !wasLooking && sinceLookedAway >= LookAwayTime )
			Activity();
	}

	/// <summary>
	/// Where on the panel (in panel pixels, 0,0 top left) the local camera or mouse cursor points, if it's on the screen.
	/// </summary>
	bool TryGetLookPoint( out Vector2 point )
	{
		point = default;

		var camera = Scene.Camera;
		var worldPanel = Components.Get<Sandbox.WorldPanel>();
		if ( camera is null || worldPanel is null || !worldPanel.Enabled ) return false;
		if ( MediaRemote.IsOpen( Scene ) ) return false;

		var ray = Mouse.Active ? camera.ScreenPixelToRay( Mouse.Position ) : new Ray( camera.WorldPosition, camera.WorldRotation.Forward );
		var range = LookRange > 0 ? LookRange : worldPanel.InteractionRange;

		var plane = new Plane( WorldPosition, WorldRotation.Forward );
		if ( plane.Trace( ray, true, range ) is not Vector3 hit ) return false;

		// The panel's x runs along our local +Y, its y down our local -Z, in ScreenToWorldScale units
		var local = WorldTransform.PointToLocal( hit );
		var scale = Sandbox.UI.WorldPanel.ScreenToWorldScale;
		var p = new Vector2( local.y / scale, -local.z / scale );

		var size = worldPanel.PanelSize;
		var origin = Vector2.Zero;
		if ( worldPanel.HorizontalAlign == Sandbox.WorldPanel.HAlignment.Center ) origin.x = -size.x * 0.5f;
		if ( worldPanel.HorizontalAlign == Sandbox.WorldPanel.HAlignment.Right ) origin.x = -size.x;
		if ( worldPanel.VerticalAlign == Sandbox.WorldPanel.VAlignment.Center ) origin.y = -size.y * 0.5f;
		if ( worldPanel.VerticalAlign == Sandbox.WorldPanel.VAlignment.Bottom ) origin.y = -size.y;

		p -= origin;
		if ( p.x < 0 || p.y < 0 || p.x > size.x || p.y > size.y ) return false;

		if ( CheckLineOfSight && IsBlocked( ray.Position, hit ) ) return false;

		point = p;
		return true;
	}

	bool IsBlocked( Vector3 from, Vector3 to )
	{
		var dir = (to - from).Normal;
		var trace = Scene.Trace.Ray( from, to - dir * 4.0f ).IgnoreGameObjectHierarchy( GameObject.Root );

		// third person cameras look through the local player's own body
		var local = Scene.GetAllComponents<PlayerController>().FirstOrDefault( p => !p.IsProxy );
		if ( local.IsValid() ) trace = trace.IgnoreGameObjectHierarchy( local.GameObject );

		return trace.Run().Hit;
	}

	/// <summary> For probes: why the controls are (or aren't) showing. </summary>
	internal string DescribeControls() => $"looked={IsLookedAt} visible={ControlsVisible} aimed={AimedControl ?? "-"} sinceActivity={(float)sinceActivity:0.0} sinceChanged={(float)sinceChanged:0.0} paused={Player?.Paused} mouseActive={Mouse.Active} mouse={Mouse.Position:0} controller={Input.UsingController} virtualCursor={Input.EnableVirtualCursor} root=[{Panel?.Classes}] osd=" +
		string.Join( ", ", Panel?.Descendants.Where( p => p.HasClass( "osd" ) || p.HasClass( "meta" ) || p.HasClass( "title" ) || p.HasClass( "time" ) ).Select( p => $"{p.ElementName}.{string.Join( ".", p.Class )} opacity {p.ComputedStyle?.Opacity}" ) ?? Enumerable.Empty<string>() );

	void Activity()
	{
		sinceActivity = 0;
	}

	//
	// Aiming at the on-screen buttons (for pressing them with USE)
	//

	Vector2 lookPoint;

	/// <summary>
	/// The control under the local player's crosshair, if the controls are showing:
	/// "back", "playpause", "forward", "next", "stop" or "progress". Null otherwise.
	/// </summary>
	public string AimedControl { get; private set; }

	static readonly string[] ControlNames = { "back", "playpause", "forward", "next", "stop", "progress" };

	string AimedClass( string name ) => AimedControl == name ? "aimed" : "";

	/// <summary>
	/// Where along the progress bar the crosshair is (0-1), when aiming at it.
	/// </summary>
	public float AimedFraction { get; private set; }

	Panel PressDot { get; set; }
	Panel aimedPanel;

	/// <summary>
	/// Show the "press E" dot on the aimed control: its top-right corner for buttons, above the aimed spot
	/// for the progress bar. Positioned from the control's layout rect (screen space) converted back to
	/// our CSS pixels, so it's right at any PanelSize / RenderScale.
	/// </summary>
	void UpdatePressDot()
	{
		if ( !PressDot.IsValid() ) return;

		var root = Panel;
		if ( AimedControl is null || !aimedPanel.IsValid() || !root.IsValid() )
		{
			PressDot.Style.Display = DisplayMode.None;
			return;
		}

		var r = aimedPanel.Box.Rect;
		var onBar = AimedControl == "progress";
		var anchor = onBar ? new Vector2( LayoutPos( lookPoint ).x, r.Top ) : new Vector2( r.Right, r.Top );

		var local = (anchor - root.Box.Rect.Position) * root.ScaleFromScreen;
		if ( onBar ) local.y -= 40; // sit above the bar

		PressDot.Style.Display = DisplayMode.Flex;
		PressDot.Style.Left = Length.Pixels( local.x );
		PressDot.Style.Top = Length.Pixels( local.y );
	}

	/// <summary>
	/// A human readable name for what pressing USE on the aimed control does, for tooltips.
	/// </summary>
	public string AimedActionName => AimedControl switch
	{
		"back" => $"Back {SeekStep:0}s",
		"playpause" => Player?.Paused == true ? "Play" : "Pause",
		"forward" => $"Forward {SeekStep:0}s",
		"next" => "Next",
		"stop" => "Stop",
		"progress" => "Seek here",
		_ => null,
	};

	/// <summary>
	/// Find the control at a point on the panel (in PanelSize pixels, see TryGetLookPoint).
	/// <paramref name="control"/> is the control's panel.
	/// </summary>
	string FindControlAt( Vector2 point, out Panel control )
	{
		control = null;
		var root = Panel;
		if ( !root.IsValid() ) return null;

		var pos = LayoutPos( point );

		foreach ( var p in root.Descendants )
		{
			if ( !p.HasClass( "btn" ) && !p.HasClass( "progress" ) ) continue;
			if ( !p.IsVisible || !p.IsInside( pos ) ) continue;

			var name = ControlNames.FirstOrDefault( p.HasClass );
			if ( name is null ) continue;

			control = p;
			return name;
		}

		return null;
	}

	/// <summary>
	/// USE while looking at the screen: press the aimed button, else open the full remote.
	/// </summary>
	void UpdateUse()
	{
		if ( !IsLookedAt || Player is null || string.IsNullOrWhiteSpace( UseAction ) ) return;
		if ( !Input.Pressed( UseAction ) ) return;

		// A PlayerController in reach may also press our MediaInteract this frame - only act once
		if ( !UseHandled.TryConsume() ) return;

		if ( !TryPressAimedControl() )
			MediaRemote.Toggle( Player );
	}

	/// <summary>
	/// Press whatever on-screen control the local player is aiming at. Returns false if they aren't
	/// aiming at one (or the controls are hidden) - the caller then opens the full remote instead.
	/// </summary>
	public bool TryPressAimedControl()
	{
		if ( Player is null || !Player.HasMedia || !ControlsVisible ) return false;
		if ( !TryGetLookPoint( out var point ) ) return false;

		var name = FindControlAt( point, out var control );
		if ( name is null ) return false;

		switch ( name )
		{
			case "back": SeekBack(); break;
			case "playpause": TogglePause(); break;
			case "forward": SeekForward(); break;
			case "next": Next(); break;
			case "stop": Stop(); break;
			case "progress":
				Activity();
				if ( Player.CanLocalControl && Player.MediaDuration > 0 )
				{
					var pos = LayoutPos( point );
					var frac = ((pos.x - control.Box.Rect.Left) / control.Box.Rect.Width).Clamp( 0, 1 );
					Flash( "fast_forward", MediaSource.FormatTime( frac * Player.MediaDuration ) );
					Player.RequestSeek( frac * Player.MediaDuration );
				}
				break;
		}

		return true;
	}

	/// <summary>
	/// PanelSize pixels -> the panel's layout coordinates. Going through the root panel's own rect keeps this
	/// right whatever the RenderScale (FitToPanel) or alignment is.
	/// </summary>
	Vector2 LayoutPos( Vector2 point )
	{
		var size = Components.Get<Sandbox.WorldPanel>()?.PanelSize ?? Vector2.One;
		var rect = Panel.Box.Rect;
		return rect.Position + new Vector2( point.x / size.x * rect.Width, point.y / size.y * rect.Height );
	}

	void UpdateVisibility()
	{
		var hasMedia = Player?.HasMedia ?? false;
		var recentlyActive = HideControlsAfter <= 0 ? IsLookedAt : sinceActivity < HideControlsAfter;

		ControlsVisible = hasMedia && (
			(IsLookedAt && recentlyActive) ||
			(ShowControlsWhilePaused && Player.Paused) ||
			sinceChanged < 4 );
	}

	// A click while the controls are up keeps them up for another HideControlsAfter. Clicks on a screen whose
	// controls have timed out don't bring them back (the hidden controls don't take clicks either).
	protected override void OnMouseDown( MousePanelEvent e )
	{
		if ( ControlsVisible ) Activity();
	}

	void EnsureWorldInput()
	{
		if ( !AddWorldInput || Scene.Camera is null ) return;
		if ( Scene.GetAllComponents<Sandbox.WorldInput>().Any() ) return;

		var input = Scene.Camera.GameObject.Components.Create<Sandbox.WorldInput>();
		input.Flags |= ComponentFlags.NotSaved | ComponentFlags.NotNetworked;
	}

	#endregion

	#region Controls

	bool CanAct()
	{
		Activity();
		if ( Player is null || !Player.HasMedia ) return false;
		if ( Player.CanLocalControl ) return true;

		Flash( "lock", "No permission" );
		return false;
	}

	void TogglePause()
	{
		if ( !CanAct() || Player.IsLive ) return;
		Flash( Player.Paused ? "play_arrow" : "pause" );
		Player.RequestSetPaused( !Player.Paused );
	}

	void SeekBack()
	{
		if ( !CanAct() || Player.IsLive ) return;
		Flash( "replay_10", $"-{SeekStep:0}s" );
		Player.RequestSeek( Player.CurrentTime - SeekStep );
	}

	void SeekForward()
	{
		if ( !CanAct() || Player.IsLive ) return;
		Flash( "forward_10", $"+{SeekStep:0}s" );
		Player.RequestSeek( Player.CurrentTime + SeekStep );
	}

	void Next()
	{
		if ( !CanAct() ) return;
		Flash( "skip_next", Player.Queue.Count > 0 ? "Next" : "End of queue" );
		Player.RequestSkip();
	}

	void Stop()
	{
		if ( !CanAct() ) return;
		Flash( "stop" );
		Player.RequestStop();
	}

	void Flash( string icon, string text = null )
	{
		FlashIcon = icon;
		FlashText = text;
		flashId++;
		sinceFlash = 0;
	}

	void OnSeekBarClick( PanelEvent e )
	{
		if ( !ControlsVisible ) return;
		Activity();
		if ( Player is null || !Player.CanLocalControl || Player.MediaDuration <= 0 ) return;
		if ( e is not MousePanelEvent me || !SeekBar.IsValid() ) return;

		var width = SeekBar.Box.Rect.Width;
		if ( width <= 0 ) return;

		var frac = (me.LocalPosition.x / width).Clamp( 0, 1 );
		Player.RequestSeek( frac * Player.MediaDuration );
	}

	#endregion

	/// <summary> The caption line(s) to show now, null for none (see <see cref="MediaPlayer.CurrentCaption"/>). </summary>
	string CaptionText => Player?.CurrentCaption;

	protected override int BuildHash()
	{
		if ( Player is null ) return 0;

		return HashCode.Combine(
			HashCode.Combine( Player.PlayId, Player.HasMedia, Player.Title, Player.Paused, Player.Status, Player.LocalNotice, Player.Queue.Count ),
			HashCode.Combine( (int)Player.CurrentTime, Player.MediaDuration, Player.Backend?.Loaded, Player.Backend?.HasVideo, Player.CanLocalControl, RootClass ),
			HashCode.Combine( FlashIcon, flashId, AimedControl, UseAction, CaptionText )
		);
	}
}
@using Sandbox;
@using Sandbox.UI;
@inherits PanelComponent
@namespace TwitchAPI.Examples

<root>
</root>

@code
{
	/// <summary>
	/// The username of the Twitch Chat to connect to.
	/// </summary>
	[Property] string Username { get; set; }

	/// <summary>
	/// Whether or not to include the user's avatar images in the chat.
	/// </summary>
	[Property] bool HasAvatarImages { get; set; } = false;

	/// <summary>
	/// How long a message should be displayed for (in seconds).
	/// </summary>
	[Property, Group("Timing")] float MessageTime { get; set; } = 30f;

	/// <summary>
	/// How long a message should take to fade out (in seconds).
	/// </summary>
	[Property, Group("Timing")] float FadeTime { get; set; } = 0.5f;

	/// <summary>
	/// The position of the chat on the screen.
	/// </summary>
	[Property, Group("Styling")] Vector2 ChatPosition { get; set; } = new(5f, 95f);

	/// <summary>
	/// The size of the chat. Set to (0, 0) to scale automatically.
	/// </summary>
	[Property, Group("Styling")] Vector2 ChatSize { get; set; } = new(400f, 600f);

	/// <summary>
	/// The background color of the chat.
	/// </summary>
	[Property, Group("Styling")] Color BackgroundColor { get; set; } = Color.Transparent;

	/// <summary>
	/// The color of the text in the chat.
	/// </summary>
	[Property, Group("Styling")] Color TextColor { get; set; } = Color.White;

	/// <summary>
	/// The size of the text in the chat.
	/// </summary>
	[Property, Group("Styling")] float TextSize { get; set; } = 16f;

	/// <summary>
	/// The font family to use for the text in the chat.
	/// </summary>
	[Property, Group("Styling")] string FontFamily { get; set; } = "Poppins";

	/// <summary>
	/// The size of the gaps between messages in the chat.
	/// </summary>
	[Property, Group("Styling")] float GapSize { get; set; } = 8f;

	/// <summary>
	/// The padding around messages in the chat.
	/// </summary>
	[Property, Group("Styling")] float Padding { get; set; } = 8f;

	/// <summary>
	/// Whether or not to reverse the order of messages in the chat.
	/// </summary>
	[Property, Group("Styling")] bool ReverseOrder { get; set; } = false;

	/// <summary>
	/// Whether or not to use custom images for subscriber badges.
	/// </summary>
	[Property, Group("Badges")] bool HasCustomSubscriberBadges { get; set; } = false;

	/// <summary>
	/// The custom subscriber badges to use.
	/// </summary>
	[Property, Group("Badges"), InlineEditor, ShowIf(nameof(TwitchChatExample.HasCustomSubscriberBadges), true)] CustomSubscriberBadges SubscriberBadges { get; set; } = new();

	TwitchChatConnection Twitch;

	protected override void OnEnabled()
	{
		if (string.IsNullOrWhiteSpace(Username)) return;
		Twitch = new TwitchChatConnection(Username);
		Twitch.OnMessageReceived += OnMessageReceived;
		Twitch.OnMessageRemoved += OnMessageRemoved;
		Twitch.OnChatCleared += OnChatCleared;
	}

	protected override void OnDisabled()
	{
		if (Twitch is not null)
		{
			Twitch.OnMessageReceived -= OnMessageReceived;
			Twitch.OnMessageRemoved -= OnMessageRemoved;
			Twitch.OnChatCleared -= OnChatCleared;
			Twitch.Dispose();
		}
	}

	protected override void OnUpdate()
	{
		Panel.Style.BackgroundColor = BackgroundColor;
		Panel.Style.FontColor = TextColor;
		Panel.Style.FontSize = TextSize;
		Panel.Style.FontFamily = FontFamily;
		Panel.Style.RowGap = GapSize;
		Panel.Style.Padding = Padding;
		Panel.Style.FlexDirection = ReverseOrder ? FlexDirection.ColumnReverse : FlexDirection.Column;
		Panel.Style.JustifyContent = Justify.FlexEnd;

		if (ChatSize == 0)
		{
			Panel.Style.Width = Length.Auto;
			Panel.Style.Height = Length.Auto;
		}
		else
		{
			Panel.Style.Width = Length.Pixels(ChatSize.x);
			Panel.Style.Height = Length.Pixels(ChatSize.y);
		}

		Panel.Style.MaxWidth = Panel.Style.Width;
		Panel.Style.MaxHeight = Panel.Style.Height;

		if (ChatPosition.x < 50f)
		{
			Panel.Style.Left = Length.Percent(ChatPosition.x);
			Panel.Style.Right = default;
		}
		else
		{
			Panel.Style.Left = default;
			Panel.Style.Right = Length.Percent((100f - ChatPosition.x));
		}

		if (ChatPosition.y < 50f)
		{
			Panel.Style.Top = Length.Percent(ChatPosition.y);
			Panel.Style.Bottom = default;
		}
		else
		{
			Panel.Style.Top = default;
			Panel.Style.Bottom = Length.Percent((100f - ChatPosition.y));
		}
	}

	void OnMessageReceived(TwitchChatMessage message)
	{
		var entry = Panel.AddChild<TwitchChatEntry>();
		entry.ChatMessage = message;
		entry.Lifetime = MessageTime;
		entry.HasAvatarImages = HasAvatarImages;
	}

	void OnMessageRemoved(TwitchChatMessage message)
	{
		foreach (var child in Panel.Children)
		{
			if (child is TwitchChatEntry entry && entry.ChatMessage == message)
			{
				entry.Delete();
				break;
			}
		}
	}

	void OnChatCleared()
	{
		Panel.DeleteChildren();
	}

	protected override int BuildHash() => System.HashCode.Combine(Username);
}
@using Sandbox
@using Sandbox.UI
@using System
@using System.Collections.Generic
@namespace FieldGuide.Placement
@inherits PanelComponent
@attribute [StyleSheet]

@*
	A live transform tuner for objects hanging off something else: an accessory on a character mount, a
	part on a vehicle, a prop in a scene. One tab per ITweakTarget supplied by the scene's TweakSession;
	the active tab edits that target GameObject's LocalPosition, LocalRotation (as pitch / yaw / roll) and
	uniform LocalScale, so what you drag is the OFFSET, which is the number your game code wants.

	Each row is a draggable slider (click-jump + drag-scrub, the engine SliderControl mechanic) with a
	small +/- stepper beside it for precision a drag can't hit; the three-state step toggle divides or
	multiplies that stepper by ten. Row ranges come from the target's TweakRanges, so an accessory gets
	character-scale sliders and a scene prop gets scene-scale ones.

	Copy puts the active target's paste-ready bake line on the clipboard (game-side Clipboard.SetText).
	Export writes every target and every placed object into FileSystem.Data as JSON plus a C# snippet.
	Reset restores the transform captured when the target was registered, never zero.

	Rows render in MAIN markup via @foreach (not a RenderFragment) per the fragment-undermeasure gotcha,
	and add shapes only (track/fill), keeping the text-run count low. Toggle with P (raw key, letter,
	never an F key), the 42px x in the header, or the `placement_panel` console convar. Starts closed
	unless OpenOnStart is set; see the boot block in OnUpdate.

	Look and layout follow the Field Kit UI system: docs/design/ui-system/placement-kit.dc.html for
	this screen, tokens.dc.html for the values. The stylesheet carries this kit's own copy of those
	tokens (kits cannot import each other) and lists the engine-legality translations at its head.
*@

<root>
@if ( PanelOpen )
{
	<div class="tp-card">
		<div class="tp-hdr">
			<span class="tp-title">TRANSFORM TWEAK</span>
			<div class="tp-hr">
				<span class="tp-key">P</span>
				<div class="tp-x" onclick=@ClosePanel>×</div>
			</div>
		</div>

		@if ( TargetList.Count == 0 )
		{
			<div class="tp-empty">No targets. Add GameObjects to a TweakSession (Objects list) or call TweakSession.Add(...).</div>
		}
		else
		{
			@* ---- one tab per target ---- *@
			<div class="tp-tabs">
				@for ( int i = 0; i < TargetList.Count; i++ )
				{
					var idx = i;
					string tab = TargetList[idx].DisplayName;
					<div class="tp-tab @(idx == ActiveIndex ? "on" : "")" onclick=@(() => SelectTab( idx ))>@tab</div>
				}
			</div>

			<div class="tp-sub">
				<span class="tp-item">@ActiveName</span>
				<span class="tp-note">@FrameNote</span>
			</div>

			@* ---- step-size toggle (segments live inside a pill group per the design system) ---- *@
			<div class="tp-steprow">
				<span class="tp-sl">Step</span>
				<div class="tp-seg-group">
					<div class="tp-seg @(_step == StepSize.XFine ? "on" : "")" onclick=@(() => _step = StepSize.XFine)>xfine ÷10</div>
					<div class="tp-seg @(_step == StepSize.Fine ? "on" : "")" onclick=@(() => _step = StepSize.Fine)>fine</div>
					<div class="tp-seg @(_step == StepSize.Coarse ? "on" : "")" onclick=@(() => _step = StepSize.Coarse)>coarse ×10</div>
				</div>
			</div>

			@* ---- editable rows (draggable slider + stepper) ---- *@
			<div class="tp-rows">
				@foreach ( var r in Rows )
				{
					var row = r;
					float cur = Get( ActiveTarget, row.field );
					string val = cur.ToString( row.fmt );
					string lab = row.label;   // plain local before interpolating: an inline field read can render blank
					int fillPct = (int)( Frac( cur, row ) * 100f );
					<div class="tp-row @(row.header ? "hdr" : "")">
						<div class="tp-rlab">
							<span class="tp-rl">@lab</span>
							<span class="tp-rv">@val</span>
						</div>
						<div class="tp-slider">
							<span class="tp-stp" onclick=@(() => Nudge( row.field, -row.step * StepMult ))>−</span>
							<div class="tp-track"
								onmousedown=@(e => TrackPointer( e, row, true ))
								onmousemove=@(e => TrackPointer( e, row, false ))>
								<div class="tp-fill" style="width: @(fillPct)%;"></div>
							</div>
							<span class="tp-stp" onclick=@(() => Nudge( row.field, row.step * StepMult ))>+</span>
						</div>
					</div>
				}
			</div>

			@* ---- actions ---- *@
			<div class="tp-btns">
				<div class="tp-btn" onclick=@ResetActive>Reset</div>
				<div class="tp-btn primary" onclick=@CopyActive>@_copyLabel</div>
			</div>
			<div class="tp-btns">
				<div class="tp-btn" onclick=@ExportNow>Export all to Data</div>
			</div>
		}
	</div>
}
</root>

@code
{
	// ---- toggle state (P raw key + `placement_panel` convar fallback) ----
	static bool _open;

	/// <summary>Console fallback: `placement_panel 1` / `placement_panel 0` toggles the panel (P also toggles).</summary>
	[ConVar( "placement_panel", Help = "Open or close the transform tweak panel (same as the P key)" )]
	public static bool PanelOpen { get => _open; set => _open = value; }

	/// <summary>
	/// Whether this panel starts open. Off by default: a tuning panel that appears unbidden over a
	/// consumer's game is a bug, not a feature. Turn it on for a scene whose whole point is the panel, the
	/// way the kit's own demo does.
	///
	/// This is what decides the panel's boot state, and it is the ONLY thing that decides it. See the boot
	/// block in OnUpdate for why that matters.
	/// </summary>
	[Property] public bool OpenOnStart { get; set; }

	int _activeIndex;
	string _copyLabel = "Copy";
	bool _wasOpen;
	bool _booted;

	/// <summary>Three-state stepper size (owner ruling, ported from the World Builder mount tuner): xfine for
	/// final seating, fine for normal work, coarse for getting into the neighbourhood.</summary>
	enum StepSize { XFine, Fine, Coarse }
	StepSize _step = StepSize.Fine;

	IReadOnlyList<ITweakTarget> TargetList => TweakSession.Instance?.Targets ?? System.Array.Empty<ITweakTarget>();

	int ActiveIndex
	{
		get => TargetList.Count == 0 ? 0 : Math.Clamp( _activeIndex, 0, TargetList.Count - 1 );
		set => _activeIndex = value;
	}

	// Named ActiveTarget (not Active): PanelComponent/Component already exposes an inherited
	// bool Active, and a razor-generated member named Active would hide it (CS0108).
	ITweakTarget ActiveTarget => TargetList.Count == 0 ? null : TargetList[ActiveIndex];

	/// <summary>The active target's label, resolved to a single-identifier property so the markup never
	/// interpolates a chained member read (which renders blank in several razor cases).</summary>
	string ActiveName => ActiveTarget?.DisplayName ?? "";

	/// <summary>The sub-line under the tabs: what the numbers below are measured against. This is the one
	/// piece of context that makes a baked offset readable later, so the panel shows it while you drag.</summary>
	string FrameNote
	{
		get
		{
			var frame = ActiveTarget?.FrameName;
			return string.IsNullOrEmpty( frame ) ? "local offset · edited live" : $"{frame} · edited live";
		}
	}

	void SelectTab( int idx )
	{
		ActiveIndex = idx;
		_copyLabel = "Copy";   // a stale "Copied!" on a different target reads as a lie
	}

	void ClosePanel()
	{
		PanelOpen = false;
		_copyLabel = "Copy";
	}

	// ---- field model ----
	public enum Field { PosX, PosY, PosZ, Pitch, Yaw, Roll, Scale }

	struct Row { public Field field; public string label; public string fmt; public float step; public float min; public float max; public bool header; }

	/// <summary>The seven rows for the ACTIVE target, bounded by that target's TweakRanges. Built per read
	/// rather than held in a static array, because an accessory and a scene prop want very different
	/// ranges out of the same three sliders.</summary>
	List<Row> Rows
	{
		get
		{
			var g = ActiveTarget?.Ranges ?? TweakRanges.Accessory;
			float p = MathF.Max( g.PositionRange, 0.001f );
			return new List<Row>
			{
				new Row { field = Field.PosX,  label = "Pos X",   fmt = "0.###", step = g.PositionStep, min = -p, max = p },
				new Row { field = Field.PosY,  label = "Pos Y",   fmt = "0.###", step = g.PositionStep, min = -p, max = p },
				new Row { field = Field.PosZ,  label = "Pos Z",   fmt = "0.###", step = g.PositionStep, min = -p, max = p },
				new Row { field = Field.Pitch, label = "Pitch °", fmt = "0.#",   step = g.RotationStep, min = -180f, max = 180f, header = true },
				new Row { field = Field.Yaw,   label = "Yaw °",   fmt = "0.#",   step = g.RotationStep, min = -180f, max = 180f },
				new Row { field = Field.Roll,  label = "Roll °",  fmt = "0.#",   step = g.RotationStep, min = -180f, max = 180f },
				new Row { field = Field.Scale, label = "Scale",   fmt = "0.###", step = g.ScaleStep,    min = g.ScaleMin, max = g.ScaleMax, header = true },
			};
		}
	}

	float StepMult => _step switch { StepSize.Coarse => 10f, StepSize.XFine => 0.1f, _ => 1f };

	static float Frac( float value, Row row )
		=> Math.Clamp( (value - row.min) / MathF.Max( row.max - row.min, 0.0001f ), 0f, 1f );

	// ---- read/write the active target's LOCAL transform ----
	static float Get( ITweakTarget t, Field f )
	{
		var go = t?.Target;
		if ( go is null || !go.IsValid() ) return 0f;
		var p = go.LocalPosition;
		var a = go.LocalRotation.Angles();
		return f switch
		{
			Field.PosX => p.x,
			Field.PosY => p.y,
			Field.PosZ => p.z,
			Field.Pitch => a.pitch,
			Field.Yaw => a.yaw,
			Field.Roll => a.roll,
			Field.Scale => go.LocalScale.x,
			_ => 0f,
		};
	}

	void Nudge( Field f, float delta )
	{
		var go = ActiveTarget?.Target;
		if ( go is null || !go.IsValid() ) return;
		var p = go.LocalPosition;
		var a = go.LocalRotation.Angles();
		switch ( f )
		{
			case Field.PosX: go.LocalPosition = p.WithX( p.x + delta ); break;
			case Field.PosY: go.LocalPosition = p.WithY( p.y + delta ); break;
			case Field.PosZ: go.LocalPosition = p.WithZ( p.z + delta ); break;
			case Field.Pitch: go.LocalRotation = new Angles( a.pitch + delta, a.yaw, a.roll ).ToRotation(); break;
			case Field.Yaw: go.LocalRotation = new Angles( a.pitch, a.yaw + delta, a.roll ).ToRotation(); break;
			case Field.Roll: go.LocalRotation = new Angles( a.pitch, a.yaw, a.roll + delta ).ToRotation(); break;
			case Field.Scale:
				float s = MathF.Max( 0.01f, go.LocalScale.x + delta );
				go.LocalScale = new Vector3( s, s, s );
				break;
		}
	}

	/// <summary>Draggable track (World Builder left-UI idiom): onmousedown JUMPS to the click, onmousemove
	/// SCRUBS while Active. Snaps to the row's fine step, applies as a DELTA through Nudge so the same
	/// write path runs whether you drag or step.</summary>
	void TrackPointer( PanelEvent ev, Row row, bool jump )
	{
		if ( ev is not MousePanelEvent e ) return;
		var track = e.This;
		if ( track is null ) return;
		if ( !jump && !track.PseudoClass.HasFlag( PseudoClass.Active ) ) return;

		float w = track.Box.Rect.Width;
		if ( w <= 0f ) return;
		float frac = Math.Clamp( e.LocalPosition.x / w, 0f, 1f );
		float target = row.min + frac * (row.max - row.min);
		if ( row.step > 0f ) target = MathF.Round( target / row.step ) * row.step;
		target = Math.Clamp( target, row.min, row.max );
		Nudge( row.field, target - Get( ActiveTarget, row.field ) );
	}

	/// <summary>Restore the transform the target was REGISTERED with, not zero. Zeroing a hand-mounted
	/// accessory collapses it into the wrist, which is never the thing you wanted back.</summary>
	void ResetActive()
	{
		var go = ActiveTarget?.Target;
		if ( go is null || !go.IsValid() ) return;
		if ( TweakSession.Instance?.ResetToSeed( go ) == true ) return;

		// Never registered here (a hand-built target list, say): identity is the only baseline we have.
		go.LocalPosition = Vector3.Zero;
		go.LocalRotation = Rotation.Identity;
		go.LocalScale = Vector3.One;
	}

	/// <summary>Copy the active target's paste-ready bake line to the system clipboard. Game-side
	/// Sandbox.UI.Clipboard.SetText, so this works in play without an editor round trip.</summary>
	void CopyActive()
	{
		var t = ActiveTarget;
		if ( t is null ) return;
		var line = PlacementExport.BakeLine( t );
		if ( string.IsNullOrEmpty( line ) ) return;
		Sandbox.UI.Clipboard.SetText( line );
		_copyLabel = "Copied!";
	}

	void ExportNow()
	{
		if ( Scene is not null )
			PlacementExport.WriteAll( Scene );
	}

	// ---- boot state, P toggle, cursor while open ----

	protected override void OnUpdate()
	{
		// BOOT. `placement_panel` is a convar and s&box PERSISTS convars across sessions, so a session can
		// otherwise come up with the panel logically open from whatever someone left set weeks ago. The rule
		// that prevents it: this component's own OpenOnStart decides the boot state, and the persisted value
		// never does. Default off means a consumer game still cannot be pre-opened by a stale convar; a scene
		// that wants the panel up says so explicitly.
		//
		// Deliberately in the FIRST UPDATE rather than OnStart. A panel built in code is configured by the
		// component that created it, and doing this in OnStart would race that assignment: whichever ran
		// first would win. The first update is after every OnStart in the frame, so the setting is always
		// read, never half-applied.
		if ( !_booted )
		{
			_booted = true;
			if ( PanelOpen && !OpenOnStart )
				Log.Info( "[placement] tweak panel was OPEN at session start (persisted convar), forcing closed" );
			PanelOpen = OpenOnStart;
		}

		if ( Input.Keyboard.Pressed( "P" ) )
			PanelOpen = !PanelOpen;

		if ( PanelOpen )
		{
			Mouse.Visibility = MouseVisibility.Visible;   // keep the cursor usable over the panel
			_wasOpen = true;
		}
		else if ( _wasOpen )
		{
			_wasOpen = false;
			_copyLabel = "Copy";   // closing clears the flash, so a reopen never claims a copy that was not made
		}
	}

	// Fold the toggle, active tab, step mode, the copy label, and every displayed value (rounded) so
	// readouts update the instant a value is nudged. Miss one and the number freezes on screen.
	protected override int BuildHash()
	{
		int h = HashCode.Combine( PanelOpen, ActiveIndex, (int)_step, _copyLabel, TargetList.Count );
		var a = ActiveTarget;
		if ( a is not null )
		{
			h = HashCode.Combine( h, a.FrameName );
			foreach ( var r in Rows )
				h = HashCode.Combine( h, (int)MathF.Round( Get( a, r.field ) * 1000f ) );
		}
		return h;
	}
}
@using Sandbox
@using Sandbox.UI
@using System
@using System.Collections.Generic
@namespace FieldGuide.DayNight
@inherits PanelComponent
@attribute [StyleSheet]

@*
	The demo's on-screen key card, up from the first frame. A scene with no visible instructions reads as
	a broken scene: you press nothing, nothing happens, you close it. So this says what the demo is and
	which keys do what, before you have touched anything.

	It also carries the one thing the demo could not otherwise show. The kit ships no sky shader and no
	sky art on purpose; the sky is a SEAM, four normalized crossfade weights per hour. Those weights have
	no picture, so the card prints them live and they visibly hand off from one slot to the next as the
	clock runs. That is the seam doing its job, on screen, with no art involved.

	Rows render in MAIN markup via @foreach per the fragment-undermeasure gotcha. H hides the card (a
	letter, never an F key, which the editor eats in play). No ESC anywhere: house law.

	Look and layout follow the Field Kit UI system: docs/design/ui-system/daynight-kit.dc.html for this
	screen, tokens.dc.html for the values. Font sizes come from the {12, 13, 14, 16} panel scale and there
	is no letter-spacing; the stylesheet head lists the rest of the engine-legality rules.

	Not part of the kit's runtime surface: delete Code/Demo when you drop the kit into your own project.
*@

<root>
@* DemoActive is the inert-by-construction gate (library law 11): only DayNightDemoBootstrap sets it, so
   this card cannot appear in a consumer's game even if Code/Demo was left in the project. *@
@if ( CardOpen && DayNightDemoBootstrap.DemoActive )
{
	<div class="dh-card">
		<div class="dh-hdr">
			<span class="dh-title">DAY / NIGHT KIT DEMO</span>
			<div class="dh-x" onclick=@(() => CardOpen = false)>×</div>
		</div>

		<div class="dh-lede">One directional light, one skybox, one clock. Watch the sun sweep and the colour grade follow it, or open the time panel and drive the cycle yourself.</div>

		<div class="dh-rows">
			@foreach ( var r in Keys )
			{
				string key = r.key;     // plain locals before interpolating: an inline tuple read can render blank
				string what = r.what;
				<div class="dh-row">
					<span class="dh-key">@key</span>
					<span class="dh-what">@what</span>
				</div>
			}
		</div>

		<div class="dh-live">
			@foreach ( var w in Weights )
			{
				string run = w;
				<span class="dh-lk">@run</span>
			}
		</div>

		<div class="dh-foot">Those four are the sky seam. The kit ships no sky shader and no sky art; you crossfade your own sky from these weights.</div>
	</div>
}
</root>

@code
{
	static bool _open = true;

	/// <summary>Console fallback: `daynight_hint 1` / `daynight_hint 0` shows or hides the card (H also
	/// toggles). Starts SHOWN, unlike the time panel, because it is the thing that tells you the time panel
	/// exists.</summary>
	[ConVar( "daynight_hint", Help = "Show or hide the demo scene's key card (same as the H key)" )]
	public static bool CardOpen { get => _open; set => _open = value; }

	static readonly List<(string key, string what)> Keys = new()
	{
		( "N", "Open the time panel: scrub the clock, change the pace, pin the weather" ),
		( "H", "Hide this card" ),
	};

	DayNightClock _clock;

	DayNightClock Clock
	{
		get
		{
			if ( _clock.IsValid() ) return _clock;
			_clock = DayNightClock.For( Scene );
			return _clock;
		}
	}

	/// <summary>The live sky weights as four short atomic runs. Split into separate spans rather than one
	/// sentence so a wrap breaks BETWEEN runs; a single long run wraps mid-word, which is a live bug class
	/// in this engine's text layout.</summary>
	List<string> Weights
	{
		get
		{
			var c = Clock;
			var cfg = c?.Config ?? DayNightConfig.Default;
			var w = SkyWeights.WeightsFor( c?.GetTimeHours() ?? 0f, cfg );
			return new List<string>
			{
				$"MORNING {w.x:0.00}",
				$"NOON {w.y:0.00}",
				$"EVENING {w.z:0.00}",
				$"NIGHT {w.w:0.00}",
			};
		}
	}

	protected override void OnUpdate()
	{
		if ( Input.Keyboard.Pressed( "H" ) )
			CardOpen = !CardOpen;
	}

	// Fold the card state and every printed weight (to the two decimals shown), or the strip freezes at
	// whatever it read on the first frame while the sun keeps moving.
	protected override int BuildHash()
	{
		var c = Clock;
		var cfg = c?.Config ?? DayNightConfig.Default;
		var w = SkyWeights.WeightsFor( c?.GetTimeHours() ?? 0f, cfg );
		return HashCode.Combine( CardOpen, DayNightDemoBootstrap.DemoActive,
			(int)MathF.Round( w.x * 100f ),
			(int)MathF.Round( w.y * 100f ),
			(int)MathF.Round( w.z * 100f ),
			(int)MathF.Round( w.w * 100f ) );
	}
}
@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();
	}
}
@using System
@using System.Collections.Generic
@using System.Linq
@using Sandbox
@using Sandbox.UI
@namespace AdminSuite.UI
@inherits Panel

<root class="tab players-tab">
	@if ( Sys is null )
	{
		<label class="empty">Admin system not available.</label>
	}
	else
	{
		<div class="column list">
			<div class="section-title"><i>group</i><label>Online Players (@Connection.All.Count)</label></div>
			<div class="scroll">
				@foreach ( var c in Connection.All.OrderByDescending( x => Sys.DisplayPriorityOf( x.SteamId ) ).ThenBy( x => x.DisplayName ) )
				{
					var rank = Sys.GetDisplayRank( c.SteamId );
					<div class="row player @(Selected == c.SteamId ? "selected" : "")" onclick=@( () => Select( c.SteamId ) )>
						<div class="avatar" style="background-color: @rank.Color"><label>@Initial( c.DisplayName )</label></div>
						<div class="meta">
							<label class="name">@c.DisplayName</label>
							<label class="sub">@c.SteamId</label>
						</div>
						<div class="badge" style="background-color: @rank.Color">@rank.Name</div>
					</div>
				}
			</div>
		</div>

		<div class="column detail">
			@{ var target = Connection.All.FirstOrDefault( x => x.SteamId == Selected ); }
			@if ( target is null )
			{
				<div class="placeholder"><i>touch_app</i><label>Select a player on the left</label></div>
			}
			else
			{
				var targetRank = Sys.GetDisplayRank( target.SteamId );
				var canTarget = Sys.LocalCanTarget( target.SteamId );

				<div class="detail-header">
					<div class="avatar big" style="background-color: @targetRank.Color"><label>@Initial( target.DisplayName )</label></div>
					<div class="meta">
						<label class="name">@target.DisplayName</label>
						<label class="sub">SteamID64: @target.SteamId</label>
					</div>
					<div class="badge" style="background-color: @targetRank.Color">@targetRank.Name</div>
				</div>

				if ( !canTarget )
				{
					<div class="notice"><i>lock</i><label>This player's rank is equal to or higher than yours — you can only view them.</label></div>
				}

				<div class="section-title"><i>military_tech</i><label>Rank</label></div>
				<div class="rank-buttons">
					@foreach ( var rank in Sys.ClientRanks.OrderByDescending( r => r.Priority ) )
					{
						var isCurrent = targetRank.Id == rank.Id;
						var canAssign = !isCurrent && Sys.LocalCanAssign( rank, target.SteamId );
						<div class="btn rank @(isCurrent ? "current" : "") @(canAssign || isCurrent ? "" : "disabled")"
							onclick=@( () => { if ( canAssign ) Sys.RequestSetRank( target.SteamId, rank.Id ); } )>
							<div class="dot" style="background-color: @rank.Color"></div>
							<label>@rank.Name</label>
							@if ( isCurrent ) { <i>check</i> }
						</div>
					}
				</div>

				<div class="section-title"><i>gavel</i><label>Actions</label></div>

				if ( Sys.LocalHas( AdminPerm.PropsCleanPlayer ) )
				{
					<div class="action-row">
						<div class="btn accent @(canTarget ? "" : "disabled")"
							onclick=@( () => { if ( canTarget ) Sys.RequestCleanPlayerProps( target.SteamId ); } )>
							<i>cleaning_services</i><label>Clean This Player's Props</label>
						</div>
					</div>
				}

				if ( Sys.LocalHas( AdminPerm.PlayersKick ) )
				{
					<div class="action-row">
						<TextEntry class="input grow" Value:bind=@KickReason Placeholder="Kick reason" />
						<div class="btn warn @(canTarget ? "" : "disabled")" onclick=@( () => DoKick( target ) )>
							<i>logout</i><label>Kick</label>
						</div>
					</div>
				}

				if ( Sys.LocalHas( AdminPerm.PlayersBan ) )
				{
					<div class="action-row">
						<TextEntry class="input grow" Value:bind=@BanReason Placeholder="Ban reason" />
						<TextEntry class="input minutes" Value:bind=@BanMinutes Placeholder="Min" />
						<div class="btn danger @(canTarget ? "" : "disabled")" onclick=@( () => DoBan( target ) )>
							<i>block</i><label>Ban</label>
						</div>
					</div>
					<label class="explain">Ban duration in minutes — 0 or empty means permanent.</label>
				}
			}
		</div>
	}
</root>

@code
{
	private AdminSystem Sys => AdminSystem.Instance;

	public ulong Selected { get; set; }
	public string KickReason { get; set; } = "";
	public string BanReason { get; set; } = "";
	public string BanMinutes { get; set; } = "0";

	private void Select( ulong steamId )
	{
		Selected = steamId;
		StateHasChanged();
	}

	private static string Initial( string name ) =>
		string.IsNullOrEmpty( name ) ? "?" : name[..1].ToUpperInvariant();

	private void DoKick( Connection target )
	{
		if ( target is null || Sys is null || !Sys.LocalCanTarget( target.SteamId ) ) return;
		Sys.RequestKick( target.SteamId, KickReason );
		KickReason = "";
	}

	private void DoBan( Connection target )
	{
		if ( target is null || Sys is null || !Sys.LocalCanTarget( target.SteamId ) ) return;
		var minutes = int.TryParse( BanMinutes, out var m ) ? Math.Max( 0, m ) : 0;
		Sys.RequestBan( target.SteamId, BanReason, minutes );
		BanReason = "";
	}

	protected override int BuildHash() =>
		HashCode.Combine( Sys?.StateHash ?? 0, Selected, Connection.All.Count, (int)RealTime.Now );
}