diff --git a/README.md b/README.md index 38b0770..e63752e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,109 @@ # TinyPlayer -## A simple video player based on GStreamer. -## Player Under development +A lightweight video player built with WPF, GStreamer, and the [WPF-UI](https://github.com/lepoco/wpfui) framework featuring a modern Fluent Design interface. + +![TinyPlayer Screenshot](Screenshot_en.png) + +![TinyPlayer Screenshot](Screenshot_de.png) + +## Features + +- Video playback via GStreamer pipeline +- Multiple audio track switching +- Subtitle support with track selection +- Volume control with mute toggle +- Seek with progress bar +- Fullscreen mode (hides taskbar) +- Fluent Design with Mica backdrop (Windows 11) +- Auto-hide controls during playback + +## Supported Formats + +All formats supported by GStreamer - including `mp4`, `mkv`, `avi`, `mov`, `wmv`, `flv`, `webm` and more. + +## Requirements + +- Windows 10/11 +- [.NET 10](https://dotnet.microsoft.com/download) +- [GStreamer 1.x](https://gstreamer.freedesktop.org/download/) (with `d3dvideosink` plugin) + +### GStreamer Installation + +1. Download and install **GStreamer runtime** [from the official site](https://gstreamer.freedesktop.org/documentation/installing/on-windows.html?gi-language=c) +2. Make sure to select **Complete** installation - not Typical +3. Add GStreamer to your `PATH`: + ``` + C:\gstreamer\1.0\msvc_x86_64\bin + ``` + +## Getting Started + +```bash +git clone https://github.com/Ledrunning/TinyPlayer +cd TinyPlayer +dotnet restore +dotnet build +dotnet run --project TinyPlayer.Desktop +``` + +## Project Structure + +``` +TinyPlayer/ +├── TinyPlayer.Core/ # GStreamer core library (framework-agnostic) +│ ├── VideoPlayerCore.cs # Pipeline management +│ ├── VideoSinkFactory.cs # Video sink setup & HWND overlay +│ ├── Enums/ +│ │ └── AvFlagsType.cs +│ └── Models/ +│ ├── MetadataModel.cs +│ └── StreamItem.cs +│ +└── TinyPlayer.Desktop/ # WPF UI application + ├── View/ + │ └── MainWindow.xaml + ├── ViewModel/ + │ └── MainViewModel.cs + └── Controls/ + └── VideoHost.cs # HwndHost for GStreamer overlay +``` + +## Architecture + +TinyPlayer follows **MVVM** pattern using [CommunityToolkit.Mvvm](https://learn.microsoft.com/en-us/dotnet/communitytoolkit/mvvm/). + +The core library (`TinyPlayer.Core`) has no UI dependencies - it communicates with the ViewModel through events: + +| Event | Description | +| ----------------- | --------------------------------------- | +| `PositionChanged` | Current playback position & duration | +| `StreamsAnalysed` | Audio/subtitle track metadata | +| `StateChanged` | Pipeline state (Playing, Paused, Ready) | +| `ErrorOccurred` | Playback errors | +| `EndOfStream` | Playback finished | + +## Keyboard Shortcuts + +| Key | Action | +| -------------- | ----------------- | +| `Double Click` | Toggle fullscreen | +| `Escape` | Exit fullscreen | + +## Dependencies + +| Package | Purpose | +| ------------------------------------------------------------------------------------ | -------------------------- | +| [WPF-UI](https://github.com/lepoco/wpfui) | Fluent Design UI framework | +| [CommunityToolkit.Mvvm](https://github.com/CommunityToolkit/dotnet) | MVVM source generators | +| [GtkSharp / GstreamerSharp](https://www.nuget.org/packages/gstreamer-sharp-netcore/) | GStreamer .NET bindings | + +## Test Videos + +Good sources for testing multiple audio tracks and subtitles: + +- [Sintel](https://download.blender.org/demo/movies/Sintel.2010.720p.mkv) - Blender Open Movie, multiple subtitle languages +- [Tears of Steel](https://download.blender.org/demo/movies/ToS/tears_of_steel_1080p.mov) - Blender Open Movie + +## License + +MIT diff --git a/Screenshot_de.png b/Screenshot_de.png new file mode 100644 index 0000000..3029d07 Binary files /dev/null and b/Screenshot_de.png differ diff --git a/Screenshot_en.png b/Screenshot_en.png new file mode 100644 index 0000000..3b1fcc9 Binary files /dev/null and b/Screenshot_en.png differ diff --git a/TinyPlayer.Core/Events/StreamsAnalysedEventArgs.cs b/TinyPlayer.Core/Events/StreamsAnalysedEventArgs.cs new file mode 100644 index 0000000..f6b0e7a --- /dev/null +++ b/TinyPlayer.Core/Events/StreamsAnalysedEventArgs.cs @@ -0,0 +1,9 @@ +using TinyPlayer.Core.Models; + +namespace TinyPlayer.Core.Events; + +public class StreamsAnalysedEventArgs(MetadataModel metadata, string info) : EventArgs +{ + public MetadataModel Metadata { get; } = metadata; + public string StreamInfo { get; } = info; +} \ No newline at end of file diff --git a/TinyPlayer.Core/Extensions/TrackResolver.cs b/TinyPlayer.Core/Extensions/TrackResolver.cs new file mode 100644 index 0000000..18c4341 --- /dev/null +++ b/TinyPlayer.Core/Extensions/TrackResolver.cs @@ -0,0 +1,32 @@ +using System.Globalization; +using TinyPlayer.Core.Models; + +namespace TinyPlayer.Core.Extensions; + +public static class TrackResolver +{ + public static string IsoToLanguageName(this string code, MetadataLanguage metadata) + { + string? name = null; + + if (!string.IsNullOrEmpty(code) && code != "und") + { + name = new CultureInfo(code).EnglishName; + } + + if (name != null) + { + return name; + } + + // Fallback codec + bitrate + if (!string.IsNullOrEmpty(metadata.Codec)) + { + return metadata.Bitrate > 0 + ? $"{metadata.Codec} {metadata.Bitrate / 1000}kbps" + : metadata.Codec; + } + + return $"{metadata.Prefix} {metadata.Index}"; + } +} \ No newline at end of file diff --git a/TinyPlayer.Core/Models/MetadataLanguage.cs b/TinyPlayer.Core/Models/MetadataLanguage.cs new file mode 100644 index 0000000..3b88bef --- /dev/null +++ b/TinyPlayer.Core/Models/MetadataLanguage.cs @@ -0,0 +1,9 @@ +namespace TinyPlayer.Core.Models; + +public class MetadataLanguage +{ + public string? Codec { get; set; } + public int Index { get; set; } + public uint Bitrate { get; set; } + public string? Prefix { get; set; } +} \ No newline at end of file diff --git a/TinyPlayer.Core/Models/MetadataModel.cs b/TinyPlayer.Core/Models/MetadataModel.cs index 28d7f62..a38cbb4 100644 --- a/TinyPlayer.Core/Models/MetadataModel.cs +++ b/TinyPlayer.Core/Models/MetadataModel.cs @@ -5,4 +5,12 @@ public class MetadataModel public int NumOfVideoStreams { get; set; } public int NumOfAudioStreams { get; set; } public int NumOfSubtitles { get; set; } -} + + // Currently active tracks (from the playbin at the time of analysis) + public int CurrentAudioIndex { get; set; } = 0; + public int CurrentSubtitleIndex { get; set; } = -1; // -1 = no subs + + // Pre-defined lists with names — create combo boxes directly from here + public List AudioTracks { get; } = []; + public List SubtitleTracks { get; } = []; +} \ No newline at end of file diff --git a/TinyPlayer.Core/Models/StreamItem.cs b/TinyPlayer.Core/Models/StreamItem.cs new file mode 100644 index 0000000..a698274 --- /dev/null +++ b/TinyPlayer.Core/Models/StreamItem.cs @@ -0,0 +1,7 @@ +namespace TinyPlayer.Core.Models; + + public class StreamItem + { + public int Index { get; set; } + public string? Title { get; set; } = string.Empty; + } diff --git a/TinyPlayer.Core/TinyPlayer.Core.csproj b/TinyPlayer.Core/TinyPlayer.Core.csproj index 0b41737..79e77e2 100644 --- a/TinyPlayer.Core/TinyPlayer.Core.csproj +++ b/TinyPlayer.Core/TinyPlayer.Core.csproj @@ -1,7 +1,7 @@  - net8.0-windows + net10.0-windows true enable enable diff --git a/TinyPlayer.Core/VideoPlayerCore.cs b/TinyPlayer.Core/VideoPlayerCore.cs index d7d0d2f..7a6e9f3 100644 --- a/TinyPlayer.Core/VideoPlayerCore.cs +++ b/TinyPlayer.Core/VideoPlayerCore.cs @@ -1,118 +1,177 @@ -using GLib; -using Gst; +using System.Diagnostics; using System.Text; +using GLib; +using Gst; +using Gst.Video; using TinyPlayer.Core.Enums; +using TinyPlayer.Core.Events; +using TinyPlayer.Core.Extensions; using TinyPlayer.Core.Models; +using Application = Gst.Application; using Constants = Gst.Constants; +using ObjectManager = GtkSharp.GstreamerSharp.ObjectManager; +using TagList = Gst.TagList; using Thread = System.Threading.Thread; +using Timeout = GLib.Timeout; +using Value = GLib.Value; namespace TinyPlayer.Core; -public class StreamsAnalysedEventArgs : EventArgs -{ - public MetadataModel Metadata { get; } - public string StreamInfo { get; } - public StreamsAnalysedEventArgs(MetadataModel metadata, string info) - { - Metadata = metadata; - StreamInfo = info; - } -} - public sealed class VideoPlayerCore : IDisposable { private const int SeekDelayMs = 250; // 33 = 30fps - private Element? _playbin; - private MainLoop? _mainLoop; + private readonly nint _hwnd; + private bool _disposed; + private long _duration = -1; private Thread? _mainGlibThread; + private MainLoop? _mainLoop; + private Element? _playbin; private uint _refreshUiHandle; - private long _duration = -1; - private bool _disposed; - - public event Action? PositionChanged; - public event EventHandler? StreamsAnalysed; - public event Action? ErrorOccurred; - public event Action? EndOfStream; - public event Action? StateChanged; - public VideoPlayerCore(string uri, IntPtr hwnd) + public VideoPlayerCore(string uri, nint hwnd) { if (string.IsNullOrWhiteSpace(uri)) { throw new ArgumentNullException(nameof(uri)); } - if (hwnd == IntPtr.Zero) + + _hwnd = hwnd; + + Application.Init(); + ObjectManager.Initialize(); + + InitPipeline(uri); + } + + public void Dispose() + { + if (_disposed) { - throw new ArgumentException("HWND must be valid.", nameof(hwnd)); + return; } - Gst.Application.Init(); - GtkSharp.GstreamerSharp.ObjectManager.Initialize(); + _disposed = true; + if (_refreshUiHandle != 0) + { + Timeout.Remove(_refreshUiHandle); + _refreshUiHandle = 0; + } - InitPipeline(uri, hwnd); + if (_playbin != null) + { + _playbin.SetState(State.Ready); + _playbin.SetState(State.Null); + _playbin.Dispose(); + _playbin = null; + } + + _mainLoop?.Quit(); + _mainGlibThread?.Join(TimeSpan.FromSeconds(3)); } - private void InitPipeline(string uri, IntPtr hwnd) + public event Action? PositionChanged; + + public event EventHandler? StreamsAnalysed; + + public event Action? ErrorOccurred; + + public event Action? EndOfStream; + + public event Action? StateChanged; + + private void InitPipeline(string uri) { + // MainLoop must have for bus events _mainLoop = new MainLoop(); _mainGlibThread = new Thread(_mainLoop.Run) { IsBackground = true, Name = "GLibMain" }; _mainGlibThread.Start(); _playbin = ElementFactory.Make("playbin"); + _playbin["uri"] = uri; - if (_playbin != null) + var audioSink = ElementFactory.Make("directsoundsink", "audio_sink"); + if (audioSink != null) { - _playbin["uri"] = uri; - ApplyFlags(AvFlagsType.EnableAllFlags); - - // Connect the bus before starting - var bus = _playbin.Bus; - bus.AddSignalWatch(); - bus.Connect("message::error", ErrorCb); - bus.Connect("message::eos", EosCb); - bus.Connect("message::state-changed", StateChangedCb); - bus.Connect("message::application", ApplicationCb); + _playbin["audio-sink"] = audioSink; + Trace.WriteLine("[Audio] directsoundsink set"); + } - _playbin.Connect("video-tags-changed", TagsCb); - _playbin.Connect("audio-tags-changed", TagsCb); - _playbin.Connect("text-tags-changed", TagsCb); + var sink = ElementFactory.Make("d3dvideosink", "video_sink"); + _playbin["video-sink"] = sink; - // Register the sync handler it will catch the prepare-window-handle - // event synchronously and pass the HWND before the sink creates its window. - VideoSinkFactory.TryCreateAndAttach(_playbin, hwnd); + var bus = _playbin.Bus; + bus.AddSignalWatch(); + bus.EnableSyncMessageEmission(); - // Directly to Playing — the HWND will be passed via a SyncMessage on the fly - _playbin.SetState(State.Playing); + bus.Connect("message::error", ErrorCb); + bus.Connect("message::eos", EosCb); + bus.Connect("message::state-changed", StateChangedCb); + bus.Connect("message::application", ApplicationCb); - _refreshUiHandle = GLib.Timeout.Add(SeekDelayMs, OnRefreshTimer); - } - else + bus.SyncMessage += (o, args) => { - throw new InvalidOperationException("GStreamer: failed to create 'playbin'."); - } + var msg = (Message)args.Args[0]; + if (msg.Type != MessageType.Element) + { + return; + } + + if (msg.Structure?.Name != "prepare-window-handle") + { + return; + } + + var overlay = new VideoOverlayAdapter(msg.Src.Handle); + overlay.WindowHandle = _hwnd; + overlay.HandleEvents(true); + Trace.WriteLine("[VIDEO] HWND attached"); + }; + + _playbin.Connect("video-tags-changed", TagsCb); + _playbin.Connect("audio-tags-changed", TagsCb); + _playbin.Connect("text-tags-changed", TagsCb); + + _playbin.SetProperty("flags", new Value((uint)AvFlagsType.EnableAllFlags)); + + // Waiting for the actual Paused even only then is the duration known + _playbin.SetState(State.Paused); + _playbin.GetState(out _, out _, Constants.SECOND * 5); + _playbin.SetState(State.Playing); + + _refreshUiHandle = Timeout.Add(SeekDelayMs, OnRefreshTimer); } - public void Play() => _playbin?.SetState(State.Playing); - public void Pause() => _playbin?.SetState(State.Paused); - public void Stop() => _playbin?.SetState(State.Ready); + public void Play() + { + _playbin?.SetState(State.Playing); + } + + public void Pause() + { + _playbin?.SetState(State.Paused); + } + + public void Stop() + { + _playbin?.SetState(State.Ready); + } public void SeekTo(int seconds) - => _playbin?.SeekSimple(Format.Time, SeekFlags.Flush | SeekFlags.KeyUnit, - (long)seconds * Constants.SECOND); + { + _playbin?.SeekSimple( + Format.Time, + SeekFlags.Flush | SeekFlags.Accurate, + seconds * Constants.SECOND); + } public void SetAudioTrack(int index) { - if (_playbin != null) - { - _playbin["current-audio"] = index; - } + _playbin?["current-audio"] = index; } + public void SetSubtitleTrack(int index) { - if (_playbin != null) - { - _playbin["current-text"] = index; - } + _playbin?["current-text"] = index; } public void SetSubtitlesEnabled(bool enabled) @@ -123,8 +182,20 @@ public void SetSubtitlesEnabled(bool enabled) } var flags = (uint)_playbin["flags"]; - _playbin["flags"] = enabled ? flags | (uint)AvFlagsType.SubText - : flags & ~(uint)AvFlagsType.SubText; + + if (enabled) + { + _playbin["flags"] = flags | (uint)AvFlagsType.SubText; + } + else + { + _playbin["flags"] = flags & ~(uint)AvFlagsType.SubText; + } + + if (!enabled) + { + _playbin["current-text"] = -1; + } } public void SetAudioEnabled(bool enabled) @@ -135,12 +206,21 @@ public void SetAudioEnabled(bool enabled) } var flags = (uint)_playbin["flags"]; - _playbin["flags"] = enabled ? flags | (uint)AvFlagsType.Audio - : flags & ~(uint)AvFlagsType.Audio; + _playbin["flags"] = enabled + ? flags | (uint)AvFlagsType.Audio + : flags & ~(uint)AvFlagsType.Audio; } - public void ApplyFlags(AvFlagsType flags) - => _playbin?.SetProperty("flags", new GLib.Value((uint)flags)); + public void SetVolume(double value) + { + if (_playbin == null) + { + return; + } + + _playbin["volume"] = value; + Trace.WriteLine($"[Volume] playbin: {value}"); + } private bool OnRefreshTimer() { @@ -149,7 +229,7 @@ private bool OnRefreshTimer() return false; } - _playbin.GetState(out State state, out _, 0); + _playbin.GetState(out var state, out _, 0); if (state != State.Playing && state != State.Paused) { return true; @@ -160,10 +240,10 @@ private bool OnRefreshTimer() _playbin.QueryDuration(Format.Time, out _duration); } - if (_playbin.QueryPosition(Format.Time, out long current)) + if (_playbin.QueryPosition(Format.Time, out var current)) { PositionChanged?.Invoke(current / Constants.SECOND, - _duration > 0 ? _duration / Constants.SECOND : 0); + _duration > 0 ? _duration / Constants.SECOND : 0); } return true; @@ -172,7 +252,7 @@ private bool OnRefreshTimer() private void ErrorCb(object o, SignalArgs args) { var msg = (Message)args.Args[0]; - msg.ParseError(out GException err, out string debug); + msg.ParseError(out var err, out var debug); _playbin?.SetState(State.Ready); ErrorOccurred?.Invoke($"Error from '{msg.Src.Name}': {err.Message}\nDebug: {debug ?? "(none)"}"); } @@ -186,7 +266,7 @@ private void EosCb(object o, SignalArgs args) private void StateChangedCb(object o, SignalArgs args) { var msg = (Message)args.Args[0]; - msg.ParseStateChanged(out _, out State newState, out _); + msg.ParseStateChanged(out _, out var newState, out _); if (msg.Src == _playbin) { @@ -204,10 +284,10 @@ private void ApplicationCb(object o, SignalArgs args) } } - private void TagsCb(object sender, SignalArgs args) + private static void TagsCb(object sender, SignalArgs args) { - var el = sender as Element; - el?.PostMessage(Gst.Message.NewApplication(el, new Structure("tags-changed"))); + var element = sender as Element; + element?.PostMessage(Message.NewApplication(element, new Structure("tags-changed"))); } private void AnalyseStreams() @@ -221,92 +301,80 @@ private void AnalyseStreams() { NumOfVideoStreams = (int)_playbin["n-video"], NumOfAudioStreams = (int)_playbin["n-audio"], - NumOfSubtitles = (int)_playbin["n-text"] + NumOfSubtitles = (int)_playbin["n-text"], + + // Current active tracks from playbin + CurrentAudioIndex = (int)_playbin["current-audio"], + CurrentSubtitleIndex = (int)_playbin["current-text"] }; var sb = new StringBuilder(); - for (int? i = 0; i < metadata.NumOfVideoStreams; i++) - { - var tags = (Gst.TagList)_playbin.Emit("get-video-tags", i); - if (tags == null) - { - continue; - } - sb.AppendLine($"Video stream {i}:"); - if (tags.GetString(Constants.TAG_VIDEO_CODEC, out string codec)) - { - sb.AppendLine($" codec: {codec}"); - } - ((GLib.Opaque)tags).Dispose(); - } - - for (int? i = 0; i < metadata.NumOfAudioStreams; i++) + // Audio + for (var i = 0; i < metadata.NumOfAudioStreams; i++) { var tags = (TagList)_playbin.Emit("get-audio-tags", i); - if (tags == null) - { - continue; - } - - sb.AppendLine($"Audio stream {i}:"); - if (tags.GetString(Constants.TAG_AUDIO_CODEC, out string codec)) - { - sb.AppendLine($" codec: {codec}"); - } + string? lang = null; + string? codec = null; + uint rate = 0; - if (tags.GetString(Constants.TAG_LANGUAGE_CODE, out string lang)) + if (tags != null) { - sb.AppendLine($" language: {lang}"); + tags.GetString(Constants.TAG_LANGUAGE_CODE, out lang); + tags.GetString(Constants.TAG_AUDIO_CODEC, out codec); + tags.GetUint(Constants.TAG_BITRATE, out rate); + tags.Dispose(); } - if (tags.GetUint(Constants.TAG_BITRATE, out uint rate)) + // Title: Language (if available), otherwise codec, otherwise "Track N" + var title = lang?.IsoToLanguageName(new MetadataLanguage { - sb.AppendLine($" bitrate: {rate}"); - } - ((GLib.Opaque)tags).Dispose(); + Codec = codec, + Bitrate = rate, + Index = i, + Prefix = "Track" + }); + + metadata.AudioTracks.Add(new StreamItem { Index = i, Title = title }); + sb.AppendLine($"Audio {i}: {title}"); } - for (int? i = 0; i < metadata.NumOfSubtitles; i++) + // Subtitles + for (var i = 0; i < metadata.NumOfSubtitles; i++) { var tags = (TagList)_playbin.Emit("get-text-tags", i); - if (tags == null) - { - continue; - } - sb.AppendLine($"Subtitle stream {i}:"); - if (tags.GetString(Constants.TAG_LANGUAGE_CODE, out string lang)) + string? lang = null; + if (tags != null) { - sb.AppendLine($" language: {lang}"); + tags.GetString(Constants.TAG_LANGUAGE_CODE, out lang); + tags.Dispose(); } - ((GLib.Opaque)tags).Dispose(); + + var title = !string.IsNullOrEmpty(lang) ? lang : $"Sub {i}"; + metadata.SubtitleTracks.Add(new StreamItem { Index = i, Title = title }); + sb.AppendLine($"Sub {i}: {title}"); } + StreamsAnalysed?.Invoke(this, new StreamsAnalysedEventArgs(metadata, sb.ToString())); } - public void Dispose() + public void SetRenderSize(int w, int h) { - if (_disposed) + if (_playbin == null) { return; } - _disposed = true; - if (_refreshUiHandle != 0) - { - GLib.Timeout.Remove(_refreshUiHandle); _refreshUiHandle = 0; - } - if (_playbin != null) + + var overlay = ((Bin)_playbin)?.GetByInterface(VideoOverlayAdapter.GType); + if (overlay == null) { - _playbin.SetState(State.Ready); - _playbin.SetState(State.Null); - _playbin.Dispose(); - _playbin = null; + return; } - _mainLoop?.Quit(); - _mainGlibThread?.Join(TimeSpan.FromSeconds(3)); - } -} - + var adapter = new VideoOverlayAdapter(overlay.Handle); + adapter.SetRenderRectangle(0, 0, w, h); + adapter.Expose(); + } +} \ No newline at end of file diff --git a/TinyPlayer.Core/VideoSinkFactory.cs b/TinyPlayer.Core/VideoSinkFactory.cs deleted file mode 100644 index d9044ca..0000000 --- a/TinyPlayer.Core/VideoSinkFactory.cs +++ /dev/null @@ -1,50 +0,0 @@ -using Gst; -using Gst.Video; -namespace TinyPlayer.Core -{ - internal static class VideoSinkFactory - { - public static bool TryCreateAndAttach(Element playbin, IntPtr hwnd) - { - var sink = ElementFactory.Make("d3dvideosink", "video_sink"); - if (sink == null) - { - Console.WriteLine("[GStreamerCore] d3dvideosink not found"); - return false; - } - - playbin["video-sink"] = sink; - - var bus = playbin.Bus; - bus.EnableSyncMessageEmission(); - bus.SyncMessage += (o, args) => - { - var msg = (Gst.Message)args.Args[0]; - - // Check manually the name of the message structure - if (msg.Type != MessageType.Element) - { - return; - } - - if (msg.Structure == null) - { - return; - } - - if (msg.Structure.Name != "prepare-window-handle") - { - return; - } - - var adapter = new VideoOverlayAdapter(msg.Src.Handle); - adapter.WindowHandle = hwnd; - adapter.HandleEvents(true); - Console.WriteLine("[GStreamerCore] HWND attached via SyncMessage"); // TODO add logger - }; - - return true; - } - } -} - diff --git a/TinyPlayer.Desktop/App.xaml.cs b/TinyPlayer.Desktop/App.xaml.cs index b43410b..882fa38 100644 --- a/TinyPlayer.Desktop/App.xaml.cs +++ b/TinyPlayer.Desktop/App.xaml.cs @@ -9,7 +9,6 @@ using System.IO; using System.Reflection; using System.Windows; -using System.Windows.Interop; using System.Windows.Threading; using TinyPlayer.Desktop.View; using TinyPlayer.Desktop.ViewModel; @@ -37,7 +36,6 @@ public partial class App services.AddScoped(); // Add services - services.AddScoped(); }).Build(); diff --git a/TinyPlayer.Desktop/Controls/VideoHost.cs b/TinyPlayer.Desktop/Controls/VideoHost.cs new file mode 100644 index 0000000..19312da --- /dev/null +++ b/TinyPlayer.Desktop/Controls/VideoHost.cs @@ -0,0 +1,64 @@ +using System.Runtime.InteropServices; +using System.Windows; +using System.Windows.Interop; + +namespace TinyPlayer.Desktop.Controls; + +/// +/// Native P/Invoke Windows panel for WPF +/// +public class VideoHost : HwndHost +{ + private const int WsChild = 0x40000000; + private const int WsVisible = 0x10000000; + + public new IntPtr Handle { get; private set; } + + protected override HandleRef BuildWindowCore(HandleRef hwndParent) + { + Handle = CreateWindowEx( + 0, "static", "", + WsChild | WsVisible, + 0, 0, 100, 100, + hwndParent.Handle, + IntPtr.Zero, IntPtr.Zero, 0); + + return new HandleRef(this, Handle); + } + + // When resizing a WPF element, resize the native window to fit the new dimensions + protected override void OnRenderSizeChanged(SizeChangedInfo info) + { + base.OnRenderSizeChanged(info); + if (Handle == IntPtr.Zero) + { + return; + } + + var w = (int)info.NewSize.Width; + var h = (int)info.NewSize.Height; + + if (w > 0 && h > 0) + { + MoveWindow(Handle, 0, 0, w, h, true); + } + } + + protected override void DestroyWindowCore(HandleRef hwnd) + { + DestroyWindow(hwnd.Handle); + } + + [DllImport("user32.dll", CharSet = CharSet.Auto)] + private static extern IntPtr CreateWindowEx( + int exStyle, string className, string windowName, int style, + int x, int y, int width, int height, + IntPtr parentHandle, IntPtr menu, IntPtr instance, object param); + + [DllImport("user32.dll")] + private static extern bool DestroyWindow(IntPtr hwnd); + + [DllImport("user32.dll")] + private static extern bool MoveWindow( + IntPtr hwnd, int x, int y, int width, int height, bool repaint); +} \ No newline at end of file diff --git a/TinyPlayer.Desktop/Controls/VideoHwndHost.cs b/TinyPlayer.Desktop/Controls/VideoHwndHost.cs deleted file mode 100644 index 2611edb..0000000 --- a/TinyPlayer.Desktop/Controls/VideoHwndHost.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System.Runtime.InteropServices; -using System.Windows.Interop; - -namespace TinyPlayer.Desktop.Controls -{ - /// - /// Minimum HwndHost creates a native Win32 window, - /// the HWND of which is passed to GStreamer VideoOverlay. - /// - public sealed class VideoHwndHost : HwndHost - { - private IntPtr _hwnd; - - public event Action HandleReady; - - protected override HandleRef BuildWindowCore(HandleRef hwndParent) - { - _hwnd = CreateWindowEx( - 0, - "STATIC", - "", - WS_CHILD | WS_VISIBLE | WS_CLIPCHILDREN | WS_CLIPSIBLINGS, - 0, 0, 1, 1, - hwndParent.Handle, - IntPtr.Zero, - IntPtr.Zero, - IntPtr.Zero); - - HandleReady?.Invoke(_hwnd); - return new HandleRef(this, _hwnd); - } - - protected override void DestroyWindowCore(HandleRef hwnd) - { - DestroyWindow(hwnd.Handle); - } - - // P/Invoke - - private const int WS_CHILD = 0x40000000; - private const int WS_VISIBLE = 0x10000000; - private const int WS_CLIPCHILDREN = 0x02000000; - private const int WS_CLIPSIBLINGS = 0x04000000; - - [DllImport("user32.dll", SetLastError = true)] - private static extern IntPtr CreateWindowEx( - int dwExStyle, string lpClassName, string lpWindowName, - int dwStyle, int x, int y, int nWidth, int nHeight, - IntPtr hWndParent, IntPtr hMenu, IntPtr hInstance, IntPtr lpParam); - - [DllImport("user32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool DestroyWindow(IntPtr hwnd); - } -} diff --git a/TinyPlayer.Desktop/Controls/VideoPanel.cs b/TinyPlayer.Desktop/Controls/VideoPanel.cs deleted file mode 100644 index 94c1eb8..0000000 --- a/TinyPlayer.Desktop/Controls/VideoPanel.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Windows.Forms.Integration; - -namespace TinyPlayer.Desktop.Controls; - -/// -/// A simple host — a WinForms Panel within WPF via WindowsFormsHost. -/// The Panel has a native HWND out of the box, just like in an original WinForms project. -/// Add the following to .csproj: -/// true -/// -public class VideoPanel : WindowsFormsHost -{ - public Panel Panel { get; } = new Panel { Dock = DockStyle.Fill, BackColor = System.Drawing.Color.Black }; - - public VideoPanel() - { - Child = Panel; - } -} diff --git a/TinyPlayer.Desktop/TinyPlayer.Desktop.csproj b/TinyPlayer.Desktop/TinyPlayer.Desktop.csproj index f907a21..3b165d2 100644 --- a/TinyPlayer.Desktop/TinyPlayer.Desktop.csproj +++ b/TinyPlayer.Desktop/TinyPlayer.Desktop.csproj @@ -1,8 +1,8 @@ - + WinExe - net8.0-windows + net10.0-windows app.manifest wpfui-icon.ico enable diff --git a/TinyPlayer.Desktop/View/MainWindow.xaml b/TinyPlayer.Desktop/View/MainWindow.xaml index af1e85b..3bfddfa 100644 --- a/TinyPlayer.Desktop/View/MainWindow.xaml +++ b/TinyPlayer.Desktop/View/MainWindow.xaml @@ -10,16 +10,19 @@ Title="TinyPlayer" Width="900" Height="550" + d:DataContext="{d:DesignInstance Type=viewmodel:MainViewModel}" Closed="Window_Closed" ExtendsContentIntoTitleBar="True" WindowBackdropType="Mica" WindowCornerPreference="Round" WindowStartupLocation="CenterScreen" + WindowState="{Binding WindowState, Mode=TwoWay}" + WindowStyle="{Binding WindowStyle, Mode=TwoWay}" mc:Ignorable="d"> - - - + + + @@ -31,96 +34,145 @@ + Margin="0,0,0,0" + Visibility="{Binding TitleBarVisibility}"> - + + + + + + + - - - + Background="Black" + MouseLeftButtonDown="VideoGrid_MouseLeftButtonDown" + MouseMove="VideoGrid_MouseMove"> + + - + CornerRadius="12,12,0,0" + Visibility="{Binding IsControlsVisible, Converter={StaticResource BooleanToVisibilityConverter}}"> + + + - + + + + + + + Command="{Binding TogglePlayPauseCommand}"> + + + + + + + Command="{Binding ToggleMuteCommand}"> + + + + - + + VerticalAlignment="Center" + IsMoveToPointEnabled="True" + Maximum="100" + Minimum="0" + Value="{Binding Volume, Mode=TwoWay}" /> + + + - + \ No newline at end of file diff --git a/TinyPlayer.Desktop/View/MainWindow.xaml.cs b/TinyPlayer.Desktop/View/MainWindow.xaml.cs index 7812584..7c3a7a8 100644 --- a/TinyPlayer.Desktop/View/MainWindow.xaml.cs +++ b/TinyPlayer.Desktop/View/MainWindow.xaml.cs @@ -1,24 +1,65 @@ -using System.Windows; +using System.Windows.Input; using TinyPlayer.Desktop.ViewModel; using Wpf.Ui.Controls; +using KeyEventArgs = System.Windows.Input.KeyEventArgs; +using MouseEventArgs = System.Windows.Input.MouseEventArgs; -namespace TinyPlayer.Desktop.View +namespace TinyPlayer.Desktop.View; + +/// +/// Interaction logic for MainWindow.xaml +/// +public partial class MainWindow : FluentWindow { - /// - /// Interaction logic for MainWindow.xaml - /// - public partial class MainWindow : FluentWindow + public MainWindow(MainViewModel viewModel) + { + InitializeComponent(); + + Vm = viewModel; + DataContext = Vm; + + Loaded += (_, _) => { Vm.SetVideoHandle(VideoSurface.Handle); }; + } + + public MainViewModel Vm { get; } + + private void OnSeekStarted(object sender, MouseButtonEventArgs e) + { + Vm.BeginSeek(); + } + + private void OnSeekCompleted(object sender, MouseButtonEventArgs e) { - private MainViewModel Vm => (MainViewModel)DataContext; + Vm.EndSeek(); + } - public MainWindow(MainViewModel viewModel) + private void Window_Closed(object sender, EventArgs e) + { + Vm.Dispose(); + } + + private void VideoGrid_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + Vm.OnVideoClick(); + + if (e.ClickCount == 2) { - InitializeComponent(); + Vm.ToggleFullscreenCommand.Execute(null); + } + } - // Pass the HWND to the VM once it will store it and use it every time LoadUri is called - Loaded += (_, _) => Vm.SetVideoHandle(VideoHost.Panel.Handle); + protected override void OnKeyDown(KeyEventArgs e) + { + if (e.Key == Key.Escape) + { + Vm.ToggleFullscreenCommand.Execute(null); } - private void Window_Closed(object sender, EventArgs e) => Vm.Dispose(); + base.OnKeyDown(e); + } + + private void VideoGrid_MouseMove(object sender, MouseEventArgs e) + { + Vm.OnVideoClick(); } -} +} \ No newline at end of file diff --git a/TinyPlayer.Desktop/ViewModel/BaseViewModel.cs b/TinyPlayer.Desktop/ViewModel/BaseViewModel.cs index 128b620..42e58aa 100644 --- a/TinyPlayer.Desktop/ViewModel/BaseViewModel.cs +++ b/TinyPlayer.Desktop/ViewModel/BaseViewModel.cs @@ -1,18 +1,38 @@ -using CommunityToolkit.Mvvm.ComponentModel; +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using TinyPlayer.Core; +using TinyPlayer.Core.Models; +using OpenFileDialog = Microsoft.Win32.OpenFileDialog; namespace TinyPlayer.Desktop.ViewModel; public abstract class BaseViewModel : ObservableObject, IDisposable { - public void OnNavigatedTo() - { - } + protected VideoPlayerCore? Core; + public ObservableCollection AudioTracks { get; } = []; + public ObservableCollection SubtitleTracks { get; } = []; - public void OnNavigatedFrom() + public readonly List AllSubtitleTracks = []; + + public void Dispose() { + Core?.Dispose(); + Core = null; } - public void Dispose() + protected virtual void OpenFile(Action loadUri) { + var dlg = new OpenFileDialog + { + Title = "Open the video file", + Filter = "Video|*.mp4;*.mkv;*.avi;*.mov;*.wmv;*.flv;*.webm|All files|*.*" + }; + + if (dlg.ShowDialog() != true) + { + return; + } + + loadUri.Invoke(new Uri(dlg.FileName).AbsoluteUri); } } \ No newline at end of file diff --git a/TinyPlayer.Desktop/ViewModel/MainViewModel.cs b/TinyPlayer.Desktop/ViewModel/MainViewModel.cs index f51c3d6..95f7996 100644 --- a/TinyPlayer.Desktop/ViewModel/MainViewModel.cs +++ b/TinyPlayer.Desktop/ViewModel/MainViewModel.cs @@ -1,73 +1,116 @@ -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using Microsoft.Win32; +using System.Diagnostics; using System.Windows; +using System.Windows.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Gst; using TinyPlayer.Core; +using TinyPlayer.Core.Events; +using TinyPlayer.Core.Models; using Application = System.Windows.Application; using MessageBox = System.Windows.MessageBox; -using OpenFileDialog = Microsoft.Win32.OpenFileDialog; +using Task = System.Threading.Tasks.Task; namespace TinyPlayer.Desktop.ViewModel; public partial class MainViewModel : BaseViewModel { - private VideoPlayerCore? _core; - private bool _coreUpdating; - private IntPtr _hwnd; + private const double MaxVolumeDelta = 100.0; + private const int SeekDebounce = 100; + private const int MaxVolumePercentage = 100; - [ObservableProperty] private long _position; + [ObservableProperty] private Visibility _controlsVisibility = Visibility.Visible; + private bool _coreUpdating; [ObservableProperty] private long _duration; - [ObservableProperty] private string _timeText = "00:00 / 00:00"; + private DispatcherTimer? _hideControlsTimer; + private nint _hwnd; + [ObservableProperty] private bool _isControlsVisible = true; + private bool _isInternalUpdate; + [ObservableProperty] private bool _isMuted; + [ObservableProperty] private bool _isPlaying; + private bool _isUserSeeking; + [ObservableProperty] private long _position; + private CancellationTokenSource? _seekCts; + [ObservableProperty] private StreamItem? _selectedAudioTrack; + [ObservableProperty] private StreamItem? _selectedSubtitleTrack; [ObservableProperty] private string _streamInfo = string.Empty; + private bool _streamsInitialized; + [ObservableProperty] private bool _subtitlesEnabled; + [ObservableProperty] private string _timeText = "00:00 / 00:00"; + [ObservableProperty] private Visibility _titleBarVisibility = Visibility.Visible; + [ObservableProperty] private double _volume = MaxVolumePercentage; + [ObservableProperty] private WindowState _windowState = WindowState.Normal; + [ObservableProperty] private WindowStyle _windowStyle = WindowStyle.SingleBorderWindow; - partial void OnPositionChanged(long value) + [RelayCommand] + private void ToggleFullscreen() { - if (_coreUpdating) return; - _core?.SeekTo((int)value); - TimeText = FormatTime(value, _duration); - } + var isFullscreen = WindowState == WindowState.Maximized; - // Public API - - /// Called from the code-behind once after the Loaded event. - public void SetVideoHandle(IntPtr hwnd) => _hwnd = hwnd; + if (!isFullscreen) + { + // Switch to fullscreen — first set to None, then Maximised; + // otherwise the taskbar won't be hidden + WindowStyle = WindowStyle.None; + WindowState = WindowState.Maximized; + TitleBarVisibility = Visibility.Collapsed; + ControlsVisibility = Visibility.Collapsed; + } + else + { + WindowStyle = WindowStyle.SingleBorderWindow; + WindowState = WindowState.Normal; + TitleBarVisibility = Visibility.Visible; + ControlsVisibility = Visibility.Visible; + } + } [RelayCommand(CanExecute = nameof(HasCore))] - private void Play() => _core?.Play(); + private void TogglePlayPause() + { + if (IsPlaying) + { + Core?.Pause(); + } + else + { + Core?.Play(); + } + } [RelayCommand(CanExecute = nameof(HasCore))] - private void Pause() => _core?.Pause(); + private void ToggleMute() + { + IsMuted = !IsMuted; + + Core?.SetVolume(IsMuted ? 0 : Math.Max(Volume / MaxVolumeDelta, 0.01)); + } [RelayCommand(CanExecute = nameof(HasCore))] - private void Stop() { _core?.Stop(); SetPosition(0); } + private void Stop() + { + Core?.Stop(); + SetPosition(0); + } [RelayCommand] private void OpenFile() { - var dlg = new OpenFileDialog - { - Title = "Open the video file", - Filter = "Video|*.mp4;*.mkv;*.avi;*.mov;*.wmv;*.flv;*.webm|All files|*.*" - }; - - if (dlg.ShowDialog() != true) - { - return; - } - - LoadUri(new Uri(dlg.FileName).AbsoluteUri); + base.OpenFile(LoadUri); } - private bool HasCore() => _core != null; + private bool HasCore() + { + return Core != null; + } // Load private void LoadUri(string uri) { DisposeCore(); - _core = new VideoPlayerCore(uri, _hwnd); - - _core.PositionChanged += (cur, dur) => + Core = new VideoPlayerCore(uri, _hwnd); + Core.PositionChanged += (cur, dur) => Application.Current?.Dispatcher.BeginInvoke(() => { SetDuration(dur); @@ -75,56 +118,300 @@ private void LoadUri(string uri) TimeText = FormatTime(cur, dur); }); - _core.StreamsAnalysed += (_, e) => - Application.Current?.Dispatcher.BeginInvoke(() => - StreamInfo = e.StreamInfo); + Core?.StreamsAnalysed -= OnStreamsAnalysed; + Core?.StreamsAnalysed += OnStreamsAnalysed; - //TODO : add custom message box! - _core.ErrorOccurred += msg => + Core?.ErrorOccurred += msg => Application.Current?.Dispatcher.Invoke(() => MessageBox.Show(msg, "Playback error", MessageBoxButton.OK, MessageBoxImage.Error)); - _core.EndOfStream += () => + Core?.EndOfStream += () => Application.Current?.Dispatcher.Invoke(() => { SetPosition(0); TimeText = FormatTime(0, Duration); }); - _core.StateChanged += _ => - Application.Current?.Dispatcher.Invoke(() => + Core?.StateChanged += state => + Application.Current?.Dispatcher.BeginInvoke(() => { - PlayCommand.NotifyCanExecuteChanged(); - PauseCommand.NotifyCanExecuteChanged(); + IsPlaying = state == State.Playing; + TogglePlayPauseCommand.NotifyCanExecuteChanged(); StopCommand.NotifyCanExecuteChanged(); }); + + ToggleMuteCommand.NotifyCanExecuteChanged(); + } + + public void OnVideoClick() + { + IsControlsVisible = true; + + _hideControlsTimer?.Stop(); + _hideControlsTimer = new DispatcherTimer + { + Interval = TimeSpan.FromSeconds(3) + }; + _hideControlsTimer.Tick += (_, _) => + { + _hideControlsTimer.Stop(); + if (IsPlaying) + { + IsControlsVisible = false; + } + }; + _hideControlsTimer.Start(); + } + + private void OnStreamsAnalysed(object? sender, StreamsAnalysedEventArgs e) + { + Application.Current.Dispatcher.Invoke(() => + { + _streamsInitialized = false; + _isInternalUpdate = true; + + try + { + AudioTracks.Clear(); + SubtitleTracks.Clear(); + AllSubtitleTracks.Clear(); + + foreach (var track in e.Metadata.AudioTracks) + { + AudioTracks.Add(track); + } + + foreach (var track in e.Metadata.SubtitleTracks) + { + AllSubtitleTracks.Add(track); + } + + // Restore the audio track and apply it + SelectedAudioTrack = AudioTracks + .FirstOrDefault(t => t.Index == e.Metadata.CurrentAudioIndex) + ?? AudioTracks.FirstOrDefault(); + + if (SelectedAudioTrack != null) + { + Core?.SetAudioTrack(SelectedAudioTrack.Index); + } + + // Subtitles — populate the list but do not enable them + // Enable only via the CC button using SubtitlesEnabled + foreach (var item in AllSubtitleTracks) + { + SubtitleTracks.Add(item); + } + + SelectedSubtitleTrack = SubtitleTracks.FirstOrDefault(); + + // Set the current state of SubtitlesEnabled + Core?.SetSubtitlesEnabled(SubtitlesEnabled); + + if (SubtitlesEnabled && SelectedSubtitleTrack != null) + { + Core?.SetSubtitleTrack(SelectedSubtitleTrack.Index); + } + } + finally + { + _isInternalUpdate = false; + _streamsInitialized = true; + } + }); + } + + private void ApplySubtitleFilter() + { + _isInternalUpdate = true; + try + { + SubtitleTracks.Clear(); + + if (!SubtitlesEnabled) + { + SelectedSubtitleTrack = null; + return; + } + + foreach (var item in AllSubtitleTracks) + { + SubtitleTracks.Add(item); + } + + // Restore the selected track if it is in the list\ + // Do NOT automatically reset to the first track + if (SelectedSubtitleTrack != null) + { + SelectedSubtitleTrack = SubtitleTracks + .FirstOrDefault(t => t.Index == SelectedSubtitleTrack.Index); + } + + SelectedSubtitleTrack ??= SubtitleTracks.FirstOrDefault(); + } + finally + { + _isInternalUpdate = false; + } + } + + partial void OnVolumeChanged(double value) + { + if (IsMuted) + { + return; + } + + Core?.SetVolume(value / MaxVolumeDelta); + } + + partial void OnPositionChanged(long value) + { + if (_coreUpdating) + { + return; + } + + if (_isUserSeeking) + { + _ = DebouncedSeek(value); + } + } + + private async Task DebouncedSeek(long value) + { + _seekCts?.CancelAsync(); + + var cts = new CancellationTokenSource(); + _seekCts = cts; + + try + { + await Task.Delay(SeekDebounce, cts.Token); + + if (!cts.IsCancellationRequested) + { + Core?.SeekTo((int)value); + } + } + catch (TaskCanceledException e) + { + Trace.TraceError(e.Message); + } + } + + public void BeginSeek() + { + _isUserSeeking = true; + } + + public void EndSeek() + { + _isUserSeeking = false; + + _seekCts?.Cancel(); + Core?.SeekTo((int)Position); } // Helpers - private void SetPosition(long s) - { - _coreUpdating = true; - Position = s; - _coreUpdating = false; + private void SetPosition(long seekPosition) + { + if (_isUserSeeking) + { + return; + } + + _coreUpdating = true; + Position = seekPosition; + _coreUpdating = false; } - private void SetDuration(long s) - { + private void SetDuration(long duration) + { _coreUpdating = true; - Duration = s; - _coreUpdating = false; + Duration = duration; + _coreUpdating = false; } private static string FormatTime(long cur, long dur) - => $"{TimeSpan.FromSeconds(cur):mm\\:ss} / {TimeSpan.FromSeconds(dur):mm\\:ss}"; + { + return $@"{TimeSpan.FromSeconds(cur):mm\:ss} / {TimeSpan.FromSeconds(dur):mm\:ss}"; + } + + public void SetVideoHandle(IntPtr hwnd) + { + _hwnd = hwnd; + } + + partial void OnSelectedAudioTrackChanged(StreamItem? value) + { + if (_isInternalUpdate) + { + return; + } + + if (!_streamsInitialized) + { + return; + } + + if (value == null) + { + return; + } + + Core?.SetAudioTrack(value.Index); + } + + partial void OnSelectedSubtitleTrackChanged(StreamItem? value) + { + if (_isInternalUpdate) + { + return; + } + + if (!_streamsInitialized) + { + return; + } + + if (!SubtitlesEnabled) + { + return; + } + + if (value == null) + { + return; + } + + Core?.SetSubtitleTrack(value.Index); + } + + partial void OnSubtitlesEnabledChanged(bool value) + { + if (!_streamsInitialized) + { + return; + } + + Core?.SetSubtitlesEnabled(value); + ApplySubtitleFilter(); + + // If subtitles are enabled, activate the currently selected track + if (value && SelectedSubtitleTrack != null) + { + Core?.SetSubtitleTrack(SelectedSubtitleTrack.Index); + } + } private void DisposeCore() { - _core?.Dispose(); - _core = null; + _streamsInitialized = false; + Core?.StreamsAnalysed -= OnStreamsAnalysed; + Dispose(); SetPosition(0); SetDuration(0); } - } \ No newline at end of file