Streamline video conversion workflow & UI integration

Removed /api/video/details endpoint; video details now fetched as first step of processing and included in progress/status responses. Updated backend models and controller logic to support integrated workflow and improved error handling. Refactored frontend to show video info and conversion progress in real time, removed legacy API calls, and enhanced UI/UX with responsive video info cards and progress bars. Updated documentation and example code to reflect new workflow.
This commit is contained in:
Michal Vrba
2025-12-11 14:59:14 +01:00
parent 8bfcc3617b
commit 160b7e7115
5 changed files with 752 additions and 174 deletions
+483 -35
View File
@@ -1,6 +1,8 @@
@page "/"
@using Microsoft.AspNetCore.Components.Web
@inject IJSRuntime JSRuntime
@inject ITuneRipperApiService ApiService
@inject ILogger<Home> Logger
<PageTitle>TuneRipper - YouTube to MP3 Downloader</PageTitle>
@@ -33,14 +35,83 @@
}
</button>
</div>
@* Video Information Section - Show as soon as we have details *@
@if (currentVideoDetails != null)
{
<div class="video-info-section">
<h3 class="video-info-title">📹 Video Information</h3>
<div class="video-info-card @(isProcessing ? "processing" : "")">
<div class="video-thumbnail">
@if (!string.IsNullOrEmpty(currentVideoDetails.ThumbnailUrl))
{
<img src="@currentVideoDetails.ThumbnailUrl" alt="Video thumbnail" class="thumbnail-img" />
@if (isProcessing)
{
<div class="processing-overlay">
<div class="processing-spinner">🎵</div>
</div>
}
}
</div>
<div class="video-details">
<h4 class="video-title">@currentVideoDetails.Title</h4>
<div class="video-meta">
<span class="meta-item">
<span class="meta-icon">👤</span>
<span>@currentVideoDetails.Author</span>
</span>
<span class="meta-item">
<span class="meta-icon">⏱️</span>
<span>@FormatDuration(currentVideoDetails.Duration)</span>
</span>
<span class="meta-item">
<span class="meta-icon">👁️</span>
<span>@FormatViewCount(currentVideoDetails.ViewCount) views</span>
</span>
<span class="meta-item">
<span class="meta-icon">📅</span>
<span>@currentVideoDetails.UploadDate.ToString("MMM dd, yyyy")</span>
</span>
</div>
@if (currentVideoDetails.Tags?.Any() == true)
{
<div class="video-tags">
@foreach (var tag in currentVideoDetails.Tags.Take(5))
{
<span class="tag">@tag</span>
}
</div>
}
@if (isProcessing)
{
<div class="conversion-status">
<span class="status-icon">⚡</span>
<span class="status-text">Converting to MP3...</span>
</div>
}
</div>
</div>
</div>
}
@if (isProcessing)
{
<div class="progress-container">
<div class="progress-container @(currentVideoDetails != null ? "with-video-details" : "")">
<div class="progress-bar">
<div class="progress-fill" style="width: @(progressPercentage)%"></div>
</div>
<div class="progress-text">@progressMessage (@progressPercentage%)</div>
<div class="progress-text">
@if (currentVideoDetails != null && progressPercentage > 15)
{
<span class="progress-highlight">@progressMessage</span>
<span class="progress-percentage">(@progressPercentage%)</span>
}
else
{
<span>@progressMessage (@progressPercentage%)</span>
}
</div>
</div>
}
@@ -50,6 +121,14 @@
<p>@outputMessage</p>
</div>
}
@* API Error Message *@
@if (!string.IsNullOrWhiteSpace(apiErrorMessage) && !isProcessing)
{
<div class="error-message">
<p>❌ @apiErrorMessage</p>
</div>
}
</div>
@if (downloadLinks.Any())
@@ -96,6 +175,14 @@
width: 100%;
}
.progress-container.with-video-details {
margin-top: 15px;
background: #f8f9ff;
padding: 15px;
border-radius: 8px;
border: 1px solid #e3e7ff;
}
.progress-bar {
width: 100%;
height: 8px;
@@ -105,6 +192,11 @@
margin-bottom: 10px;
}
.progress-container.with-video-details .progress-bar {
height: 6px;
background-color: #d1d9ff;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #4CAF50, #45a049);
@@ -119,6 +211,22 @@
font-weight: 500;
}
.progress-container.with-video-details .progress-text {
font-size: 13px;
color: #4a5568;
}
.progress-highlight {
color: #2d3748;
font-weight: 600;
}
.progress-percentage {
color: #718096;
font-weight: 400;
margin-left: 4px;
}
.download-links-section {
margin-top: 30px;
width: 100%;
@@ -197,15 +305,224 @@
.download-icon {
font-size: 16px;
}
/* Video Info Styles */
.video-info-section {
margin-top: 20px;
width: 100%;
animation: slideIn 0.5s ease-out;
}
.video-info-title {
color: #333;
margin-bottom: 15px;
text-align: center;
font-size: 18px;
font-weight: 600;
}
.video-info-card {
background: #ffffff;
border: 1px solid #e0e0e0;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
display: flex;
gap: 20px;
align-items: flex-start;
}
.video-info-card.processing {
border-color: #4CAF50;
box-shadow: 0 2px 12px rgba(76, 175, 80, 0.15);
}
.video-thumbnail {
flex-shrink: 0;
position: relative;
}
.thumbnail-img {
width: 160px;
height: 90px;
object-fit: cover;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: opacity 0.3s ease;
}
.processing-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.3s ease-out;
}
.processing-spinner {
font-size: 24px;
animation: spin 2s linear infinite;
}
.video-details {
flex: 1;
min-width: 0;
}
.video-title {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 12px;
line-height: 1.4;
word-wrap: break-word;
}
.video-meta {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-bottom: 12px;
}
.meta-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #666;
}
.meta-icon {
font-size: 14px;
}
.video-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tag {
background: #f0f0f0;
color: #666;
font-size: 11px;
padding: 4px 8px;
border-radius: 12px;
font-weight: 500;
}
.error-message {
margin-top: 15px;
padding: 12px;
background: #ffebee;
border: 1px solid #ffcdd2;
border-radius: 8px;
color: #c62828;
text-align: center;
}
.conversion-status {
margin-top: 8px;
padding: 8px 12px;
background: linear-gradient(90deg, #4CAF50, #45a049);
color: white;
border-radius: 6px;
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 500;
animation: pulse 2s ease-in-out infinite;
}
.status-icon {
font-size: 16px;
}
@@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@@keyframes pulse {
0%, 100% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(76, 175, 80, 0.4);
}
50% {
transform: scale(1.02);
box-shadow: 0 0 0 10px rgba(76, 175, 80, 0);
}
}
@@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Responsive Design */
@@media (max-width: 768px) {
.video-info-card {
flex-direction: column;
align-items: center;
text-align: center;
}
.thumbnail-img {
width: 200px;
height: 112px;
}
.video-meta {
justify-content: center;
}
}
@@media (max-width: 480px) {
.video-info-card {
padding: 15px;
}
.thumbnail-img {
width: 160px;
height: 90px;
}
.video-meta {
flex-direction: column;
align-items: center;
gap: 8px;
}
}
</style>
@code {
private string youtubeUrl = "";
private string outputMessage = "";
private string apiErrorMessage = "";
private bool isProcessing = false;
private int progressPercentage = 0;
private string progressMessage = "";
private List<DownloadItem> downloadLinks = new();
private YouTubeVideoDetails? currentVideoDetails = null;
public class DownloadItem
{
@@ -221,59 +538,190 @@
isProcessing = true;
progressPercentage = 0;
outputMessage = "";
apiErrorMessage = "";
currentVideoDetails = null;
await SimulateProgress();
try
{
// Step 1: Start processing (which includes video details fetching)
progressMessage = "Starting video processing...";
progressPercentage = 5;
StateHasChanged();
var request = new ProcessVideoRequest
{
Url = youtubeUrl,
Format = "mp3",
Quality = "128"
};
var processingResponse = await ApiService.StartProcessingAsync(request);
if (processingResponse?.Success == true)
{
var jobId = processingResponse.FileName; // This contains the job ID
// Step 2: Monitor processing status and get video details
await MonitorProcessingAndShowDetails(jobId);
}
else
{
// Processing start failed
isProcessing = false;
apiErrorMessage = processingResponse?.Message ?? "Failed to start processing. Please check the URL and try again.";
StateHasChanged();
return;
}
}
catch (Exception ex)
{
isProcessing = false;
apiErrorMessage = $"An error occurred: {ex.Message}";
StateHasChanged();
}
}
}
private async Task MonitorProcessingAndShowDetails(string jobId)
{
try
{
bool videoDetailsReceived = false;
int maxAttempts = 60; // Maximum polling attempts (about 2 minutes)
int attemptCount = 0;
string title = ExtractTitleFromUrl(youtubeUrl);
while (attemptCount < maxAttempts && isProcessing)
{
var status = await ApiService.GetProcessingStatusAsync(jobId);
if (status != null)
{
// Update progress
progressPercentage = status.PercentageComplete;
progressMessage = status.Message ?? "Processing...";
// Check if we received video details
if (!videoDetailsReceived && status.VideoDetails != null)
{
currentVideoDetails = status.VideoDetails;
videoDetailsReceived = true;
Logger.LogInformation("Video details received: {Title}", status.VideoDetails.Title);
}
StateHasChanged();
// Check if processing is complete
if (status.Status == "Completed")
{
// Get final result
var result = await ApiService.GetProcessingResultAsync(jobId);
if (result?.Success == true)
{
await CompleteProcessing(result);
}
else
{
apiErrorMessage = result?.Message ?? "Processing completed but failed to get result.";
isProcessing = false;
StateHasChanged();
}
return;
}
else if (status.Status == "Failed")
{
apiErrorMessage = status.Message ?? "Processing failed.";
isProcessing = false;
StateHasChanged();
return;
}
}
attemptCount++;
await Task.Delay(2000); // Poll every 2 seconds
}
// Timeout handling
if (attemptCount >= maxAttempts)
{
apiErrorMessage = "Processing timeout. Please try again.";
isProcessing = false;
StateHasChanged();
}
}
catch (Exception ex)
{
isProcessing = false;
apiErrorMessage = $"Error monitoring processing: {ex.Message}";
StateHasChanged();
}
}
private async Task CompleteProcessing(ProcessVideoResponse result)
{
try
{
// Use the video details from the result if we don't have them yet
if (currentVideoDetails == null && result.VideoDetails != null)
{
currentVideoDetails = result.VideoDetails;
}
// Use the actual video title from API
string title = currentVideoDetails?.Title ?? result.VideoDetails?.Title ?? ExtractTitleFromUrl(youtubeUrl);
downloadLinks.Add(new DownloadItem
{
Title = title,
FileName = "sample.mp3",
FileName = result.FileName ?? "sample.mp3",
CreatedAt = DateTime.Now
});
outputMessage = $"✅ Successfully converted: {title}";
progressPercentage = 100;
progressMessage = "Complete!";
// Small delay to show completion
await Task.Delay(500);
isProcessing = false;
youtubeUrl = "";
StateHasChanged();
}
catch (Exception ex)
{
isProcessing = false;
apiErrorMessage = $"Error completing processing: {ex.Message}";
StateHasChanged();
}
}
private async Task SimulateProgress()
private string FormatDuration(TimeSpan duration)
{
string[] progressSteps = {
"Fetching video information...",
"Analyzing audio stream...",
"Downloading audio data...",
"Converting to MP3...",
"Optimizing quality...",
"Finalizing download..."
};
int stepDuration = 800;
int stepsPerProgress = 100 / progressSteps.Length;
for (int i = 0; i < progressSteps.Length; i++)
if (duration.TotalHours >= 1)
{
progressMessage = progressSteps[i];
int startProgress = i * stepsPerProgress;
int endProgress = (i + 1) * stepsPerProgress;
for (int progress = startProgress; progress <= endProgress; progress++)
{
progressPercentage = Math.Min(progress, 100);
StateHasChanged();
await Task.Delay(stepDuration / stepsPerProgress);
}
return duration.ToString(@"h\:mm\:ss");
}
else
{
return duration.ToString(@"m\:ss");
}
}
private string FormatViewCount(int viewCount)
{
if (viewCount >= 1_000_000)
{
return $"{viewCount / 1_000_000.0:F1}M";
}
else if (viewCount >= 1_000)
{
return $"{viewCount / 1_000.0:F1}K";
}
else
{
return viewCount.ToString("N0");
}
progressPercentage = 100;
progressMessage = "Complete!";
StateHasChanged();
await Task.Delay(500);
}
private string ExtractTitleFromUrl(string url)