I Built an Anime Themed Pomodoro App With WebAssembly Blazor
Blazor is pretty cool, I hope Microsoft keeps investing into it.
The JavaScript Obligation
I've been consistently obligated to use JavaScript whenever I want to build a web application. Thankfully I discovered TypeScript when learning Angular a few years back which has reduced my stress levels tangibly. However, it's fundamentally just a layer on top of JavaScript and it has its own set of annoyances.
2018, Blazor
I recall reading about Blazor several years ago and how Microsoft was building a fun little framework for controlling your HTML/CSS web UI with C#. But wait, how could you possibly run C# on the browser? Would it be required for the site visitor to have .NET Runtime installed on their machine? But wait, the browser environment is sandboxed anyway, so what gives?
The trick was that you would run your C# on a server, separate from your UI, and they could interact over a SignalR connection! Pretty clever and interesting, and I'd meant to take a look at it at some point, but never got to it. After all, while something like that certainly sounds cool, I can't imagine it would ever become widespread or mainstream. It's just not pragmatic.
Blazor w/ WebAssembly
It turns out that this was all a farce, and the real goal was to compile the entire .NET Runtime into WebAssembly. And last year (or the year before maybe? idk) they released Blazor that runs on WebAssembly. When I heard about it, I knew it was going to be big. We've been hearing about WebAssembly for a decade now, and while it has certainly been useful in some targeted instances, I feel like Blazor is the first major framework pushed by a major company that is attempting to replace JavaScript from the ground up. Good luck Microsoft!
Building pomodoro.moe
I like learning new things by actually building something. But obviously I need something fairly simple since I am completely new to Blazor (and I mean, how the heck do you even control HTML/CSS with C# anyway?!). So I figured I'd make a little themed Pomodoro app. This way I'll get to test the core mechanics and get a feel for the project structure.
Starting Out
There's probably quite a bit to be said about the different components and interactions in a Blazor project, but at the base of everything is the "Razor File" which can contain both HTML and C#, interwoven into eachother. Here is the Index razor file for Moe Pomodoro that contains all of the HTML and C# for the app.
Index.razor
@page "/"
@inject IJSRuntime JSRuntime
@using SystemTimer = System.Timers.Timer;
@using Newtonsoft.Json;
<PageTitle>Moe Pomodoro</PageTitle>
<div id="background-1" style="--background-image-1:url('@_backgroundImage1');--background-z-1:@_backgroundZ1;"></div>
<div id="background-2" style="--background-image-2:url('@_backgroundImage2');--background-z-2:@_backgroundZ2;"></div>
<div class="dark-overlay"></div>
<div id="outer-container">
<div id="inner-container">
<h1>Moe Pomodoro</h1>
<a class="github-button" href="https://github.com/cppshane/moe-pomodoro" data-icon="octicon-star" data-size="large" data-show-count="true">Star</a>
<div id="clock-container" @onclick="ToggleState">
<p id="time-text">@TimerString</p>
<div id="clock-background" style="--clock-background-color:@_clockBackgroundColor"></div>
<div id="clock-foreground"></div>
</div>
<div id="reset-container" @onclick="Reset">
<img src="https://cdn.shaneduffy.io/moe-pomodoro/reset.webp" />
<div id="reset-background"></div>
</div>
<p>(╯°□°)╯︵ <a href="https://waifu.im" target="_blank">waifu.im</a></p>
</div>
</div>
@if (_soundPath != String.Empty) {
<audio autoplay controls hidden><source src="@_soundPath" /></audio>
}
@code {
private IJSObjectReference jsModule;
private static readonly string PicEndpoint = "https://api.waifu.im/random/?is_nsfw=false";
private static readonly string MoshiSound = "https://cdn.shaneduffy.io/moe-pomodoro/audio/moshi.mp3";
private static readonly TimeSpan WorkTimeSpan = TimeSpan.FromMinutes(25);
private static readonly TimeSpan BreakTimeSpan = TimeSpan.FromMinutes(5);
private static readonly string BlackClockColor = "rgba(0, 0, 0, 0.3)";
private static readonly string GreenClockColor = "rgba(0, 255, 20, 0.15)";
private static readonly string BlueClockColor = "rgba(0, 184, 255, 0.1)";
private HttpClient client = new HttpClient();
private SystemTimer _timer = new SystemTimer(1000);
private string _backgroundImage1 = String.Empty;
private string _backgroundImage2 = String.Empty;
private string _backgroundZ1 = "90";
private string _backgroundZ2 = "89";
private string _soundPath;
private string _clockBackgroundColor = BlackClockColor;
private bool _showBackground1 = true;
private bool _workMode = true;
private bool _timerActive = false;
private TimeSpan _originalTimeSpan = WorkTimeSpan;
private TimeSpan _clockTime = WorkTimeSpan;
private DateTime _startTime;
public string TimerString {
get {
return _clockTime.ToString(@"mm\:ss");
}
}
public class WindowDimensions {
public double Width { get; set; }
public double Height { get; set; }
}
public Index() {
_timer.Elapsed += (sender, e) => Tick(e.SignalTime);
}
private void StartTimer() {
_startTime = DateTime.Now;
_timer.Start();
}
private void PauseTimer() {
_timer.Stop();
}
private void Tick(DateTime signalTime) {
var time = _originalTimeSpan.Subtract(signalTime - _startTime).Add(TimeSpan.FromSeconds(1));
if (_clockTime.TotalSeconds == 0) {
PlaySound(MoshiSound);
Reset();
} else {
_clockTime = time;
}
StateHasChanged();
}
private void PlaySound(string soundPath) {
_soundPath = String.Empty;
StateHasChanged();
_soundPath = soundPath;
}
private async void RotateImage() {
var dimensions = await jsModule.InvokeAsync("getWindowSize");
var useLandscape = dimensions.Width / dimensions.Height > 1.2;
var requestResult = await client.GetAsync($"{PicEndpoint}&orientation={(useLandscape ? "LANDSCAPE" : "PORTRAIT")}");
var content = await requestResult.Content.ReadAsStringAsync();
if (_showBackground1) {
_backgroundImage2 = String.Empty;
_backgroundZ1 = "89";
_backgroundZ2 = "90";
_backgroundImage2 = (JsonConvert.DeserializeObject(content))?.images[0].url ?? String.Empty;
} else {
_backgroundImage1 = String.Empty;
_backgroundZ1 = "90";
_backgroundZ2 = "89";
_backgroundImage1 = (JsonConvert.DeserializeObject(content))?.images[0].url ?? String.Empty;
}
_showBackground1 = !_showBackground1;
StateHasChanged();
}
protected override async Task OnAfterRenderAsync(bool firstRender) {
if (firstRender) {
jsModule = await JSRuntime.InvokeAsync("import", "./js/get-window-size.js");
RotateImage();
}
}
private void Reset() {
_timer.Stop();
if (_workMode) {
_originalTimeSpan = BreakTimeSpan;
} else {
_originalTimeSpan = WorkTimeSpan;
}
_clockBackgroundColor = BlackClockColor;
_clockTime = _originalTimeSpan;
_workMode = !_workMode;
_timerActive = false;
RotateImage();
}
private void ToggleState() {
if (_timerActive) {
_originalTimeSpan = _clockTime;
_timer.Stop();
_clockBackgroundColor = BlackClockColor;
} else {
_startTime = DateTime.Now;
_timer.Start();
_clockBackgroundColor = _workMode ? GreenClockColor : BlueClockColor;
}
_timerActive = !_timerActive;
}
}
The @ Symbol
As you can see in the HTML, we can use @TimerString
to reference our TimerString
accessor. We can also use @onclick="ToggleState"
and @onclick="Reset"
to connect our click events to the ToggleState()
and Reset()
functions respectively.
We can also use this notation in conjunction with the style attribute to pass our C# variables to our CSS style:
Index.razor (truncated)
<div id="background-1" style="--background-image-1:url('@_backgroundImage1');--background-z-1:@_backgroundZ1;"></div>
Index.razor.css (truncated)
#background-1 {
position: absolute;
height: 100%;
width: 100%;
background-image: var(--background-image-1);
background-size: cover;
background-repeat: no-repeat;
background-position: center center;
z-index: var(--background-z-1);
}
Importing JavaScript
Blazor doesn't yet support everything, so we can import JavaScript where necessary. In this case, I needed to get the current dimensions of the window so I could request either a portrait or landscape background image from the waifu.im API endpoint, however Blazor doesn't seem to have this capability. To get around this, we can can just define a JavaScript file and import it as a callable module with IJSObjectReference
.
get-window-size.js
export function getWindowSize() {
return {
width: window.innerWidth,
height: window.innerHeight
};
};
Index.razor (truncated)
@inject IJSRuntime JSRuntime
private IJSObjectReference jsModule;
public class WindowDimensions {
public double Width { get; set; }
public double Height { get; set; }
}
private async void RotateImage() {
var dimensions = await jsModule.InvokeAsync("getWindowSize");
// ... truncated
}
protected override async Task OnAfterRenderAsync(bool firstRender) {
if (firstRender) {
jsModule = await JSRuntime.InvokeAsync("import", "./js/get-window-size.js");
RotateImage();
}
}
Loading External JS Libraries
I like to use buttons.js to put a link to the GitHub repo on my open source projects. However I noticed it wasn't rendering initially, and it just showed up as a regular, unformatted text link even though I included the script import in index.html. This is because it needs to load after Blazor has initialized. I figured out that I can do this with the following trick:
index.html (truncated)
<script src="_framework/blazor.webassembly.js" autostart="false"></script>
<script>
Blazor.start().then(function () {
var customScript = document.createElement('script');
customScript.setAttribute('src', 'https://buttons.github.io/buttons.js');
document.head.appendChild(customScript);
});
</script>
We just add autostart="false"
to our Blazor JS script element, and add a new script tag that manually initializes Blazor instead, while also adding a promise so we can inject a new script element to load afterward.
Performance?
I didn't do any formal testing but let's just say that it's a bit... clunky. It seems to use quite a bit of memory, presumably in order to run the full .NET Runtime. And Visual Studio's hot reload seems to be a bit fussy when it comes to Blazor, so I wouldn't trust it if I were you, it might trick you into thinking something doesn't work when you really just need a proper application restart...
In any case, I'm sure this stuff will improve over time. Don't give up Microsoft, we're all rooting for you!