Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,23 @@ All notable changes to Trdo will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added
- **Windows 11 Widget Support**: Control your radio playback directly from the Windows Widgets panel
- Real-time playback status display
- Current station name and status
- Play/Pause control button
- Support for small, medium, and large widget sizes
- Automatic updates when playback state changes
- Comprehensive user and developer documentation

### Technical
- COM-based widget provider implementation
- Adaptive Card UI template for widgets
- Widget lifecycle management (create, activate, deactivate, delete)
- Integration with existing PlayerViewModel for live updates

## [1.1.0] - 2025

### Added
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ Get the code:
- 💾 Save and organize your favorite stations
- 🎵 Now playing information display
- 🌙 Support for Windows 11 themes
- 🪟 **Windows 11 Widget support** - Control playback from the Widgets panel

## 🛠️ Built With

Expand Down
183 changes: 178 additions & 5 deletions Trdo/App.xaml.cs
Original file line number Diff line number Diff line change
@@ -1,30 +1,34 @@
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
using System;
using System.ComponentModel;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Trdo.Pages;
using Trdo.ViewModels;
using Trdo.Widgets;
using Trdo.Widgets.Helper;
using Windows.UI;
using Windows.UI.ViewManagement;
using WinUIEx;

namespace Trdo;

/// <summary>
/// Provides application-specific behavior to supplement the default Application class.
/// </summary>
public partial class App : Application
{
private TrayIcon? _trayIcon;
private readonly PlayerViewModel _playerVm = PlayerViewModel.Shared;
private readonly UISettings _uiSettings = new();
private Mutex? _singleInstanceMutex;
private DispatcherQueueTimer? _trayIconWatchdogTimer;
private DispatcherQueueTimer? _sharedStatePollingTimer;
private ShellPage? _shellPage;
private RegistrationManager<TrdoWidgetProvider>? _widgetRegistrationManager;
private bool _isComServerMode = false;

Check warning on line 30 in Trdo/App.xaml.cs

View workflow job for this annotation

GitHub Actions / build

The field 'App._isComServerMode' is assigned but its value is never used
private bool _lastKnownPlayingState = false;

public App()
{
Expand All @@ -37,7 +41,27 @@

protected override async void OnLaunched(LaunchActivatedEventArgs args)
{
// Check for single instance using a named mutex
// Check if launched to register COM server for widgets
string[] cmdLineArgs = Environment.GetCommandLineArgs();
if (cmdLineArgs.Contains("-RegisterProcessAsComServer"))
{
_isComServerMode = true;

// Initialize COM wrappers for widget provider
WinRT.ComWrappersSupport.InitializeComWrappers();
_widgetRegistrationManager = RegistrationManager<TrdoWidgetProvider>.RegisterProvider();

// Start shared state polling even in COM server mode
// This ensures the widget process syncs its MediaPlayer with main app
StartSharedStatePollingForComServer();

// Keep the app running as a COM server
// Widget provider will handle widget requests
// Don't initialize tray icon or UI in COM server mode
return;
}

// Normal app mode - check for single instance using a named mutex
const string mutexName = "Global\\Trdo_SingleInstance_Mutex";

try
Expand All @@ -62,6 +86,7 @@
await UpdateTrayIconAsync();
UpdatePlayPauseCommandText();
StartTrayIconWatchdog();
StartSharedStatePolling();
}

private void PlayerVmOnPropertyChanged(object? sender, PropertyChangedEventArgs e)
Expand All @@ -76,6 +101,11 @@
{
UpdatePlayPauseCommandText();
}
else if (e.PropertyName == nameof(PlayerViewModel.SelectedStation))
{
// Station changed, update tray icon tooltip
UpdatePlayPauseCommandText();
}
}

private void OnColorValuesChanged(UISettings sender, object args)
Expand Down Expand Up @@ -259,6 +289,146 @@
}
}

private void StartSharedStatePolling()
{
// Get the dispatcher queue for the current thread
DispatcherQueue? dispatcherQueue = DispatcherQueue.GetForCurrentThread();
if (dispatcherQueue is null)
return;

// Create a timer that polls shared state every 2 seconds
// This ensures we detect changes from the widget process
_sharedStatePollingTimer = dispatcherQueue.CreateTimer();
_sharedStatePollingTimer.Interval = TimeSpan.FromSeconds(2);
_sharedStatePollingTimer.Tick += (sender, args) =>
{
CheckSharedState();
};
_sharedStatePollingTimer.Start();

// Initialize the last known state
_lastKnownPlayingState = _playerVm.IsPlaying;
}

