Code/InteractiveComputer/Apps/MediaPlayerApp.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Sandbox;
using Sandbox.UI;
using PaneOS.InteractiveComputer.Core;

namespace PaneOS.InteractiveComputer.Apps;

[ComputerApp( "system.mediaplayer", "Media Player", Icon = "MP", SortOrder = 26 )]
public sealed class MediaPlayerApp : IComputerApp
{
	public ComputerAppSession Run( ComputerAppContext context )
	{
		return new ComputerAppSession
		{
			Title = "Media Player",
			Icon = "MP",
			Content = new MediaPlayerPanel( context )
		};
	}
}

[StyleSheet( "InteractiveComputerApps.scss" )]
public sealed class MediaPlayerPanel : ComputerWarmupPanel
{
	private readonly ComputerAppContext context;
	private readonly List<string> basePlaylist = new();
	private Label statusLabel = null!;
	private Label nowPlayingLabel = null!;
	private Label repeatLabel = null!;
	private Label shuffleLabel = null!;
	private Panel progressFill = null!;
	private Panel playlistHost = null!;
	private Panel playlistSection = null!;
	private Panel videoMeta = null!;
	private int trackIndex;
	private float progress;
	private bool isPlaying = true;
	private bool showPlaylist;
	private bool shuffleEnabled;
	private ComputerMediaRepeatMode repeatMode = ComputerMediaRepeatMode.Playlist;

	public MediaPlayerPanel( ComputerAppContext context )
	{
		this.context = context;
		AddClass( "media-player-app" );
		SeedDefaultPlaylist();
		RestoreSessionState();
		BuildUi();
		UpdateDisplay();
	}

	protected override void WarmupRefresh()
	{
		BuildUi();
		UpdateDisplay();
	}

	public override void Tick()
	{
		base.Tick();

		if ( !isPlaying )
			return;

		progress = MathF.Min( 1f, progress + Time.Delta * 0.12f );
		if ( progress >= 1f )
			NextTrack();

		UpdateDisplay();
	}

	private void BuildUi()
	{
		DeleteChildren( true );

		var header = new Panel { Parent = this };
		header.AddClass( "media-player-header" );
		new Label( "Media Player" ) { Parent = header }.AddClass( "media-player-heading" );
		statusLabel = new Label { Parent = header };
		statusLabel.AddClass( "media-player-status" );

		var stage = new Panel { Parent = this };
		stage.AddClass( "media-player-stage" );

		var videoPanel = new MediaDropZone( AddDraggedMediaFile ) { Parent = stage };
		videoPanel.AddClass( "media-player-video" );
		var scanline = new Panel { Parent = videoPanel };
		scanline.AddClass( "media-player-video-scanline" );
		nowPlayingLabel = new Label { Parent = videoPanel };
		nowPlayingLabel.AddClass( "media-player-video-title" );
		videoMeta = new Panel { Parent = videoPanel };
		videoMeta.AddClass( "media-player-video-meta" );
		new Label( "Drop files from Pane Explorer here" ) { Parent = videoMeta };
		new Label( "Playlist and video preview share the same queue" ) { Parent = videoMeta };

		playlistSection = new Panel { Parent = stage };
		playlistSection.AddClass( "media-player-playlist-section" );
		playlistSection.SetClass( "hidden", !showPlaylist );

		var playlistHeader = new Panel { Parent = playlistSection };
		playlistHeader.AddClass( "media-player-playlist-header" );
		new Label( "Playlist" ) { Parent = playlistHeader };
		repeatLabel = new Label { Parent = playlistHeader };
		repeatLabel.AddClass( "media-player-playlist-mode" );
		shuffleLabel = new Label { Parent = playlistHeader };
		shuffleLabel.AddClass( "media-player-playlist-mode" );

		playlistHost = new Panel { Parent = playlistSection };
		playlistHost.AddClass( "media-player-playlist" );

		var progressBar = new Panel { Parent = this };
		progressBar.AddClass( "media-player-progress" );
		progressFill = new Panel { Parent = progressBar };
		progressFill.AddClass( "media-player-progress-fill" );

		var controls = new Panel { Parent = this };
		controls.AddClass( "media-player-controls" );
		CreateButton( controls, "Prev", PreviousTrack );
		CreateButton( controls, "Play/Pause", TogglePlay );
		CreateButton( controls, "Next", NextTrack );
		CreateButton( controls, showPlaylist ? "Hide Playlist" : "Playlist", TogglePlaylist );
		CreateButton( controls, $"Repeat: {repeatMode}", CycleRepeatMode );
		CreateButton( controls, shuffleEnabled ? "Shuffle On" : "Shuffle Off", ToggleShuffle );
	}

