From 88b9153b5e4264b85cd147948f5ec22abcd05ccb Mon Sep 17 00:00:00 2001 From: mateofuentespombo Date: Mon, 22 Dec 2025 18:05:48 +0100 Subject: [PATCH 1/9] feat: add playlists --- .../Controllers/FileController.cs | 63 +- TelegramDownloader/Data/FileService.cs | 121 +++ TelegramDownloader/Data/IFileService.cs | 1 + TelegramDownloader/Data/db/DbService.cs | 132 +++ TelegramDownloader/Data/db/IDbService.cs | 10 + TelegramDownloader/Models/PlaylistModel.cs | 60 ++ .../Pages/Modals/AudioPlayerModal.razor | 256 +++++- .../Pages/Modals/PlaylistManagerModal.razor | 860 ++++++++++++++++++ .../Pages/Modals/SaveToPlaylistModal.razor | 425 +++++++++ .../Pages/Partials/impl/FileManagerImpl.razor | 76 +- TelegramDownloader/Shared/MainLayout.razor | 115 +++ .../MobileFileManager/MobileFileManager.razor | 22 + .../MobileFileManager.razor.cs | 35 +- .../MobileFileManagerEventArgs.cs | 8 + .../wwwroot/js/fileUploadVue.js | 39 + 15 files changed, 2194 insertions(+), 29 deletions(-) create mode 100644 TelegramDownloader/Models/PlaylistModel.cs create mode 100644 TelegramDownloader/Pages/Modals/PlaylistManagerModal.razor create mode 100644 TelegramDownloader/Pages/Modals/SaveToPlaylistModal.razor diff --git a/TelegramDownloader/Controllers/FileController.cs b/TelegramDownloader/Controllers/FileController.cs index 895d357..84f9def 100644 --- a/TelegramDownloader/Controllers/FileController.cs +++ b/TelegramDownloader/Controllers/FileController.cs @@ -240,29 +240,56 @@ public async Task GetFile(string id, string? idChannel, string? i var file = _fs.ExistFileIntempFolder($"{idChannel}-{idFile}-{id}"); if ( file == null ) { - // HttpResponseMessage fullResponse = new HttpResponseMessage(HttpStatusCode.OK); // Request.CreateResponse(HttpStatusCode.OK); - TL.Message idM = await _ts.getMessageFile(idChannel, Convert.ToInt32(idFile)); - ChatMessages cm = new ChatMessages(); - cm.message = idM; string filePath = System.IO.Path.Combine(FileService.TEMPDIR, "_temp", $"{idChannel}-{idFile}-{id}"); - file = new FileStream(filePath, FileMode.Create, FileAccess.ReadWrite); - DownloadModel dm = new DownloadModel(); - dm.tis = _tis; - dm.startDate = DateTime.Now; - dm.path = filePath; - if (cm.message is Message msgBase) + + // Check if file exists (might be downloading by another request - race condition) + if (System.IO.File.Exists(filePath)) { - if (msgBase.media is MessageMediaDocument mediaDoc && - mediaDoc.document is TL.Document doc) + // File exists, wait for it to be ready and return it + for (int i = 0; i < 60; i++) // Wait up to 30 seconds { - dm._size = doc.size; - dm.name = doc.Filename; + await Task.Delay(500); + try + { + file = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + break; + } + catch (IOException) + { + // File still being written, wait more + } + } + if (file == null) + { + return StatusCode(503, "File is being processed, please try again"); } } - dm.channelName = _ts.getChatName(Convert.ToInt64(idChannel)); - _tis.addToDownloadList(dm); - await _ts.DownloadFileAndReturn(cm, file, model: dm); - file.Position = 0; + else + { + // File doesn't exist, download it + TL.Message idM = await _ts.getMessageFile(idChannel, Convert.ToInt32(idFile)); + ChatMessages cm = new ChatMessages(); + cm.message = idM; + + file = new FileStream(filePath, FileMode.Create, FileAccess.ReadWrite, FileShare.ReadWrite); + DownloadModel dm = new DownloadModel(); + dm.tis = _tis; + dm.startDate = DateTime.Now; + dm.path = filePath; + if (cm.message is Message msgBase) + { + if (msgBase.media is MessageMediaDocument mediaDoc && + mediaDoc.document is TL.Document doc) + { + dm._size = doc.size; + dm.name = doc.Filename; + } + } + dm.channelName = _ts.getChatName(Convert.ToInt64(idChannel)); + _tis.addToDownloadList(dm); + await _ts.DownloadFileAndReturn(cm, file, model: dm); + file.Position = 0; + } } var request = HttpContext.Request; var rangeHeader = request.Headers["Range"].ToString(); diff --git a/TelegramDownloader/Data/FileService.cs b/TelegramDownloader/Data/FileService.cs index 9cd1395..adc22b0 100644 --- a/TelegramDownloader/Data/FileService.cs +++ b/TelegramDownloader/Data/FileService.cs @@ -2264,5 +2264,126 @@ public virtual Task PreloadFilesToTemp(string channelId, List splitPaths = new List(); + foreach (int messageId in file.ListMessageId) + { + string filePathPart = System.IO.Path.Combine(destinationFolder, $"({i})" + track.FileName); + await downloadFromTelegram(track.ChannelId, messageId, filePathPart, file); + splitPaths.Add(filePathPart); + i++; + } + await mergeFileStreamAsync(splitPaths, destPath); + foreach (string filePath in splitPaths) + { + File.Delete(filePath); + } + } + else + { + // Download single file + await downloadFromTelegram(track.ChannelId, (int)file.MessageId, destPath, file); + } + + downloaded++; + _logger.LogInformation("Downloaded track {Downloaded}/{Total}: {FileName}", + downloaded, playlist.TrackCount, track.FileName); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error downloading track: {FileName}", track.FileName); + failed++; + } + } + + string message = failed > 0 + ? $"Playlist '{playlist.Name}' completed: {downloaded} downloaded, {failed} failed" + : $"Playlist '{playlist.Name}' downloaded successfully ({downloaded} tracks)"; + + nm.sendEvent(new Notification(message, "Playlist Download", + failed > 0 ? NotificationTypes.Warn : NotificationTypes.Success)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error downloading playlist: {Name}", playlist.Name); + nm.sendEvent(new Notification($"Error downloading playlist: {ex.Message}", "Playlist Download", NotificationTypes.Error)); + throw; + } + } } } diff --git a/TelegramDownloader/Data/IFileService.cs b/TelegramDownloader/Data/IFileService.cs index adee9d4..bc2ab84 100644 --- a/TelegramDownloader/Data/IFileService.cs +++ b/TelegramDownloader/Data/IFileService.cs @@ -44,5 +44,6 @@ public interface IFileService Task refreshChannelFIles(string channelId, bool force = false); bool isChannelRefreshing(string channelId); Task PreloadFilesToTemp(string channelId, List items); + Task DownloadPlaylistToLocal(PlaylistModel playlist, string destinationFolder); } } \ No newline at end of file diff --git a/TelegramDownloader/Data/db/DbService.cs b/TelegramDownloader/Data/db/DbService.cs index 3561a2d..075bc60 100644 --- a/TelegramDownloader/Data/db/DbService.cs +++ b/TelegramDownloader/Data/db/DbService.cs @@ -1181,5 +1181,137 @@ private string CalculateFilterId(string parentId, Dictionary CreatePlaylist(PlaylistModel playlist) + { + if (string.IsNullOrEmpty(playlist.Id)) + { + playlist.Id = MongoDB.Bson.ObjectId.GenerateNewId().ToString(); + } + playlist.DateCreated = DateTime.Now; + playlist.DateModified = DateTime.Now; + + var collection = getDatabase(CONFIG_DB_NAME).GetCollection(PLAYLIST_COLLECTION); + await collection.InsertOneAsync(playlist); + + _logger.LogInformation("Created playlist {Name} with Id {Id}", playlist.Name, playlist.Id); + return playlist; + } + + public async Task> GetAllPlaylists() + { + var collection = getDatabase(CONFIG_DB_NAME).GetCollection(PLAYLIST_COLLECTION); + var playlists = await (await collection.FindAsync(Builders.Filter.Empty)).ToListAsync(); + return playlists.OrderByDescending(p => p.DateModified).ToList(); + } + + public async Task GetPlaylistById(string id) + { + var collection = getDatabase(CONFIG_DB_NAME).GetCollection(PLAYLIST_COLLECTION); + return await (await collection.FindAsync(Builders.Filter.Eq(x => x.Id, id))).FirstOrDefaultAsync(); + } + + public async Task UpdatePlaylist(PlaylistModel playlist) + { + playlist.DateModified = DateTime.Now; + + var collection = getDatabase(CONFIG_DB_NAME).GetCollection(PLAYLIST_COLLECTION); + var filter = Builders.Filter.Eq(x => x.Id, playlist.Id); + await collection.ReplaceOneAsync(filter, playlist, new ReplaceOptions { IsUpsert = false }); + + _logger.LogInformation("Updated playlist {Name} with Id {Id}", playlist.Name, playlist.Id); + } + + public async Task DeletePlaylist(string id) + { + var collection = getDatabase(CONFIG_DB_NAME).GetCollection(PLAYLIST_COLLECTION); + await collection.DeleteOneAsync(Builders.Filter.Eq(x => x.Id, id)); + + _logger.LogInformation("Deleted playlist with Id {Id}", id); + } + + public async Task AddTrackToPlaylist(string playlistId, PlaylistTrackModel track) + { + var collection = getDatabase(CONFIG_DB_NAME).GetCollection(PLAYLIST_COLLECTION); + + // Get current playlist to determine next order + var playlist = await GetPlaylistById(playlistId); + if (playlist == null) + { + _logger.LogWarning("Playlist {Id} not found when adding track", playlistId); + return; + } + + // Set order to be at the end + track.Order = playlist.Tracks.Count; + track.DateAdded = DateTime.Now; + + var filter = Builders.Filter.Eq(x => x.Id, playlistId); + var update = Builders.Update + .Push(x => x.Tracks, track) + .Set(x => x.DateModified, DateTime.Now); + + await collection.UpdateOneAsync(filter, update); + + _logger.LogInformation("Added track {FileName} to playlist {Id}", track.FileName, playlistId); + } + + public async Task RemoveTrackFromPlaylist(string playlistId, string fileId) + { + var collection = getDatabase(CONFIG_DB_NAME).GetCollection(PLAYLIST_COLLECTION); + + var filter = Builders.Filter.Eq(x => x.Id, playlistId); + var update = Builders.Update + .PullFilter(x => x.Tracks, t => t.FileId == fileId) + .Set(x => x.DateModified, DateTime.Now); + + await collection.UpdateOneAsync(filter, update); + + // Reorder remaining tracks + var playlist = await GetPlaylistById(playlistId); + if (playlist != null && playlist.Tracks.Count > 0) + { + for (int i = 0; i < playlist.Tracks.Count; i++) + { + playlist.Tracks[i].Order = i; + } + await UpdatePlaylist(playlist); + } + + _logger.LogInformation("Removed track {FileId} from playlist {Id}", fileId, playlistId); + } + + public async Task ReorderPlaylistTracks(string playlistId, List orderedFileIds) + { + var playlist = await GetPlaylistById(playlistId); + if (playlist == null) + { + _logger.LogWarning("Playlist {Id} not found when reordering tracks", playlistId); + return; + } + + // Reorder tracks based on the provided order + var reorderedTracks = new List(); + for (int i = 0; i < orderedFileIds.Count; i++) + { + var track = playlist.Tracks.FirstOrDefault(t => t.FileId == orderedFileIds[i]); + if (track != null) + { + track.Order = i; + reorderedTracks.Add(track); + } + } + + playlist.Tracks = reorderedTracks; + await UpdatePlaylist(playlist); + + _logger.LogInformation("Reordered {Count} tracks in playlist {Id}", reorderedTracks.Count, playlistId); + } + + #endregion + } } diff --git a/TelegramDownloader/Data/db/IDbService.cs b/TelegramDownloader/Data/db/IDbService.cs index 0dc04d8..264dcaf 100644 --- a/TelegramDownloader/Data/db/IDbService.cs +++ b/TelegramDownloader/Data/db/IDbService.cs @@ -74,6 +74,16 @@ public interface IDbService Task GetDatabaseStats(string dbName); Task AnalyzeFilterPaths(string dbName, string collectionName = "directory"); Task RepairFilterPaths(string dbName, string collectionName = "directory"); + + // Playlist operations + Task CreatePlaylist(PlaylistModel playlist); + Task> GetAllPlaylists(); + Task GetPlaylistById(string id); + Task UpdatePlaylist(PlaylistModel playlist); + Task DeletePlaylist(string id); + Task AddTrackToPlaylist(string playlistId, PlaylistTrackModel track); + Task RemoveTrackFromPlaylist(string playlistId, string fileId); + Task ReorderPlaylistTracks(string playlistId, List orderedFileIds); } public class DatabaseStats diff --git a/TelegramDownloader/Models/PlaylistModel.cs b/TelegramDownloader/Models/PlaylistModel.cs new file mode 100644 index 0000000..952f5ab --- /dev/null +++ b/TelegramDownloader/Models/PlaylistModel.cs @@ -0,0 +1,60 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace TelegramDownloader.Models +{ + public class PlaylistModel + { + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string Id { get; set; } = ObjectId.GenerateNewId().ToString(); + + public string Name { get; set; } = string.Empty; + + public string? Description { get; set; } + + public List Tracks { get; set; } = new(); + + [BsonDateTimeOptions(Kind = DateTimeKind.Local)] + public DateTime DateCreated { get; set; } = DateTime.Now; + + [BsonDateTimeOptions(Kind = DateTimeKind.Local)] + public DateTime DateModified { get; set; } = DateTime.Now; + + [BsonIgnore] + public int TrackCount => Tracks?.Count ?? 0; + } + + public class PlaylistTrackModel + { + public string FileId { get; set; } = string.Empty; + + public string ChannelId { get; set; } = string.Empty; + + public string ChannelName { get; set; } = string.Empty; + + public string FileName { get; set; } = string.Empty; + + public string FilePath { get; set; } = string.Empty; + + public string FileType { get; set; } = string.Empty; + + public long FileSize { get; set; } + + public int Order { get; set; } + + /// + /// Direct URL for local files. When set, the track is a local file and this URL should be used directly. + /// + public string? DirectUrl { get; set; } + + [BsonDateTimeOptions(Kind = DateTimeKind.Local)] + public DateTime DateAdded { get; set; } = DateTime.Now; + + /// + /// Returns true if this is a local file (has DirectUrl set) + /// + [BsonIgnore] + public bool IsLocalFile => !string.IsNullOrEmpty(DirectUrl); + } +} diff --git a/TelegramDownloader/Pages/Modals/AudioPlayerModal.razor b/TelegramDownloader/Pages/Modals/AudioPlayerModal.razor index ddfef46..767830c 100644 --- a/TelegramDownloader/Pages/Modals/AudioPlayerModal.razor +++ b/TelegramDownloader/Pages/Modals/AudioPlayerModal.razor @@ -1,3 +1,4 @@ +@using TelegramDownloader.Shared @inject IJSRuntime JSRuntime @implements IDisposable @@ -459,6 +460,67 @@ cursor: pointer; } + .audio-btn-visualizer { + background: none; + border: none; + color: rgba(255, 255, 255, 0.5); + font-size: 1rem; + cursor: pointer; + padding: 0.25rem 0.5rem; + border-radius: 4px; + transition: all 0.2s ease; + margin-left: 0.5rem; + } + + .audio-btn-visualizer:hover { + color: #e94560; + background: rgba(233, 69, 96, 0.1); + } + + .audio-btn-visualizer.active { + color: #e94560; + } + + /* Saved Playlist Actions */ + .saved-playlist-actions { + display: flex; + gap: 0.5rem; + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid rgba(255, 255, 255, 0.1); + } + + .btn-saved-playlist { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 8px; + color: rgba(255, 255, 255, 0.8); + font-size: 0.8rem; + cursor: pointer; + transition: all 0.2s; + } + + .btn-saved-playlist:hover:not(:disabled) { + background: rgba(233, 69, 96, 0.2); + border-color: #e94560; + color: #fff; + } + + .btn-saved-playlist:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .btn-saved-playlist i { + font-size: 1rem; + } + /* Playlist Styles */ .audio-playlist { max-height: 180px; @@ -720,9 +782,24 @@ + + +
+ + +
+ @if (playlist.Count > 0) {
@@ -805,6 +882,7 @@ // Real-time visualizer private bool useRealVisualizer = false; + private bool enableVisualizer = true; // User preference - can be toggled private double[] visualizerBars = new double[13]; private CancellationTokenSource? visualizerCts; @@ -892,12 +970,14 @@ if (_disposed) return; try { - // Show modal with current song without changing anything + // Show modal - works even without current song (shows empty playlist or playlist) + isVisible = true; + await InvokeAsync(StateHasChanged); + await Task.Delay(100); + + // Only start progress timer if there's a current track if (!string.IsNullOrEmpty(audioUrl)) { - isVisible = true; - await InvokeAsync(StateHasChanged); - await Task.Delay(100); StartProgressTimer(); await UpdateProgress(); } @@ -1085,6 +1165,13 @@ private async Task StartRealVisualizer() { + // Don't start visualizer if user disabled it + if (!enableVisualizer) + { + useRealVisualizer = false; + return; + } + try { // Initialize the audio visualizer in JavaScript @@ -1103,6 +1190,45 @@ } } + private async Task ToggleVisualizer() + { + enableVisualizer = !enableVisualizer; + + // Save preference to localStorage + await JSRuntime.InvokeVoidAsync("localStorage.setItem", "audioPlayerVisualizerEnabled", enableVisualizer.ToString().ToLower()); + + if (enableVisualizer && isPlaying) + { + // Re-initialize visualizer if playing + await StartRealVisualizer(); + } + else if (!enableVisualizer) + { + // Destroy visualizer and disconnect Web Audio API + StopRealVisualizer(); + useRealVisualizer = false; + await JSRuntime.InvokeVoidAsync("destroyAudioVisualizer"); + } + + await InvokeAsync(StateHasChanged); + } + + private async Task LoadVisualizerPreference() + { + try + { + var stored = await JSRuntime.InvokeAsync("localStorage.getItem", "audioPlayerVisualizerEnabled"); + if (!string.IsNullOrEmpty(stored)) + { + enableVisualizer = stored.ToLower() == "true"; + } + } + catch + { + enableVisualizer = true; + } + } + private void StopRealVisualizer() { visualizerCts?.Cancel(); @@ -1199,8 +1325,114 @@ catch { } } - // Playlist methods - private async Task PlayTrack(int index) + // Saved Playlist methods (for persistent playlists in MongoDB) + private void SaveCurrentToPlaylist() + { + if (string.IsNullOrEmpty(audioUrl)) return; + + // Open SaveToPlaylistModal - need to extract track info from URL + // The modal is in MainLayout, we call it via static method + var saveModal = MainLayout.GetSaveToPlaylistModal(); + if (saveModal != null) + { + // Check if it's a local file (URL doesn't contain /api/file/) + bool isLocalFile = !audioUrl.Contains("/api/file/"); + + // Create track model from current playing track + var track = new Models.PlaylistTrackModel + { + FileId = isLocalFile ? "" : ExtractFileIdFromUrl(audioUrl), + ChannelId = isLocalFile ? "" : ExtractChannelIdFromUrl(audioUrl), + ChannelName = isLocalFile ? "Local" : "", + FileName = audioTitle, + FileType = ExtractFileTypeFromUrl(audioUrl), + FileSize = 0, + DirectUrl = isLocalFile ? audioUrl : null + }; + + _ = saveModal.Open(track); + } + } + + private void OpenPlaylistManager() + { + MainLayout.OpenPlaylistManager(); + } + + private string ExtractFileIdFromUrl(string url) + { + try + { + // Format 1: /api/file/GetFile/{name}?idChannel={channelId}&idFile={msgId}&docId={fileId} + if (url.Contains("GetFile") && url.Contains("docId=")) + { + var uri = new Uri(url, UriKind.RelativeOrAbsolute); + if (!uri.IsAbsoluteUri) + uri = new Uri("http://localhost" + (url.StartsWith("/") ? url : "/" + url)); + var query = System.Web.HttpUtility.ParseQueryString(uri.Query); + return query["docId"] ?? ""; + } + + // Format 2: /api/file/GetFileStream/{channelId}/{fileId}/filename + var parts = url.Split('/'); + var getFileStreamIndex = Array.IndexOf(parts, "GetFileStream"); + if (getFileStreamIndex >= 0 && getFileStreamIndex + 2 < parts.Length) + { + return parts[getFileStreamIndex + 2]; + } + } + catch { } + return ""; + } + + private string ExtractChannelIdFromUrl(string url) + { + try + { + // Format 1: /api/file/GetFile/{name}?idChannel={channelId}&idFile={msgId}&docId={fileId} + if (url.Contains("GetFile") && url.Contains("idChannel=")) + { + var uri = new Uri(url, UriKind.RelativeOrAbsolute); + if (!uri.IsAbsoluteUri) + uri = new Uri("http://localhost" + (url.StartsWith("/") ? url : "/" + url)); + var query = System.Web.HttpUtility.ParseQueryString(uri.Query); + return query["idChannel"] ?? ""; + } + + // Format 2: /api/file/GetFileStream/{channelId}/{fileId}/filename + var parts = url.Split('/'); + var getFileStreamIndex = Array.IndexOf(parts, "GetFileStream"); + if (getFileStreamIndex >= 0 && getFileStreamIndex + 1 < parts.Length) + { + return parts[getFileStreamIndex + 1]; + } + } + catch { } + return ""; + } + + private string ExtractFileTypeFromUrl(string url) + { + try + { + // Remove query string first + var urlWithoutQuery = url.Split('?')[0]; + var lastPart = urlWithoutQuery.Split('/').LastOrDefault() ?? ""; + var ext = System.IO.Path.GetExtension(lastPart); + return string.IsNullOrEmpty(ext) ? ".mp3" : ext; + } + catch { return ".mp3"; } + } + + // Playlist methods (in-memory playlist for current session) + public async Task ClearPlaylist() + { + playlist.Clear(); + currentTrackIndex = -1; + await InvokeAsync(StateHasChanged); + } + + public async Task PlayTrack(int index) { if (index >= 0 && index < playlist.Count) { @@ -1596,6 +1828,18 @@ catch { } } + private bool _firstRender = true; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + _firstRender = false; + await LoadVisualizerPreference(); + await InvokeAsync(StateHasChanged); + } + } + public void Dispose() { _disposed = true; diff --git a/TelegramDownloader/Pages/Modals/PlaylistManagerModal.razor b/TelegramDownloader/Pages/Modals/PlaylistManagerModal.razor new file mode 100644 index 0000000..806ef8c --- /dev/null +++ b/TelegramDownloader/Pages/Modals/PlaylistManagerModal.razor @@ -0,0 +1,860 @@ +@using TelegramDownloader.Data.db +@using TelegramDownloader.Models +@using TelegramDownloader.Shared + +@inject IDbService db +@inject IJSRuntime JSRuntime +@inject NavigationManager NavigationManager + + + + +@if (showDownloadModal) +{ + +} + + +@if (playlistToDelete != null) +{ + +} + +@if (ShowBackdrop) +{ + +} + + + +@code { + public string ModalDisplay = "none;"; + public string ModalClass = ""; + public bool ShowBackdrop = false; + + private List playlists = new(); + private PlaylistModel? selectedPlaylist = null; + private PlaylistModel? playlistToDelete = null; + private string newPlaylistName = ""; + private bool isLoading = false; + + // Edit name state + private bool isEditingName = false; + private string editingPlaylistName = ""; + + // Drag and drop state + private int? draggedIndex = null; + private int? dragOverIndex = null; + + // Download modal state + private bool showDownloadModal = false; + private bool isDownloading = false; + private Ddtree? ddtree; + + [Parameter] + public EventCallback<(PlaylistModel playlist, int? startIndex)> OnPlayPlaylist { get; set; } + + [Parameter] + public EventCallback<(PlaylistModel playlist, string folder)> OnDownloadPlaylist { get; set; } + + [Parameter] + public EventCallback OnNotify { get; set; } + + public async Task Open() + { + isLoading = true; + selectedPlaylist = null; + playlistToDelete = null; + newPlaylistName = ""; + + ModalDisplay = "block;"; + ModalClass = "Show"; + ShowBackdrop = true; + StateHasChanged(); + + await LoadPlaylists(); + isLoading = false; + StateHasChanged(); + } + + private async Task LoadPlaylists() + { + try + { + playlists = await db.GetAllPlaylists(); + } + catch (Exception ex) + { + Console.WriteLine($"Error loading playlists: {ex.Message}"); + playlists = new(); + } + } + + private void HandleClose() + { + if (showDownloadModal) + { + CloseDownloadModal(); + return; + } + Close(); + } + + public void Close() + { + ModalDisplay = "none"; + ModalClass = ""; + ShowBackdrop = false; + selectedPlaylist = null; + playlistToDelete = null; + StateHasChanged(); + } + + private async Task HandleNewPlaylistKeyPress(KeyboardEventArgs e) + { + if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(newPlaylistName)) + { + await CreateNewPlaylist(); + } + } + + private async Task CreateNewPlaylist() + { + if (string.IsNullOrWhiteSpace(newPlaylistName)) return; + + try + { + var playlist = new PlaylistModel + { + Name = newPlaylistName.Trim() + }; + + await db.CreatePlaylist(playlist); + newPlaylistName = ""; + await LoadPlaylists(); + await OnNotify.InvokeAsync($"Playlist '{playlist.Name}' created"); + } + catch (Exception ex) + { + Console.WriteLine($"Error creating playlist: {ex.Message}"); + } + } + + private void SelectPlaylist(PlaylistModel playlist) + { + selectedPlaylist = playlist; + StateHasChanged(); + } + + private void BackToList() + { + selectedPlaylist = null; + isEditingName = false; + StateHasChanged(); + } + + // Edit name + private void StartEditName() + { + editingPlaylistName = selectedPlaylist?.Name ?? ""; + isEditingName = true; + } + + private void CancelEditName() + { + isEditingName = false; + } + + private async Task HandleEditNameKeyPress(KeyboardEventArgs e) + { + if (e.Key == "Enter") + { + await SavePlaylistName(); + } + else if (e.Key == "Escape") + { + CancelEditName(); + } + } + + private async Task SavePlaylistName() + { + if (selectedPlaylist == null || string.IsNullOrWhiteSpace(editingPlaylistName)) return; + + selectedPlaylist.Name = editingPlaylistName.Trim(); + await db.UpdatePlaylist(selectedPlaylist); + isEditingName = false; + await LoadPlaylists(); + + // Refresh selected playlist + selectedPlaylist = playlists.FirstOrDefault(p => p.Id == selectedPlaylist.Id); + await OnNotify.InvokeAsync("Playlist name updated"); + } + + // Delete playlist + private void ConfirmDeletePlaylist(PlaylistModel playlist) + { + playlistToDelete = playlist; + } + + private void CancelDeletePlaylist() + { + playlistToDelete = null; + } + + private async Task DeletePlaylist() + { + if (playlistToDelete == null) return; + + var name = playlistToDelete.Name; + await db.DeletePlaylist(playlistToDelete.Id); + playlistToDelete = null; + + if (selectedPlaylist?.Id == playlistToDelete?.Id) + { + selectedPlaylist = null; + } + + await LoadPlaylists(); + await OnNotify.InvokeAsync($"Playlist '{name}' deleted"); + } + + // Play playlist + private async Task PlayPlaylist(PlaylistModel playlist) + { + await OnPlayPlaylist.InvokeAsync((playlist, null)); + Close(); + } + + private async Task PlayTrack(PlaylistTrackModel track, int index) + { + if (selectedPlaylist != null) + { + await OnPlayPlaylist.InvokeAsync((selectedPlaylist, index)); + Close(); + } + } + + // Remove track + private async Task RemoveTrack(PlaylistTrackModel track) + { + if (selectedPlaylist == null) return; + + await db.RemoveTrackFromPlaylist(selectedPlaylist.Id, track.FileId); + + // Refresh playlist + selectedPlaylist = await db.GetPlaylistById(selectedPlaylist.Id); + await LoadPlaylists(); + await OnNotify.InvokeAsync($"Track removed from playlist"); + } + + // Drag and drop + private void OnDragStart(int index) + { + draggedIndex = index; + } + + private void OnDragOver(int index) + { + if (draggedIndex != null && draggedIndex != index) + { + dragOverIndex = index; + } + } + + private async Task OnDrop(int targetIndex) + { + if (draggedIndex == null || selectedPlaylist == null) return; + + var tracks = selectedPlaylist.Tracks; + if (draggedIndex.Value >= 0 && draggedIndex.Value < tracks.Count && + targetIndex >= 0 && targetIndex < tracks.Count) + { + var item = tracks[draggedIndex.Value]; + tracks.RemoveAt(draggedIndex.Value); + tracks.Insert(targetIndex, item); + + // Update order + var orderedIds = tracks.Select(t => t.FileId).ToList(); + await db.ReorderPlaylistTracks(selectedPlaylist.Id, orderedIds); + + // Refresh + selectedPlaylist = await db.GetPlaylistById(selectedPlaylist.Id); + } + + draggedIndex = null; + dragOverIndex = null; + } + + private void OnDragEnd() + { + draggedIndex = null; + dragOverIndex = null; + } + + // Download + private void OpenDownloadModal() + { + showDownloadModal = true; + } + + private void CloseDownloadModal() + { + showDownloadModal = false; + isDownloading = false; + } + + private async Task StartDownload() + { + if (selectedPlaylist == null || ddtree?.selectedNode == null) return; + + var folder = ddtree.selectedNode.FirstOrDefault(); + if (string.IsNullOrEmpty(folder)) return; + + isDownloading = true; + StateHasChanged(); + + await OnDownloadPlaylist.InvokeAsync((selectedPlaylist, folder)); + + CloseDownloadModal(); + await OnNotify.InvokeAsync($"Download started for playlist '{selectedPlaylist.Name}'"); + } + + public async Task RefreshPlaylists() + { + await LoadPlaylists(); + if (selectedPlaylist != null) + { + selectedPlaylist = playlists.FirstOrDefault(p => p.Id == selectedPlaylist.Id); + } + StateHasChanged(); + } +} diff --git a/TelegramDownloader/Pages/Modals/SaveToPlaylistModal.razor b/TelegramDownloader/Pages/Modals/SaveToPlaylistModal.razor new file mode 100644 index 0000000..fc2f6fd --- /dev/null +++ b/TelegramDownloader/Pages/Modals/SaveToPlaylistModal.razor @@ -0,0 +1,425 @@ +@using TelegramDownloader.Data.db +@using TelegramDownloader.Models + +@inject IDbService db + + + +@if (ShowBackdrop) +{ + +} + + + +@code { + public string ModalDisplay = "none;"; + public string ModalClass = ""; + public bool ShowBackdrop = false; + + private List playlists = new(); + private List tracks = new(); + private string trackName = ""; + private string? selectedPlaylistId = null; + private string newPlaylistName = ""; + private bool isLoading = false; + private bool isSaving = false; + private bool isCreatingNew = false; + + [Parameter] + public EventCallback OnTrackAdded { get; set; } + + public async Task Open(PlaylistTrackModel track) + { + tracks = new List { track }; + trackName = track.FileName; + await OpenInternal(); + } + + public async Task Open(List trackList) + { + tracks = trackList; + trackName = trackList.Count > 0 ? trackList[0].FileName : ""; + await OpenInternal(); + } + + private async Task OpenInternal() + { + isLoading = true; + selectedPlaylistId = null; + newPlaylistName = ""; + isSaving = false; + + ModalDisplay = "block;"; + ModalClass = "Show"; + ShowBackdrop = true; + StateHasChanged(); + + await LoadPlaylists(); + isLoading = false; + StateHasChanged(); + } + + private async Task LoadPlaylists() + { + try + { + playlists = await db.GetAllPlaylists(); + } + catch + { + playlists = new(); + } + } + + public void Close() + { + ModalDisplay = "none"; + ModalClass = ""; + ShowBackdrop = false; + tracks.Clear(); + StateHasChanged(); + } + + private void SelectPlaylist(string id) + { + selectedPlaylistId = id; + } + + private async Task HandleNewPlaylistKeyPress(KeyboardEventArgs e) + { + if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(newPlaylistName)) + { + await CreateAndAddToNew(); + } + } + + private async Task CreateAndAddToNew() + { + if (string.IsNullOrWhiteSpace(newPlaylistName) || tracks.Count == 0) return; + + isSaving = true; + isCreatingNew = true; + StateHasChanged(); + + try + { + // Create new playlist + var playlist = new PlaylistModel + { + Name = newPlaylistName.Trim(), + Tracks = tracks.Select((t, i) => { t.Order = i; return t; }).ToList() + }; + + await db.CreatePlaylist(playlist); + + await OnTrackAdded.InvokeAsync($"Added {tracks.Count} track(s) to new playlist '{playlist.Name}'"); + Close(); + } + catch (Exception ex) + { + Console.WriteLine($"Error creating playlist: {ex.Message}"); + } + finally + { + isSaving = false; + isCreatingNew = false; + } + } + + private async Task AddToSelected() + { + if (string.IsNullOrEmpty(selectedPlaylistId) || tracks.Count == 0) return; + + isSaving = true; + isCreatingNew = false; + StateHasChanged(); + + try + { + var playlist = playlists.FirstOrDefault(p => p.Id == selectedPlaylistId); + if (playlist == null) return; + + foreach (var track in tracks) + { + // Check if track already exists in playlist + if (!playlist.Tracks.Any(t => t.FileId == track.FileId)) + { + await db.AddTrackToPlaylist(selectedPlaylistId, track); + } + } + + await OnTrackAdded.InvokeAsync($"Added {tracks.Count} track(s) to '{playlist.Name}'"); + Close(); + } + catch (Exception ex) + { + Console.WriteLine($"Error adding to playlist: {ex.Message}"); + } + finally + { + isSaving = false; + } + } +} diff --git a/TelegramDownloader/Pages/Partials/impl/FileManagerImpl.razor b/TelegramDownloader/Pages/Partials/impl/FileManagerImpl.razor index 760804a..0e61d5b 100644 --- a/TelegramDownloader/Pages/Partials/impl/FileManagerImpl.razor +++ b/TelegramDownloader/Pages/Partials/impl/FileManagerImpl.razor @@ -11,6 +11,7 @@ @using TelegramDownloader.Pages.Partials @using TelegramDownloader.Services @using TelegramDownloader.Shared.MobileFileManager +@using TelegramDownloader.Shared @inject IFileService fs @inject ITelegramService ts @inject ILogger Logger @@ -96,6 +97,8 @@ OnPreloadFiles="OnMobilePreloadFilesAsync" CanAddToPlaylist="true" OnAddToPlaylist="OnMobileAddToPlaylistAsync" + CanSaveToPlaylist="true" + OnSaveToPlaylist="OnMobileSaveToPlaylistAsync" OnPathChanged="OnMobilePathChanged" />
} @@ -156,7 +159,8 @@ new ToolBarItemModel() { Name = "View" }, new ToolBarItemModel() { Name = "Details" }, new ToolBarItemModel() { Name = "UrlMedia", Text = "Url Media", TooltipText = "Url Media", PrefixIcon = "bi bi-collection-play-fill", Visible = false }, - new ToolBarItemModel() { Name = "Strm", Text = "Strm", TooltipText = "Strm folder", PrefixIcon = "bi bi-collection-play-fill", Visible = false } + new ToolBarItemModel() { Name = "Strm", Text = "Strm", TooltipText = "Strm folder", PrefixIcon = "bi bi-collection-play-fill", Visible = false }, + new ToolBarItemModel() { Name = "Playlists", Text = "Playlists", TooltipText = "Manage Playlists", PrefixIcon = "bi bi-music-note-list" } }; @@ -309,7 +313,8 @@ new ToolBarItemModel() { Name = "View" }, new ToolBarItemModel() { Name = "Details" }, new ToolBarItemModel() { Name = "UrlMedia", Text = "Url Media", TooltipText = "Url Media", PrefixIcon = "bi bi-collection-play-fill", Visible = false }, - new ToolBarItemModel() { Name = "Strm", Text = "Strm", TooltipText = "Strm folder", PrefixIcon = "bi bi-collection-play-fill", Visible = false } + new ToolBarItemModel() { Name = "Strm", Text = "Strm", TooltipText = "Strm folder", PrefixIcon = "bi bi-collection-play-fill", Visible = false }, + new ToolBarItemModel() { Name = "Playlists", Text = "Playlists", TooltipText = "Manage Playlists", PrefixIcon = "bi bi-music-note-list" } }; if (isMyChannel) @@ -482,6 +487,10 @@ nm.sendMessage("Preload Complete", $"Preloaded {count} files to cache", NotificationTypes.Success); }); } + if (args.Item.Text == "Playlists") + { + MainLayout.OpenPlaylistManager(); + } Console.WriteLine(""); } @@ -961,6 +970,60 @@ } } + private async Task OnMobileSaveToPlaylistAsync(MfmSaveToPlaylistEventArgs args) + { + try + { + var tracks = new List(); + var channelId = isShared ? bsi.ChannelId : id; + var channelName = isShared ? bsi.Name : channelId; + + if (args.IsMultiple && args.Files.Length > 0) + { + foreach (var file in args.Files) + { + tracks.Add(new PlaylistTrackModel + { + FileId = file.Id, + ChannelId = channelId, + ChannelName = channelName, + FileName = file.Name, + FileType = Path.GetExtension(file.Name).TrimStart('.').ToLowerInvariant(), + FileSize = file.Size, + Order = tracks.Count + }); + } + } + else if (args.File != null) + { + tracks.Add(new PlaylistTrackModel + { + FileId = args.File.Id, + ChannelId = channelId, + ChannelName = channelName, + FileName = args.File.Name, + FileType = Path.GetExtension(args.File.Name).TrimStart('.').ToLowerInvariant(), + FileSize = args.File.Size, + Order = 0 + }); + } + + if (tracks.Count > 0) + { + var saveModal = MainLayout.GetSaveToPlaylistModal(); + if (saveModal != null) + { + await saveModal.Open(tracks); + } + } + } + catch (Exception e) + { + Logger.LogError(e, "Error on OnMobileSaveToPlaylistAsync"); + nm.sendMessage("Error", "Error saving to playlist", NotificationTypes.Error); + } + } + #endregion #region Shared Helper Methods @@ -1137,11 +1200,16 @@ else item = await fs.getItemById(id, idFile); string localdir = MyNavigationManager.BaseUri; + var channelId = isShared ? bsi.ChannelId : id; + + // Always use GetFileStream format with MongoDB document ID for consistency + // This ensures playlist functionality can extract channelId and fileId correctly if (forcePreDownload || (HelperService.bytesToMegaBytes(size) <= GeneralConfigStatic.config.MaxPreloadFileSizeInMb && item != null && item.MessageId != null)) { - return new System.Uri(Path.Combine(localdir, "api/file/GetFile", name).Replace("\\", "/") + $"?idChannel={(isShared ? bsi.ChannelId : id)}&idFile={item.MessageId}").AbsoluteUri; + // Still use the preload endpoint but append the file ID info in a way that's extractable + return new System.Uri(Path.Combine(localdir, "api/file/GetFile", name).Replace("\\", "/") + $"?idChannel={channelId}&idFile={item.MessageId}&docId={idFile}").AbsoluteUri; } - return new System.Uri(Path.Combine(localdir, "api/file/GetFileStream", isShared ? bsi.ChannelId : id, idFile, name).Replace("\\", "/")).AbsoluteUri; + return new System.Uri(Path.Combine(localdir, "api/file/GetFileStream", channelId, idFile, name).Replace("\\", "/")).AbsoluteUri; } private async Task getVideoUrl(string idFile, String name, long size) diff --git a/TelegramDownloader/Shared/MainLayout.razor b/TelegramDownloader/Shared/MainLayout.razor index b48f769..c6a0e1f 100644 --- a/TelegramDownloader/Shared/MainLayout.razor +++ b/TelegramDownloader/Shared/MainLayout.razor @@ -12,6 +12,7 @@ @inject IJSRuntime JS @inject ModalService ModalService @inject TransactionInfoService tis +@inject IFileService FileService TelegramFileManager @@ -139,6 +140,12 @@ + + @@ -170,6 +177,14 @@ Logs +
+
Media
+ + +
Account
@@ -312,6 +327,8 @@ private Pages.Modals.DeleteLeaveChannelModal deleteLeaveChannelModal { get; set; } = default!; private Pages.Modals.DatabaseMaintenanceModal databaseMaintenanceModal { get; set; } = default!; private Pages.Modals.PathRepairModal pathRepairModal { get; set; } = default!; + private Pages.Modals.PlaylistManagerModal playlistManager { get; set; } = default!; + private Pages.Modals.SaveToPlaylistModal saveToPlaylist { get; set; } = default!; private NavMenu menu { get; set; } private VersionBadge versionBadge { get; set; } private bool active { get; set; } = true; @@ -591,6 +608,16 @@ NavManager.NavigateTo("/"); } + private async Task OpenPlaylistsAndClose() + { + await JS.InvokeVoidAsync("blurActiveElement"); + await offcanvas.HideAsync(); + if (playlistManager != null) + { + await playlistManager.Open(); + } + } + private async Task checkWorkingTasks() { try { @@ -646,6 +673,94 @@ } } + // Playlist Manager Modal - static methods + public static void OpenPlaylistManager() + { + if (_instance != null && _instance.playlistManager != null) + { + _instance.InvokeAsync(async () => await _instance.playlistManager.Open()); + } + } + + public static Pages.Modals.PlaylistManagerModal? GetPlaylistManager() => _instance?.playlistManager; + public static Pages.Modals.SaveToPlaylistModal? GetSaveToPlaylistModal() => _instance?.saveToPlaylist; + + // Playlist event handlers + private async Task OnPlayPlaylist((Models.PlaylistModel playlist, int? startIndex) args) + { + if (audioPlayer == null || args.playlist.Tracks.Count == 0) return; + + // Convert playlist tracks to audio player format and add all to playlist + var playlistItems = args.playlist.Tracks + .OrderBy(t => t.Order) + .Select(t => ( + Url: GetTrackUrl(t), + Type: GetAudioMimeType(t.FileType), + Title: t.FileName + )) + .ToList(); + + // Clear current playlist and add all tracks + await audioPlayer.ClearPlaylist(); + await audioPlayer.AddMultipleToPlaylist(playlistItems); + + // Show the modal first (in case it's not visible) + await audioPlayer.ShowCurrentModal(); + + // Play from start index or first track + int startIdx = args.startIndex ?? 0; + await audioPlayer.PlayTrack(startIdx); + } + + private string GetTrackUrl(Models.PlaylistTrackModel track) + { + // Local files have DirectUrl set + if (track.IsLocalFile) + { + return track.DirectUrl!; + } + + // Telegram files - use GetFileByTfmId which supports cache + return $"{NavManager.BaseUri}api/file/GetFileByTfmId/{track.FileName}?idChannel={track.ChannelId}&idFile={track.FileId}"; + } + + private string GetAudioMimeType(string fileType) + { + return fileType?.ToLower() switch + { + ".mp3" => "audio/mpeg", + ".flac" => "audio/flac", + ".wav" => "audio/wav", + ".ogg" => "audio/ogg", + ".m4a" => "audio/mp4", + ".aac" => "audio/aac", + _ => "audio/mpeg" + }; + } + + private async Task OnDownloadPlaylist((Models.PlaylistModel playlist, string folder) args) + { + // Run download in background to not block UI + _ = Task.Run(async () => + { + try + { + await FileService.DownloadPlaylistToLocal(args.playlist, args.folder); + } + catch (Exception ex) + { + NotificationModel nm = new NotificationModel(); + nm.sendEvent(new Notification($"Error downloading playlist: {ex.Message}", "Playlist Download", NotificationTypes.Error)); + } + }); + } + + private void OnPlaylistNotify(string message) + { + NotificationModel nm = new NotificationModel(); + nm.sendEvent(new Notification(message, "Playlist", NotificationTypes.Success)); + } + public void Dispose() { _disposed = true; diff --git a/TelegramDownloader/Shared/MobileFileManager/MobileFileManager.razor b/TelegramDownloader/Shared/MobileFileManager/MobileFileManager.razor index 1d623ea..2c6b907 100644 --- a/TelegramDownloader/Shared/MobileFileManager/MobileFileManager.razor +++ b/TelegramDownloader/Shared/MobileFileManager/MobileFileManager.razor @@ -280,6 +280,17 @@ Playlist } + @if (CanSaveToPlaylist && HasSelectedAudioFiles()) + { + + } @if (CanStrm && SelectedItems.Count == 1 && !SelectedItems.First().IsFile) { } + @if (CanSaveToPlaylist && IsAudioFile(ContextMenuItem)) + { + + } @if (CanStrm && !ContextMenuItem.IsFile) { - -
-
- @if (!string.IsNullOrEmpty(artworkUrl)) + +
+ + +
+ @if (!string.IsNullOrEmpty(currentPlaylistName)) { - Album Art - } - else - { - - } -
- - @if (!string.IsNullOrEmpty(audioTitle)) - { -
- -
- @audioTitle +
+ + @currentPlaylistName
-
- } + } - @if (isInitialLoading || isBuffering) - { -
-
- @loadingStatus - @if (bufferPercent > 0 && bufferPercent < 100) +
+ @if (!string.IsNullOrEmpty(artworkUrl)) { -
-
-
- @bufferPercent.ToString("F0")% + Album Art } -
- } - else - { -
- @for (int i = 0; i < 13; i++) + else { - var height = useRealVisualizer && visualizerBars != null && i < visualizerBars.Length - ? visualizerBars[i] - : GetDefaultBarHeight(i); -
+ }
- } -
-
- - - -
-
-
- -
- @currentTimeDisplay - @durationDisplay + @if (!string.IsNullOrEmpty(audioTitle)) + { +
+ +
+ @audioTitle
-
- + + + + -
- -
-
- -
- - -
+
+
+ +
+ @currentTimeDisplay + @durationDisplay +
+
+
- @if (playlist.Count > 0) - { -
- Playlist - @playlist.Count @(playlist.Count == 1 ? "track" : "tracks") +
+
+ + +
+
+ + +
+
-
- @for (int i = 0; i < playlist.Count; i++) - { - var index = i; - var item = playlist[i]; -
- - - - @(index + 1) - @if (index == currentTrackIndex && isPlaying) - { - - } - else + + @if (playlist.Count > 0) + { +
+
+ Queue + @playlist.Count @(playlist.Count == 1 ? "track" : "tracks") +
+
+ @for (int i = 0; i < playlist.Count; i++) { - + var index = i; + var item = playlist[i]; +
+ + + + @(index + 1) + @if (index == currentTrackIndex && isPlaying) + { + + } + else + { + + } + @item.Title +
+ + +
+
} - @item.Title -
+
+ } +
+
+ + +
+ +
+ @if (savedPlaylists != null && savedPlaylists.Count > 0) + { + @foreach (var pl in savedPlaylists) + { +
+
+ +
+
+
@pl.Name
+
@pl.TrackCount tracks
+
+
} -
- } + } + else + { +
+ No playlists yet +
+ } +
@@ -870,7 +1162,7 @@ // Album artwork private string? artworkUrl = null; -#pragma warning disable CS0414 // Value assigned but never read +#pragma warning disable CS0414 private bool isLoadingArtwork = false; #pragma warning restore CS0414 @@ -881,7 +1173,7 @@ // Real-time visualizer private bool useRealVisualizer = false; - private bool enableVisualizer = true; // User preference - can be toggled + private bool enableVisualizer = true; private double[] visualizerBars = new double[13]; private CancellationTokenSource? visualizerCts; @@ -889,6 +1181,16 @@ private List playlist = new(); private int currentTrackIndex = -1; + // Repeat mode + private RepeatMode repeatMode = RepeatMode.None; + + // Current saved playlist info + private string? currentPlaylistId = null; + private string? currentPlaylistName = null; + + // Saved playlists for sidebar + private List? savedPlaylists = null; + // Drag and drop state private int? draggedIndex = null; private int? dragOverIndex = null; @@ -896,6 +1198,13 @@ // Disposed state private bool _disposed = false; + public enum RepeatMode + { + None = 0, + All = 1, + One = 2 + } + public class PlaylistItem { public string Url { get; set; } = ""; @@ -903,8 +1212,34 @@ public string Title { get; set; } = ""; } - private bool CanPlayPrevious => currentTrackIndex > 0; - private bool CanPlayNext => currentTrackIndex < playlist.Count - 1; + private bool CanPlayPrevious => currentTrackIndex > 0 || repeatMode == RepeatMode.All; + private bool CanPlayNext => currentTrackIndex < playlist.Count - 1 || repeatMode == RepeatMode.All; + + private string GetRepeatModeIcon() => repeatMode switch + { + RepeatMode.One => "bi-repeat-1", + _ => "bi-repeat" + }; + + private string GetRepeatModeTitle() => repeatMode switch + { + RepeatMode.None => "Repeat: Off", + RepeatMode.All => "Repeat: All", + RepeatMode.One => "Repeat: One", + _ => "Repeat" + }; + + private void ToggleRepeatMode() + { + repeatMode = repeatMode switch + { + RepeatMode.None => RepeatMode.All, + RepeatMode.All => RepeatMode.One, + RepeatMode.One => RepeatMode.None, + _ => RepeatMode.None + }; + StateHasChanged(); + } public async Task ShowModal(string url, string type = "audio/mpeg", string title = "") { @@ -917,7 +1252,6 @@ audioType = string.IsNullOrEmpty(type) ? "audio/mpeg" : type; audioTitle = !string.IsNullOrEmpty(title) ? title : ExtractFileName(url); - // Add to playlist if not exists var existingIndex = playlist.FindIndex(p => p.Url == url); if (existingIndex < 0) { @@ -930,38 +1264,31 @@ } isVisible = true; + await LoadSavedPlaylists(); await InvokeAsync(StateHasChanged); await Task.Delay(100); - // Only auto-play if different track if (!isSameTrack) { isPlaying = false; progressPercent = 0; bufferPercent = 0; currentTimeDisplay = "0:00"; - artworkUrl = null; // Reset artwork before loading new one + artworkUrl = null; - // Set loading state - check if it's a Telegram file (not local) bool isTelegramFile = audioUrl.Contains("/api/file/") || audioUrl.Contains("GetFileStream"); isInitialLoading = isTelegramFile; isBuffering = false; loadingStatus = isTelegramFile ? "Loading from Telegram..." : ""; await JSRuntime.InvokeVoidAsync("playAudioPlayer", audioUrl, audioType); - - // Extract artwork and update Media Session (async, won't block playback) _ = ExtractArtworkAndUpdateMediaSession(audioUrl); } - // Start progress update StartProgressTimer(); await UpdateProgress(); } - catch - { - // Prevent circuit crash - } + catch { } } public async Task ShowCurrentModal() @@ -969,22 +1296,71 @@ if (_disposed) return; try { - // Show modal - works even without current song (shows empty playlist or playlist) isVisible = true; + await LoadSavedPlaylists(); await InvokeAsync(StateHasChanged); await Task.Delay(100); - // Only start progress timer if there's a current track if (!string.IsNullOrEmpty(audioUrl)) { StartProgressTimer(); await UpdateProgress(); } } + catch { } + } + + private async Task LoadSavedPlaylists() + { + try + { + savedPlaylists = await DbService.GetAllPlaylists(); + } catch { - // Prevent circuit crash + savedPlaylists = new List(); + } + } + + private async Task LoadSavedPlaylist(Models.PlaylistModel pl) + { + currentPlaylistId = pl.Id; + currentPlaylistName = pl.Name; + await InvokeAsync(StateHasChanged); + } + + private async Task PlaySavedPlaylist(Models.PlaylistModel pl) + { + try + { + currentPlaylistId = pl.Id; + currentPlaylistName = pl.Name; + + playlist.Clear(); + foreach (var track in pl.Tracks.OrderBy(t => t.Order)) + { + var url = track.IsLocalFile ? track.DirectUrl! : GetTrackUrl(track); + playlist.Add(new PlaylistItem + { + Url = url, + Type = track.FileType, + Title = track.FileName + }); + } + + if (playlist.Count > 0) + { + await PlayTrack(0); + } + + await InvokeAsync(StateHasChanged); } + catch { } + } + + private string GetTrackUrl(Models.PlaylistTrackModel track) + { + return $"/api/file/GetFileByTfmId/{track.FileName}?idChannel={track.ChannelId}&idFile={track.FileId}"; } public async Task OnHideModalClick() @@ -1020,7 +1396,6 @@ private async Task SeekAudioInput(Microsoft.AspNetCore.Components.ChangeEventArgs e) { - // Preview while dragging (updates display but doesn't commit) if (double.TryParse(e.Value?.ToString(), System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out double percent)) { progressPercent = percent; @@ -1030,7 +1405,6 @@ private async Task SeekAudioChange(Microsoft.AspNetCore.Components.ChangeEventArgs e) { - // Commit the seek when user releases if (double.TryParse(e.Value?.ToString(), System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out double percent)) { if (percent < 0) percent = 0; @@ -1042,7 +1416,6 @@ private async Task ChangeVolumeInput(Microsoft.AspNetCore.Components.ChangeEventArgs e) { - // Update volume in real-time while dragging if (int.TryParse(e.Value?.ToString(), out int newVolume)) { volume = newVolume; @@ -1088,18 +1461,9 @@ await InvokeAsync(StateHasChanged); await Task.Delay(500, token); } - catch (TaskCanceledException) - { - break; - } - catch (ObjectDisposedException) - { - break; - } - catch - { - // Ignore other errors - } + catch (TaskCanceledException) { break; } + catch (ObjectDisposedException) { break; } + catch { } } } @@ -1124,13 +1488,11 @@ currentTimeDisplay = FormatTime(audioInfo.CurrentTime); durationDisplay = FormatTime(audioInfo.Duration); - // Update Media Session position for lock screen progress bar if (audioInfo.Duration > 0) { await UpdateMediaSessionPosition(audioInfo.Duration, audioInfo.CurrentTime); } - // Update playback state if changed if (wasPlaying != isPlaying) { await UpdateMediaSessionPlaybackState(isPlaying ? "playing" : "paused"); @@ -1182,14 +1544,12 @@ private int GetDefaultBarHeight(int index) { - // Default animated heights when not using real visualizer int[] defaultHeights = { 20, 40, 60, 80, 100, 80, 60, 40, 20, 40, 60, 80, 60 }; return index < defaultHeights.Length ? defaultHeights[index] : 50; } private async Task StartRealVisualizer() { - // Don't start visualizer if user disabled it if (!enableVisualizer) { useRealVisualizer = false; @@ -1198,7 +1558,6 @@ try { - // Initialize the audio visualizer in JavaScript var initialized = await JSRuntime.InvokeAsync("initAudioVisualizer"); if (initialized) { @@ -1217,18 +1576,14 @@ private async Task ToggleVisualizer() { enableVisualizer = !enableVisualizer; - - // Save preference to localStorage await JSRuntime.InvokeVoidAsync("localStorage.setItem", "audioPlayerVisualizerEnabled", enableVisualizer.ToString().ToLower()); if (enableVisualizer && isPlaying) { - // Re-initialize visualizer if playing await StartRealVisualizer(); } else if (!enableVisualizer) { - // Destroy visualizer and disconnect Web Audio API StopRealVisualizer(); useRealVisualizer = false; await JSRuntime.InvokeVoidAsync("destroyAudioVisualizer"); @@ -1256,7 +1611,6 @@ private void StopRealVisualizer() { visualizerCts?.Cancel(); - // Reset bars to minimum for (int i = 0; i < visualizerBars.Length; i++) { visualizerBars[i] = 5; @@ -1274,25 +1628,15 @@ { for (int i = 0; i < 13 && i < data.Length; i++) { - // Add minimum height and smooth the values visualizerBars[i] = Math.Max(5, data[i]); } await InvokeAsync(StateHasChanged); } - await Task.Delay(50, token); // ~20 FPS update rate - } - catch (TaskCanceledException) - { - break; - } - catch (ObjectDisposedException) - { - break; - } - catch - { - // Ignore other errors, continue loop + await Task.Delay(50, token); } + catch (TaskCanceledException) { break; } + catch (ObjectDisposedException) { break; } + catch { } } } @@ -1301,10 +1645,22 @@ try { isPlaying = false; - if (CanPlayNext) + + if (repeatMode == RepeatMode.One) + { + // Repeat current track + await PlayTrack(currentTrackIndex); + } + else if (CanPlayNext) { await PlayNext(); } + else if (repeatMode == RepeatMode.All && playlist.Count > 0) + { + // Loop back to first track + await PlayTrack(0); + } + await InvokeAsync(StateHasChanged); } catch { } @@ -1337,7 +1693,6 @@ { try { - // Only show loading for Telegram files bool isTelegramFile = audioUrl.Contains("/api/file/") || audioUrl.Contains("GetFileStream"); if (isTelegramFile) { @@ -1349,20 +1704,15 @@ catch { } } - // Saved Playlist methods (for persistent playlists in MongoDB) private void SaveCurrentToPlaylist() { if (string.IsNullOrEmpty(audioUrl)) return; - // Open SaveToPlaylistModal - need to extract track info from URL - // The modal is in MainLayout, we call it via static method var saveModal = MainLayout.GetSaveToPlaylistModal(); if (saveModal != null) { - // Check if it's a local file (URL doesn't contain /api/file/) bool isLocalFile = !audioUrl.Contains("/api/file/"); - // Create track model from current playing track var track = new Models.PlaylistTrackModel { FileId = isLocalFile ? "" : ExtractFileIdFromUrl(audioUrl), @@ -1378,6 +1728,31 @@ } } + private void SaveTrackToPlaylist(int index) + { + if (index < 0 || index >= playlist.Count) return; + + var item = playlist[index]; + var saveModal = MainLayout.GetSaveToPlaylistModal(); + if (saveModal != null) + { + bool isLocalFile = !item.Url.Contains("/api/file/"); + + var track = new Models.PlaylistTrackModel + { + FileId = isLocalFile ? "" : ExtractFileIdFromUrl(item.Url), + ChannelId = isLocalFile ? "" : ExtractChannelIdFromUrl(item.Url), + ChannelName = isLocalFile ? "Local" : "", + FileName = item.Title, + FileType = ExtractFileTypeFromUrl(item.Url), + FileSize = 0, + DirectUrl = isLocalFile ? item.Url : null + }; + + _ = saveModal.Open(track); + } + } + private void OpenPlaylistManager() { MainLayout.OpenPlaylistManager(); @@ -1387,7 +1762,6 @@ { try { - // Format 1: /api/file/GetFile/{name}?idChannel={channelId}&idFile={msgId}&docId={fileId} if (url.Contains("GetFile") && url.Contains("docId=")) { var uri = new Uri(url, UriKind.RelativeOrAbsolute); @@ -1397,7 +1771,6 @@ return query["docId"] ?? ""; } - // Format 2: /api/file/GetFileStream/{channelId}/{fileId}/filename var parts = url.Split('/'); var getFileStreamIndex = Array.IndexOf(parts, "GetFileStream"); if (getFileStreamIndex >= 0 && getFileStreamIndex + 2 < parts.Length) @@ -1413,7 +1786,6 @@ { try { - // Format 1: /api/file/GetFile/{name}?idChannel={channelId}&idFile={msgId}&docId={fileId} if (url.Contains("GetFile") && url.Contains("idChannel=")) { var uri = new Uri(url, UriKind.RelativeOrAbsolute); @@ -1423,7 +1795,6 @@ return query["idChannel"] ?? ""; } - // Format 2: /api/file/GetFileStream/{channelId}/{fileId}/filename var parts = url.Split('/'); var getFileStreamIndex = Array.IndexOf(parts, "GetFileStream"); if (getFileStreamIndex >= 0 && getFileStreamIndex + 1 < parts.Length) @@ -1439,7 +1810,6 @@ { try { - // Remove query string first var urlWithoutQuery = url.Split('?')[0]; var lastPart = urlWithoutQuery.Split('/').LastOrDefault() ?? ""; var ext = System.IO.Path.GetExtension(lastPart); @@ -1448,11 +1818,12 @@ catch { return ".mp3"; } } - // Playlist methods (in-memory playlist for current session) public async Task ClearPlaylist() { playlist.Clear(); currentTrackIndex = -1; + currentPlaylistId = null; + currentPlaylistName = null; await InvokeAsync(StateHasChanged); } @@ -1468,19 +1839,15 @@ progressPercent = 0; bufferPercent = 0; currentTimeDisplay = "0:00"; - artworkUrl = null; // Reset artwork before loading new one + artworkUrl = null; - // Set loading state - check if it's a Telegram file (not local) bool isTelegramFile = audioUrl.Contains("/api/file/") || audioUrl.Contains("GetFileStream"); isInitialLoading = isTelegramFile; isBuffering = false; loadingStatus = isTelegramFile ? "Loading from Telegram..." : ""; await JSRuntime.InvokeVoidAsync("playAudioPlayer", audioUrl, audioType); - await InvokeAsync(StateHasChanged); - - // Extract artwork from audio file (async, won't block playback) _ = ExtractArtworkAndUpdateMediaSession(audioUrl); } } @@ -1490,30 +1857,34 @@ try { await ExtractArtwork(url); - // Update Media Session for mobile/Bluetooth with artwork await InitializeMediaSession(); await UpdateMediaSessionPlaybackState("playing"); } - catch - { - // Ignore errors - don't crash the circuit - } + catch { } } private async Task PlayPrevious() { - if (CanPlayPrevious) + if (currentTrackIndex > 0) { await PlayTrack(currentTrackIndex - 1); } + else if (repeatMode == RepeatMode.All && playlist.Count > 0) + { + await PlayTrack(playlist.Count - 1); + } } private async Task PlayNext() { - if (CanPlayNext) + if (currentTrackIndex < playlist.Count - 1) { await PlayTrack(currentTrackIndex + 1); } + else if (repeatMode == RepeatMode.All && playlist.Count > 0) + { + await PlayTrack(0); + } } private void RemoveFromPlaylist(int index) @@ -1615,6 +1986,13 @@ catch { } } + public async Task SetPlaylistName(string? name, string? id = null) + { + currentPlaylistName = name; + currentPlaylistId = id; + await InvokeAsync(StateHasChanged); + } + // Drag and drop handlers private void OnDragStart(int index) { @@ -1644,16 +2022,12 @@ var sourceIndex = draggedIndex.Value; var item = playlist[sourceIndex]; - // Remove from source playlist.RemoveAt(sourceIndex); - // Adjust target index if needed var adjustedTargetIndex = sourceIndex < targetIndex ? targetIndex - 1 : targetIndex; - // Insert at target playlist.Insert(adjustedTargetIndex, item); - // Update current track index if (currentTrackIndex == sourceIndex) { currentTrackIndex = adjustedTargetIndex; @@ -1695,14 +2069,7 @@ } } - private string GetDisplayTitle() - { - if (string.IsNullOrEmpty(audioTitle)) return ""; - return audioTitle.Length > 50 ? audioTitle.Substring(0, 47) + "..." : audioTitle; - } - - // ===== Artwork Extraction ===== - + // Artwork Extraction private async Task ExtractArtwork(string url) { if (string.IsNullOrEmpty(url)) return; @@ -1722,22 +2089,12 @@ } } - // ===== Media Session API Integration ===== - + // Media Session API Integration private async Task InitializeMediaSession() { try { - await JSRuntime.InvokeVoidAsync("initMediaSession", audioTitle, "TelegramFileManager", "Playlist", artworkUrl); - } - catch { } - } - - private async Task UpdateMediaSessionMetadata() - { - try - { - await JSRuntime.InvokeVoidAsync("updateMediaSessionMetadata", audioTitle, "TelegramFileManager", "Playlist", artworkUrl); + await JSRuntime.InvokeVoidAsync("initMediaSession", audioTitle, "TelegramFileManager", currentPlaylistName ?? "Playlist", artworkUrl); } catch { } } @@ -1760,7 +2117,7 @@ catch { } } - // Media Session action handlers (called from MainLayout via JS) + // Media Session action handlers public async Task ResumeFromMediaSession() { try @@ -1789,10 +2146,7 @@ { try { - if (CanPlayPrevious) - { - await PlayTrack(currentTrackIndex - 1); - } + await PlayPrevious(); } catch { } } @@ -1801,10 +2155,7 @@ { try { - if (CanPlayNext) - { - await PlayTrack(currentTrackIndex + 1); - } + await PlayNext(); } catch { } } @@ -1852,13 +2203,10 @@ catch { } } - private bool _firstRender = true; - protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) { - _firstRender = false; await LoadVisualizerPreference(); await InvokeAsync(StateHasChanged); } From f059fa91d6786cbcb2639cdbcd309831ad234c7c Mon Sep 17 00:00:00 2001 From: mateofuentespombo Date: Tue, 23 Dec 2025 12:17:33 +0100 Subject: [PATCH 5/9] feat: playlist improvements --- .../Pages/Modals/AudioPlayerModal.razor | 682 +++++++++++++++++- .../Pages/Modals/PlaylistManagerModal.razor | 21 + TelegramDownloader/Shared/MainLayout.razor | 23 +- .../wwwroot/js/fileUploadVue.js | 62 +- 4 files changed, 764 insertions(+), 24 deletions(-) diff --git a/TelegramDownloader/Pages/Modals/AudioPlayerModal.razor b/TelegramDownloader/Pages/Modals/AudioPlayerModal.razor index 8cbcdb2..1d41d0e 100644 --- a/TelegramDownloader/Pages/Modals/AudioPlayerModal.razor +++ b/TelegramDownloader/Pages/Modals/AudioPlayerModal.razor @@ -776,6 +776,13 @@ opacity: 1; } + /* Always show on mobile */ + @@media (max-width: 768px) { + .playlist-item-actions { + opacity: 1; + } + } + .playlist-item-btn { color: rgba(255, 255, 255, 0.5); background: none; @@ -807,8 +814,14 @@ } .playlist-header-count { - color: rgba(255, 255, 255, 0.4); - font-size: 0.75rem; + color: rgba(255, 255, 255, 0.9); + font-size: 0.7rem; + background: rgba(233, 69, 96, 0.3); + padding: 0.15rem 0.5rem; + border-radius: 10px; + font-weight: 500; + min-width: 1.5rem; + text-align: center; } /* Track options dropdown */ @@ -922,6 +935,277 @@ font-size: 3rem; } } + + /* Queue header with compact buttons */ + .queue-header-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + } + + .queue-header-actions { + display: flex; + gap: 0.25rem; + } + + .queue-btn { + width: 28px; + height: 28px; + padding: 0; + background: rgba(255, 255, 255, 0.1); + border: none; + border-radius: 50%; + color: rgba(255, 255, 255, 0.6); + font-size: 0.8rem; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + } + + .queue-btn:hover:not(:disabled) { + background: rgba(233, 69, 96, 0.3); + color: white; + } + + .queue-btn:disabled { + opacity: 0.3; + cursor: not-allowed; + } + + .queue-btn.active { + background: rgba(233, 69, 96, 0.4); + color: #e94560; + } + + /* Expanded playlist mode */ + .audio-player-content.expanded .album-art, + .audio-player-content.expanded .audio-title-display, + .audio-player-content.expanded .audio-visualizer, + .audio-player-content.expanded .audio-loading-indicator, + .audio-player-content.expanded .current-playlist-badge { + display: none; + } + + .audio-player-content.expanded .audio-controls { + padding: 0.75rem; + } + + .audio-player-content.expanded .audio-main-controls { + margin-bottom: 0.25rem; + } + + .audio-player-content.expanded .audio-secondary-controls { + padding-top: 0.25rem; + } + + .audio-player-content.expanded .playlist-section { + margin-top: 0.5rem; + } + + .audio-player-content.expanded .audio-playlist { + max-height: calc(100vh - 280px); + } + + @@media (max-width: 768px) { + .audio-player-content.expanded .audio-playlist { + max-height: calc(100vh - 220px); + } + } + + /* Compact now playing bar in expanded mode */ + .compact-now-playing { + display: none; + background: rgba(0, 0, 0, 0.3); + border-radius: 0.5rem; + padding: 0.5rem 0.75rem; + margin-bottom: 0.5rem; + align-items: center; + gap: 0.5rem; + } + + .audio-player-content.expanded .compact-now-playing { + display: flex; + } + + .compact-now-playing-icon { + width: 32px; + height: 32px; + background: linear-gradient(135deg, #e94560 0%, #0f3460 100%); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-size: 0.8rem; + flex-shrink: 0; + } + + .compact-now-playing-info { + flex: 1; + min-width: 0; + } + + .compact-now-playing-title { + color: white; + font-size: 0.8rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .compact-now-playing-time { + color: rgba(255, 255, 255, 0.5); + font-size: 0.7rem; + } + + /* Track Info Modal */ + .track-info-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.7); + z-index: 1100; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + visibility: hidden; + transition: all 0.2s ease; + } + + .track-info-overlay.show { + opacity: 1; + visibility: visible; + } + + .track-info-modal { + width: 90%; + max-width: 400px; + max-height: 80vh; + background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%); + border-radius: 1rem; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5); + overflow: hidden; + transform: scale(0.9); + transition: transform 0.2s ease; + } + + .track-info-overlay.show .track-info-modal { + transform: scale(1); + } + + .track-info-header { + padding: 1rem; + background: rgba(233, 69, 96, 0.15); + border-bottom: 1px solid rgba(233, 69, 96, 0.2); + display: flex; + align-items: center; + justify-content: space-between; + } + + .track-info-header h4 { + margin: 0; + color: white; + font-size: 1rem; + display: flex; + align-items: center; + gap: 0.5rem; + } + + .track-info-header h4 i { + color: #e94560; + } + + .track-info-close { + background: none; + border: none; + color: rgba(255, 255, 255, 0.6); + font-size: 1.25rem; + cursor: pointer; + padding: 0.25rem; + } + + .track-info-close:hover { + color: white; + } + + .track-info-content { + padding: 1rem; + overflow-y: auto; + max-height: calc(80vh - 60px); + } + + .track-info-title { + background: rgba(0, 0, 0, 0.3); + border-radius: 0.5rem; + padding: 0.75rem; + margin-bottom: 1rem; + color: white; + font-weight: 500; + word-break: break-word; + } + + .track-info-row { + display: flex; + padding: 0.5rem 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + } + + .track-info-row:last-child { + border-bottom: none; + } + + .track-info-label { + width: 100px; + color: rgba(255, 255, 255, 0.5); + font-size: 0.8rem; + flex-shrink: 0; + } + + .track-info-value { + flex: 1; + color: white; + font-size: 0.85rem; + word-break: break-word; + } + + .track-info-value.highlight { + color: #e94560; + } + + .track-info-playlists { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid rgba(255, 255, 255, 0.1); + } + + .track-info-playlists-title { + color: rgba(255, 255, 255, 0.6); + font-size: 0.75rem; + text-transform: uppercase; + margin-bottom: 0.5rem; + } + + .track-info-playlist-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem; + background: rgba(0, 0, 0, 0.2); + border-radius: 0.375rem; + margin-bottom: 0.25rem; + color: rgba(255, 255, 255, 0.8); + font-size: 0.8rem; + } + + .track-info-playlist-item i { + color: #e94560; + }
@@ -932,7 +1216,7 @@ -
+
@if (!string.IsNullOrEmpty(currentPlaylistName)) {
@@ -1049,12 +1333,42 @@
+ @* Compact now playing bar (shown in expanded mode) *@ +
+
+ +
+
+
@audioTitle
+
@currentTimeDisplay / @durationDisplay
+
+
+ @if (playlist.Count > 0) {
-
- Queue - @playlist.Count @(playlist.Count == 1 ? "track" : "tracks") +
+
+ Queue + @playlist.Count +
+
+ + + +
@for (int i = 0; i < playlist.Count; i++) @@ -1084,6 +1398,9 @@ } @item.Title
+ @@ -1110,7 +1427,7 @@ { @foreach (var pl in savedPlaylists) { -
+
@@ -1146,6 +1463,82 @@ @onloadstart="OnLoadStart"> +@* Track Info Modal *@ +
+
+
+

Track Info

+ +
+ @if (trackInfoData != null) + { +
+
+ + @trackInfoData.Title +
+ +
+ Source + + @(trackInfoData.IsLocal ? "Local File" : trackInfoData.ChannelName) + +
+ +
+ Type + @trackInfoData.FileType.ToUpperInvariant() +
+ +
+ Position + @(trackInfoData.QueuePosition + 1) of @playlist.Count +
+ + @if (!string.IsNullOrEmpty(trackInfoData.Duration)) + { +
+ Duration + @trackInfoData.Duration +
+ } + + @if (trackInfoData.FileSize > 0) + { +
+ Size + @FormatFileSize(trackInfoData.FileSize) +
+ } + + @if (!string.IsNullOrEmpty(currentPlaylistName)) + { +
+ Playing from + @currentPlaylistName +
+ } + + @if (trackInfoData.ContainedInPlaylists?.Count > 0) + { +
+
Also in playlists
+ @foreach (var plName in trackInfoData.ContainedInPlaylists) + { +
+ + @plName +
+ } +
+ } +
+ } +
+
+ @code { private bool isVisible = false; private string audioUrl = ""; @@ -1195,9 +1588,34 @@ private int? draggedIndex = null; private int? dragOverIndex = null; + // Playlist expanded mode + private bool isPlaylistExpanded = false; + + // Track info modal + private bool showTrackInfo = false; + private TrackInfoData? trackInfoData = null; + // Disposed state private bool _disposed = false; + // DotNet reference for JS interop + private DotNetObjectReference? _dotNetRef; + + public class TrackInfoData + { + public string Title { get; set; } = ""; + public string Url { get; set; } = ""; + public string FileType { get; set; } = ""; + public string ChannelName { get; set; } = ""; + public string ChannelId { get; set; } = ""; + public string FileId { get; set; } = ""; + public long FileSize { get; set; } + public string? Duration { get; set; } + public bool IsLocal { get; set; } + public int QueuePosition { get; set; } + public List? ContainedInPlaylists { get; set; } + } + public enum RepeatMode { None = 0, @@ -1241,6 +1659,12 @@ StateHasChanged(); } + private void TogglePlaylistExpanded() + { + isPlaylistExpanded = !isPlaylistExpanded; + StateHasChanged(); + } + public async Task ShowModal(string url, string type = "audio/mpeg", string title = "") { if (_disposed) return; @@ -1263,8 +1687,9 @@ currentTrackIndex = existingIndex; } + // Always set visible and update UI first isVisible = true; - await LoadSavedPlaylists(); + try { await LoadSavedPlaylists(); } catch { /* Don't block showing the modal */ } await InvokeAsync(StateHasChanged); await Task.Delay(100); @@ -1281,14 +1706,25 @@ isBuffering = false; loadingStatus = isTelegramFile ? "Loading from Telegram..." : ""; - await JSRuntime.InvokeVoidAsync("playAudioPlayer", audioUrl, audioType); + try + { + await JSRuntime.InvokeVoidAsync("playAudioPlayer", audioUrl, audioType); + } + catch { /* Continue showing modal even if playback fails */ } _ = ExtractArtworkAndUpdateMediaSession(audioUrl); } StartProgressTimer(); - await UpdateProgress(); + try { await UpdateProgress(); } catch { } + } + catch + { + // If we got here and isVisible was set, ensure UI still updates + if (isVisible) + { + try { await InvokeAsync(StateHasChanged); } catch { } + } } - catch { } } public async Task ShowCurrentModal() @@ -1297,17 +1733,24 @@ try { isVisible = true; - await LoadSavedPlaylists(); + try { await LoadSavedPlaylists(); } catch { /* Don't block showing the modal */ } await InvokeAsync(StateHasChanged); await Task.Delay(100); if (!string.IsNullOrEmpty(audioUrl)) { StartProgressTimer(); - await UpdateProgress(); + try { await UpdateProgress(); } catch { } + } + } + catch + { + // Ensure UI updates if we got here with isVisible set + if (isVisible) + { + try { await InvokeAsync(StateHasChanged); } catch { } } } - catch { } } private async Task LoadSavedPlaylists() @@ -1324,9 +1767,28 @@ private async Task LoadSavedPlaylist(Models.PlaylistModel pl) { - currentPlaylistId = pl.Id; - currentPlaylistName = pl.Name; - await InvokeAsync(StateHasChanged); + // Load the playlist tracks into the queue (without playing) + try + { + currentPlaylistId = pl.Id; + currentPlaylistName = pl.Name; + + playlist.Clear(); + foreach (var track in pl.Tracks.OrderBy(t => t.Order)) + { + var url = track.IsLocalFile ? track.DirectUrl! : GetTrackUrl(track); + playlist.Add(new PlaylistItem + { + Url = url, + Type = track.FileType, + Title = track.FileName + }); + } + + currentTrackIndex = -1; // No track selected yet + await InvokeAsync(StateHasChanged); + } + catch { } } private async Task PlaySavedPlaylist(Models.PlaylistModel pl) @@ -1358,6 +1820,12 @@ catch { } } + private void OpenPlaylistForEditing(string playlistId) + { + // Open the PlaylistManagerModal with this playlist selected for editing + TelegramDownloader.Shared.MainLayout.OpenPlaylistManagerWithPlaylist(playlistId); + } + private string GetTrackUrl(Models.PlaylistTrackModel track) { return $"/api/file/GetFileByTfmId/{track.FileName}?idChannel={track.ChannelId}&idFile={track.FileId}"; @@ -1758,6 +2226,161 @@ MainLayout.OpenPlaylistManager(); } + /// + /// Public method to refresh saved playlists in sidebar (call after adding tracks) + /// + public async Task RefreshSavedPlaylists() + { + await LoadSavedPlaylists(); + await InvokeAsync(StateHasChanged); + } + + private async Task SaveQueueToCurrentPlaylist() + { + if (string.IsNullOrEmpty(currentPlaylistId) || playlist.Count == 0) return; + + try + { + var currentPl = savedPlaylists?.FirstOrDefault(p => p.Id == currentPlaylistId); + if (currentPl == null) return; + + int addedCount = 0; + foreach (var item in playlist) + { + bool isLocalFile = !item.Url.Contains("/api/file/"); + var fileId = isLocalFile ? "" : ExtractFileIdFromUrl(item.Url); + + // Check if track already exists in playlist + bool alreadyExists = currentPl.Tracks.Any(t => + (isLocalFile && t.DirectUrl == item.Url) || + (!isLocalFile && t.FileId == fileId)); + + if (!alreadyExists) + { + var track = new Models.PlaylistTrackModel + { + FileId = fileId, + ChannelId = isLocalFile ? "" : ExtractChannelIdFromUrl(item.Url), + ChannelName = isLocalFile ? "Local" : "", + FileName = item.Title, + FileType = ExtractFileTypeFromUrl(item.Url), + FileSize = 0, + DirectUrl = isLocalFile ? item.Url : null, + Order = currentPl.Tracks.Count + }; + + await DbService.AddTrackToPlaylist(currentPlaylistId, track); + currentPl.Tracks.Add(track); + addedCount++; + } + } + + await RefreshSavedPlaylists(); + } + catch { } + } + + private void SaveQueueToOtherPlaylist() + { + if (playlist.Count == 0) return; + + // Create tracks from queue + var tracks = playlist.Select(item => + { + bool isLocalFile = !item.Url.Contains("/api/file/"); + return new Models.PlaylistTrackModel + { + FileId = isLocalFile ? "" : ExtractFileIdFromUrl(item.Url), + ChannelId = isLocalFile ? "" : ExtractChannelIdFromUrl(item.Url), + ChannelName = isLocalFile ? "Local" : "", + FileName = item.Title, + FileType = ExtractFileTypeFromUrl(item.Url), + FileSize = 0, + DirectUrl = isLocalFile ? item.Url : null + }; + }).ToList(); + + // Open the save modal with multiple tracks + var saveModal = MainLayout.GetSaveToPlaylistModal(); + if (saveModal != null) + { + _ = saveModal.Open(tracks); + } + } + + private async Task ShowTrackInfo(int index) + { + if (index < 0 || index >= playlist.Count) return; + + var item = playlist[index]; + bool isLocalFile = !item.Url.Contains("/api/file/"); + + // Get duration from audio element if this is the current track + string? duration = null; + if (index == currentTrackIndex && !string.IsNullOrEmpty(durationDisplay) && durationDisplay != "0:00") + { + duration = durationDisplay; + } + + // Find which playlists contain this track + var containedIn = new List(); + if (savedPlaylists != null) + { + var fileId = isLocalFile ? "" : ExtractFileIdFromUrl(item.Url); + foreach (var pl in savedPlaylists) + { + bool found = pl.Tracks.Any(t => + (isLocalFile && t.DirectUrl == item.Url) || + (!isLocalFile && !string.IsNullOrEmpty(fileId) && t.FileId == fileId) || + t.FileName == item.Title); + + if (found && pl.Name != currentPlaylistName) + { + containedIn.Add(pl.Name); + } + } + } + + trackInfoData = new TrackInfoData + { + Title = item.Title, + Url = item.Url, + FileType = ExtractFileTypeFromUrl(item.Url).TrimStart('.'), + ChannelName = isLocalFile ? "Local" : "Telegram", + ChannelId = isLocalFile ? "" : ExtractChannelIdFromUrl(item.Url), + FileId = isLocalFile ? "" : ExtractFileIdFromUrl(item.Url), + FileSize = 0, + Duration = duration, + IsLocal = isLocalFile, + QueuePosition = index, + ContainedInPlaylists = containedIn.Count > 0 ? containedIn : null + }; + + showTrackInfo = true; + await InvokeAsync(StateHasChanged); + } + + private void CloseTrackInfo() + { + showTrackInfo = false; + trackInfoData = null; + StateHasChanged(); + } + + private string FormatFileSize(long bytes) + { + if (bytes <= 0) return "Unknown"; + string[] sizes = { "B", "KB", "MB", "GB" }; + int order = 0; + double size = bytes; + while (size >= 1024 && order < sizes.Length - 1) + { + order++; + size /= 1024; + } + return $"{size:0.##} {sizes[order]}"; + } + private string ExtractFileIdFromUrl(string url) { try @@ -1847,6 +2470,7 @@ loadingStatus = isTelegramFile ? "Loading from Telegram..." : ""; await JSRuntime.InvokeVoidAsync("playAudioPlayer", audioUrl, audioType); + StartProgressTimer(); // Ensure timer is running when playing a track await InvokeAsync(StateHasChanged); _ = ExtractArtworkAndUpdateMediaSession(audioUrl); } @@ -2207,16 +2831,40 @@ { if (firstRender) { + // Register DotNet reference for JS calls + try + { + _dotNetRef = DotNetObjectReference.Create(this); + await JSRuntime.InvokeVoidAsync("setAudioPlayerRef", _dotNetRef); + } + catch + { + // Function may not be available yet (browser cache), ignore - fallback will be used + } + await LoadVisualizerPreference(); await InvokeAsync(StateHasChanged); } } + [JSInvokable] + public async Task ShowModalFromJs(string url, string type, string title) + { + await ShowModal(url, type ?? "audio/mpeg", title ?? ""); + } + + [JSInvokable] + public async Task ShowCurrentModalFromJs() + { + await ShowCurrentModal(); + } + public void Dispose() { _disposed = true; StopProgressTimer(); StopRealVisualizer(); visualizerCts?.Dispose(); + _dotNetRef?.Dispose(); } } diff --git a/TelegramDownloader/Pages/Modals/PlaylistManagerModal.razor b/TelegramDownloader/Pages/Modals/PlaylistManagerModal.razor index 806ef8c..2e9c5c2 100644 --- a/TelegramDownloader/Pages/Modals/PlaylistManagerModal.razor +++ b/TelegramDownloader/Pages/Modals/PlaylistManagerModal.razor @@ -605,6 +605,27 @@ StateHasChanged(); } + public async Task OpenWithPlaylist(string playlistId) + { + isLoading = true; + selectedPlaylist = null; + playlistToDelete = null; + newPlaylistName = ""; + + ModalDisplay = "block;"; + ModalClass = "Show"; + ShowBackdrop = true; + StateHasChanged(); + + await LoadPlaylists(); + + // Select the specified playlist + selectedPlaylist = playlists.FirstOrDefault(p => p.Id == playlistId); + + isLoading = false; + StateHasChanged(); + } + private async Task LoadPlaylists() { try diff --git a/TelegramDownloader/Shared/MainLayout.razor b/TelegramDownloader/Shared/MainLayout.razor index c6a0e1f..291da9c 100644 --- a/TelegramDownloader/Shared/MainLayout.razor +++ b/TelegramDownloader/Shared/MainLayout.razor @@ -685,6 +685,14 @@ public static Pages.Modals.PlaylistManagerModal? GetPlaylistManager() => _instance?.playlistManager; public static Pages.Modals.SaveToPlaylistModal? GetSaveToPlaylistModal() => _instance?.saveToPlaylist; + public static void OpenPlaylistManagerWithPlaylist(string playlistId) + { + if (_instance != null && _instance.playlistManager != null) + { + _instance.InvokeAsync(async () => await _instance.playlistManager.OpenWithPlaylist(playlistId)); + } + } + // Playlist event handlers private async Task OnPlayPlaylist((Models.PlaylistModel playlist, int? startIndex) args) { @@ -704,6 +712,9 @@ await audioPlayer.ClearPlaylist(); await audioPlayer.AddMultipleToPlaylist(playlistItems); + // Set the playlist name and ID so it shows as active in the sidebar + await audioPlayer.SetPlaylistName(args.playlist.Name, args.playlist.Id); + // Show the modal first (in case it's not visible) await audioPlayer.ShowCurrentModal(); @@ -755,10 +766,20 @@ }); } - private void OnPlaylistNotify(string message) + private async Task OnPlaylistNotify(string message) { NotificationModel nm = new NotificationModel(); nm.sendEvent(new Notification(message, "Playlist", NotificationTypes.Success)); + + // Refresh audio player's saved playlists sidebar + if (audioPlayer != null) + { + try + { + await audioPlayer.RefreshSavedPlaylists(); + } + catch { } + } } public void Dispose() diff --git a/TelegramDownloader/wwwroot/js/fileUploadVue.js b/TelegramDownloader/wwwroot/js/fileUploadVue.js index a5f0d01..6ce0e19 100644 --- a/TelegramDownloader/wwwroot/js/fileUploadVue.js +++ b/TelegramDownloader/wwwroot/js/fileUploadVue.js @@ -394,21 +394,71 @@ window.openFileUploadModal = (id, path, url) => { fileModal.showModal = true; } +// Store reference to audio player for direct calls +window._audioPlayerRef = null; + +window.setAudioPlayerRef = (dotNetRef) => { + window._audioPlayerRef = dotNetRef; +} + window.openAudioPlayerModal = (file, type = "audio/mpeg", title = "") => { - // Call Blazor component via JSInvokable if (type === null) { type = "audio/mpeg"; } + + // Try using direct reference first (more reliable) + if (window._audioPlayerRef) { + try { + window._audioPlayerRef.invokeMethodAsync('ShowModalFromJs', file, type, title) + .catch(e => { + console.log('ShowModalFromJs error, falling back to static:', e); + fallbackOpenAudioPlayer(file, type, title); + }); + return; + } catch (e) { + console.log('Direct ref error:', e); + } + } + + // Fallback to static method + fallbackOpenAudioPlayer(file, type, title); +} + +function fallbackOpenAudioPlayer(file, type, title) { try { - DotNet.invokeMethodAsync('TelegramDownloader', 'OpenAudioPlayer', file, type, title).catch(() => {}); - } catch (e) { console.log('OpenAudioPlayer error:', e); } + DotNet.invokeMethodAsync('TelegramDownloader', 'OpenAudioPlayer', file, type, title) + .catch(e => console.log('OpenAudioPlayer static error:', e)); + } catch (e) { + console.log('OpenAudioPlayer error:', e); + } } window.openAudioModal = () => { - // Abrir el modal con la canción actual (si hay una) + // Try using direct reference first + if (window._audioPlayerRef) { + try { + window._audioPlayerRef.invokeMethodAsync('ShowCurrentModalFromJs') + .catch(e => { + console.log('ShowCurrentModalFromJs error, falling back to static:', e); + fallbackOpenAudioModal(); + }); + return; + } catch (e) { + console.log('Direct ref error:', e); + } + } + + // Fallback to static method + fallbackOpenAudioModal(); +} + +function fallbackOpenAudioModal() { try { - DotNet.invokeMethodAsync('TelegramDownloader', 'OpenAudioPlayerCurrent').catch(() => {}); - } catch (e) { console.log('OpenAudioPlayerCurrent error:', e); } + DotNet.invokeMethodAsync('TelegramDownloader', 'OpenAudioPlayerCurrent') + .catch(e => console.log('OpenAudioPlayerCurrent static error:', e)); + } catch (e) { + console.log('OpenAudioPlayerCurrent error:', e); + } } window.playAudioPlayer = (url, type) => { From 000c7e1e5d78492055d35807750383c03e0c6f49 Mon Sep 17 00:00:00 2001 From: mateofuentespombo Date: Tue, 23 Dec 2025 13:09:50 +0100 Subject: [PATCH 6/9] feat: refresh data files improvement --- TelegramDownloader/Data/FileService.cs | 8 +- TelegramDownloader/Data/IFileService.cs | 2 +- TelegramDownloader/Data/ITelegramService.cs | 2 +- TelegramDownloader/Data/TelegramService.cs | 459 +++++++++++++++--- .../Models/RefreshChannelOptions.cs | 46 ++ TelegramDownloader/Pages/FileManager.razor | 117 ++++- 6 files changed, 547 insertions(+), 87 deletions(-) create mode 100644 TelegramDownloader/Models/RefreshChannelOptions.cs diff --git a/TelegramDownloader/Data/FileService.cs b/TelegramDownloader/Data/FileService.cs index adc22b0..38fc522 100644 --- a/TelegramDownloader/Data/FileService.cs +++ b/TelegramDownloader/Data/FileService.cs @@ -1639,7 +1639,7 @@ private string GetMimeType(string fileName) return contentType; } - public async Task refreshChannelFIles(string channelId, bool force = false) + public async Task refreshChannelFIles(string channelId, bool force = false, RefreshChannelOptions? refreshOptions = null) { int totalNewMessages = 0; refreshMutex.WaitOne(); @@ -1648,7 +1648,11 @@ public async Task refreshChannelFIles(string channelId, bool force = false) DateTime init = DateTime.Now; List presentIds = await _db.getAllIdsFromChannel(channelId); _logger.LogInformation($"Refresh channel with id: {channelId}"); - List telegramChatDocuments = (await _ts.searchAllChannelFiles(Convert.ToInt64(channelId), (presentIds.Count > 0 && !force) ? presentIds.Max() : 0)).Where(x => x.name != null).ToList(); + + // Use default options if none provided + refreshOptions ??= new RefreshChannelOptions(); + + List telegramChatDocuments = (await _ts.searchAllChannelFiles(Convert.ToInt64(channelId), (presentIds.Count > 0 && !force) ? presentIds.Max() : 0, refreshOptions)).Where(x => x.name != null).ToList(); _logger.LogInformation($"Get the telegram files in: {(DateTime.Now - init).TotalSeconds} seconds for channel id:{channelId}"); List fileNames = await _db.getAllFileNamesFromChannel(channelId); var nameCount = new Dictionary(StringComparer.OrdinalIgnoreCase); diff --git a/TelegramDownloader/Data/IFileService.cs b/TelegramDownloader/Data/IFileService.cs index bc2ab84..cee27cd 100644 --- a/TelegramDownloader/Data/IFileService.cs +++ b/TelegramDownloader/Data/IFileService.cs @@ -41,7 +41,7 @@ public interface IFileService Task UploadFile(string dbName, string currentPath, UploadFiles file); Task UploadFileFromServer(string dbName, string currentPath, List files, InfoDownloadTaksModel dm = null); Task AddUploadFileFromServer(string dbName, string currentPath, List files, InfoDownloadTaksModel idt = null); - Task refreshChannelFIles(string channelId, bool force = false); + Task refreshChannelFIles(string channelId, bool force = false, RefreshChannelOptions? refreshOptions = null); bool isChannelRefreshing(string channelId); Task PreloadFilesToTemp(string channelId, List items); Task DownloadPlaylistToLocal(PlaylistModel playlist, string destinationFolder); diff --git a/TelegramDownloader/Data/ITelegramService.cs b/TelegramDownloader/Data/ITelegramService.cs index 7adc9f4..951714e 100644 --- a/TelegramDownloader/Data/ITelegramService.cs +++ b/TelegramDownloader/Data/ITelegramService.cs @@ -41,7 +41,7 @@ public interface ITelegramService Task logOff(); Task sendVerificationCode(string vc); Task uploadFile(string chatId, Stream file, string fileName, string mimeType = null, UploadModel um = null, string caption = null); - Task> searchAllChannelFiles(long id, int lastId); + Task> searchAllChannelFiles(long id, int lastId, Models.RefreshChannelOptions? options = null); bool isMyChat(long id); bool isChannelOwner(long id); Task CreateChannel(string title, string about); diff --git a/TelegramDownloader/Data/TelegramService.cs b/TelegramDownloader/Data/TelegramService.cs index 290fbce..4d51f63 100644 --- a/TelegramDownloader/Data/TelegramService.cs +++ b/TelegramDownloader/Data/TelegramService.cs @@ -1261,90 +1261,403 @@ public async Task DownloadFile(ChatMessages message, string fileName = n return null; } - public async Task> searchAllChannelFiles(long id, int lastId = 0) + /// + /// Optimized method to get all media files from a channel using Messages_Search with filters. + /// This is much faster than iterating through all messages because Telegram's servers do the filtering. + /// + /// Channel ID + /// Only get messages with ID greater than this (for incremental updates) + /// Options specifying which media types to fetch + /// List of documents found in the channel + public async Task> searchAllChannelFiles(long id, int lastId = 0, Models.RefreshChannelOptions? options = null) { + options ??= new Models.RefreshChannelOptions(); + + var startTime = DateTime.Now; + _logger.LogInformation("═══════════════════════════════════════════════════════════════"); + _logger.LogInformation("Starting channel refresh - ChannelId: {ChannelId}, LastId: {LastId}", id, lastId); + _logger.LogInformation("Selected types: {Types}", options.GetSelectionSummary()); + _logger.LogInformation("═══════════════════════════════════════════════════════════════"); + List telegramChatDocuments = new List(); - List result = await getAllFileMessages(id, lastId); + InputPeer peer = chats.chats[id]; + + int phaseNumber = 0; + int docsCount = 0, audioCount = 0, videoCount = 0, photosCount = 0; + + // Search for documents (general files like PDF, ZIP, etc.) + if (options.IncludeDocuments) + { + phaseNumber++; + _logger.LogInformation("📁 Phase {Phase}: Searching for documents (PDF, ZIP, etc.)...", phaseNumber); + docsCount = await SearchDocuments(peer, lastId, telegramChatDocuments); + _logger.LogInformation("📁 Phase {Phase} complete: Found {Count} documents", phaseNumber, docsCount); + } + + // Search for audio files + if (options.IncludeAudio) + { + phaseNumber++; + _logger.LogInformation("🎵 Phase {Phase}: Searching for audio files...", phaseNumber); + audioCount = await SearchAudio(peer, lastId, telegramChatDocuments); + _logger.LogInformation("🎵 Phase {Phase} complete: Found {Count} audio files", phaseNumber, audioCount); + } + + // Search for video files + if (options.IncludeVideo) + { + phaseNumber++; + _logger.LogInformation("🎬 Phase {Phase}: Searching for video files...", phaseNumber); + videoCount = await SearchVideo(peer, lastId, telegramChatDocuments); + _logger.LogInformation("🎬 Phase {Phase} complete: Found {Count} video files", phaseNumber, videoCount); + } + + // Search for photos + if (options.IncludePhotos) + { + phaseNumber++; + _logger.LogInformation("🖼️ Phase {Phase}: Searching for photos...", phaseNumber); + photosCount = await SearchPhotos(peer, lastId, telegramChatDocuments); + _logger.LogInformation("🖼️ Phase {Phase} complete: Found {Count} photos", phaseNumber, photosCount); + } - foreach (var msg in result) - if (msg.message is Message msgBase) + var elapsed = DateTime.Now - startTime; + _logger.LogInformation("═══════════════════════════════════════════════════════════════"); + _logger.LogInformation("✅ Refresh complete - Total: {Total} files", telegramChatDocuments.Count); + _logger.LogInformation(" 📁 Documents: {Docs} | 🎵 Audio: {Audio} | 🎬 Video: {Video} | 🖼️ Photos: {Photos}", + docsCount, audioCount, videoCount, photosCount); + _logger.LogInformation("⏱️ Time elapsed: {Elapsed:mm\\:ss\\.fff}", elapsed); + _logger.LogInformation("═══════════════════════════════════════════════════════════════"); + + return telegramChatDocuments; + } + + private async Task SearchDocuments(InputPeer peer, int lastId, List results) + { + int totalCount = 0; + int fetchedCount = 0; + int addedCount = 0; + int batchNumber = 0; + int initialResultsCount = results.Count; + HashSet existingIds = results.Select(r => r.id).ToHashSet(); + + for (int offset_id = 0; ;) + { + batchNumber++; + var searchResult = await client.Messages_Search( + peer: peer, + q: "", + offset_id: offset_id, + limit: 100); + + if (searchResult.Messages.Length == 0) break; + + // Get total count from first batch + if (totalCount == 0) + { + totalCount = searchResult.Count; + _logger.LogInformation(" 📊 Total documents in channel: {Total}", totalCount); + } + + int batchAdded = 0; + foreach (var msgBase in searchResult.Messages.OfType()) { + fetchedCount++; + + // Skip messages we already have (incremental update) + if (lastId > 0 && msgBase.id <= lastId) + { + _logger.LogInformation(" ⏭️ Reached lastId ({LastId}), stopping. Fetched: {Fetched}, Added: {Added}", + lastId, fetchedCount, addedCount); + return results.Count - initialResultsCount; + } + + // Skip if already added (from another filter) + if (existingIds.Contains(msgBase.id)) continue; + if (msgBase.media is MessageMediaDocument mediaDoc && mediaDoc.document is TL.Document doc) { - TelegramChatDocuments tcd = new TelegramChatDocuments(); - tcd.id = msgBase.id; - tcd.documentType = DocumentType.Document; - tcd.name = doc.Filename; - tcd.fileSize = doc.size; - tcd.extension = Path.GetExtension(doc.Filename); - tcd.creationDate = msgBase.date; - tcd.modifiedDate = msgBase.edit_date < msgBase.date ? msgBase.date : msgBase.edit_date; - telegramChatDocuments.Add(tcd); + if (!string.IsNullOrEmpty(doc.Filename)) + { + results.Add(new TelegramChatDocuments + { + id = msgBase.id, + documentType = DocumentType.Document, + name = doc.Filename, + fileSize = doc.size, + extension = Path.GetExtension(doc.Filename), + creationDate = msgBase.date, + modifiedDate = msgBase.edit_date < msgBase.date ? msgBase.date : msgBase.edit_date + }); + existingIds.Add(msgBase.id); + addedCount++; + batchAdded++; + } } } - return telegramChatDocuments; + + double progress = totalCount > 0 ? (double)fetchedCount / totalCount * 100 : 0; + _logger.LogInformation(" 📦 Batch {Batch}: Fetched {Fetched}/{Total} ({Progress:F1}%) - Added {BatchAdded} docs (Total added: {Added})", + batchNumber, fetchedCount, totalCount, progress, batchAdded, addedCount); + + offset_id = searchResult.Messages[^1].ID; + + // Small delay to avoid rate limiting + await Task.Delay(100); + } + + return results.Count - initialResultsCount; } - //public async Task> searchAllChannelFiles(long id) - //{ - // InputPeer channel = chats.chats[id]; - // List telegramChatDocuments = new List(); - // for (int offset_id = 0; ;) - // { - // Messages_MessagesBase result = await client.Messages_Search( - // peer: channel, - // text: "", - // offset_id: offset_id, - // limit: 0); - // if (result.Messages.Length == 0) break; - // foreach (var msgBase in result.Messages.OfType()) - // { - // if (msgBase.media is MessageMediaDocument mediaDoc && - // mediaDoc.document is TL.Document doc) - // { - // TelegramChatDocuments tcd = new TelegramChatDocuments(); - // tcd.id = msgBase.id; - // tcd.documentType = DocumentType.Document; - // tcd.name = doc.Filename; - // tcd.extension = Path.GetExtension(doc.Filename); - // tcd.creationDate = msgBase.date; - // tcd.modifiedDate = msgBase.edit_date; - // telegramChatDocuments.Add(tcd); - // } - // } - // offset_id = result.Messages[^1].ID; - // } - - // for (int offset_id = 0; ;) - // { - // Messages_MessagesBase result = await client.Messages_Search( - // peer: channel, - // text: "", - // offset_id: offset_id, - // limit: 0); - // if (result.Messages.Length == 0) break; - // foreach (var msgBase in result.Messages.OfType()) - // { - // if (msgBase.media is MessageMediaPhoto mediaPhoto && - // mediaPhoto.photo is Photo photo) - // { - // TelegramChatDocuments tcd = new TelegramChatDocuments(); - // tcd.documentType = DocumentType.Photo; - // tcd.id = msgBase.id; - // // Nombre "falso" porque la foto no trae filename - // tcd.name = $"{photo.id}.jpg"; - // tcd.extension = ".jpg"; - // tcd.creationDate = msgBase.date; - // tcd.modifiedDate = msgBase.edit_date; - // telegramChatDocuments.Add(tcd); - // } - // } - // offset_id = result.Messages[^1].ID; - // } - - // return telegramChatDocuments; - //} + private async Task SearchAudio(InputPeer peer, int lastId, List results) + { + int totalCount = 0; + int fetchedCount = 0; + int addedCount = 0; + int batchNumber = 0; + int initialResultsCount = results.Count; + HashSet existingIds = results.Select(r => r.id).ToHashSet(); + + for (int offset_id = 0; ;) + { + batchNumber++; + var searchResult = await client.Messages_Search( + peer: peer, + q: "", + offset_id: offset_id, + limit: 100); + + if (searchResult.Messages.Length == 0) break; + + // Get total count from first batch + if (totalCount == 0) + { + totalCount = searchResult.Count; + _logger.LogInformation(" 📊 Total audio files in channel: {Total}", totalCount); + } + + int batchAdded = 0; + foreach (var msgBase in searchResult.Messages.OfType()) + { + fetchedCount++; + + // Skip messages we already have (incremental update) + if (lastId > 0 && msgBase.id <= lastId) + { + _logger.LogInformation(" ⏭️ Reached lastId ({LastId}), stopping. Fetched: {Fetched}, Added: {Added}", + lastId, fetchedCount, addedCount); + return results.Count - initialResultsCount; + } + + // Skip if already added (from another filter) + if (existingIds.Contains(msgBase.id)) continue; + + if (msgBase.media is MessageMediaDocument mediaDoc && + mediaDoc.document is TL.Document doc) + { + string filename = doc.Filename; + // If no filename, create one from audio attributes + if (string.IsNullOrEmpty(filename)) + { + var audioAttr = doc.attributes.OfType().FirstOrDefault(); + if (audioAttr != null) + { + filename = !string.IsNullOrEmpty(audioAttr.title) + ? $"{audioAttr.performer} - {audioAttr.title}.mp3" + : $"audio_{doc.id}.mp3"; + } + else + { + filename = $"audio_{doc.id}.mp3"; + } + } + + results.Add(new TelegramChatDocuments + { + id = msgBase.id, + documentType = DocumentType.Document, + name = filename, + fileSize = doc.size, + extension = Path.GetExtension(filename), + creationDate = msgBase.date, + modifiedDate = msgBase.edit_date < msgBase.date ? msgBase.date : msgBase.edit_date + }); + existingIds.Add(msgBase.id); + addedCount++; + batchAdded++; + } + } + + double progress = totalCount > 0 ? (double)fetchedCount / totalCount * 100 : 0; + _logger.LogInformation(" 🎵 Batch {Batch}: Fetched {Fetched}/{Total} ({Progress:F1}%) - Added {BatchAdded} audio (Total added: {Added})", + batchNumber, fetchedCount, totalCount, progress, batchAdded, addedCount); + + offset_id = searchResult.Messages[^1].ID; + + // Small delay to avoid rate limiting + await Task.Delay(100); + } + + return results.Count - initialResultsCount; + } + + private async Task SearchVideo(InputPeer peer, int lastId, List results) + { + int totalCount = 0; + int fetchedCount = 0; + int addedCount = 0; + int batchNumber = 0; + int initialResultsCount = results.Count; + HashSet existingIds = results.Select(r => r.id).ToHashSet(); + + for (int offset_id = 0; ;) + { + batchNumber++; + var searchResult = await client.Messages_Search( + peer: peer, + q: "", + offset_id: offset_id, + limit: 100); + + if (searchResult.Messages.Length == 0) break; + + // Get total count from first batch + if (totalCount == 0) + { + totalCount = searchResult.Count; + _logger.LogInformation(" 📊 Total video files in channel: {Total}", totalCount); + } + + int batchAdded = 0; + foreach (var msgBase in searchResult.Messages.OfType()) + { + fetchedCount++; + + // Skip messages we already have (incremental update) + if (lastId > 0 && msgBase.id <= lastId) + { + _logger.LogInformation(" ⏭️ Reached lastId ({LastId}), stopping. Fetched: {Fetched}, Added: {Added}", + lastId, fetchedCount, addedCount); + return results.Count - initialResultsCount; + } + + // Skip if already added (from another filter) + if (existingIds.Contains(msgBase.id)) continue; + + if (msgBase.media is MessageMediaDocument mediaDoc && + mediaDoc.document is TL.Document doc) + { + string filename = doc.Filename; + // If no filename, create one + if (string.IsNullOrEmpty(filename)) + { + filename = $"video_{doc.id}.mp4"; + } + + results.Add(new TelegramChatDocuments + { + id = msgBase.id, + documentType = DocumentType.Document, + name = filename, + fileSize = doc.size, + extension = Path.GetExtension(filename), + creationDate = msgBase.date, + modifiedDate = msgBase.edit_date < msgBase.date ? msgBase.date : msgBase.edit_date + }); + existingIds.Add(msgBase.id); + addedCount++; + batchAdded++; + } + } + + double progress = totalCount > 0 ? (double)fetchedCount / totalCount * 100 : 0; + _logger.LogInformation(" 🎬 Batch {Batch}: Fetched {Fetched}/{Total} ({Progress:F1}%) - Added {BatchAdded} video (Total added: {Added})", + batchNumber, fetchedCount, totalCount, progress, batchAdded, addedCount); + + offset_id = searchResult.Messages[^1].ID; + + // Small delay to avoid rate limiting + await Task.Delay(100); + } + + return results.Count - initialResultsCount; + } + + private async Task SearchPhotos(InputPeer peer, int lastId, List results) + { + int totalCount = 0; + int fetchedCount = 0; + int addedCount = 0; + int batchNumber = 0; + int initialResultsCount = results.Count; + HashSet existingIds = results.Select(r => r.id).ToHashSet(); + + for (int offset_id = 0; ;) + { + batchNumber++; + var searchResult = await client.Messages_Search( + peer: peer, + q: "", + offset_id: offset_id, + limit: 100); + + if (searchResult.Messages.Length == 0) break; + + // Get total count from first batch + if (totalCount == 0) + { + totalCount = searchResult.Count; + _logger.LogInformation(" 📊 Total photos in channel: {Total}", totalCount); + } + + int batchAdded = 0; + foreach (var msgBase in searchResult.Messages.OfType()) + { + fetchedCount++; + + // Skip messages we already have (incremental update) + if (lastId > 0 && msgBase.id <= lastId) + { + _logger.LogInformation(" ⏭️ Reached lastId ({LastId}), stopping. Fetched: {Fetched}, Added: {Added}", + lastId, fetchedCount, addedCount); + return results.Count - initialResultsCount; + } + + // Skip if already added + if (existingIds.Contains(msgBase.id)) continue; + + if (msgBase.media is MessageMediaPhoto mediaPhoto && + mediaPhoto.photo is Photo photo) + { + results.Add(new TelegramChatDocuments + { + id = msgBase.id, + documentType = DocumentType.Photo, + name = $"{photo.id}.jpg", + fileSize = photo.LargestPhotoSize?.FileSize ?? 0, + extension = ".jpg", + creationDate = msgBase.date, + modifiedDate = msgBase.edit_date < msgBase.date ? msgBase.date : msgBase.edit_date + }); + existingIds.Add(msgBase.id); + addedCount++; + batchAdded++; + } + } + + double progress = totalCount > 0 ? (double)fetchedCount / totalCount * 100 : 0; + _logger.LogInformation(" 📷 Batch {Batch}: Fetched {Fetched}/{Total} ({Progress:F1}%) - Added {BatchAdded} photos (Total added: {Added})", + batchNumber, fetchedCount, totalCount, progress, batchAdded, addedCount); + + offset_id = searchResult.Messages[^1].ID; + + // Small delay to avoid rate limiting + await Task.Delay(100); + } + + return results.Count - initialResultsCount; + } public async Task CreateChannel(string title, string about) { diff --git a/TelegramDownloader/Models/RefreshChannelOptions.cs b/TelegramDownloader/Models/RefreshChannelOptions.cs new file mode 100644 index 0000000..e43535e --- /dev/null +++ b/TelegramDownloader/Models/RefreshChannelOptions.cs @@ -0,0 +1,46 @@ +namespace TelegramDownloader.Models +{ + /// + /// Options for refreshing channel data, specifying which media types to fetch + /// + public class RefreshChannelOptions + { + /// + /// Include documents (files with filename like PDFs, ZIPs, etc.) + /// + public bool IncludeDocuments { get; set; } = true; + + /// + /// Include audio files (music, voice messages) + /// + public bool IncludeAudio { get; set; } = true; + + /// + /// Include video files + /// + public bool IncludeVideo { get; set; } = true; + + /// + /// Include photos + /// + public bool IncludePhotos { get; set; } = true; + + /// + /// Returns true if at least one media type is selected + /// + public bool HasAnySelection => IncludeDocuments || IncludeAudio || IncludeVideo || IncludePhotos; + + /// + /// Returns a summary string of selected types + /// + public string GetSelectionSummary() + { + var types = new List(); + if (IncludeDocuments) types.Add("Documents"); + if (IncludeAudio) types.Add("Audio"); + if (IncludeVideo) types.Add("Video"); + if (IncludePhotos) types.Add("Photos"); + return types.Count > 0 ? string.Join(", ", types) : "None"; + } + } +} diff --git a/TelegramDownloader/Pages/FileManager.razor b/TelegramDownloader/Pages/FileManager.razor index 741d60b..79cad79 100644 --- a/TelegramDownloader/Pages/FileManager.razor +++ b/TelegramDownloader/Pages/FileManager.razor @@ -83,20 +83,108 @@ ConfirmText="Refresh" ConfirmIcon="bi-arrow-clockwise" ProcessingText="Refreshing..." + Size="TelegramDownloader.Pages.Modals.ConfirmationModal.ConfirmationSize.Large" Type="TelegramDownloader.Pages.Modals.ConfirmationModal.ConfirmationType.Warning" OnConfirm="executeRefreshData">
-

Are you sure you want to refresh data for @chatName?

+

Refresh data for @chatName. Select which media types to fetch:

-
- - This will: -
    -
  • Re-download all file metadata from Telegram
  • -
  • Sync any changes made in the channel
  • -
  • May take a long time for large channels
  • -
+ +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ +
+ + Only new files will be added. Existing files won't be duplicated.
+ + @code { @@ -117,6 +205,9 @@ private bool showRefresh = true; private bool isRefreshing = false; + // Refresh options + private RefreshChannelOptions refreshOptions = new RefreshChannelOptions(); + // Show refresh button if: not my channel OR (is my channel AND EnableRefreshOwnChannels is enabled) private bool canShowRefresh => !isMyChannel || GeneralConfigStatic.config.EnableRefreshOwnChannels; @@ -173,13 +264,19 @@ private async Task executeRefreshData() { + if (!refreshOptions.HasAnySelection) + { + nm.sendMessage("Warning", "Please select at least one media type", NotificationTypes.Error); + return; + } + isRefreshing = true; showRefresh = false; await InvokeAsync(StateHasChanged); try { - await fs.refreshChannelFIles(id); + await fs.refreshChannelFIles(id, refreshOptions: refreshOptions); // Refresh the file manager to show updated data if (fileManagerImpl != null) From 1529a84a0b983225d600460ddcc662179b084b02 Mon Sep 17 00:00:00 2001 From: mateofuentespombo Date: Tue, 23 Dec 2025 20:54:42 +0100 Subject: [PATCH 7/9] feat: improvements in playlist --- TelegramDownloader/Data/TelegramService.cs | 2 + TelegramDownloader/Models/GeneralConfig.cs | 1 + .../Pages/Modals/AudioPlayerModal.razor | 111 +++++++++++++++++- TelegramDownloader/Program.cs | 5 +- TelegramDownloader/TelegramDownloader.csproj | 2 +- 5 files changed, 114 insertions(+), 7 deletions(-) diff --git a/TelegramDownloader/Data/TelegramService.cs b/TelegramDownloader/Data/TelegramService.cs index 4d51f63..5a2dc68 100644 --- a/TelegramDownloader/Data/TelegramService.cs +++ b/TelegramDownloader/Data/TelegramService.cs @@ -259,6 +259,8 @@ public async Task checkAuth(string number, bool isPhone = false) } try { + if (number == null) + return "phone"; return await DoLogin(number); } catch (Exception ex) diff --git a/TelegramDownloader/Models/GeneralConfig.cs b/TelegramDownloader/Models/GeneralConfig.cs index 470a395..0bf1e85 100644 --- a/TelegramDownloader/Models/GeneralConfig.cs +++ b/TelegramDownloader/Models/GeneralConfig.cs @@ -120,6 +120,7 @@ public class TLConfig public string? hash_id { get; set;} public string? mongo_connection_string { get; set; } public bool? avoid_checking_certificate { get; set; } + public bool? open_browser_on_startup { get; set; } } public class WebDavModel diff --git a/TelegramDownloader/Pages/Modals/AudioPlayerModal.razor b/TelegramDownloader/Pages/Modals/AudioPlayerModal.razor index 1d41d0e..8a245de 100644 --- a/TelegramDownloader/Pages/Modals/AudioPlayerModal.razor +++ b/TelegramDownloader/Pages/Modals/AudioPlayerModal.razor @@ -672,6 +672,27 @@ background: rgba(0, 0, 0, 0.2); } + /* Expanded mode - show more tracks */ + .audio-player-content.expanded { + display: flex; + flex-direction: column; + height: 100%; + } + + .audio-player-content.expanded .playlist-section { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; + } + + .audio-player-content.expanded .audio-playlist { + flex: 1; + max-height: none !important; + overflow-y: auto; + } + .audio-playlist::-webkit-scrollbar { width: 6px; } @@ -873,12 +894,27 @@ } .audio-player-sidebar { - display: none; + display: none !important; + } + + .audio-player-main { + height: 100%; + max-height: 100%; + overflow: hidden; } .audio-player-content { padding: 1rem; padding-top: 3rem; + height: 100%; + max-height: 100%; + box-sizing: border-box; + } + + /* Avoid overlap with close button */ + .current-playlist-badge { + margin-right: 3rem; + max-width: calc(100% - 3.5rem); } .album-art { @@ -917,6 +953,7 @@ .audio-playlist { max-height: 35vh; + padding-bottom: 1rem; } .audio-volume-container { @@ -1005,13 +1042,79 @@ margin-top: 0.5rem; } - .audio-player-content.expanded .audio-playlist { - max-height: calc(100vh - 280px); + @@media (min-width: 769px) { + .audio-player-content.expanded { + overflow: hidden; + padding: 0.75rem; + } + + .audio-player-content.expanded .compact-now-playing { + padding: 0.35rem 0.5rem; + margin-bottom: 0.35rem; + } + + .audio-player-content.expanded .audio-controls { + padding: 0.5rem; + } + + .audio-player-content.expanded .playlist-section { + display: flex; + flex-direction: column; + margin-top: 0.25rem; + } + + .audio-player-content.expanded .queue-header-row { + flex-shrink: 0; + padding-bottom: 0.25rem; + } + + .audio-player-content.expanded .audio-playlist { + flex: 1; + min-height: 0; + max-height: calc(90vh - 200px); + overflow-y: auto; + } + + .audio-player-content.expanded .playlist-item { + padding: 0.4rem 0.5rem; + } } @@media (max-width: 768px) { + .audio-player-content.expanded { + overflow: hidden; + display: flex; + flex-direction: column; + } + + .audio-player-content.expanded .compact-now-playing { + flex: 0 0 auto; + } + + .audio-player-content.expanded .audio-controls { + flex: 0 0 auto; + } + + .audio-player-content.expanded .playlist-section { + flex: 1 1 0; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + } + + .audio-player-content.expanded .queue-header-row { + flex: 0 0 auto; + margin-bottom: 0.5rem; + } + .audio-player-content.expanded .audio-playlist { - max-height: calc(100vh - 220px); + flex: 1 1 0; + min-height: 0; + max-height: none; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + padding-bottom: 1rem; } } diff --git a/TelegramDownloader/Program.cs b/TelegramDownloader/Program.cs index 85c7789..7ef3502 100644 --- a/TelegramDownloader/Program.cs +++ b/TelegramDownloader/Program.cs @@ -303,8 +303,9 @@ Log.Information("TelegramFileManager application started. Listening on: {Urls}", string.Join(", ", listeningUrls)); - // Open browser automatically if not in Docker - if (!isDocker && listeningUrls.Any()) + // Open browser automatically if not in Docker and not disabled in config + var shouldOpenBrowser = GeneralConfigStatic.tlconfig?.open_browser_on_startup ?? true; + if (!isDocker && shouldOpenBrowser && listeningUrls.Any()) { var urlToOpen = listeningUrls.FirstOrDefault(u => u.StartsWith("http://")) ?? listeningUrls.First(); diff --git a/TelegramDownloader/TelegramDownloader.csproj b/TelegramDownloader/TelegramDownloader.csproj index 975d008..e204688 100644 --- a/TelegramDownloader/TelegramDownloader.csproj +++ b/TelegramDownloader/TelegramDownloader.csproj @@ -1,7 +1,7 @@  - 3.2.0.0 + 3.3.0.0 Mateo TelegramFileManager net10.0 From cfefdec674bee793e9cc513e949908f6352c166d Mon Sep 17 00:00:00 2001 From: mateofuentespombo Date: Sat, 27 Dec 2025 13:33:55 +0100 Subject: [PATCH 8/9] feat: add tfm audio app --- .github/workflows/buildrelease.yml | 373 +++++++- TFMAudioApp/.gitignore | 132 +++ TFMAudioApp/App.xaml | 14 + TFMAudioApp/App.xaml.cs | 68 ++ TFMAudioApp/AppShell.xaml | 82 ++ TFMAudioApp/AppShell.xaml.cs | 66 ++ TFMAudioApp/Behaviors/TapFeedbackBehavior.cs | 91 ++ TFMAudioApp/Behaviors/TouchBehavior.cs | 119 +++ .../Controls/CircularProgressControl.cs | 221 +++++ TFMAudioApp/Controls/ConfirmationPopup.xaml | 58 ++ .../Controls/ConfirmationPopup.xaml.cs | 113 +++ TFMAudioApp/Controls/FilterPopup.xaml | 145 +++ TFMAudioApp/Controls/FilterPopup.xaml.cs | 271 ++++++ TFMAudioApp/Controls/MiniPlayerControl.xaml | 184 ++++ .../Controls/MiniPlayerControl.xaml.cs | 256 +++++ TFMAudioApp/Controls/OptionsBottomSheet.xaml | 210 +++++ .../Controls/OptionsBottomSheet.xaml.cs | 42 + TFMAudioApp/Controls/PlaylistPickerPopup.xaml | 143 +++ .../Controls/PlaylistPickerPopup.xaml.cs | 76 ++ TFMAudioApp/Controls/QueuePopup.xaml | 150 +++ TFMAudioApp/Controls/QueuePopup.xaml.cs | 58 ++ TFMAudioApp/Controls/SkeletonView.cs | 337 +++++++ .../Data/Entities/CachedTrackEntity.cs | 26 + .../Data/Entities/DownloadQueueEntity.cs | 34 + .../Data/Entities/OfflinePlaylistEntity.cs | 29 + .../Data/Entities/PlayHistoryEntity.cs | 19 + TFMAudioApp/Data/LocalDatabase.cs | 283 ++++++ TFMAudioApp/GlobalXmlns.cs | 2 + TFMAudioApp/Helpers/Constants.cs | 45 + .../Converters/BoolToColorConverter.cs | 83 ++ .../Helpers/Converters/DurationConverter.cs | 22 + .../Helpers/Converters/FileIconConverter.cs | 65 ++ .../Helpers/Converters/FileSizeConverter.cs | 20 + .../Helpers/Converters/IndexEqualConverter.cs | 57 ++ .../Converters/InverseBoolConverter.cs | 24 + TFMAudioApp/Helpers/Extensions.cs | 94 ++ TFMAudioApp/MauiProgram.cs | 83 ++ TFMAudioApp/Models/ApiResponse.cs | 36 + TFMAudioApp/Models/Channel.cs | 146 +++ TFMAudioApp/Models/FileItem.cs | 91 ++ TFMAudioApp/Models/Playlist.cs | 77 ++ TFMAudioApp/Models/ServerConfig.cs | 33 + TFMAudioApp/Models/Track.cs | 123 +++ .../Platforms/Android/AndroidManifest.xml | 12 + TFMAudioApp/Platforms/Android/MainActivity.cs | 11 + .../Platforms/Android/MainApplication.cs | 16 + .../Android/Resources/values/colors.xml | 6 + .../Resources/xml/network_security_config.xml | 17 + .../Services/DownloadNotificationService.cs | 102 ++ .../Services/MediaNotificationService.cs | 39 + .../Android/Services/MediaPlayerService.cs | 340 +++++++ .../Platforms/MacCatalyst/AppDelegate.cs | 10 + .../Platforms/MacCatalyst/Entitlements.plist | 14 + TFMAudioApp/Platforms/MacCatalyst/Info.plist | 38 + TFMAudioApp/Platforms/MacCatalyst/Program.cs | 16 + TFMAudioApp/Platforms/Tizen/Main.cs | 17 + .../Platforms/Tizen/tizen-manifest.xml | 15 + TFMAudioApp/Platforms/Windows/App.xaml | 8 + TFMAudioApp/Platforms/Windows/App.xaml.cs | 25 + .../Platforms/Windows/Package.appxmanifest | 49 + TFMAudioApp/Platforms/Windows/app.manifest | 15 + TFMAudioApp/Platforms/iOS/AppDelegate.cs | 10 + TFMAudioApp/Platforms/iOS/Info.plist | 32 + TFMAudioApp/Platforms/iOS/Program.cs | 16 + .../iOS/Resources/PrivacyInfo.xcprivacy | 51 + TFMAudioApp/Properties/launchSettings.json | 8 + TFMAudioApp/Resources/AppIcon/appicon.svg | 13 + TFMAudioApp/Resources/AppIcon/appiconfg.svg | 29 + .../Resources/Fonts/OpenSans-Regular.ttf | Bin 0 -> 107304 bytes .../Resources/Fonts/OpenSans-Semibold.ttf | Bin 0 -> 111168 bytes TFMAudioApp/Resources/Images/dotnet_bot.png | Bin 0 -> 93437 bytes TFMAudioApp/Resources/Raw/AboutAssets.txt | 15 + TFMAudioApp/Resources/Splash/splash.svg | 29 + TFMAudioApp/Resources/Styles/Colors.xaml | 84 ++ TFMAudioApp/Resources/Styles/Styles.xaml | 520 +++++++++++ TFMAudioApp/Services/ApiServiceFactory.cs | 119 +++ TFMAudioApp/Services/AudioPlayerService.cs | 842 +++++++++++++++++ TFMAudioApp/Services/CacheService.cs | 879 ++++++++++++++++++ TFMAudioApp/Services/ConnectivityService.cs | 63 ++ TFMAudioApp/Services/DownloadService.cs | 436 +++++++++ .../Services/Interfaces/IApiService.cs | 90 ++ .../Interfaces/IAudioPlayerService.cs | 223 +++++ .../Services/Interfaces/ICacheService.cs | 183 ++++ .../Interfaces/IConnectivityService.cs | 22 + .../IDownloadNotificationService.cs | 18 + .../Services/Interfaces/IDownloadService.cs | 161 ++++ .../Interfaces/IMediaNotificationService.cs | 19 + .../Services/Interfaces/ISettingsService.cs | 44 + .../Services/MediaNotificationService.cs | 18 + TFMAudioApp/Services/SettingsService.cs | 58 ++ TFMAudioApp/TFMAudioApp.csproj | 104 +++ TFMAudioApp/ViewModels/BaseViewModel.cs | 88 ++ .../ViewModels/ChannelDetailViewModel.cs | 648 +++++++++++++ TFMAudioApp/ViewModels/ChannelsViewModel.cs | 261 ++++++ TFMAudioApp/ViewModels/DownloadsViewModel.cs | 194 ++++ TFMAudioApp/ViewModels/PlayerViewModel.cs | 420 +++++++++ .../ViewModels/PlaylistDetailViewModel.cs | 629 +++++++++++++ TFMAudioApp/ViewModels/PlaylistsViewModel.cs | 293 ++++++ TFMAudioApp/ViewModels/SettingsViewModel.cs | 137 +++ TFMAudioApp/ViewModels/SetupViewModel.cs | 128 +++ TFMAudioApp/Views/ChannelDetailPage.xaml | 311 +++++++ TFMAudioApp/Views/ChannelDetailPage.xaml.cs | 66 ++ TFMAudioApp/Views/ChannelsPage.xaml | 344 +++++++ TFMAudioApp/Views/ChannelsPage.xaml.cs | 65 ++ TFMAudioApp/Views/DownloadsPage.xaml | 271 ++++++ TFMAudioApp/Views/DownloadsPage.xaml.cs | 32 + TFMAudioApp/Views/FolderDetailPage.xaml | 116 +++ TFMAudioApp/Views/FolderDetailPage.xaml.cs | 54 ++ TFMAudioApp/Views/HomePage.xaml | 107 +++ TFMAudioApp/Views/HomePage.xaml.cs | 31 + TFMAudioApp/Views/PlayerPage.xaml | 757 +++++++++++++++ TFMAudioApp/Views/PlayerPage.xaml.cs | 308 ++++++ TFMAudioApp/Views/PlaylistDetailPage.xaml | 273 ++++++ TFMAudioApp/Views/PlaylistDetailPage.xaml.cs | 12 + TFMAudioApp/Views/PlaylistsPage.xaml | 217 +++++ TFMAudioApp/Views/PlaylistsPage.xaml.cs | 21 + TFMAudioApp/Views/SettingsPage.xaml | 137 +++ TFMAudioApp/Views/SettingsPage.xaml.cs | 21 + TFMAudioApp/Views/SetupPage.xaml | 114 +++ TFMAudioApp/Views/SetupPage.xaml.cs | 12 + .../Configuration/config.example.json | 4 +- .../Controllers/FileController.cs | 80 ++ .../Mobile/MobileChannelController.cs | 572 ++++++++++++ .../Mobile/MobileFileController.cs | 371 ++++++++ .../Mobile/MobilePlaylistController.cs | 350 +++++++ .../Mobile/MobileStreamController.cs | 800 ++++++++++++++++ .../Middleware/ApiKeyMiddleware.cs | 97 ++ TelegramDownloader/Models/GeneralConfig.cs | 4 + .../Models/Mobile/ApiResponse.cs | 67 ++ .../Models/Mobile/ChannelDTOs.cs | 228 +++++ TelegramDownloader/Models/Mobile/FileDTOs.cs | 191 ++++ .../Models/Mobile/PlaylistDTOs.cs | 186 ++++ .../Pages/Modals/AudioPlayerModal.razor | 24 +- TelegramDownloader/Program.cs | 65 ++ .../Services/ProgressiveDownloadService.cs | 296 ++++++ TelegramDownloader/TelegramDownloader.csproj | 1 + TelegramDownloader/TelegramDownloader.sln | 6 + 137 files changed, 18791 insertions(+), 55 deletions(-) create mode 100644 TFMAudioApp/.gitignore create mode 100644 TFMAudioApp/App.xaml create mode 100644 TFMAudioApp/App.xaml.cs create mode 100644 TFMAudioApp/AppShell.xaml create mode 100644 TFMAudioApp/AppShell.xaml.cs create mode 100644 TFMAudioApp/Behaviors/TapFeedbackBehavior.cs create mode 100644 TFMAudioApp/Behaviors/TouchBehavior.cs create mode 100644 TFMAudioApp/Controls/CircularProgressControl.cs create mode 100644 TFMAudioApp/Controls/ConfirmationPopup.xaml create mode 100644 TFMAudioApp/Controls/ConfirmationPopup.xaml.cs create mode 100644 TFMAudioApp/Controls/FilterPopup.xaml create mode 100644 TFMAudioApp/Controls/FilterPopup.xaml.cs create mode 100644 TFMAudioApp/Controls/MiniPlayerControl.xaml create mode 100644 TFMAudioApp/Controls/MiniPlayerControl.xaml.cs create mode 100644 TFMAudioApp/Controls/OptionsBottomSheet.xaml create mode 100644 TFMAudioApp/Controls/OptionsBottomSheet.xaml.cs create mode 100644 TFMAudioApp/Controls/PlaylistPickerPopup.xaml create mode 100644 TFMAudioApp/Controls/PlaylistPickerPopup.xaml.cs create mode 100644 TFMAudioApp/Controls/QueuePopup.xaml create mode 100644 TFMAudioApp/Controls/QueuePopup.xaml.cs create mode 100644 TFMAudioApp/Controls/SkeletonView.cs create mode 100644 TFMAudioApp/Data/Entities/CachedTrackEntity.cs create mode 100644 TFMAudioApp/Data/Entities/DownloadQueueEntity.cs create mode 100644 TFMAudioApp/Data/Entities/OfflinePlaylistEntity.cs create mode 100644 TFMAudioApp/Data/Entities/PlayHistoryEntity.cs create mode 100644 TFMAudioApp/Data/LocalDatabase.cs create mode 100644 TFMAudioApp/GlobalXmlns.cs create mode 100644 TFMAudioApp/Helpers/Constants.cs create mode 100644 TFMAudioApp/Helpers/Converters/BoolToColorConverter.cs create mode 100644 TFMAudioApp/Helpers/Converters/DurationConverter.cs create mode 100644 TFMAudioApp/Helpers/Converters/FileIconConverter.cs create mode 100644 TFMAudioApp/Helpers/Converters/FileSizeConverter.cs create mode 100644 TFMAudioApp/Helpers/Converters/IndexEqualConverter.cs create mode 100644 TFMAudioApp/Helpers/Converters/InverseBoolConverter.cs create mode 100644 TFMAudioApp/Helpers/Extensions.cs create mode 100644 TFMAudioApp/MauiProgram.cs create mode 100644 TFMAudioApp/Models/ApiResponse.cs create mode 100644 TFMAudioApp/Models/Channel.cs create mode 100644 TFMAudioApp/Models/FileItem.cs create mode 100644 TFMAudioApp/Models/Playlist.cs create mode 100644 TFMAudioApp/Models/ServerConfig.cs create mode 100644 TFMAudioApp/Models/Track.cs create mode 100644 TFMAudioApp/Platforms/Android/AndroidManifest.xml create mode 100644 TFMAudioApp/Platforms/Android/MainActivity.cs create mode 100644 TFMAudioApp/Platforms/Android/MainApplication.cs create mode 100644 TFMAudioApp/Platforms/Android/Resources/values/colors.xml create mode 100644 TFMAudioApp/Platforms/Android/Resources/xml/network_security_config.xml create mode 100644 TFMAudioApp/Platforms/Android/Services/DownloadNotificationService.cs create mode 100644 TFMAudioApp/Platforms/Android/Services/MediaNotificationService.cs create mode 100644 TFMAudioApp/Platforms/Android/Services/MediaPlayerService.cs create mode 100644 TFMAudioApp/Platforms/MacCatalyst/AppDelegate.cs create mode 100644 TFMAudioApp/Platforms/MacCatalyst/Entitlements.plist create mode 100644 TFMAudioApp/Platforms/MacCatalyst/Info.plist create mode 100644 TFMAudioApp/Platforms/MacCatalyst/Program.cs create mode 100644 TFMAudioApp/Platforms/Tizen/Main.cs create mode 100644 TFMAudioApp/Platforms/Tizen/tizen-manifest.xml create mode 100644 TFMAudioApp/Platforms/Windows/App.xaml create mode 100644 TFMAudioApp/Platforms/Windows/App.xaml.cs create mode 100644 TFMAudioApp/Platforms/Windows/Package.appxmanifest create mode 100644 TFMAudioApp/Platforms/Windows/app.manifest create mode 100644 TFMAudioApp/Platforms/iOS/AppDelegate.cs create mode 100644 TFMAudioApp/Platforms/iOS/Info.plist create mode 100644 TFMAudioApp/Platforms/iOS/Program.cs create mode 100644 TFMAudioApp/Platforms/iOS/Resources/PrivacyInfo.xcprivacy create mode 100644 TFMAudioApp/Properties/launchSettings.json create mode 100644 TFMAudioApp/Resources/AppIcon/appicon.svg create mode 100644 TFMAudioApp/Resources/AppIcon/appiconfg.svg create mode 100644 TFMAudioApp/Resources/Fonts/OpenSans-Regular.ttf create mode 100644 TFMAudioApp/Resources/Fonts/OpenSans-Semibold.ttf create mode 100644 TFMAudioApp/Resources/Images/dotnet_bot.png create mode 100644 TFMAudioApp/Resources/Raw/AboutAssets.txt create mode 100644 TFMAudioApp/Resources/Splash/splash.svg create mode 100644 TFMAudioApp/Resources/Styles/Colors.xaml create mode 100644 TFMAudioApp/Resources/Styles/Styles.xaml create mode 100644 TFMAudioApp/Services/ApiServiceFactory.cs create mode 100644 TFMAudioApp/Services/AudioPlayerService.cs create mode 100644 TFMAudioApp/Services/CacheService.cs create mode 100644 TFMAudioApp/Services/ConnectivityService.cs create mode 100644 TFMAudioApp/Services/DownloadService.cs create mode 100644 TFMAudioApp/Services/Interfaces/IApiService.cs create mode 100644 TFMAudioApp/Services/Interfaces/IAudioPlayerService.cs create mode 100644 TFMAudioApp/Services/Interfaces/ICacheService.cs create mode 100644 TFMAudioApp/Services/Interfaces/IConnectivityService.cs create mode 100644 TFMAudioApp/Services/Interfaces/IDownloadNotificationService.cs create mode 100644 TFMAudioApp/Services/Interfaces/IDownloadService.cs create mode 100644 TFMAudioApp/Services/Interfaces/IMediaNotificationService.cs create mode 100644 TFMAudioApp/Services/Interfaces/ISettingsService.cs create mode 100644 TFMAudioApp/Services/MediaNotificationService.cs create mode 100644 TFMAudioApp/Services/SettingsService.cs create mode 100644 TFMAudioApp/TFMAudioApp.csproj create mode 100644 TFMAudioApp/ViewModels/BaseViewModel.cs create mode 100644 TFMAudioApp/ViewModels/ChannelDetailViewModel.cs create mode 100644 TFMAudioApp/ViewModels/ChannelsViewModel.cs create mode 100644 TFMAudioApp/ViewModels/DownloadsViewModel.cs create mode 100644 TFMAudioApp/ViewModels/PlayerViewModel.cs create mode 100644 TFMAudioApp/ViewModels/PlaylistDetailViewModel.cs create mode 100644 TFMAudioApp/ViewModels/PlaylistsViewModel.cs create mode 100644 TFMAudioApp/ViewModels/SettingsViewModel.cs create mode 100644 TFMAudioApp/ViewModels/SetupViewModel.cs create mode 100644 TFMAudioApp/Views/ChannelDetailPage.xaml create mode 100644 TFMAudioApp/Views/ChannelDetailPage.xaml.cs create mode 100644 TFMAudioApp/Views/ChannelsPage.xaml create mode 100644 TFMAudioApp/Views/ChannelsPage.xaml.cs create mode 100644 TFMAudioApp/Views/DownloadsPage.xaml create mode 100644 TFMAudioApp/Views/DownloadsPage.xaml.cs create mode 100644 TFMAudioApp/Views/FolderDetailPage.xaml create mode 100644 TFMAudioApp/Views/FolderDetailPage.xaml.cs create mode 100644 TFMAudioApp/Views/HomePage.xaml create mode 100644 TFMAudioApp/Views/HomePage.xaml.cs create mode 100644 TFMAudioApp/Views/PlayerPage.xaml create mode 100644 TFMAudioApp/Views/PlayerPage.xaml.cs create mode 100644 TFMAudioApp/Views/PlaylistDetailPage.xaml create mode 100644 TFMAudioApp/Views/PlaylistDetailPage.xaml.cs create mode 100644 TFMAudioApp/Views/PlaylistsPage.xaml create mode 100644 TFMAudioApp/Views/PlaylistsPage.xaml.cs create mode 100644 TFMAudioApp/Views/SettingsPage.xaml create mode 100644 TFMAudioApp/Views/SettingsPage.xaml.cs create mode 100644 TFMAudioApp/Views/SetupPage.xaml create mode 100644 TFMAudioApp/Views/SetupPage.xaml.cs create mode 100644 TelegramDownloader/Controllers/Mobile/MobileChannelController.cs create mode 100644 TelegramDownloader/Controllers/Mobile/MobileFileController.cs create mode 100644 TelegramDownloader/Controllers/Mobile/MobilePlaylistController.cs create mode 100644 TelegramDownloader/Controllers/Mobile/MobileStreamController.cs create mode 100644 TelegramDownloader/Middleware/ApiKeyMiddleware.cs create mode 100644 TelegramDownloader/Models/Mobile/ApiResponse.cs create mode 100644 TelegramDownloader/Models/Mobile/ChannelDTOs.cs create mode 100644 TelegramDownloader/Models/Mobile/FileDTOs.cs create mode 100644 TelegramDownloader/Models/Mobile/PlaylistDTOs.cs create mode 100644 TelegramDownloader/Services/ProgressiveDownloadService.cs diff --git a/.github/workflows/buildrelease.yml b/.github/workflows/buildrelease.yml index aa50623..783e12f 100644 --- a/.github/workflows/buildrelease.yml +++ b/.github/workflows/buildrelease.yml @@ -1,4 +1,4 @@ -name: Publish utility on release +name: Build and Release on: release: @@ -7,76 +7,362 @@ on: permissions: contents: write +env: + DOTNET_VERSION: '9.0.x' + jobs: - build: - name: Build binaries + # ========================================== + # Build Server (TelegramDownloader) + # ========================================== + build-server: + name: Build Server runs-on: ubuntu-latest steps: + - name: '📄 Checkout' + uses: actions/checkout@v4 + + - name: '🔧 Setup .NET' + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: '📦 Extract version' + id: version + run: | + TAG=${{ github.event.release.tag_name }} + VERSION=${TAG#v} + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "tag=$TAG" >> $GITHUB_OUTPUT + + - name: '🔨 Build all server platforms' + run: | + VERSION=${{ steps.version.outputs.version }} + platforms=("win-x64" "win-x86" "win-arm64" "linux-x64" "linux-arm" "linux-arm64" "osx-x64" "osx-arm64") + + for platform in "${platforms[@]}"; do + echo "Building $platform..." + dotnet publish TelegramDownloader/TelegramDownloader.csproj \ + -r $platform \ + -c Release \ + -o bin/server-$platform \ + -p:PublishSingleFile=true \ + -p:Version=$VERSION \ + --self-contained + done + + - name: '📦 Create server ZIP archives' + run: | + TAG=${{ steps.version.outputs.tag }} + mkdir -p releases + + for dir in bin/server-*; do + platform=$(basename $dir | sed 's/server-//') + zip -r "releases/TelegramFileManager-Server-${TAG}-${platform}.zip" "$dir" + done + + ls -la releases/ + + - name: '📤 Upload server artifacts' + uses: actions/upload-artifact@v4 + with: + name: server-binaries + path: releases/*.zip + retention-days: 1 + + # ========================================== + # Build Android APK (Signed) + # ========================================== + build-android: + name: Build Android APK + runs-on: ubuntu-latest + steps: - name: '📄 Checkout' uses: actions/checkout@v4 - name: '🔧 Setup .NET' uses: actions/setup-dotnet@v4 with: - dotnet-version: '10.0.x' + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: '📱 Install Android workload' + run: dotnet workload install android maui-android - name: '📦 Extract version' id: version run: | - TAG=${{github.event.release.tag_name}} - VERSION=${TAG:1} + TAG=${{ github.event.release.tag_name }} + VERSION=${TAG#v} + # Extract version number for Android (must be integer) + VERSION_CODE=$(echo $VERSION | sed 's/\.//g' | sed 's/[^0-9]//g') + # If empty or starts with 0, use date-based version + if [ -z "$VERSION_CODE" ] || [ "${VERSION_CODE:0:1}" = "0" ]; then + VERSION_CODE=$(date +%Y%m%d) + fi echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "Building version: $VERSION" + echo "version_code=$VERSION_CODE" >> $GITHUB_OUTPUT + echo "tag=$TAG" >> $GITHUB_OUTPUT - - name: '🔨 Build all platforms' + - name: '🔐 Decode Keystore' + if: ${{ env.ANDROID_KEYSTORE_BASE64 != '' }} + env: + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + run: | + echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > tfmaudio.keystore + echo "ANDROID_KEYSTORE_PATH=$(pwd)/tfmaudio.keystore" >> $GITHUB_ENV + + - name: '🔨 Build Android APK (Signed)' + if: ${{ env.ANDROID_KEYSTORE_PATH != '' }} + env: + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + run: | + VERSION=${{ steps.version.outputs.version }} + VERSION_CODE=${{ steps.version.outputs.version_code }} + + dotnet publish TFMAudioApp/TFMAudioApp.csproj \ + -c Release \ + -f net9.0-android \ + -p:ApplicationDisplayVersion=$VERSION \ + -p:ApplicationVersion=$VERSION_CODE \ + -p:AndroidKeyStore=true \ + -p:AndroidSigningKeyStore=$ANDROID_KEYSTORE_PATH \ + -p:AndroidSigningKeyAlias=$ANDROID_KEY_ALIAS \ + -p:AndroidSigningKeyPass=$ANDROID_KEY_PASSWORD \ + -p:AndroidSigningStorePass=$ANDROID_KEYSTORE_PASSWORD + + - name: '🔨 Build Android APK (Unsigned - for testing)' + if: ${{ env.ANDROID_KEYSTORE_PATH == '' }} run: | VERSION=${{ steps.version.outputs.version }} - TAG=${{github.event.release.tag_name}} + VERSION_CODE=${{ steps.version.outputs.version_code }} + + echo "⚠️ Building unsigned APK (no keystore configured)" + dotnet publish TFMAudioApp/TFMAudioApp.csproj \ + -c Release \ + -f net9.0-android \ + -p:ApplicationDisplayVersion=$VERSION \ + -p:ApplicationVersion=$VERSION_CODE + + - name: '📦 Prepare APK for release' + run: | + TAG=${{ steps.version.outputs.tag }} + mkdir -p releases + + # Find the APK (signed or unsigned) + APK_PATH=$(find TFMAudioApp/bin/Release -name "*.apk" | head -1) + + if [ -n "$APK_PATH" ]; then + cp "$APK_PATH" "releases/TFMAudioApp-${TAG}.apk" + echo "✅ APK prepared: releases/TFMAudioApp-${TAG}.apk" + ls -la releases/ + else + echo "❌ No APK found!" + exit 1 + fi + + - name: '📤 Upload Android artifact' + uses: actions/upload-artifact@v4 + with: + name: android-apk + path: releases/*.apk + retention-days: 1 + + # ========================================== + # Build Windows App + # ========================================== + build-windows: + name: Build Windows App + runs-on: windows-latest + steps: + - name: '📄 Checkout' + uses: actions/checkout@v4 + + - name: '🔧 Setup .NET' + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} - echo "Building win-x64..." - dotnet publish TelegramDownloader/TelegramDownloader.csproj -r win-x64 -c Release -o bin/win-x64 -p:PublishSingleFile=true,Version=$VERSION --self-contained + - name: '🪟 Install MAUI workload' + run: dotnet workload install maui-windows - echo "Building win-x86..." - dotnet publish TelegramDownloader/TelegramDownloader.csproj -r win-x86 -c Release -o bin/win-x86 -p:PublishSingleFile=true,Version=$VERSION --self-contained + - name: '📦 Extract version' + id: version + shell: bash + run: | + TAG=${{ github.event.release.tag_name }} + VERSION=${TAG#v} + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "tag=$TAG" >> $GITHUB_OUTPUT - echo "Building win-arm64..." - dotnet publish TelegramDownloader/TelegramDownloader.csproj -r win-arm64 -c Release -o bin/win-arm64 -p:PublishSingleFile=true,Version=$VERSION --self-contained + - name: '🔨 Build Windows App' + run: | + $VERSION = "${{ steps.version.outputs.version }}" + + dotnet publish TFMAudioApp/TFMAudioApp.csproj ` + -c Release ` + -f net9.0-windows10.0.19041.0 ` + -p:ApplicationDisplayVersion=$VERSION ` + -p:WindowsPackageType=None ` + -p:PublishSingleFile=false ` + -o bin/windows-app - echo "Building linux-x64..." - dotnet publish TelegramDownloader/TelegramDownloader.csproj -r linux-x64 -c Release -o bin/linux-x64 -p:PublishSingleFile=true,Version=$VERSION --self-contained + - name: '🔐 Sign Windows App (if certificate available)' + if: ${{ secrets.WINDOWS_CERT_BASE64 != '' }} + shell: pwsh + env: + WINDOWS_CERT_BASE64: ${{ secrets.WINDOWS_CERT_BASE64 }} + WINDOWS_CERT_PASSWORD: ${{ secrets.WINDOWS_CERT_PASSWORD }} + run: | + # Decode certificate + $certBytes = [Convert]::FromBase64String($env:WINDOWS_CERT_BASE64) + $certPath = "$(Get-Location)\code_signing.pfx" + [IO.File]::WriteAllBytes($certPath, $certBytes) - echo "Building linux-arm..." - dotnet publish TelegramDownloader/TelegramDownloader.csproj -r linux-arm -c Release -o bin/linux-arm -p:PublishSingleFile=true,Version=$VERSION --self-contained + # Find signtool + $signTool = Get-ChildItem -Path "C:\Program Files (x86)\Windows Kits\10\bin" -Recurse -Filter "signtool.exe" | + Where-Object { $_.FullName -match "x64" } | + Select-Object -First 1 -ExpandProperty FullName - echo "Building osx-x64..." - dotnet publish TelegramDownloader/TelegramDownloader.csproj -r osx-x64 -c Release -o bin/osx-x64 -p:PublishSingleFile=true,Version=$VERSION --self-contained + if ($signTool) { + # Sign all EXE and DLL files + Get-ChildItem -Path "bin\windows-app" -Include "*.exe","*.dll" -Recurse | ForEach-Object { + & $signTool sign /f $certPath /p $env:WINDOWS_CERT_PASSWORD /t http://timestamp.digicert.com /fd SHA256 $_.FullName + } + Write-Host "✅ Windows app signed successfully" + } else { + Write-Host "⚠️ SignTool not found, skipping signing" + } - echo "Building osx-arm64..." - dotnet publish TelegramDownloader/TelegramDownloader.csproj -r osx-arm64 -c Release -o bin/osx-arm64 -p:PublishSingleFile=true,Version=$VERSION --self-contained + # Clean up certificate + Remove-Item $certPath -Force - - name: '📦 Create ZIP archives' + - name: '📦 Create Windows ZIP' + shell: bash run: | - TAG=${{github.event.release.tag_name}} + TAG=${{ steps.version.outputs.tag }} mkdir -p releases - echo "Creating ZIP archives..." - zip -r releases/TelegramFileManager-${TAG}-win-x64.zip bin/win-x64 - zip -r releases/TelegramFileManager-${TAG}-win-x86.zip bin/win-x86 - zip -r releases/TelegramFileManager-${TAG}-win-arm64.zip bin/win-arm64 - zip -r releases/TelegramFileManager-${TAG}-linux-x64.zip bin/linux-x64 - zip -r releases/TelegramFileManager-${TAG}-linux-arm.zip bin/linux-arm - zip -r releases/TelegramFileManager-${TAG}-osx-x64.zip bin/osx-x64 - zip -r releases/TelegramFileManager-${TAG}-osx-arm64.zip bin/osx-arm64 + cd bin + 7z a -tzip "../releases/TFMAudioApp-Windows-${TAG}.zip" windows-app/* + cd .. - echo "Created archives:" ls -la releases/ - - name: '🚀 Upload all release assets' + - name: '📤 Upload Windows artifact' + uses: actions/upload-artifact@v4 + with: + name: windows-app + path: releases/*.zip + retention-days: 1 + + # ========================================== + # Build macOS App + # ========================================== + build-macos: + name: Build macOS App + runs-on: macos-latest + steps: + - name: '📄 Checkout' + uses: actions/checkout@v4 + + - name: '🔧 Setup .NET' + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: '🍎 Install MAUI workload' + run: dotnet workload install maui + + - name: '📦 Extract version' + id: version + run: | + TAG=${{ github.event.release.tag_name }} + VERSION=${TAG#v} + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "tag=$TAG" >> $GITHUB_OUTPUT + + - name: '🔨 Build macOS App (Intel x64)' + run: | + VERSION=${{ steps.version.outputs.version }} + + dotnet publish TFMAudioApp/TFMAudioApp.csproj \ + -c Release \ + -f net9.0-maccatalyst \ + -r maccatalyst-x64 \ + -p:ApplicationDisplayVersion=$VERSION \ + -p:CreatePackage=true \ + -o bin/macos-x64 + + - name: '🔨 Build macOS App (Apple Silicon arm64)' + run: | + VERSION=${{ steps.version.outputs.version }} + + dotnet publish TFMAudioApp/TFMAudioApp.csproj \ + -c Release \ + -f net9.0-maccatalyst \ + -r maccatalyst-arm64 \ + -p:ApplicationDisplayVersion=$VERSION \ + -p:CreatePackage=true \ + -o bin/macos-arm64 + + - name: '📦 Create macOS ZIPs' + run: | + TAG=${{ steps.version.outputs.tag }} + mkdir -p releases + + # Package Intel (x64) version + APP_X64=$(find bin/macos-x64 -name "*.app" -type d | head -1) + if [ -n "$APP_X64" ]; then + cd "$(dirname "$APP_X64")" + zip -r "${GITHUB_WORKSPACE}/releases/TFMAudioApp-macOS-Intel-${TAG}.zip" "$(basename "$APP_X64")" + cd "${GITHUB_WORKSPACE}" + echo "✅ macOS Intel app packaged" + fi + + # Package Apple Silicon (arm64) version + APP_ARM64=$(find bin/macos-arm64 -name "*.app" -type d | head -1) + if [ -n "$APP_ARM64" ]; then + cd "$(dirname "$APP_ARM64")" + zip -r "${GITHUB_WORKSPACE}/releases/TFMAudioApp-macOS-AppleSilicon-${TAG}.zip" "$(basename "$APP_ARM64")" + cd "${GITHUB_WORKSPACE}" + echo "✅ macOS Apple Silicon app packaged" + fi + + ls -la releases/ + + - name: '📤 Upload macOS artifact' + uses: actions/upload-artifact@v4 + with: + name: macos-app + path: releases/* + retention-days: 1 + + # ========================================== + # Upload all artifacts to Release + # ========================================== + upload-release: + name: Upload to Release + needs: [build-server, build-android, build-windows, build-macos] + runs-on: ubuntu-latest + steps: + - name: '📥 Download all artifacts' + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: '📋 List artifacts' + run: | + echo "Downloaded artifacts:" + find artifacts -type f -name "*.*" | head -50 + + - name: '🚀 Upload to GitHub Release' env: GH_TOKEN: ${{ secrets.TOKENBuild }} + GH_REPO: ${{ github.repository }} run: | - TAG=${{github.event.release.tag_name}} + TAG=${{ github.event.release.tag_name }} # Function to upload with retry upload_with_retry() { @@ -86,27 +372,26 @@ jobs: local delay=10 while [ $attempt -le $max_attempts ]; do - echo "Uploading $file (attempt $attempt/$max_attempts)..." + echo "Uploading $(basename $file) (attempt $attempt/$max_attempts)..." if gh release upload "$TAG" "$file" --clobber 2>&1; then - echo "✅ Successfully uploaded $file" + echo "✅ Successfully uploaded $(basename $file)" return 0 else echo "⚠️ Upload failed, waiting ${delay}s before retry..." sleep $delay - delay=$((delay * 2)) # Exponential backoff + delay=$((delay * 2)) attempt=$((attempt + 1)) fi done - echo "❌ Failed to upload $file after $max_attempts attempts" + echo "❌ Failed to upload $(basename $file) after $max_attempts attempts" return 1 } - # Upload all files with delays between each - for file in releases/*.zip; do + # Upload all files (zip, apk, pkg) + find artifacts -type f \( -name "*.zip" -o -name "*.apk" -o -name "*.pkg" \) | while read file; do upload_with_retry "$file" - echo "Waiting 5s before next upload..." - sleep 5 + sleep 3 done echo "✅ All uploads completed!" diff --git a/TFMAudioApp/.gitignore b/TFMAudioApp/.gitignore new file mode 100644 index 0000000..72a71f5 --- /dev/null +++ b/TFMAudioApp/.gitignore @@ -0,0 +1,132 @@ +# Build results +bin/ +obj/ +[Dd]ebug/ +[Rr]elease/ +x64/ +x86/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio files +.vs/ +*.suo +*.user +*.userosscache +*.sln.docstates +*.userprefs + +# Rider files +.idea/ + +# VS Code files +.vscode/ + +# macOS +.DS_Store +.AppleDouble +.LSOverride + +# Windows +Thumbs.db +ehthumbs.db +Desktop.ini + +# MAUI / .NET specific +*.csproj.user +*.rsuser +*.svclog +*.scc +*.aps + +# NuGet +*.nupkg +*.snupkg +**/[Pp]ackages/* +!**/[Pp]ackages/build/ +*.nuget.props +*.nuget.targets + +# Android +*.apk +*.aab +*.keystore +!tfmaudio-release.keystore +*.jks +local.properties +*.dex +*.class +gen/ +proguard/ + +# iOS / macOS +*.ipa +*.dSYM.zip +*.dSYM +*.xcuserstate +*.xcworkspace +!*.xcworkspace/contents.xcworkspacedata +xcuserdata/ +*.pbxuser +*.perspective +*.perspectivev3 +*.mode1v3 +*.mode2v3 +Pods/ +Podfile.lock + +# Windows MSIX +*.msix +*.msixbundle +*.appx +*.appxbundle +*.appxupload + +# Certificates and secrets +*.pfx +*.p12 +*.cer +*.pem +certificates/ +!certificates/.gitkeep + +# Database files +*.db +*.db-shm +*.db-wal +*.sqlite +*.sqlite3 + +# Crash reports +crashlytics-build.properties +fabric.properties + +# Test results +TestResults/ +*.trx +*.coverage +*.coveragexml + +# Publish output +publish/ +artifacts/ + +# Temporary files +*.tmp +*.temp +*.bak +*.swp +*~ + +# Generated files +Generated/ +*.g.cs +*.g.i.cs + +# MlaunchLog +MlaunchLog/ diff --git a/TFMAudioApp/App.xaml b/TFMAudioApp/App.xaml new file mode 100644 index 0000000..3cd665f --- /dev/null +++ b/TFMAudioApp/App.xaml @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/TFMAudioApp/App.xaml.cs b/TFMAudioApp/App.xaml.cs new file mode 100644 index 0000000..4d21340 --- /dev/null +++ b/TFMAudioApp/App.xaml.cs @@ -0,0 +1,68 @@ +using TFMAudioApp.Helpers; +using TFMAudioApp.Services.Interfaces; +using TFMAudioApp.Views; + +namespace TFMAudioApp; + +public partial class App : Application +{ + private readonly ISettingsService _settingsService; + private readonly IAudioPlayerService _audioPlayerService; + + public App(ISettingsService settingsService, IAudioPlayerService audioPlayerService) + { + InitializeComponent(); + _settingsService = settingsService; + _audioPlayerService = audioPlayerService; + } + + protected override Window CreateWindow(IActivationState? activationState) + { + var window = new Window(new AppShell()); + + // Handle window closing (app terminated) + window.Destroying += OnWindowDestroying; + + return window; + } + + protected override async void OnStart() + { + base.OnStart(); + await NavigateToStartPageAsync(); + } + + private void OnWindowDestroying(object? sender, EventArgs e) + { + // Stop playback when app window is destroyed + System.Diagnostics.Debug.WriteLine("[App] Window destroying - stopping audio player"); + try + { + _audioPlayerService.StopAsync().ConfigureAwait(false); + + // Dispose the player to clean up resources + if (_audioPlayerService is IDisposable disposable) + { + disposable.Dispose(); + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"[App] Error stopping player on destroy: {ex.Message}"); + } + } + + private async Task NavigateToStartPageAsync() + { + var config = await _settingsService.GetServerConfigAsync(); + + if (config == null || !config.IsValid) + { + await Shell.Current.GoToAsync($"//{Constants.SetupRoute}"); + } + else + { + await Shell.Current.GoToAsync($"//{Constants.HomeRoute}"); + } + } +} diff --git a/TFMAudioApp/AppShell.xaml b/TFMAudioApp/AppShell.xaml new file mode 100644 index 0000000..1d8b85e --- /dev/null +++ b/TFMAudioApp/AppShell.xaml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/TFMAudioApp/AppShell.xaml.cs b/TFMAudioApp/AppShell.xaml.cs new file mode 100644 index 0000000..6ce4219 --- /dev/null +++ b/TFMAudioApp/AppShell.xaml.cs @@ -0,0 +1,66 @@ +using TFMAudioApp.Services.Interfaces; +using TFMAudioApp.Views; + +namespace TFMAudioApp; + +public partial class AppShell : Shell +{ + private IConnectivityService? _connectivityService; + + public AppShell() + { + InitializeComponent(); + + // Register routes for pages that are navigated to with parameters + Routing.RegisterRoute("playlistdetail", typeof(PlaylistDetailPage)); + Routing.RegisterRoute("channeldetail", typeof(ChannelDetailPage)); + Routing.RegisterRoute("folderdetail", typeof(FolderDetailPage)); + Routing.RegisterRoute("player", typeof(PlayerPage)); + } + + protected override void OnHandlerChanged() + { + base.OnHandlerChanged(); + + if (Handler != null) + { + InitializeConnectivityService(); + } + } + + private void InitializeConnectivityService() + { + _connectivityService = Application.Current?.Handler?.MauiContext?.Services.GetService(); + + if (_connectivityService != null) + { + _connectivityService.ConnectivityChanged += OnConnectivityChanged; + UpdateConnectionUI(_connectivityService.IsConnected); + } + } + + private void OnConnectivityChanged(object? sender, bool isConnected) + { + MainThread.BeginInvokeOnMainThread(() => + { + UpdateConnectionUI(isConnected); + }); + } + + private void UpdateConnectionUI(bool isConnected) + { + if (ConnectionIndicator != null && ConnectionText != null) + { + if (isConnected) + { + ConnectionIndicator.TextColor = Color.FromArgb("#22C55E"); // Green + ConnectionText.Text = "Online"; + } + else + { + ConnectionIndicator.TextColor = Color.FromArgb("#EF4444"); // Red + ConnectionText.Text = "Offline"; + } + } + } +} diff --git a/TFMAudioApp/Behaviors/TapFeedbackBehavior.cs b/TFMAudioApp/Behaviors/TapFeedbackBehavior.cs new file mode 100644 index 0000000..c9aff3f --- /dev/null +++ b/TFMAudioApp/Behaviors/TapFeedbackBehavior.cs @@ -0,0 +1,91 @@ +namespace TFMAudioApp.Behaviors; + +/// +/// A behavior that provides visual feedback (opacity animation) when an element is tapped. +/// This works with TapGestureRecognizer without requiring RelativeSource bindings. +/// +public class TapFeedbackBehavior : Behavior +{ + private View? _associatedView; + private TapGestureRecognizer? _tapGesture; + + public static readonly BindableProperty PressedOpacityProperty = + BindableProperty.Create(nameof(PressedOpacity), typeof(double), typeof(TapFeedbackBehavior), 0.6); + + public static readonly BindableProperty AnimationDurationProperty = + BindableProperty.Create(nameof(AnimationDuration), typeof(uint), typeof(TapFeedbackBehavior), (uint)100); + + public double PressedOpacity + { + get => (double)GetValue(PressedOpacityProperty); + set => SetValue(PressedOpacityProperty, value); + } + + public uint AnimationDuration + { + get => (uint)GetValue(AnimationDurationProperty); + set => SetValue(AnimationDurationProperty, value); + } + + protected override void OnAttachedTo(View bindable) + { + base.OnAttachedTo(bindable); + _associatedView = bindable; + + // Find existing TapGestureRecognizer or add press handlers + foreach (var gesture in bindable.GestureRecognizers) + { + if (gesture is TapGestureRecognizer tap) + { + _tapGesture = tap; + break; + } + } + + // Use PointerGestureRecognizer for better press detection + var pointerGesture = new PointerGestureRecognizer(); + pointerGesture.PointerPressed += OnPointerPressed; + pointerGesture.PointerReleased += OnPointerReleased; + pointerGesture.PointerExited += OnPointerExited; + bindable.GestureRecognizers.Add(pointerGesture); + } + + protected override void OnDetachingFrom(View bindable) + { + base.OnDetachingFrom(bindable); + + // Remove pointer gesture + var pointerGestures = bindable.GestureRecognizers.OfType().ToList(); + foreach (var pg in pointerGestures) + { + pg.PointerPressed -= OnPointerPressed; + pg.PointerReleased -= OnPointerReleased; + pg.PointerExited -= OnPointerExited; + bindable.GestureRecognizers.Remove(pg); + } + + _associatedView = null; + _tapGesture = null; + } + + private async void OnPointerPressed(object? sender, PointerEventArgs e) + { + if (_associatedView == null) return; + + await _associatedView.FadeTo(PressedOpacity, AnimationDuration / 2, Easing.CubicOut); + } + + private async void OnPointerReleased(object? sender, PointerEventArgs e) + { + if (_associatedView == null) return; + + await _associatedView.FadeTo(1.0, AnimationDuration, Easing.CubicIn); + } + + private async void OnPointerExited(object? sender, PointerEventArgs e) + { + if (_associatedView == null) return; + + await _associatedView.FadeTo(1.0, AnimationDuration, Easing.CubicIn); + } +} diff --git a/TFMAudioApp/Behaviors/TouchBehavior.cs b/TFMAudioApp/Behaviors/TouchBehavior.cs new file mode 100644 index 0000000..2b235a2 --- /dev/null +++ b/TFMAudioApp/Behaviors/TouchBehavior.cs @@ -0,0 +1,119 @@ +using Microsoft.Maui.Controls; + +namespace TFMAudioApp.Behaviors; + +/// +/// Behavior that adds touch feedback (press effect) to any View. +/// Apply this to Border, Frame, Grid, or any container to get visual feedback on touch. +/// +public class TouchBehavior : Behavior +{ + private View? _element; + private double _originalOpacity; + private double _originalScale; + + /// + /// Scale factor when pressed (default 0.97 = slight shrink) + /// + public double PressedScale { get; set; } = 0.97; + + /// + /// Opacity when pressed (default 0.8) + /// + public double PressedOpacity { get; set; } = 0.8; + + /// + /// Duration of the press animation in milliseconds (default 80ms) + /// + public uint AnimationDuration { get; set; } = 80; + + /// + /// Whether to use native ripple effect on Android (requires specific setup) + /// + public bool UseNativeEffect { get; set; } = false; + + protected override void OnAttachedTo(View element) + { + base.OnAttachedTo(element); + _element = element; + + // Store original values + _originalOpacity = element.Opacity; + _originalScale = element.Scale; + + // Add pointer/touch handlers + var pointerGesture = new PointerGestureRecognizer(); + pointerGesture.PointerPressed += OnPointerPressed; + pointerGesture.PointerReleased += OnPointerReleased; + pointerGesture.PointerExited += OnPointerExited; + element.GestureRecognizers.Add(pointerGesture); + } + + protected override void OnDetachingFrom(View element) + { + base.OnDetachingFrom(element); + + // Remove gesture recognizers + var toRemove = element.GestureRecognizers + .OfType() + .ToList(); + + foreach (var gesture in toRemove) + { + gesture.PointerPressed -= OnPointerPressed; + gesture.PointerReleased -= OnPointerReleased; + gesture.PointerExited -= OnPointerExited; + element.GestureRecognizers.Remove(gesture); + } + + _element = null; + } + + private async void OnPointerPressed(object? sender, PointerEventArgs e) + { + if (_element == null) return; + + try + { + // Animate to pressed state + await Task.WhenAll( + _element.ScaleTo(PressedScale, AnimationDuration, Easing.CubicOut), + _element.FadeTo(PressedOpacity, AnimationDuration, Easing.CubicOut) + ); + } + catch + { + // Animation cancelled, ignore + } + } + + private async void OnPointerReleased(object? sender, PointerEventArgs e) + { + await AnimateToNormal(); + } + + private async void OnPointerExited(object? sender, PointerEventArgs e) + { + await AnimateToNormal(); + } + + private async Task AnimateToNormal() + { + if (_element == null) return; + + try + { + // Animate back to normal state + await Task.WhenAll( + _element.ScaleTo(_originalScale, AnimationDuration, Easing.CubicOut), + _element.FadeTo(_originalOpacity, AnimationDuration, Easing.CubicOut) + ); + } + catch + { + // Animation cancelled, reset immediately + _element.Scale = _originalScale; + _element.Opacity = _originalOpacity; + } + } +} diff --git a/TFMAudioApp/Controls/CircularProgressControl.cs b/TFMAudioApp/Controls/CircularProgressControl.cs new file mode 100644 index 0000000..b566675 --- /dev/null +++ b/TFMAudioApp/Controls/CircularProgressControl.cs @@ -0,0 +1,221 @@ +using Microsoft.Maui.Controls.Shapes; +using Path = Microsoft.Maui.Controls.Shapes.Path; + +namespace TFMAudioApp.Controls; + +public class CircularProgressControl : ContentView +{ + public static readonly BindableProperty ProgressProperty = + BindableProperty.Create(nameof(Progress), typeof(double), typeof(CircularProgressControl), 0.0, + propertyChanged: OnProgressChanged); + + public static readonly BindableProperty ProgressColorProperty = + BindableProperty.Create(nameof(ProgressColor), typeof(Color), typeof(CircularProgressControl), Colors.Blue, + propertyChanged: OnVisualPropertyChanged); + + public static readonly BindableProperty BackgroundRingColorProperty = + BindableProperty.Create(nameof(BackgroundRingColor), typeof(Color), typeof(CircularProgressControl), Colors.Gray, + propertyChanged: OnVisualPropertyChanged); + + public static readonly BindableProperty RingThicknessProperty = + BindableProperty.Create(nameof(RingThickness), typeof(double), typeof(CircularProgressControl), 8.0, + propertyChanged: OnVisualPropertyChanged); + + public static readonly BindableProperty TextProperty = + BindableProperty.Create(nameof(Text), typeof(string), typeof(CircularProgressControl), string.Empty, + propertyChanged: OnTextChanged); + + public static readonly BindableProperty SubTextProperty = + BindableProperty.Create(nameof(SubText), typeof(string), typeof(CircularProgressControl), string.Empty, + propertyChanged: OnTextChanged); + + public double Progress + { + get => (double)GetValue(ProgressProperty); + set => SetValue(ProgressProperty, value); + } + + public Color ProgressColor + { + get => (Color)GetValue(ProgressColorProperty); + set => SetValue(ProgressColorProperty, value); + } + + public Color BackgroundRingColor + { + get => (Color)GetValue(BackgroundRingColorProperty); + set => SetValue(BackgroundRingColorProperty, value); + } + + public double RingThickness + { + get => (double)GetValue(RingThicknessProperty); + set => SetValue(RingThicknessProperty, value); + } + + public string Text + { + get => (string)GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public string SubText + { + get => (string)GetValue(SubTextProperty); + set => SetValue(SubTextProperty, value); + } + + private readonly Grid _container; + private readonly Ellipse _backgroundRing; + private readonly Path _progressArc; + private readonly Label _textLabel; + private readonly Label _subTextLabel; + + public CircularProgressControl() + { + _container = new Grid(); + + // Background ring + _backgroundRing = new Ellipse + { + Stroke = new SolidColorBrush(BackgroundRingColor), + StrokeThickness = RingThickness, + Fill = Brush.Transparent, + HorizontalOptions = LayoutOptions.Fill, + VerticalOptions = LayoutOptions.Fill + }; + + // Progress arc + _progressArc = new Path + { + Stroke = new SolidColorBrush(ProgressColor), + StrokeThickness = RingThickness, + StrokeLineCap = PenLineCap.Round, + Fill = Brush.Transparent, + HorizontalOptions = LayoutOptions.Fill, + VerticalOptions = LayoutOptions.Fill + }; + + // Center text + _textLabel = new Label + { + HorizontalOptions = LayoutOptions.Center, + VerticalOptions = LayoutOptions.Center, + FontSize = 24, + FontAttributes = FontAttributes.Bold, + TextColor = Colors.White + }; + + _subTextLabel = new Label + { + HorizontalOptions = LayoutOptions.Center, + VerticalOptions = LayoutOptions.Center, + FontSize = 12, + TextColor = Colors.Gray, + Margin = new Thickness(0, 30, 0, 0) + }; + + _container.Children.Add(_backgroundRing); + _container.Children.Add(_progressArc); + _container.Children.Add(_textLabel); + _container.Children.Add(_subTextLabel); + + Content = _container; + SizeChanged += OnSizeChanged; + } + + private void OnSizeChanged(object? sender, EventArgs e) + { + UpdateProgressArc(); + } + + private static void OnProgressChanged(BindableObject bindable, object oldValue, object newValue) + { + if (bindable is CircularProgressControl control) + { + control.UpdateProgressArc(); + } + } + + private static void OnVisualPropertyChanged(BindableObject bindable, object oldValue, object newValue) + { + if (bindable is CircularProgressControl control) + { + control.UpdateVisuals(); + } + } + + private static void OnTextChanged(BindableObject bindable, object oldValue, object newValue) + { + if (bindable is CircularProgressControl control) + { + control._textLabel.Text = control.Text; + control._subTextLabel.Text = control.SubText; + } + } + + private void UpdateVisuals() + { + _backgroundRing.Stroke = new SolidColorBrush(BackgroundRingColor); + _backgroundRing.StrokeThickness = RingThickness; + _progressArc.Stroke = new SolidColorBrush(ProgressColor); + _progressArc.StrokeThickness = RingThickness; + } + + private void UpdateProgressArc() + { + var size = Math.Min(Width, Height); + if (size <= 0) return; + + var centerX = size / 2; + var centerY = size / 2; + var radius = (size - RingThickness) / 2; + + var progress = Math.Clamp(Progress, 0, 1); + var angle = progress * 360; + + if (angle <= 0) + { + _progressArc.Data = null; + return; + } + + if (angle >= 360) + { + angle = 359.99; // Prevent full circle issue + } + + var startAngle = -90; // Start from top + var endAngle = startAngle + angle; + + var startRad = startAngle * Math.PI / 180; + var endRad = endAngle * Math.PI / 180; + + var startX = centerX + radius * Math.Cos(startRad); + var startY = centerY + radius * Math.Sin(startRad); + var endX = centerX + radius * Math.Cos(endRad); + var endY = centerY + radius * Math.Sin(endRad); + + var largeArc = angle > 180; + + var pathData = new PathGeometry(); + var figure = new PathFigure + { + StartPoint = new Point(startX, startY), + IsClosed = false + }; + + var arcSegment = new ArcSegment + { + Point = new Point(endX, endY), + Size = new Size(radius, radius), + SweepDirection = SweepDirection.Clockwise, + IsLargeArc = largeArc + }; + + figure.Segments.Add(arcSegment); + pathData.Figures.Add(figure); + + _progressArc.Data = pathData; + } +} diff --git a/TFMAudioApp/Controls/ConfirmationPopup.xaml b/TFMAudioApp/Controls/ConfirmationPopup.xaml new file mode 100644 index 0000000..d95498a --- /dev/null +++ b/TFMAudioApp/Controls/ConfirmationPopup.xaml @@ -0,0 +1,58 @@ + + + + + + + + + + + +