Code/MediaScreen.razor
@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 ( 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

	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 )
		);
	}
}