MediaRemote.razor
@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) )
			{
				<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>
					}
					<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;
	}

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

	int QueueHash()
	{
		var h = new HashCode();
		h.Add( Target.Queue.Count );
		foreach ( var item in Target.Queue ) h.Add( item?.Url );
		return h.ToHashCode();
	}
}