	private void CreateButton( Panel parent, string text, Action onClick )
	{
		var button = new Button( text ) { Parent = parent };
		button.AddClass( "media-player-button" );
		button.AddEventListener( "onclick", onClick );
	}

	private void TogglePlay()
	{
		isPlaying = !isPlaying;
		PersistSessionState();
		UpdateDisplay();
	}

	private void PreviousTrack()
	{
		var playlist = GetActivePlaylist();
		trackIndex = playlist.Count == 0 ? 0 : (trackIndex + playlist.Count - 1) % playlist.Count;
		progress = 0f;
		PersistSessionState();
		UpdateDisplay();
	}

	private void NextTrack()
	{
		trackIndex = ComputerMediaPlaylistPolicy.ResolveNextIndex( trackIndex, GetActivePlaylist().Count, repeatMode );
		if ( trackIndex < 0 )
			trackIndex = 0;
		progress = 0f;
		PersistSessionState();
		UpdateDisplay();
	}

	private void UpdateDisplay()
	{
		var playlist = GetActivePlaylist();
		var trackTitle = playlist.Count == 0 ? "No media loaded" : FormatTrackTitle( playlist[Math.Clamp( trackIndex, 0, playlist.Count - 1 )] );
		statusLabel.Text = $"{trackTitle} {(isPlaying ? "Playing" : "Paused")}";
		nowPlayingLabel.Text = trackTitle;
		repeatLabel.Text = $"Repeat {repeatMode}";
		shuffleLabel.Text = shuffleEnabled ? "Shuffle enabled" : "Shuffle off";
		progressFill.Style.Width = Length.Percent( progress * 100f );
		playlistSection?.SetClass( "hidden", !showPlaylist );
		RebuildPlaylist();
	}

	private void TogglePlaylist()
	{
		showPlaylist = !showPlaylist;
		PersistSessionState();
		BuildUi();
		UpdateDisplay();
	}

	private void CycleRepeatMode()
	{
		repeatMode = repeatMode switch
		{
			ComputerMediaRepeatMode.Playlist => ComputerMediaRepeatMode.Single,
			ComputerMediaRepeatMode.Single => ComputerMediaRepeatMode.None,
			_ => ComputerMediaRepeatMode.Playlist
		};

		PersistSessionState();
		BuildUi();
		UpdateDisplay();
	}

	private void ToggleShuffle()
	{
		shuffleEnabled = !shuffleEnabled;
		trackIndex = 0;
		progress = 0f;
		PersistSessionState();
		BuildUi();
		UpdateDisplay();
	}

	private void AddDraggedMediaFile( string virtualPath )
	{
		if ( string.IsNullOrWhiteSpace( virtualPath ) )
			return;

		if ( !IsSupportedMediaPath( virtualPath ) )
			return;

		if ( basePlaylist.Contains( virtualPath, StringComparer.OrdinalIgnoreCase ) )
			return;

		basePlaylist.Add( virtualPath );
		showPlaylist = true;
		PersistSessionState();
		BuildUi();
		UpdateDisplay();
	}

	private void SelectTrack( int index )
	{
		trackIndex = Math.Clamp( index, 0, Math.Max( 0, GetActivePlaylist().Count - 1 ) );
		progress = 0f;
		isPlaying = true;
		PersistSessionState();
		UpdateDisplay();
	}

