diff --git a/.github/workflows/buildrelease.yml b/.github/workflows/buildrelease.yml
index aa50623..1fbaca8 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,369 @@ 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 }}
- TAG=${{github.event.release.tag_name}}
+ 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 }}
+ 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
- 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
+ # Find the APK (signed or unsigned)
+ APK_PATH=$(find TFMAudioApp/bin/Release -name "*.apk" | head -1)
- 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
+ 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
- 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: 'π€ Upload Android artifact'
+ uses: actions/upload-artifact@v4
+ with:
+ name: android-apk
+ path: releases/*.apk
+ retention-days: 1
- 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
+ # ==========================================
+ # Build Windows App
+ # ==========================================
+ build-windows:
+ name: Build Windows App
+ runs-on: windows-latest
+ steps:
+ - name: 'π Checkout'
+ uses: actions/checkout@v4
- 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
+ - name: 'π§ Setup .NET'
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: ${{ env.DOTNET_VERSION }}
- 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
+ - name: 'πͺ Install MAUI workload'
+ run: dotnet workload install maui-windows
- 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
+ - 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
- - name: 'π¦ Create ZIP archives'
+ - name: 'π¨ Build Windows App'
run: |
- TAG=${{github.event.release.tag_name}}
+ $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
+
+ - name: 'π Sign Windows App (if certificate available)'
+ shell: pwsh
+ env:
+ WINDOWS_CERT_BASE64: ${{ secrets.WINDOWS_CERT_BASE64 }}
+ WINDOWS_CERT_PASSWORD: ${{ secrets.WINDOWS_CERT_PASSWORD }}
+ run: |
+ # Check if certificate is configured
+ if ([string]::IsNullOrEmpty($env:WINDOWS_CERT_BASE64)) {
+ Write-Host "β οΈ No Windows certificate configured, skipping signing"
+ exit 0
+ }
+
+ # Decode certificate
+ $certBytes = [Convert]::FromBase64String($env:WINDOWS_CERT_BASE64)
+ $certPath = "$(Get-Location)\code_signing.pfx"
+ [IO.File]::WriteAllBytes($certPath, $certBytes)
+
+ # 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
+
+ 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"
+ }
+
+ # Clean up certificate
+ if (Test-Path $certPath) {
+ Remove-Item $certPath -Force
+ }
+
+ - name: 'π¦ Create Windows ZIP'
+ shell: bash
+ run: |
+ TAG=${{ steps.version.outputs.tag }}
+ mkdir -p releases
+
+ cd bin
+ 7z a -tzip "../releases/TFMAudioApp-Windows-${TAG}.zip" windows-app/*
+ cd ..
+
+ ls -la releases/
+
+ - 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
- 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
+ # 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
- echo "Created archives:"
ls -la releases/
- - name: 'π Upload all release assets'
+ - 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 +379,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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/TFMAudioApp/Controls/ConfirmationPopup.xaml.cs b/TFMAudioApp/Controls/ConfirmationPopup.xaml.cs
new file mode 100644
index 0000000..fa90caf
--- /dev/null
+++ b/TFMAudioApp/Controls/ConfirmationPopup.xaml.cs
@@ -0,0 +1,113 @@
+using CommunityToolkit.Maui.Views;
+
+namespace TFMAudioApp.Controls;
+
+public partial class ConfirmationPopup : Popup
+{
+ public new bool? Result { get; private set; }
+
+ public ConfirmationPopup(
+ string title,
+ string message,
+ string acceptText = "OK",
+ string? cancelText = null,
+ string icon = "",
+ bool isDanger = false)
+ {
+ InitializeComponent();
+
+ TitleLabel.Text = title;
+ MessageLabel.Text = message;
+ AcceptButton.Text = acceptText;
+
+ if (!string.IsNullOrEmpty(icon))
+ {
+ IconLabel.Text = icon;
+ IconLabel.IsVisible = true;
+ }
+ else
+ {
+ IconLabel.IsVisible = false;
+ }
+
+ if (isDanger)
+ {
+ AcceptButton.BackgroundColor = Color.FromArgb("#FF5252");
+ }
+
+ if (string.IsNullOrEmpty(cancelText))
+ {
+ // Single button mode - hide cancel and make accept full width
+ CancelButton.IsVisible = false;
+ Grid.SetColumnSpan(AcceptButton, 3);
+ Grid.SetColumn(AcceptButton, 0);
+ }
+ else
+ {
+ CancelButton.Text = cancelText;
+ }
+ }
+
+ private void OnCancelClicked(object? sender, EventArgs e)
+ {
+ Result = false;
+ Close(false);
+ }
+
+ private void OnAcceptClicked(object? sender, EventArgs e)
+ {
+ Result = true;
+ Close(true);
+ }
+}
+
+///
+/// Helper class to show confirmation popups easily
+///
+public static class ConfirmationHelper
+{
+ ///
+ /// Show a confirmation dialog with Accept/Cancel buttons
+ ///
+ public static async Task ShowConfirmAsync(
+ string title,
+ string message,
+ string acceptText = "Yes",
+ string cancelText = "Cancel",
+ string icon = "",
+ bool isDanger = false)
+ {
+ var popup = new ConfirmationPopup(title, message, acceptText, cancelText, icon, isDanger);
+ var result = await Shell.Current.ShowPopupAsync(popup);
+ return result is true;
+ }
+
+ ///
+ /// Show an alert dialog with a single OK button
+ ///
+ public static async Task ShowAlertAsync(
+ string title,
+ string message,
+ string buttonText = "OK",
+ string icon = "")
+ {
+ var popup = new ConfirmationPopup(title, message, buttonText, null, icon);
+ await Shell.Current.ShowPopupAsync(popup);
+ }
+
+ ///
+ /// Show an error dialog
+ ///
+ public static async Task ShowErrorAsync(string title, string message)
+ {
+ await ShowAlertAsync(title, message, "OK", "");
+ }
+
+ ///
+ /// Show a success dialog
+ ///
+ public static async Task ShowSuccessAsync(string title, string message)
+ {
+ await ShowAlertAsync(title, message, "OK", "");
+ }
+}
diff --git a/TFMAudioApp/Controls/FilterPopup.xaml b/TFMAudioApp/Controls/FilterPopup.xaml
new file mode 100644
index 0000000..468f633
--- /dev/null
+++ b/TFMAudioApp/Controls/FilterPopup.xaml
@@ -0,0 +1,145 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/TFMAudioApp/Controls/FilterPopup.xaml.cs b/TFMAudioApp/Controls/FilterPopup.xaml.cs
new file mode 100644
index 0000000..f36f981
--- /dev/null
+++ b/TFMAudioApp/Controls/FilterPopup.xaml.cs
@@ -0,0 +1,271 @@
+using CommunityToolkit.Maui.Views;
+using Microsoft.Maui.Controls.Shapes;
+
+namespace TFMAudioApp.Controls;
+
+public partial class FilterPopup : Popup
+{
+ private readonly List _audioExtensions = new()
+ {
+ ".mp3", ".flac", ".ogg", ".opus", ".aac", ".wav", ".m4a", ".wma", ".ape"
+ };
+
+ private readonly List _sortOptions = new() { "Name", "Date", "Size", "Type" };
+ private readonly Dictionary _selectedExtensions = new();
+ private readonly Dictionary _extensionBorders = new();
+ private readonly Dictionary _sortBorders = new();
+ private bool _sortDescending = true;
+ private int _selectedSortIndex = 0;
+
+ public new FilterResult? Result { get; private set; }
+
+ public FilterPopup(FilterOptions? currentOptions = null)
+ {
+ InitializeComponent();
+ InitializeExtensions(currentOptions?.SelectedExtensions);
+ InitializeSortOptions(currentOptions?.SortIndex ?? 0);
+
+ if (currentOptions != null)
+ {
+ ShowFoldersSwitch.IsToggled = currentOptions.ShowFolders;
+ _sortDescending = currentOptions.SortDescending;
+ }
+
+ UpdateSortDirectionUI();
+ }
+
+ private void InitializeExtensions(List? selectedExtensions)
+ {
+ ExtensionsContainer.Children.Clear();
+ _extensionBorders.Clear();
+
+ foreach (var ext in _audioExtensions)
+ {
+ var isSelected = selectedExtensions?.Contains(ext) ?? true;
+ _selectedExtensions[ext] = isSelected;
+
+ var chip = CreateExtensionChip(ext, isSelected);
+ _extensionBorders[ext] = chip;
+ ExtensionsContainer.Children.Add(chip);
+ }
+ }
+
+ private void InitializeSortOptions(int selectedIndex)
+ {
+ SortOptionsContainer.Children.Clear();
+ _sortBorders.Clear();
+ _selectedSortIndex = selectedIndex;
+
+ for (int i = 0; i < _sortOptions.Count; i++)
+ {
+ var option = _sortOptions[i];
+ var isSelected = i == selectedIndex;
+ var chip = CreateSortChip(option, i, isSelected);
+ _sortBorders[option] = chip;
+ SortOptionsContainer.Children.Add(chip);
+ }
+ }
+
+ private Border CreateExtensionChip(string extension, bool isSelected)
+ {
+ var border = new Border
+ {
+ BackgroundColor = isSelected ? Color.FromArgb("#512BD4") : Color.FromArgb("#3D3D3D"),
+ StrokeThickness = 0,
+ Padding = new Thickness(14, 10),
+ Margin = new Thickness(0, 0, 8, 8),
+ StrokeShape = new RoundRectangle { CornerRadius = 20 }
+ };
+
+ var label = new Label
+ {
+ Text = extension.ToUpper().TrimStart('.'),
+ FontSize = 13,
+ FontAttributes = isSelected ? FontAttributes.Bold : FontAttributes.None,
+ TextColor = Colors.White,
+ VerticalOptions = LayoutOptions.Center
+ };
+
+ border.Content = label;
+
+ var tapGesture = new TapGestureRecognizer();
+ tapGesture.Tapped += (s, e) =>
+ {
+ _selectedExtensions[extension] = !_selectedExtensions[extension];
+ var selected = _selectedExtensions[extension];
+ border.BackgroundColor = selected ? Color.FromArgb("#512BD4") : Color.FromArgb("#3D3D3D");
+ ((Label)border.Content).FontAttributes = selected ? FontAttributes.Bold : FontAttributes.None;
+ };
+ border.GestureRecognizers.Add(tapGesture);
+
+ return border;
+ }
+
+ private Border CreateSortChip(string option, int index, bool isSelected)
+ {
+ var border = new Border
+ {
+ BackgroundColor = isSelected ? Color.FromArgb("#512BD4") : Color.FromArgb("#3D3D3D"),
+ StrokeThickness = 0,
+ Padding = new Thickness(16, 10),
+ Margin = new Thickness(0, 0, 8, 8),
+ StrokeShape = new RoundRectangle { CornerRadius = 20 }
+ };
+
+ var icon = option switch
+ {
+ "Name" => "Aa",
+ "Date" => "π
",
+ "Size" => "π",
+ "Type" => "π",
+ _ => ""
+ };
+
+ var stack = new HorizontalStackLayout { Spacing = 6 };
+ stack.Children.Add(new Label
+ {
+ Text = icon,
+ FontSize = 12,
+ VerticalOptions = LayoutOptions.Center
+ });
+ stack.Children.Add(new Label
+ {
+ Text = option,
+ FontSize = 13,
+ FontAttributes = isSelected ? FontAttributes.Bold : FontAttributes.None,
+ TextColor = Colors.White,
+ VerticalOptions = LayoutOptions.Center
+ });
+
+ border.Content = stack;
+
+ var tapGesture = new TapGestureRecognizer();
+ tapGesture.Tapped += (s, e) =>
+ {
+ // Deselect previous
+ foreach (var kvp in _sortBorders)
+ {
+ kvp.Value.BackgroundColor = Color.FromArgb("#3D3D3D");
+ if (kvp.Value.Content is HorizontalStackLayout sl && sl.Children.Count > 1)
+ {
+ ((Label)sl.Children[1]).FontAttributes = FontAttributes.None;
+ }
+ }
+
+ // Select current
+ _selectedSortIndex = index;
+ border.BackgroundColor = Color.FromArgb("#512BD4");
+ if (border.Content is HorizontalStackLayout stack && stack.Children.Count > 1)
+ {
+ ((Label)stack.Children[1]).FontAttributes = FontAttributes.Bold;
+ }
+ };
+ border.GestureRecognizers.Add(tapGesture);
+
+ return border;
+ }
+
+ private void UpdateSortDirectionUI()
+ {
+ if (_sortDescending)
+ {
+ DescendingBorder.BackgroundColor = Color.FromArgb("#512BD4");
+ AscendingBorder.BackgroundColor = Color.FromArgb("#3D3D3D");
+ }
+ else
+ {
+ AscendingBorder.BackgroundColor = Color.FromArgb("#512BD4");
+ DescendingBorder.BackgroundColor = Color.FromArgb("#3D3D3D");
+ }
+ }
+
+ private void OnAscendingTapped(object? sender, TappedEventArgs e)
+ {
+ _sortDescending = false;
+ UpdateSortDirectionUI();
+ }
+
+ private void OnDescendingTapped(object? sender, TappedEventArgs e)
+ {
+ _sortDescending = true;
+ UpdateSortDirectionUI();
+ }
+
+ private void OnClearAllFormatsClicked(object? sender, EventArgs e)
+ {
+ // Deselect all extensions
+ foreach (var ext in _audioExtensions)
+ {
+ _selectedExtensions[ext] = false;
+ if (_extensionBorders.TryGetValue(ext, out var border))
+ {
+ border.BackgroundColor = Color.FromArgb("#3D3D3D");
+ ((Label)border.Content).FontAttributes = FontAttributes.None;
+ }
+ }
+ }
+
+ private void OnCloseClicked(object? sender, EventArgs e)
+ {
+ Close(null);
+ }
+
+ private void OnResetClicked(object? sender, EventArgs e)
+ {
+ // Reset to defaults
+ ShowFoldersSwitch.IsToggled = true;
+ _sortDescending = true;
+ UpdateSortDirectionUI();
+
+ // Select all extensions
+ foreach (var ext in _audioExtensions)
+ {
+ _selectedExtensions[ext] = true;
+ if (_extensionBorders.TryGetValue(ext, out var border))
+ {
+ border.BackgroundColor = Color.FromArgb("#512BD4");
+ ((Label)border.Content).FontAttributes = FontAttributes.Bold;
+ }
+ }
+
+ // Reset sort to Name
+ _selectedSortIndex = 0;
+ InitializeSortOptions(0);
+ }
+
+ private void OnApplyClicked(object? sender, EventArgs e)
+ {
+ Result = new FilterResult
+ {
+ ShowFolders = ShowFoldersSwitch.IsToggled,
+ SelectedExtensions = _selectedExtensions.Where(x => x.Value).Select(x => x.Key).ToList(),
+ SortBy = _selectedSortIndex switch
+ {
+ 0 => "name",
+ 1 => "date",
+ 2 => "size",
+ 3 => "type",
+ _ => "name"
+ },
+ SortDescending = _sortDescending
+ };
+
+ Close(Result);
+ }
+}
+
+public class FilterOptions
+{
+ public bool ShowFolders { get; set; } = true;
+ public List? SelectedExtensions { get; set; }
+ public int SortIndex { get; set; }
+ public bool SortDescending { get; set; } = true;
+}
+
+public class FilterResult
+{
+ public bool ShowFolders { get; set; }
+ public List SelectedExtensions { get; set; } = new();
+ public string SortBy { get; set; } = "name";
+ public bool SortDescending { get; set; }
+}
diff --git a/TFMAudioApp/Controls/MiniPlayerControl.xaml b/TFMAudioApp/Controls/MiniPlayerControl.xaml
new file mode 100644
index 0000000..2293990
--- /dev/null
+++ b/TFMAudioApp/Controls/MiniPlayerControl.xaml
@@ -0,0 +1,184 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/TFMAudioApp/Controls/MiniPlayerControl.xaml.cs b/TFMAudioApp/Controls/MiniPlayerControl.xaml.cs
new file mode 100644
index 0000000..44129e5
--- /dev/null
+++ b/TFMAudioApp/Controls/MiniPlayerControl.xaml.cs
@@ -0,0 +1,256 @@
+using TFMAudioApp.Services.Interfaces;
+
+namespace TFMAudioApp.Controls;
+
+public partial class MiniPlayerControl : ContentView
+{
+ private IAudioPlayerService? _playerService;
+
+ #region Bindable Properties
+
+ public static readonly BindableProperty TrackNameProperty =
+ BindableProperty.Create(nameof(TrackName), typeof(string), typeof(MiniPlayerControl), "Not Playing");
+
+ public static readonly BindableProperty ArtistNameProperty =
+ BindableProperty.Create(nameof(ArtistName), typeof(string), typeof(MiniPlayerControl), "");
+
+ public static readonly BindableProperty PlayPauseIconProperty =
+ BindableProperty.Create(nameof(PlayPauseIcon), typeof(string), typeof(MiniPlayerControl), ">");
+
+ public static readonly BindableProperty ProgressProperty =
+ BindableProperty.Create(nameof(Progress), typeof(double), typeof(MiniPlayerControl), 0.0);
+
+ public static readonly BindableProperty CanSeekProperty =
+ BindableProperty.Create(nameof(CanSeek), typeof(bool), typeof(MiniPlayerControl), true);
+
+ public static readonly BindableProperty IsPlayerVisibleProperty =
+ BindableProperty.Create(nameof(IsPlayerVisible), typeof(bool), typeof(MiniPlayerControl), false,
+ propertyChanged: OnIsPlayerVisibleChanged);
+
+ public string TrackName
+ {
+ get => (string)GetValue(TrackNameProperty);
+ set => SetValue(TrackNameProperty, value);
+ }
+
+ public string ArtistName
+ {
+ get => (string)GetValue(ArtistNameProperty);
+ set => SetValue(ArtistNameProperty, value);
+ }
+
+ public string PlayPauseIcon
+ {
+ get => (string)GetValue(PlayPauseIconProperty);
+ set => SetValue(PlayPauseIconProperty, value);
+ }
+
+ public double Progress
+ {
+ get => (double)GetValue(ProgressProperty);
+ set => SetValue(ProgressProperty, value);
+ }
+
+ public bool CanSeek
+ {
+ get => (bool)GetValue(CanSeekProperty);
+ set => SetValue(CanSeekProperty, value);
+ }
+
+ public bool IsPlayerVisible
+ {
+ get => (bool)GetValue(IsPlayerVisibleProperty);
+ set => SetValue(IsPlayerVisibleProperty, value);
+ }
+
+ private static void OnIsPlayerVisibleChanged(BindableObject bindable, object oldValue, object newValue)
+ {
+ if (bindable is MiniPlayerControl control)
+ {
+ control.IsVisible = (bool)newValue;
+ }
+ }
+
+ #endregion
+
+ public MiniPlayerControl()
+ {
+ InitializeComponent();
+ IsVisible = false;
+ }
+
+ protected override void OnHandlerChanged()
+ {
+ base.OnHandlerChanged();
+
+ if (Handler != null)
+ {
+ // Get service after control is loaded
+ InitializePlayerService();
+ }
+ }
+
+ private void InitializePlayerService()
+ {
+ System.Diagnostics.Debug.WriteLine($"[MiniPlayer] InitializePlayerService called");
+ _playerService = Application.Current?.Handler?.MauiContext?.Services.GetService();
+
+ if (_playerService != null)
+ {
+ System.Diagnostics.Debug.WriteLine($"[MiniPlayer] Got player service, subscribing to events");
+ // Subscribe to events
+ _playerService.TrackChanged += OnTrackChanged;
+ _playerService.StateChanged += OnStateChanged;
+ _playerService.PositionChanged += OnPositionChanged;
+ _playerService.DurationChanged += OnDurationChanged;
+
+ // Update UI with current state
+ UpdateUI();
+ }
+ else
+ {
+ System.Diagnostics.Debug.WriteLine($"[MiniPlayer] Player service is NULL!");
+ }
+ }
+
+ private void OnTrackChanged(object? sender, Models.Track? track)
+ {
+ MainThread.BeginInvokeOnMainThread(() =>
+ {
+ if (track != null)
+ {
+ TrackName = track.DisplayName;
+ ArtistName = track.DisplayArtist;
+ CanSeek = true; // Always allow seeking (server supports progressive streaming)
+ IsPlayerVisible = true;
+ }
+ // Don't hide when track is null - user might have stopped playback
+ // but still wants to see the mini player with last track info
+ });
+ }
+
+ private void OnStateChanged(object? sender, PlaybackState state)
+ {
+ System.Diagnostics.Debug.WriteLine($"[MiniPlayer] OnStateChanged received: {state}");
+ MainThread.BeginInvokeOnMainThread(() =>
+ {
+ // Show pause icon for Playing, Buffering, and Loading states
+ // Loading = waiting for server to download from Telegram, but user can cancel
+ var isPlaying = state == PlaybackState.Playing ||
+ state == PlaybackState.Buffering ||
+ state == PlaybackState.Loading;
+ System.Diagnostics.Debug.WriteLine($"[MiniPlayer] Setting PlayPause icons, isPlaying={isPlaying}");
+ UpdatePlayPauseIcon(isPlaying);
+ });
+ }
+
+ private void UpdatePlayPauseIcon(bool isPlaying)
+ {
+ // Toggle between play and pause icons
+ PlayIcon.IsVisible = !isPlaying;
+ PauseIcon.IsVisible = isPlaying;
+ }
+
+ private void OnPositionChanged(object? sender, TimeSpan position)
+ {
+ // Don't update progress while user is seeking
+ if (_isSeeking) return;
+
+ MainThread.BeginInvokeOnMainThread(() =>
+ {
+ if (_playerService?.Duration.TotalSeconds > 0)
+ {
+ Progress = position.TotalSeconds / _playerService.Duration.TotalSeconds;
+ }
+ });
+ }
+
+ private void OnDurationChanged(object? sender, TimeSpan duration)
+ {
+ // Duration updated, progress calculation will use this
+ }
+
+ private void UpdateUI()
+ {
+ System.Diagnostics.Debug.WriteLine($"[MiniPlayer] UpdateUI called");
+ if (_playerService == null)
+ {
+ System.Diagnostics.Debug.WriteLine($"[MiniPlayer] _playerService is null!");
+ return;
+ }
+
+ var track = _playerService.CurrentTrack;
+ if (track != null)
+ {
+ TrackName = track.DisplayName;
+ ArtistName = track.DisplayArtist;
+ CanSeek = true; // Always allow seeking (server supports progressive streaming)
+ IsPlayerVisible = true;
+
+ // Update progress
+ if (_playerService.Duration.TotalSeconds > 0)
+ {
+ Progress = _playerService.Position.TotalSeconds / _playerService.Duration.TotalSeconds;
+ }
+ }
+
+ // Always update play/pause icon based on current state
+ var isPlaying = _playerService.IsPlaying;
+ System.Diagnostics.Debug.WriteLine($"[MiniPlayer] UpdateUI: IsPlaying={isPlaying}");
+ UpdatePlayPauseIcon(isPlaying);
+
+ // Show mini player if there's a track or if there are items in queue
+ if (track != null || _playerService.Queue.Count > 0)
+ {
+ IsPlayerVisible = true;
+ }
+ }
+
+ private async void OnPlayPauseClicked(object? sender, EventArgs e)
+ {
+ if (_playerService == null) return;
+ await _playerService.TogglePlayPauseAsync();
+ }
+
+ private async void OnPreviousClicked(object? sender, EventArgs e)
+ {
+ if (_playerService == null) return;
+ await _playerService.PreviousAsync();
+ }
+
+ private async void OnNextClicked(object? sender, EventArgs e)
+ {
+ if (_playerService == null) return;
+ await _playerService.NextAsync();
+ }
+
+ private async void OnPlayerTapped(object? sender, TappedEventArgs e)
+ {
+ // Navigate to full player page
+ await Shell.Current.GoToAsync("player");
+ }
+
+ private bool _isSeeking;
+
+ private void OnSeekStarted(object? sender, EventArgs e)
+ {
+ _isSeeking = true;
+ }
+
+ private void OnSeekCompleted(object? sender, EventArgs e)
+ {
+ _isSeeking = false;
+
+ if (_playerService == null) return;
+
+ // Seeking is always allowed (server supports progressive streaming)
+
+ var duration = _playerService.Duration;
+ if (duration.TotalSeconds > 0)
+ {
+ var targetPosition = TimeSpan.FromSeconds(duration.TotalSeconds * ProgressSlider.Value);
+ System.Diagnostics.Debug.WriteLine($"[MiniPlayer] Seeking to {targetPosition}");
+ _ = _playerService.SeekAsync(targetPosition);
+ }
+ }
+}
diff --git a/TFMAudioApp/Controls/OptionsBottomSheet.xaml b/TFMAudioApp/Controls/OptionsBottomSheet.xaml
new file mode 100644
index 0000000..88f49ee
--- /dev/null
+++ b/TFMAudioApp/Controls/OptionsBottomSheet.xaml
@@ -0,0 +1,210 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/TFMAudioApp/Controls/OptionsBottomSheet.xaml.cs b/TFMAudioApp/Controls/OptionsBottomSheet.xaml.cs
new file mode 100644
index 0000000..dcd5c53
--- /dev/null
+++ b/TFMAudioApp/Controls/OptionsBottomSheet.xaml.cs
@@ -0,0 +1,42 @@
+namespace TFMAudioApp.Controls;
+
+public partial class OptionsBottomSheet : ContentView
+{
+ public event EventHandler? OptionSelected;
+ public event EventHandler? Dismissed;
+
+ public OptionsBottomSheet()
+ {
+ InitializeComponent();
+ }
+
+ private void OnBackgroundTapped(object? sender, TappedEventArgs e)
+ {
+ Dismissed?.Invoke(this, EventArgs.Empty);
+ }
+
+ private void OnAddToPlaylistTapped(object? sender, TappedEventArgs e)
+ {
+ OptionSelected?.Invoke(this, "AddToPlaylist");
+ }
+
+ private void OnViewQueueTapped(object? sender, TappedEventArgs e)
+ {
+ OptionSelected?.Invoke(this, "ViewQueue");
+ }
+
+ private void OnShareTapped(object? sender, TappedEventArgs e)
+ {
+ OptionSelected?.Invoke(this, "Share");
+ }
+
+ private void OnStopTapped(object? sender, TappedEventArgs e)
+ {
+ OptionSelected?.Invoke(this, "Stop");
+ }
+
+ private void OnCancelTapped(object? sender, TappedEventArgs e)
+ {
+ Dismissed?.Invoke(this, EventArgs.Empty);
+ }
+}
diff --git a/TFMAudioApp/Controls/PlaylistPickerPopup.xaml b/TFMAudioApp/Controls/PlaylistPickerPopup.xaml
new file mode 100644
index 0000000..6a3c83d
--- /dev/null
+++ b/TFMAudioApp/Controls/PlaylistPickerPopup.xaml
@@ -0,0 +1,143 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/TFMAudioApp/Controls/PlaylistPickerPopup.xaml.cs b/TFMAudioApp/Controls/PlaylistPickerPopup.xaml.cs
new file mode 100644
index 0000000..cb40469
--- /dev/null
+++ b/TFMAudioApp/Controls/PlaylistPickerPopup.xaml.cs
@@ -0,0 +1,76 @@
+using CommunityToolkit.Maui.Views;
+using TFMAudioApp.Models;
+
+namespace TFMAudioApp.Controls;
+
+public partial class PlaylistPickerPopup : Popup
+{
+ private readonly List _allPlaylists;
+ private Playlist? _selectedPlaylist;
+
+ public new PlaylistPickerResult? Result { get; private set; }
+
+ public PlaylistPickerPopup(List playlists, string trackName)
+ {
+ InitializeComponent();
+
+ _allPlaylists = playlists;
+ TrackNameLabel.Text = trackName;
+ PlaylistsCollection.ItemsSource = playlists;
+
+ if (playlists.Count == 0)
+ {
+ EmptyLabel.Text = "No playlists yet. Create one!";
+ }
+ }
+
+ private void OnSearchTextChanged(object? sender, TextChangedEventArgs e)
+ {
+ var searchText = e.NewTextValue?.ToLowerInvariant() ?? string.Empty;
+
+ if (string.IsNullOrWhiteSpace(searchText))
+ {
+ PlaylistsCollection.ItemsSource = _allPlaylists;
+ }
+ else
+ {
+ PlaylistsCollection.ItemsSource = _allPlaylists
+ .Where(p => p.Name.ToLowerInvariant().Contains(searchText))
+ .ToList();
+ }
+ }
+
+ private void OnPlaylistSelected(object? sender, SelectionChangedEventArgs e)
+ {
+ if (e.CurrentSelection.FirstOrDefault() is Playlist playlist)
+ {
+ _selectedPlaylist = playlist;
+ Result = new PlaylistPickerResult
+ {
+ SelectedPlaylist = playlist,
+ CreateNew = false
+ };
+ Close(Result);
+ }
+ }
+
+ private void OnCloseClicked(object? sender, EventArgs e)
+ {
+ Close(null);
+ }
+
+ private void OnCreatePlaylistClicked(object? sender, EventArgs e)
+ {
+ Result = new PlaylistPickerResult
+ {
+ CreateNew = true
+ };
+ Close(Result);
+ }
+}
+
+public class PlaylistPickerResult
+{
+ public Playlist? SelectedPlaylist { get; set; }
+ public bool CreateNew { get; set; }
+}
diff --git a/TFMAudioApp/Controls/QueuePopup.xaml b/TFMAudioApp/Controls/QueuePopup.xaml
new file mode 100644
index 0000000..3a2087e
--- /dev/null
+++ b/TFMAudioApp/Controls/QueuePopup.xaml
@@ -0,0 +1,150 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/TFMAudioApp/Controls/QueuePopup.xaml.cs b/TFMAudioApp/Controls/QueuePopup.xaml.cs
new file mode 100644
index 0000000..0a1a605
--- /dev/null
+++ b/TFMAudioApp/Controls/QueuePopup.xaml.cs
@@ -0,0 +1,58 @@
+using CommunityToolkit.Maui.Views;
+using TFMAudioApp.Models;
+
+namespace TFMAudioApp.Controls;
+
+public partial class QueuePopup : Popup
+{
+ public event EventHandler? ClearQueueRequested;
+ public event EventHandler? ShuffleRequested;
+
+ public QueuePopup(IList