private void CheckSharedState()
{
try
{
// Get shared state (what should be happening)
bool sharedIsPlaying = false;
try
{
if (Windows.Storage.ApplicationData.Current.LocalSettings.Values.TryGetValue("RadioIsPlaying", out object? storedValue))
{
sharedIsPlaying = storedValue is bool b && b;
}
}
catch { }

// Get local MediaPlayer state (what is actually happening)
var playerService = Services.RadioPlayerService.Instance;
bool localMediaPlayerIsPlaying = playerService.IsLocalMediaPlayerPlaying;

// Check if shared state changed since last check
if (sharedIsPlaying != _lastKnownPlayingState)
{
Debug.WriteLine($"[App] Shared state changed: IsPlaying {_lastKnownPlayingState} → {sharedIsPlaying}");
_lastKnownPlayingState = sharedIsPlaying;

// Sync the local MediaPlayer state to match shared state
// This is critical: if widget paused, we need to pause the main app's MediaPlayer too
if (sharedIsPlaying != localMediaPlayerIsPlaying)
{
Debug.WriteLine($"[App] Syncing MediaPlayer: shared={sharedIsPlaying}, localMediaPlayer={localMediaPlayerIsPlaying}");

try
{
if (sharedIsPlaying)
{
// Shared state says playing, but local MediaPlayer isn't - start it
Debug.WriteLine("[App] Starting local MediaPlayer to match shared state");
if (!string.IsNullOrEmpty(playerService.StreamUrl))
{
playerService.Play();
}
}
else
{
// Shared state says paused, but local MediaPlayer is playing - pause it
Debug.WriteLine("[App] Pausing local MediaPlayer to match shared state");
playerService.Pause();
}
}
catch (Exception ex)
{
Debug.WriteLine($"[App] Error syncing MediaPlayer state: {ex.Message}");
}
}

// Manually trigger the property changed handler to update UI
PlayerVmOnPropertyChanged(this, new PropertyChangedEventArgs(nameof(PlayerViewModel.IsPlaying)));
}
}
catch (Exception ex)
{
Debug.WriteLine($"[App] Error checking shared state: {ex.Message}");
}
}

private void StartSharedStatePollingForComServer()
{
// Get the dispatcher queue for the current thread
DispatcherQueue? dispatcherQueue = DispatcherQueue.GetForCurrentThread();
if (dispatcherQueue is null)
{
Debug.WriteLine("[App] No DispatcherQueue in COM server mode, cannot start polling");
return;
}

// Create a timer that polls shared state every 2 seconds
_sharedStatePollingTimer = dispatcherQueue.CreateTimer();
_sharedStatePollingTimer.Interval = TimeSpan.FromSeconds(2);
_sharedStatePollingTimer.Tick += (sender, args) =>
{
CheckSharedStateForComServer();
};
_sharedStatePollingTimer.Start();

// Initialize the last known state
_lastKnownPlayingState = _playerVm.IsPlaying;
Debug.WriteLine("[App] Started shared state polling for COM server mode");
}

private void CheckSharedStateForComServer()
{
try
{
// Get shared state (what should be happening)
bool sharedIsPlaying = false;
try
{
if (Windows.Storage.ApplicationData.Current.LocalSettings.Values.TryGetValue("RadioIsPlaying", out object? storedValue))
{
sharedIsPlaying = storedValue is bool b && b;
}
}
catch { }

// In COM server mode (widget), we should NOT sync the MediaPlayer
// Only the main app process should actually play audio
// The widget process only updates shared state, it doesn't play audio itself

// Therefore, we don't sync MediaPlayer in COM server mode
// This prevents duplicate audio streams

Debug.WriteLine($"[App-COM] Shared state: IsPlaying={sharedIsPlaying} (MediaPlayer sync disabled in COM server mode)");
}
catch (Exception ex)
{
Debug.WriteLine($"[App-COM] Error checking shared state: {ex.Message}");
}
}

/// <summary>
/// Cleanup resources when the application exits
/// </summary>
Expand All @@ -268,6 +438,9 @@
{
_singleInstanceMutex?.ReleaseMutex();
_singleInstanceMutex?.Dispose();
_widgetRegistrationManager?.Dispose();
_trayIconWatchdogTimer?.Stop();
_sharedStatePollingTimer?.Stop();
}
catch
{
Expand Down
Binary file added Trdo/Assets/Widget-Medium.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added Trdo/Assets/Widget-Small.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading