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 @@ + + + + + + + + + + + +