國慶假期各種活動比較多,直到上班才有時間來更新文章~
不過這兩天我還是做了個小玩意(Clipify),起因是想給之前開發來自己用的簡單視頻剪輯工具 QuickCutSharp 加個功能,不過這個軟件是基于 WinForms 開發的,做界面得拖拉控件,感覺繁瑣又不靈活,于是索性重新做一個。
原有代碼是C#,于是我就繼續在這個生態里尋找開發方案,Avalonia、MAUI等都是不錯的選擇,前者我之前用過,做了個簡單的圖片管理工具,后者聽說是微軟新推出的跨平臺開發方案,我這次也試了一下,不過單純處理環境就比較復雜了,直接勸退。
接下來我就把目光瞄準了類似 Electron 這類套殼開發,既然要用前端技術開發軟件界面,那么 C# 生態的 Blazor 就可以拿出來了,我之前也用 Blazor 開發過幾個項目,感覺使用 Blazor 搭配 TailwindCSS 應該可以有不錯的開發體驗。
說干就干,我選擇了 Blazor Hybrid 這個方向,然后宿主容器依然選擇 WinForms,原因是暫時沒有跨平臺的需求,而且 Blazor Hybrid 目前也沒有比較好的跨平臺方案,雖然有 MAUI 但太重而且也不支持 Linux…
項目已經開源,Github: https://github.com/Deali-Axy/clipify
一些截圖#
老規矩前面先放一些截圖,軟件的功能直接看圖就清楚了。
軟件主頁
提取音頻界面
導出視頻界面
PS:目前只實現了部分功能
主要技術
正如前言說到的,使用了 Blazor Hybrid 來開發,那么界面就是 Blazor 實現的,然后運行在一個 Winforms 軟件的 BlazorWebView 中。
視頻相關的功能是調用了 ffmpeg (實際上在沒有這個軟件之前,我都是手動輸入命令操作的…)
Microsoft.AspNetCore.Components.WebView.WindowsForms - 微軟官方的 Blazor Hybrid 方案,可以依托 WinForms 運行 Blazor
MediatR - C#版的EventBus,用于實現瀏覽器和WinForms的通信
xFFmpeg.NET - 用于簡化 ffmpeg 的調用(實際上這個庫已經停更兩三年了,很多功能只能自己去實現,我甚至打算fork一個來適配新版ffmpeg)
Microsoft.Extensions.Logging - 日志組件,沒啥好說的,AspNetCore項目里的常客
AntDesign - 一些組件不想自己封裝(如modal和message)就用這個
前端方面依然是 pnpm、gulp、tailwindcss、flowbite、fortawesome 這些
關于 Blazor Hybrid
Electron技術大家都很熟悉了,現在連QQ都用Electron重構了,在開發了這個項目之后,我也能理解這種做法,用前端技術來寫界面真的爽,只要稍微犧牲一下性能,就可以獲得不錯的效果,而且現在電腦的性能都已經足夠了,正好給web技術上桌面提供了條件。
而 Blazor 對于 C# 開發人員的優勢是不需要學習各種 JavaScript 框架就可以開發交互式的 web 應用;雖然我做過不少前端項目,React也用得比較熟了,不過 Blazor Hybrid 還有一個優勢是可以直接使用 C# 調用系統功能,Blazor Hybrid 一方面是運行在瀏覽器中,一方面又是直接在操作系統層面運行,C# 代碼可以不受瀏覽器沙箱的限制,直接訪問系統文件、設備等(雖然本項目中還是用到了Blazor與WinForms通信,不過那不是 C# 的功能限制,而是必須用到 WinForms 的功能)。
創建 Blazor Hybrid 項目
創建一個基于 WinForms 的 Blazor Hybrid 項目很簡單,首先是創建 .NetCore(.Net8) 的 WinForms 項目,然后添加 Microsoft.AspNetCore.Components.WebView.WindowsForms
依賴
接著把 BlazorWebView 組件添加到 Form 上面
然后開始寫代碼初始化
public partial class FormMain : Form {
public FormMain() {
InitializeComponent();
var services = new ServiceCollection();
services.AddLogging(c => {
c.AddDebug();
c.AddFilter("Microsoft.AspNetCore.Components.WebView", LogLevel.Trace);
});
services.AddAntDesign();
services.AddMediatR(cfg => { cfg.RegisterServicesFromAssemblyContaining<FormMain>(); });
services.AddWindowsFormsBlazorWebView();
#if DEBUG
services.AddBlazorWebViewDeveloperTools();
#endif
services.AddSingleton(this);
services.AddScoped<IHostingEnvironment, HostingEnvironment>();
services.AddScoped<DialogService>();
services.AddScoped<VideoService>();
blazorWebView1.HostPage = "wwwroot\\index.html";
blazorWebView1.Services = services.BuildServiceProvider();
blazorWebView1.RootComponents.Add<App>("#app");
}
}
關鍵的就在于最下面的三行代碼,設置主頁、把服務容器綁定的 Blazor 控件上,設置根組件。
然后其他的就和普通的 Blazor 項目一樣。
搭建項目基礎架構
本文限于篇幅,只能簡單介紹一下。
想要進一步了解的同學可以看官網的指引文檔和實例項目。
不過微軟官網關于這方面的文檔也不是很詳細,只是淺嘗輒止,很多內容要靠自己摸索。
index.html
按需添加了各種 css 和 js 引用
<!DOCTYPE html><html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Clipify</title>
<base href="/"/>
<link href="css/app.css" rel="stylesheet"/>
<link href="css/tailwind.min.css" rel="stylesheet"/>
<link href="lib/font-awesome/css/all.min.css" rel="stylesheet">
<link href="_content/AntDesign/css/ant-design-blazor.css" rel="stylesheet" />
<link href="Clipify.Forms.styles.css" rel="stylesheet"/>
</head>
<body>
<div id="app">Loading...</div>
<div id="blazor-error-ui" data-nosnippet>
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">??</a>
</div>
<script src="_framework/blazor.webview.js"></script>
<script src="lib/flowbite/flowbite.min.js"></script>
<script src="_content/AntDesign/js/ant-design-blazor.js"></script>
<script>
window.initializeFlowbite = () => {
initFlowbite();
}
</script>
</body></html>
App.razor
這個是根組件
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"/>
<FocusOnNavigate RouteData="@routeData" Selector="h1"/>
</Found>
<NotFound>
<PageTitle>Not found</PageTitle>
<LayoutView Layout="@typeof(MainLayout)">
<p role="alert">Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound></Router><AntContainer />
MainLayout.razor
布局組件。
@inherits LayoutComponentBase@inject IJSRuntime Js<PageTitle>Clipify</PageTitle><button data-drawer-target="logo-sidebar" data-drawer-toggle="logo-sidebar" aria-controls="logo-sidebar" type="button" class="inline-flex items-center p-2 mt-2 ms-3 text-sm text-gray-500 rounded-lg sm:hidden hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600">
<span class="sr-only">Open sidebar</span>
<svg class="w-6 h-6" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path clip-rule="evenodd" fill-rule="evenodd" d="M2 4.75A.75.75 0 012.75 4h14.5a.75.75 0 010 1.5H2.75A.75.75 0 012 4.75zm0 10.5a.75.75 0 01.75-.75h7.5a.75.75 0 010 1.5h-7.5a.75.75 0 01-.75-.75zM2 10a.75.75 0 01.75-.75h14.5a.75.75 0 010 1.5H2.75A.75.75 0 012 10z"></path>
</svg></button><aside id="logo-sidebar" class="fixed top-0 left-0 z-40 w-64 h-screen transition-transform -translate-x-full sm:translate-x-0" aria-label="Sidebar">
<Navbar/></aside><div class="p-4 sm:ml-64">
@Body</div>@code {
protected override async Task OnAfterRenderAsync(bool isFirstRender) {#if DEBUG
await Js.InvokeVoidAsync("window.initializeFlowbite");#endif
if (isFirstRender) {
await Js.InvokeVoidAsync("window.initializeFlowbite");
}
}}
基礎功能到這里就搞定了
我習慣在項目里加一個 RouterMap ,這樣在路由跳轉的時候比較方便。
namespace Clipify.Forms;
public static class RouterMap {
public const string Index = "/";
public const string VideoSplit = "/video-split";
public const string ExtractAudio = "/extract-audio";
}
導航欄
導航欄的完整代碼省略了,有興趣的同學之間在 Github 上看完整代碼吧。
這里記錄一個老生常談的問題,如何高亮當前菜單?
有兩種方式:
NavigationManager 獲取當前路徑
NavLink組件
在本文中我使用的是 NavLink 組件,類似這樣:
當路徑與菜單的 href 相同時,元素會自動加上 ActiveClass 里的 class,從而實現高亮當前菜單的效果。
<NavLink href="@RouterMap.ExtractAudio" ActiveClass="bg-gray-200">
<i class="fa-solid fa-music"></i>
<span>提取音頻</span></NavLink>
因為篇幅關系省略了 TailwindCSS 的 class
目前是把 MediatR 用在了對話框的數據交互上。
因為要處理視頻,所以需要一個打開文件的對話框,和一個選擇輸出目錄的對話框。
Blazor 組件是運行在瀏覽器里的,瀏覽器自然也能打開文件,不過打開后程序只能拿到文件的 stream ,而我需要拿到文件在電腦里的存儲路徑,用于調用 ffmpeg 命令進行處理。
這種情況下只能使用 WinForms 的對話框控件了,Blazor 組件與 WinForms 處在同個進程,這種情況下,使用 MediatR 這類進程內消息隊列就很合適了。
MediatR 支持兩種類型的消息,分別是
Request/response messages, dispatched to a single handler
Notification messages, dispatched to multiple handlers
一種是一對一,另一種是一對多。
我的用法是這樣:
封裝 Service
為了屏蔽細節和解耦,我封裝了 DialogService
,這樣做的好處是可以進一步簡化組件與 MediatR 之間的通信,確保所有與文件對話框相關的邏輯集中在一個地方,使代碼更具可維護性和一致性。
public class DialogService {
private readonly IMediator _mediator;
public event Func<string, Task>? OnFileSelected;
public event Func<string, Task>? OnDirSelected;
public DialogService(IMediator mediator) {
_mediator = mediator;
}
public async Task<string> OpenFileAsync() {
return await _mediator.Send(new OpenFileRequest());
}
public async Task<string> OpenDirAsync() {
return await _mediator.Send(new OpenDirRequest());
}
public void NotifyFileSelected(string path) {
OnFileSelected?.Invoke(path);
}
public void NotifyDirSelected(string path) {
OnDirSelected?.Invoke(path);
}
}
其中有兩個事件,分別是打開文件和選擇目錄。這樣設計的好處有幾點:
集中管理:所有與文件對話框相關的邏輯都封裝在 DialogService
,包括 MediatR 的請求和處理。這樣可以在一個地方輕松維護代碼,提高可讀性和可維護性。
松耦合:Blazor 組件不需要知道 MediatR 的細節,只需與服務進行簡單的交互,符合單一職責原則。MediatR 的調用邏輯被隱藏在服務中,不會污染其他部分的代碼。
便于測試:通過將 MediatR 的調用封裝到服務中,你可以更容易地測試服務邏輯和 MediatR 的交互,而不需要在 Blazor 組件中進行復雜的測試。
以打開文件為例。
一對一的 Request
代碼 Clipify.Forms/EventBus/Request/OpenFileRequest.cs
using Clipify.Forms.EventBus.Notification;
using MediatR;
namespace Clipify.Forms.EventBus.Request;
public class OpenFileRequest : IRequest<string> { }
public class OpenFileHandler : IRequestHandler<OpenFileRequest, string> {
private readonly IMediator _mediator;
private readonly FormMain _formMain;
public OpenFileHandler(FormMain formMain, IMediator mediator) {
_formMain = formMain;
_mediator = mediator;
}
public Task<string> Handle(OpenFileRequest request, CancellationToken cancellationToken) {
var result = _formMain.openFileDialog.ShowDialog();
if (result == DialogResult.OK) {
var path = _formMain.openFileDialog.FileName;
_mediator.Publish(new FileSelectedNoti {
SelectedPath = path
}, cancellationToken);
return Task.FromResult(path);
}
return Task.FromResult("");
}
}
收到 Request 之后,RequestHandler 里通過依賴注入拿到 MainForm 的實例,然后調用對話框拿到文件路徑,再發送通知。
一對多的 Notification
代碼 Clipify.Forms/EventBus/Notification/FileSelectedNoti.cs
PS:其實也可以使用 Request 的返回值來拿到文件路徑,不過我還是”多此一舉“使用了 Notification
using Clipify.Forms.Services;
using MediatR;
namespace Clipify.Forms.EventBus.Notification;
public class FileSelectedNoti : INotification {
public string SelectedPath { get; set; }
}
public class FileSelectedHandler : INotificationHandler<FileSelectedNoti> {
private readonly DialogService _dialogService;
public FileSelectedHandler(DialogService dialogService) {
_dialogService = dialogService;
}
public Task Handle(FileSelectedNoti notification, CancellationToken cancellationToken) {
_dialogService.NotifyFileSelected(notification.SelectedPath);
return Task.CompletedTask;
}
}
這個代碼很簡單,就是調用了 DialogService 的事件處理器。
與 ffmpeg 交互
在開發 Clipify 工具時,視頻處理的核心依賴于 ffmpeg,這是一款強大的多媒體處理工具。為了實現視頻剪輯、音頻提取等功能,我探索了多種與 ffmpeg 交互的方式,包括使用現有的 C# 庫以及直接通過系統進程調用 ffmpeg。
經過研究,可以用這幾種方式來實現。
前兩種都是用第三方庫,我就不太多介紹了,有興趣的同學直接看官方文檔就行。另外提一點,C# 這邊的生態還是差了點,就算是1k多star的FFMpegCore也沒啥文檔,只有一個項目的 README;前面那個 FFmpeg.NET 就更不用說了,已經停更了,而且文檔有些代碼和實際使用還對不上。
不過這些都是對于 ffmpeg 的調用,自己實現也是沒問題的。下面是簡單的例子:
Process ffmpegProcess = new Process();
ffmpegProcess.StartInfo.FileName = "ffmpeg";
ffmpegProcess.StartInfo.Arguments = "-i input.mp4 -progress pipe:1 -f mp4 output.mp4";
ffmpegProcess.StartInfo.RedirectStandardOutput = true;
ffmpegProcess.StartInfo.UseShellExecute = false;
ffmpegProcess.StartInfo.CreateNoWindow = true;
ffmpegProcess.OutputDataReceived += (sender, e) => {
if (!string.IsNullOrEmpty(e.Data)) {
Console.WriteLine(e.Data);
}
};
ffmpegProcess.Start();
ffmpegProcess.BeginOutputReadLine();
ffmpegProcess.WaitForExit();
參數說明:
在 ffmpeg 的參數里加上 -progress pipe:1
,FFmpeg 會輸出類似于以下內容的進度信息:
frame=1000
fps=24.0
stream_0_0_q=28.0
bitrate=456.8kbits/s
total_size=1024000
out_time_us=42000000
out_time_ms=42000
out_time=00:00:42.000000
dup_frames=0
drop_frames=0
speed=2.00x
progress=continue
這樣就可以簡單的獲取更詳細的視頻處理進度信息。
不過 FFmpeg.NET 的 onData
事件是無法獲取這段信息的,一般會獲取到類似這樣的輸出:
size= 16522KiB time=00:21:19.01 bitrate= 105.8kbits/s speed=68.9x
就算添加了參數,也只能獲取這一行的信息,所以要詳細信息的話只能自己調用 Process 來處理。
并且 FFmpeg.NET 的 OnProgress 事件是有問題的,只能獲取到 ProcessedDuration
信息,其他的都沒辦法了,不知道是不是版本太老,不匹配新版 ffmpeg ,如果有需要可以自己寫正則解析一下。
string sizePattern = @"size=\s*(\d+)(\w+)";string timePattern = @"time=(\d{2}:\d{2}:\d{2}\.\d{2})";string bitratePattern = @"bitrate=\s*(\d+\.\d+|\d+)(\w+)";string speedPattern = @"speed=\s*(\d+\.\d+|\d+)x";
縮略圖
在 Clipify 中,視頻縮略圖是幫助用戶快速預覽視頻的重要功能。
在本項目的開發中,我探索了幾種不同的縮略圖策略:
視頻文件的 MD5 - 如果視頻文件較大且頻繁進行哈希計算,可能會帶來一定的性能開銷
文件路徑 MD5 - 如果文件路徑改變了(例如文件移動或重命名),盡管文件內容未變,MD5 仍然會不同,導致生成新的縮略圖。這可能會造成不必要的重復生成縮略圖。
結合文件的其他屬性(如文件名、修改時間等)進行 MD5 計算 - 這種方式可以兼顧路徑變化和文件唯一性的平衡,進一步減少重復縮略圖的生成
為了避免重復生成縮略圖,我采用了基于 MD5 哈希的策略為每個視頻生成唯一的縮略圖文件名。這樣可以確保同一視頻即使在不同時間被訪問,仍然可以使用緩存的縮略圖,提升性能。
這部分代碼集成在 VideoService
里面。
生成縮略圖的代碼
使用了 FFmpeg.NET 提供的生成縮略圖功能(其實就是調用ffmpeg對視頻進行截圖),根據規則生成文件名,之后把縮略圖文件保存到 wwwroot/temp/thumbnails
目錄里面。
public async Task<string> GenerateThumbnailAsync(string videoPath, CancellationToken? cancellationToken = null) {
var inputFile = new InputFile(videoPath);
var tempThumbnailDir = Path.Combine(_environment.WebRootPath, "temp", "thumbnails");
if (!Directory.Exists(tempThumbnailDir)) {
Directory.CreateDirectory(tempThumbnailDir);
}
var filename = $"{GetFileMetadataMd5(videoPath)}.jpeg";
var outputPath = Path.Combine(tempThumbnailDir, filename);
var outputFile = new OutputFile(outputPath);
var opt = new ConversionOptions {
HideBanner = true,
HWAccelOutputFormatCopy = true,
MapMetadata = true,
};
if (!File.Exists(outputPath)) {
await FFmpeg.GetThumbnailAsync(inputFile, outputFile, cancellationToken ?? CancellationToken.None);
}
return $"temp/thumbnails/{filename}";
}
視頻文件的 MD5 哈希
最直接的方式是對整個視頻文件進行 MD5 哈希運算,將其生成的哈希值作為縮略圖的文件名。然而,如果視頻文件較大,頻繁進行哈希計算可能帶來顯著的性能開銷。
public static string GetFileMd5(string filePath) {
using var md5 = MD5.Create();
using var stream = File.OpenRead(filePath);
var hash = md5.ComputeHash(stream);
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
}
優點:文件內容唯一性強,可以確保不同內容的視頻不會生成相同的縮略圖。
缺點:對于大型文件,MD5 計算耗時較長,影響性能。實測幾個G的視頻要花好幾秒的時間。
文件路徑的 MD5 哈希
為了提高性能,也可以僅對文件路徑進行 MD5 計算。這種方式大大減少了計算量,適用于那些文件內容不變但需要頻繁生成縮略圖的場景。然而,當文件被移動或重命名時,盡管視頻內容沒有變化,生成的 MD5 值會不同,可能導致不必要的重復縮略圖生成。
string filePathHash;
using (var md5 = MD5.Create()) {
var pathBytes = Encoding.UTF8.GetBytes(videoFilePath);
var hash = md5.ComputeHash(pathBytes);
filePathHash = BitConverter.ToString(hash).Replace("-", "").ToLower();
}
優點:高效,MD5 計算速度極快,適合頻繁使用。
缺點:文件路徑變動時,即使文件內容不變,仍會生成新縮略圖,可能導致冗余的縮略圖生成。
結合文件屬性進行 MD5 計算
為了在路徑變化和文件內容唯一性之間找到平衡,Clipify 還可以結合文件的其他屬性,如文件名、修改時間等進行 MD5 計算。這樣即使文件路徑發生變化,只要文件內容和其屬性不變,MD5 也不會變化,避免不必要的重復生成。
public static string GetFileMetadataMd5(string filePath) {
var fileName = Path.GetFileName(filePath);
var fileInfo = new FileInfo(filePath);
var metaData = fileName + fileInfo.LastWriteTimeUtc.ToString();
using var md5 = MD5.Create();
var metaBytes = System.Text.Encoding.UTF8.GetBytes(metaData);
var hash = md5.ComputeHash(metaBytes);
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
}
優點:
兼顧了文件內容的唯一性和文件路徑的變化。
減少了重復縮略圖生成的情況。
缺點:需要結合多個文件屬性,計算稍微復雜,但仍能有效提升性能。
小結
在 Clipify 中,選擇如何生成視頻縮略圖的哈希值需要在性能和唯一性之間做平衡。
對于較大的視頻文件,直接對文件進行 MD5 計算雖然保證了內容的唯一性,但對性能影響較大。
而通過結合文件路徑和文件屬性來生成哈希值,可以減少性能消耗并避免冗余的縮略圖生成。
在后續的版本中,可以考慮小文件使用文件內容生成MD5,大文件繼續用綜合路徑和屬性的方式來生成MD5。
顯示視頻導出進度
目前是用 FFmpeg.Net 的 OnProgress
事件,保留小數點后兩位
private async void OnProgress(object? sender, ConversionProgressEventArgs e) {
Status.Status = StatusEnum.Running;
Status.Progress = Math.Round(e.ProcessedDuration.TotalSeconds / MetaData.Duration.TotalSeconds * 100, 2);
await InvokeAsync(StateHasChanged);
}
如果要更詳細的顯示處理時的其他信息,可以參考前面的與FFmpeg交互部分。
細節
在 Clipify 的設計過程中,我非常注重用戶體驗中的細節,尤其是如何讓用戶更直觀、輕松地理解視頻文件的屬性。因此,除了基本的視頻編輯功能,我還在界面上優化了文件大小和視頻長度的顯示方式。
本文選擇了這兩點來介紹:
顯示更友好的文件大小
視頻文件通常較大,直接顯示以字節(bytes)為單位的大小可能不夠直觀。為了提升用戶體驗,我選擇了將文件大小轉換為更常見的單位,如 KB、MB 或 GB,并使用四舍五入讓顯示更簡潔。
例如,如果視頻文件大小為 3,304,582 字節,則會顯示為 3.30 MB
。這樣一來,用戶不需要進行單位換算,直接可以看到文件的大致大小。
這里我寫了一個擴展方法來實現。
public static class FileInfoExtensions {
public static string GetFriendlySize(this FileInfo fileInfo) {
string[] sizeUnits = { "Bytes", "KB", "MB", "GB", "TB" };
double fileSize = fileInfo.Length;
int unitIndex = 0;
while (fileSize >= 1024 && unitIndex < sizeUnits.Length - 1) {
fileSize /= 1024;
unitIndex++;
}
return $"{fileSize:F2} {sizeUnits[unitIndex]}";
}
}
效果:
顯示更友好的視頻長度
對于視頻文件的長度,直接以秒或毫秒顯示并不友好。為了提供更直觀的體驗,我選擇了將視頻長度轉換為格式化的時間顯示,如 HH:mm:ss
,讓用戶能夠快速了解視頻的時長。
例如,一個長 5 分鐘 44 秒的視頻,系統會顯示為 00:05:44
,而不是直接顯示秒數(如 344 秒)。這種顯示方式符合用戶日常的認知習慣,讓用戶能更輕松地估計視頻內容的時間跨度。
依然是使用擴展方法來實現(我甚至還寫了英文版本)
public static class TimeSpanExtensions {
public static string ToFriendlyString(this TimeSpan timeSpan, string locale = "zh-cn") {
var parts = new List<string>();
switch (locale) {
case "zh-cn":
if (timeSpan.Days > 0)
parts.Add($"{timeSpan.Days}天");
if (timeSpan.Hours > 0)
parts.Add($"{timeSpan.Hours}小時");
if (timeSpan.Minutes > 0)
parts.Add($"{timeSpan.Minutes}分鐘");
if (timeSpan.Seconds > 0)
parts.Add($"{timeSpan.Seconds}秒");
if (parts.Count == 0)
return "0 秒";
break;
default:
if (timeSpan.Days > 0)
parts.Add($"{timeSpan.Days} day{(timeSpan.Days > 1 ? "s" : "")}");
if (timeSpan.Hours > 0)
parts.Add($"{timeSpan.Hours} hour{(timeSpan.Hours > 1 ? "s" : "")}");
if (timeSpan.Minutes > 0)
parts.Add($"{timeSpan.Minutes} minute{(timeSpan.Minutes > 1 ? "s" : "")}");
if (timeSpan.Seconds > 0)
parts.Add($"{timeSpan.Seconds} second{(timeSpan.Seconds > 1 ? "s" : "")}");
if (parts.Count == 0)
return "0 seconds";
break;
}
return string.Join(", ", parts);
}
}
不過如果要固定格式的話,可以直接使用更簡短的代碼:
public static string FormatVideoDuration(TimeSpan duration){
return string.Format(
"{0:D2}:{1:D2}:{2:D2}",
duration.Hours,
duration.Minutes,
duration.Seconds);
}
小結
細節決定體驗。在 Clipify 的設計中,顯示更友好的文件大小和視頻長度是提升用戶體驗的關鍵步驟。通過將技術邏輯轉化為直觀的界面元素,用戶可以更加輕松地操作視頻文件,減少因信息不直觀帶來的困擾。這些小細節的優化將有助于提升整個工具的易用性和用戶滿意度。
文章小結
相比之前的 QuickCutSharp,這個新工具在開發體驗和界面設計上更加靈活,也更加適合我的需求。雖然起初嘗試了一些其他的開發方案,如 Avalonia 和 MAUI,但最終因為環境復雜或平臺不支持而放棄。
使用 Blazor 和 TailwindCSS 構建界面,既保持了熟悉的 C# 開發生態,又帶來了現代化的前端體驗,這讓整個項目的開發更加順暢。雖然 Clipify 目前只實現了部分功能,但我對其未來的發展充滿期待。項目已經開源,希望能對有類似需求的開發者提供一些幫助。
轉自https://www.cnblogs.com/deali/p/18458357
該文章在 2025/1/17 10:20:04 編輯過