	private void RebuildPlaylist()
	{
		if ( playlistHost is null )
			return;

		playlistHost.DeleteChildren( true );
		var playlist = GetActivePlaylist();
		if ( playlist.Count == 0 )
		{
			new Label( "No files queued yet." ) { Parent = playlistHost }.AddClass( "media-player-empty" );
			return;
		}

		for ( var index = 0; index < playlist.Count; index++ )
		{
			var row = new Button { Parent = playlistHost };
			row.AddClass( "media-player-playlist-row" );
			row.SetClass( "active", index == trackIndex );
			var slot = index + 1;
			var itemPath = playlist[index];
			row.Text = $"{slot:00}  {FormatTrackTitle( itemPath )}";
			var capturedIndex = index;
			row.AddEventListener( "onclick", () => SelectTrack( capturedIndex ) );
		}
	}

	private IReadOnlyList<string> GetActivePlaylist()
	{
		if ( basePlaylist.Count == 0 )
			return Array.Empty<string>();

		return shuffleEnabled
			? ComputerMediaPlaylistPolicy.Shuffle( basePlaylist, context.State.InstanceId.GetHashCode() )
			: basePlaylist.ToArray();
	}

	private void SeedDefaultPlaylist()
	{
		var documentsRoot = context.GetDefaultDocumentsPath().TrimEnd( '/' );
		basePlaylist.Clear();
		basePlaylist.AddRange( new[]
		{
			$"{documentsRoot}/PaneOS Theme.mp3",
			$"{documentsRoot}/Startup Chime.wav",
			$"{documentsRoot}/Floppy Dreams.ogg"
		} );
	}

	private void RestoreSessionState()
	{
		showPlaylist = string.Equals( context.LoadValue( "showPlaylist" ), "1", StringComparison.Ordinal );
		shuffleEnabled = string.Equals( context.LoadValue( "shuffleEnabled" ), "1", StringComparison.Ordinal );
		isPlaying = !string.Equals( context.LoadValue( "isPaused" ), "1", StringComparison.Ordinal );
		trackIndex = int.TryParse( context.LoadValue( "trackIndex" ), out var parsedTrackIndex ) ? Math.Max( 0, parsedTrackIndex ) : 0;
		progress = float.TryParse( context.LoadValue( "trackProgress" ), out var parsedProgress ) ? Math.Clamp( parsedProgress, 0f, 1f ) : 0f;
		if ( Enum.TryParse<ComputerMediaRepeatMode>( context.LoadValue( "repeatMode" ), true, out var parsedRepeatMode ) )
			repeatMode = parsedRepeatMode;

		var persistedPlaylist = context.LoadValue( "playlist" );
		if ( !string.IsNullOrWhiteSpace( persistedPlaylist ) )
		{
			basePlaylist.Clear();
			basePlaylist.AddRange( persistedPlaylist.Split( '|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries ) );
		}
	}

	private void PersistSessionState()
	{
		context.SaveValue( "showPlaylist", showPlaylist ? "1" : "0" );
		context.SaveValue( "shuffleEnabled", shuffleEnabled ? "1" : "0" );
		context.SaveValue( "isPaused", isPlaying ? "0" : "1" );
		context.SaveValue( "trackIndex", trackIndex.ToString() );
		context.SaveValue( "trackProgress", progress.ToString( "0.###" ) );
		context.SaveValue( "repeatMode", repeatMode.ToString() );
		context.SaveValue( "playlist", string.Join( "|", basePlaylist ) );
	}

	private static bool IsSupportedMediaPath( string virtualPath )
	{
		var extension = Path.GetExtension( virtualPath );
		return extension.Equals( ".mp3", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".wav", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".ogg", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".mp4", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".avi", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".webm", StringComparison.OrdinalIgnoreCase );
	}

	private static string FormatTrackTitle( string playlistItem )
	{
		var fileName = Path.GetFileNameWithoutExtension( playlistItem );
		return string.IsNullOrWhiteSpace( fileName ) ? playlistItem : fileName;
	}
}

public sealed class MediaDropZone : Panel
{
	private readonly Action<string> onDropped;

	public MediaDropZone( Action<string> onDropped )
	{
		this.onDropped = onDropped;
	}

	protected override void OnMouseUp( MousePanelEvent e )
	{
		base.OnMouseUp( e );
		var draggedPath = ComputerUiDragState.ConsumeDraggedVirtualPath();
		if ( string.IsNullOrWhiteSpace( draggedPath ) )
			return;

		onDropped( draggedPath );
	}
}