diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..20bb1f2 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,125 @@ +name: .NET Build + Publish + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build-windows: + runs-on: windows-2022 + + permissions: + contents: write + + outputs: + version-number: ${{ steps.version.outputs.number}} + + steps: + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + - name: Restore dependencies + run: dotnet restore + - name: Build + run: dotnet build --no-restore -p:PostBuildEvent= + - name: Publish Windows 64bit + if: ${{ github.event_name != 'pull_request' }} + run: dotnet publish --os win --arch x64 -c Release --self-contained false MSUScripter/MSUScripter.csproj + - name: Publish Windows 32bit + if: ${{ github.event_name != 'pull_request' }} + run: dotnet publish --os win --arch x86 -c Release --self-contained false MSUScripter/MSUScripter.csproj + - name: Get version number + if: ${{ github.event_name != 'pull_request' }} + id: version + run: | + $version = (Get-Item "MSUScripter\bin\Release\net9.0\win-x86\publish\MSUScripter.exe").VersionInfo.ProductVersion + $version = $version.Split("+")[0] + Write-Host $version + Write-Output "number=$version" >> $env:GITHUB_OUTPUT + shell: pwsh + - name: Building the Windows installer + if: ${{ github.event_name != 'pull_request' }} + run: '"%programfiles(x86)%/Inno Setup 6/iscc.exe" "Setup/MSUScripter.iss"' + shell: cmd + - name: Upload artifact + uses: actions/upload-artifact@v4 + if: ${{ github.event_name != 'pull_request' }} + with: + path: "setup/Output/*" + name: MSUScripterWindows + + build-linux: + runs-on: ubuntu-22.04 + if: ${{ github.event_name != 'pull_request' }} + needs: [build-windows] + + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + - name: Update VersionOverride in source file + run: | + pwd + VERSION="${{ needs.build-windows.outputs.version-number }}" + BASE_VERSION="${VERSION%%-*}" + FILE="MSUScripter/App.axaml.cs" + sed -i -E "s|^[[:space:]]*private static readonly string\?[[:space:]]+VersionOverride[[:space:]]*=[[:space:]]*null;|private static readonly string? VersionOverride = \"${VERSION}\";|" "$FILE" + sed -i "s/^AppVersionRelease *= *.*/AppVersionRelease = ${BASE_VERSION}/" Setup/AppImage.pupnet.conf + echo "Updated VersionOverride to: ${VERSION}" + - name: Install PupNet + run: dotnet tool install -g KuiperZone.PupNet + - name: Download AppImageTool + run: | + wget -P "$HOME/.local/bin" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage" + chmod +x "$HOME/.local/bin/appimagetool-x86_64.AppImage" + appimagetool-x86_64.AppImage --version + - name: Run PupNet + run: pupnet Setup/AppImage.pupnet.conf --kind appimage -y + - name: Upload artifact + uses: actions/upload-artifact@v4 + if: ${{ github.event_name != 'pull_request' }} + with: + path: "Setup/Output/MSUScripter*" + name: MSUScripterLinux + + package: + runs-on: ubuntu-22.04 + needs: [build-windows, build-linux] + if: ${{ github.event_name != 'pull_request' }} + + permissions: + contents: write + + steps: + - uses: actions/download-artifact@v5 + with: + name: MSUScripterWindows + path: out + - uses: actions/download-artifact@v5 + with: + name: MSUScripterLinux + path: out + - name: Extract some files + run: | + ls -alR + - name: Upload artifact + uses: actions/upload-artifact@v4 + if: ${{ github.event_name != 'pull_request' }} + with: + path: "out/*" + name: MSUScripter_${{ needs.build-windows.outputs.version-number }} + - name: Delete old artifacts + uses: geekyeggo/delete-artifact@v5 + with: + name: | + MSUScripterWindows + MSUScripterLinux diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml deleted file mode 100644 index 2ecfcb5..0000000 --- a/.github/workflows/dotnet.yml +++ /dev/null @@ -1,58 +0,0 @@ -name: .NET Build + Publish - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - build: - runs-on: windows-latest - - permissions: - contents: write - - steps: - - uses: actions/checkout@v4 - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 9.0.x - - name: Restore dependencies - run: dotnet restore - - name: Build - run: dotnet build --no-restore -p:PostBuildEvent= - - name: Publish Windows 64bit - if: ${{ github.event_name != 'pull_request' }} - run: dotnet publish --os win --arch x64 -c Release --self-contained false MSUScripter/MSUScripter.csproj - - name: Publish Windows 32bit - if: ${{ github.event_name != 'pull_request' }} - run: dotnet publish --os win --arch x86 -c Release --self-contained false MSUScripter/MSUScripter.csproj - - name: Publish Linux 64bit - if: ${{ github.event_name != 'pull_request' }} - run: dotnet publish --os linux --arch x64 -c Release --self-contained false MSUScripter/MSUScripter.csproj - - name: Get version number - if: ${{ github.event_name != 'pull_request' }} - id: version - run: | - $version = (Get-Item "MSUScripter\bin\Release\net9.0\win-x86\publish\MSUScripter.exe").VersionInfo.ProductVersion - $version = $version.Split("+")[0] - Write-Host $version - Write-Output "number=$version" >> $env:GITHUB_OUTPUT - shell: pwsh - - name: Building the Windows installer - if: ${{ github.event_name != 'pull_request' }} - run: '"%programfiles(x86)%/Inno Setup 6/iscc.exe" "setup/MSUScripter.iss"' - shell: cmd - - name: Building the Linux 64bit package - if: ${{ github.event_name != 'pull_request' }} - working-directory: setup - run: "./LinuxBuildZipper.ps1" - shell: pwsh - - name: Upload artifact - uses: actions/upload-artifact@v4 - if: ${{ github.event_name != 'pull_request' }} - with: - path: "setup/Output/*" - name: MSUScripter_${{ steps.version.outputs.number }} diff --git a/.gitignore b/.gitignore index 78fdf50..9f5fb0d 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ bld/ [Oo]bj/ [Ll]og/ [Ll]ogs/ +[Dd]eploy/ # Visual Studio 2015/2017 cache/options directory .vs/ @@ -396,5 +397,6 @@ FodyWeavers.xsd # JetBrains Rider *.sln.iml +.idea/**/workspace.xml -Setup/output/* \ No newline at end of file +[Ss]etup/[Oo]utput/* \ No newline at end of file diff --git a/.idea/.idea.MSUScripter/.idea/.gitignore b/.idea/.idea.MSUScripter/.idea/.gitignore index 6a40d01..44cf8ba 100644 --- a/.idea/.idea.MSUScripter/.idea/.gitignore +++ b/.idea/.idea.MSUScripter/.idea/.gitignore @@ -2,10 +2,10 @@ /shelf/ /workspace.xml # Rider ignored files -/projectSettingsUpdater.xml /modules.xml -/.idea.MSUScripter.iml /contentModel.xml +/projectSettingsUpdater.xml +/.idea.MSUScripter.iml # Editor-based HTTP Client requests /httpRequests/ # Datasource local storage ignored files diff --git a/.idea/.idea.MSUScripter/.idea/avalonia.xml b/.idea/.idea.MSUScripter/.idea/avalonia.xml index 662d442..b0eb439 100644 --- a/.idea/.idea.MSUScripter/.idea/avalonia.xml +++ b/.idea/.idea.MSUScripter/.idea/avalonia.xml @@ -4,62 +4,35 @@ diff --git a/.idea/.idea.MSUScripter/.idea/projectSettingsUpdater.xml b/.idea/.idea.MSUScripter/.idea/projectSettingsUpdater.xml new file mode 100644 index 0000000..ef20cb0 --- /dev/null +++ b/.idea/.idea.MSUScripter/.idea/projectSettingsUpdater.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/.idea/.idea.MSUScripter/.idea/riderPublish.xml b/.idea/.idea.MSUScripter/.idea/riderPublish.xml deleted file mode 100644 index df325f2..0000000 --- a/.idea/.idea.MSUScripter/.idea/riderPublish.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/Docs/changelog.md b/Docs/changelog.md new file mode 100644 index 0000000..26df81d --- /dev/null +++ b/Docs/changelog.md @@ -0,0 +1,67 @@ +# MSU Scripter 5.0.0 + +Release 5.0.0 introduces some major overhauls to the MSU Scripter in order to make the experience better for users. + +## Updated UI + +The UI has been completely redesigned to allow you to make things easier and more streamlined. + +### Main Starting Window + +image + +The main starting window has been redone to have tabs for the creating new projects, opening projects, and changing settings. +The first time launching, it will default to the new project tab, which now allows you to populate additional fields before creating the MSU project than before. +When launching the MSU Scripter after creating a project, it will default to the open project tab. + +### MSU Project Window + +image + +After opening a project, the list of all tracks and songs is now displayed on the left. +Using this left panel, you can select the track you want to view, add songs to tracks, and even move songs around between different tracks. +You can easily search for specific tracks as well as customize the view to show additional icons to easily see the status of the project. + +### Basic vs Advanced Song Views + +image + +When editing the details of a song, you can choose to either use the basic view with integrated PyMusicLooper that will only show the most commonly edited fields, +or you can use the advanced view which has all MsuPcm++ fields accessible. The advanced view has a panel similar to the track panel that allows you to add, copy, +and move sub tracks and sub channels easily. By default the MSU Scripter will ask each time which view you want to use, but you can select a default view if desired. + +The buttons for playing the song and the audio controls are now always accessible at the bottom when viewing a song as well. + +## Dependency Installation + +image + +A common issue people have ran into has been getting some of the dependencies installed such as PyMusicLooper and the YouTube video creation application. +In order to help alleviate that, the MSU Scripter when first launching will check for dependencies and offer to install them for you. This will install +portable versions of Python and ffmpeg. If you'd like to avoid the extra space, you can still install the dependencies manually by following the +install documentation. + +## Better Linux Support + +Previously the Linux version of the MSU Scripter was limited in functionality. You were unable to jump to to specific parts of songs while playing them, +it did not provide any warnings regarding the sample rate, and you had to manually install dotnet to get it to run. Going forward the Linux version is +now being released as an AppImage file, so dotnet will no longer be a required pre-requisite to run the MSU Scripter. + +The AppImage file has been tested onto Linux Mint 21 (based on Ubuntu 22.04), Linux Mint Debian Edition 6 (based on Debian Bookworm), EndeavourOS +(based on Arch), and Fedora. When first starting, the application will offer to create a Desktop file to add it to your desktop environment's menu. + +## Miscellaneous Changes and Fixes + +- Pressing space bar after clicking the button to play a song will now pause playing songs. +- An additional track list format has been added. You can select "album - song (artist)", "song by artist (album)", and the "table" formats. +- Fixed an issue where packaging MSUs into a zip file was adding in files that were no longer selected to be added. +- Dither has been added as a per track option. If this is enabled, you will no longer be able to generate a tracks.json to send to other people to generate the MSU. +- Fixed an issue where the file inputs would allow you to type into the them. +- For non-looped tracks, the audio player will add a small pause before replaying from the beginning. +- Fixed an issue where clicking prev in the PyMusicLooper panel would prevent you from clicking next again. +- Fixed a crash that would occur when running PyMusicLooper and the starting samples would filter out all results. +- Fixed an issue where pausing, moving the play tracker location, and resuming play would play a few incorrect samples before playing from the correct location. +- Lowered the memory footprint that used to occur when changing tracks/songs (fixed by UI rewrite) +- Fixed an issue where sometimes you would scroll accidentally down the page after entering values (fixed by UI redesign) +- The MSU scripter sould now auto set itself as the default application for .msup (MSU Scripter Project) files +- The pending changes window when closing a project now allows you to save diff --git a/Docs/install.md b/Docs/install.md new file mode 100644 index 0000000..e564cae --- /dev/null +++ b/Docs/install.md @@ -0,0 +1,39 @@ +# Installation Instructions + +## Basic Installation + +### Windows + +1. Download the MSUScripterSetupWin file from the [latest release](https://github.com/MattEqualsCoder/MSUScripter/releases) +2. Run the executable to install +3. Install all dependencies in the dependency installer window + +### Linux + +1. Download the MSUScripter AppImage file from the [latest release](https://github.com/MattEqualsCoder/MSUScripter/releases) +2. Place the AppImage file where you'd like it to exist on your computer and make the file executable +3. Install all dependencies in the dependency installer window + +## Manual Dependency Installation + +With the exception of msupcm++, you can install the dependencies manually. This can be useful to preserve space if you are going to use FFmpeg or Python for other things. + +### FFmpeg + +FFmpeg is used by the Python Companion App to create YouTube videos for testing for copyright strikes. + +1. Download [FFmpeg](https://www.ffmpeg.org/download.html) +2. Make sure FFmpeg is available in your PATH +3. To verify you should be able to open up a new terminal/command prompt window and simply type in `ffmpeg -version` to get a response + +### Python Companion App + +The Python Companion App combines both PyMusicLooper and the copyright test video creator. It is not needed for basic MsuPcm++ usage. + +1. Download and install [Python](https://www.python.org/downloads/) (not needed on Linux) + - Make sure you select the option to add it to the path. +2. Download and install [pipx](https://pipx.pypa.io/stable/installation/) + - Make sure to run `pipx ensurepath` +3. Run the command `pipx install py-msu-scripter-app` + - If preferred, you can also install pip and install via `python -m pip install py-msu-scripter-app` or `pip install py-msu-scripter-app` based on your environment +4. Verify it's installed by running either `py-msu-scripter-app --version` or `python -m py-msu-scripter-app --version` diff --git a/LICENSE b/LICENSE index edd5502..6503052 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023 MattEqualsCoder +Copyright (c) 2023-2025 MattEqualsCoder Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/MSUScripter/App.axaml b/MSUScripter/App.axaml index 39674d6..d3e93ab 100644 --- a/MSUScripter/App.axaml +++ b/MSUScripter/App.axaml @@ -21,6 +21,7 @@ + \ No newline at end of file diff --git a/MSUScripter/App.axaml.cs b/MSUScripter/App.axaml.cs index 5e4afe9..73dc681 100644 --- a/MSUScripter/App.axaml.cs +++ b/MSUScripter/App.axaml.cs @@ -1,5 +1,7 @@ using System.Diagnostics; using System.Reflection; +using System.Runtime.Versioning; +using AppImageManager; using Avalonia; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; @@ -7,20 +9,25 @@ using AvaloniaControls.Controls; using Microsoft.Extensions.DependencyInjection; using MSUScripter.Configs; +using MSUScripter.Models; using MSUScripter.Views; namespace MSUScripter; -public partial class App : Application +public class App : Application { public static MainWindow MainWindow = null!; + public const string AppId = "org.mattequalscoder.msuscripter"; + public const string AppName = "MSU Scripter"; + + private static readonly string? VersionOverride = null; public static string Version { get { var version = FileVersionInfo.GetVersionInfo(Assembly.GetEntryAssembly()!.Location); - return (version.ProductVersion ?? "").Split("+")[0]; + return VersionOverride ?? (version.ProductVersion ?? "").Split("+")[0]; } } @@ -43,4 +50,13 @@ public override void OnFrameworkInitializationCompleted() base.OnFrameworkInitializationCompleted(); } + + [SupportedOSPlatform("linux")] + internal static CreateDesktopFileResponse BuildLinuxDesktopFile() + { + return new DesktopFileBuilder(AppId, AppName) + .AddUninstallAction(Directories.BaseFolder) + .WithMimeType("application/x-msu-scripter-project", "MSU Scripter Project", "*.msup", true) + .Build(); + } } \ No newline at end of file diff --git a/MSUScripter/empty.pcm b/MSUScripter/Assets/empty.pcm similarity index 100% rename from MSUScripter/empty.pcm rename to MSUScripter/Assets/empty.pcm diff --git a/MSUScripter/msu-randomizer-settings.yaml b/MSUScripter/Assets/msu-randomizer-settings.yaml similarity index 100% rename from MSUScripter/msu-randomizer-settings.yaml rename to MSUScripter/Assets/msu-randomizer-settings.yaml diff --git a/MSUScripter/Configs/DitherType.cs b/MSUScripter/Configs/DitherType.cs new file mode 100644 index 0000000..f984a41 --- /dev/null +++ b/MSUScripter/Configs/DitherType.cs @@ -0,0 +1,21 @@ +using System.ComponentModel; + +namespace MSUScripter.Configs; + +public enum DitherType +{ + [Description("")] + Default, + + [Description("All Tracks")] + All, + + [Description("No Tracks")] + None, + + [Description("Per Track (Default On)")] + DefaultOn, + + [Description("Per Track (Default Off)")] + DefaultOff, +} \ No newline at end of file diff --git a/MSUScripter/Configs/MsuBasicInfo.cs b/MSUScripter/Configs/MsuBasicInfo.cs index bb0fc15..1db3fa6 100644 --- a/MSUScripter/Configs/MsuBasicInfo.cs +++ b/MSUScripter/Configs/MsuBasicInfo.cs @@ -14,13 +14,17 @@ public class MsuBasicInfo public string? Url { get; set; } public double? Normalization { get; set; } public bool? Dither { get; set; } + public DitherType DitherType { get; set; } = DitherType.Default; + public bool HasSeenDitherWarning { get; set; } public bool IsMsuPcmProject { get; set; } = true; public bool CreateAltSwapperScript { get; set; } = true; public bool CreateSplitSmz3Script { get; set; } - public string TrackList { get; set; } = TrackListType.List; + public string TrackList { get; } = TrackListTypeDeprecated.List; + public TrackList TrackListType { get; set; } = Configs.TrackList.ListAlbumFirst; public bool WriteYamlFile { get; set; } = true; public string? ZeldaMsuPath { get; set; } public string? MetroidMsuPath { get; set; } public bool IsSmz3Project { get; set; } + public bool? IncludeJson { get; set; } public DateTime LastModifiedDate { get; set; } } \ No newline at end of file diff --git a/MSUScripter/Configs/MsuProject.cs b/MSUScripter/Configs/MsuProject.cs index b2c57cb..7ae358d 100644 --- a/MSUScripter/Configs/MsuProject.cs +++ b/MSUScripter/Configs/MsuProject.cs @@ -1,26 +1,97 @@ using System; using System.Collections.Generic; +using System.IO; using MSURandomizerLibrary.Configs; using MSUScripter.Models; -using MSUScripter.Tools; using YamlDotNet.Serialization; namespace MSUScripter.Configs; public class MsuProject { + public string Id { get; set; } = ""; public string ProjectFilePath { get; set; } = ""; public string BackupFilePath { get; set; } = ""; public string MsuPath { get; set; } = ""; public string MsuTypeName { get; set; } = ""; public DateTime LastSaveTime { get; set; } - public List IgnoreWarnings { get; set; } = new(); + public List IgnoreWarnings { get; set; } = []; [YamlIgnore, SkipConvert] public MsuType MsuType { get; set; } = null!; [SkipConvert] - public MsuBasicInfo BasicInfo { get; set; } = new(); + public MsuBasicInfo BasicInfo { get; init; } = new(); [SkipConvert] - public List Tracks { get; set; } = new(); + public List Tracks { get; set; } = []; + public Dictionary SampleRates { get; set; } = []; [YamlIgnore, SkipConvert] public bool IsNewProject { get; set; } + + public string GetMsuGenerationCacheFilePath() + { + return Path.Combine(Directories.CacheFolder, "Generation", $"{Id}.yml"); + } + + public string GetMsuGenerationTempFilePath(MsuSongInfo? song = null) + { + return song == null + ? Path.Combine(Directories.TempFolder, "Generation", Id, Guid.NewGuid().ToString("N")) + : Path.Combine(Directories.TempFolder, "Generation", Id, song.Id); + } + + public string GetYamlPath() + { + return Path.ChangeExtension(MsuPath, ".yml"); + } + + public string GetMetroidMsuPath() + { + return BasicInfo.MetroidMsuPath ?? ""; + } + + public string GetZeldaMsuPath() + { + return BasicInfo.ZeldaMsuPath ?? ""; + } + + public string GetMetroidMsuYamlPath() + { + return Path.ChangeExtension(GetMetroidMsuPath(), ".yml"); + } + + public string GetZeldaMsuYamlPath() + { + return Path.ChangeExtension(GetZeldaMsuPath(), ".yml"); + } + + public string GetTracksJsonPath() + { + var fileInfo = new FileInfo(MsuPath); + return Path.Combine(fileInfo.DirectoryName ?? "", "tracks.json"); + } + + public string GetTracksTextPath() + { + var msuFileInfo = new FileInfo(MsuPath); + return Path.Combine(msuFileInfo.DirectoryName!, "Track List.txt"); + } + + public string GetAltSwapperPath() + { + var fileInfo = new FileInfo(MsuPath); + return Path.Combine(fileInfo.DirectoryName ?? "", "!Swap_Alt_Tracks.bat"); + } + + public string GetSmz3SwapperPath() + { + var fileInfo = new FileInfo(MsuPath); + return Path.Combine(fileInfo.DirectoryName ?? "", "!Split_Or_Combine_SMZ3_ALttP_SM_MSUs.bat"); + } + + [YamlIgnore, SkipConvert] public MsuProjectGenerationCache GenerationCache { get; set; } = new(); +} + +public class FileSampleInfo +{ + public long FileLength { get; init; } + public int SampleRate { get; init; } } \ No newline at end of file diff --git a/MSUScripter/Configs/MsuSongInfo.cs b/MSUScripter/Configs/MsuSongInfo.cs index 4ac614c..5fd0184 100644 --- a/MSUScripter/Configs/MsuSongInfo.cs +++ b/MSUScripter/Configs/MsuSongInfo.cs @@ -7,8 +7,9 @@ namespace MSUScripter.Configs; [Description("Details about a song for YAML and PCM generation")] public class MsuSongInfo { - [JsonSchemaIgnore] - public int TrackNumber { get; set; } + [JsonSchemaIgnore] public string Id { get; set; } = ""; + + [JsonSchemaIgnore] public int TrackNumber { get; set; } [JsonSchemaIgnore] public string? TrackName { get; set; } @@ -42,15 +43,20 @@ public class MsuSongInfo [JsonSchemaIgnore] public bool IsComplete { get; set; } - - [JsonSchemaIgnore] - public bool ShowPanel { get; set; } = true; - + [JsonSchemaIgnore] public DateTime LastModifiedDate { get; set; } [JsonSchemaIgnore] public DateTime LastGeneratedDate { get; set; } - + [JsonSchemaIgnore] + public bool? DisplayAdvancedMode { get; set; } + + public bool HasAudioFiles() => MsuPcmInfo.HasFiles(); + + public bool HasChangesSince(DateTime time) + { + return MsuPcmInfo.HasChangesSince(time) || LastModifiedDate > time; + } } \ No newline at end of file diff --git a/MSUScripter/Configs/MsuSongMsuPcmInfo.cs b/MSUScripter/Configs/MsuSongMsuPcmInfo.cs index b27b3d6..bdfbdd0 100644 --- a/MSUScripter/Configs/MsuSongMsuPcmInfo.cs +++ b/MSUScripter/Configs/MsuSongMsuPcmInfo.cs @@ -52,6 +52,9 @@ public class MsuSongMsuPcmInfo [Description("The file to be used as the input for this track/sub-track/sub-channel")] public string? File { get; set; } + [Description("Whether or not to apply audio dither to the final output.")] + public bool? Dither { get; set; } + [JsonSchemaIgnore] public bool ShowPanel { get; set; } = true; @@ -71,8 +74,18 @@ public void ClearFieldsForYaml() } } + public bool AreFilesValid() + { + return GetFiles().All(System.IO.File.Exists); + } + public List GetFiles() { + if (!string.IsNullOrEmpty(Output)) + { + var outputFile = Output; + } + List files = new List(); if (!string.IsNullOrEmpty(File)) @@ -96,4 +109,79 @@ public bool HasBothSubTracksAndSubChannels SubTracks.Any(x => x.HasBothSubTracksAndSubChannels); } } + + [YamlIgnore] + public bool HasValidSubChannelCount + { + get + { + return SubChannels.Count != 1 && SubChannels.All(x => x.HasValidSubChannelCount) && + SubTracks.All(x => x.HasValidSubChannelCount); + } + } + + [YamlIgnore] + public bool HasValidChildTypes + { + get + { + return SubChannels.All(x => x.SubChannels.Count == 0 && x.HasValidChildTypes) && + SubTracks.All(x => x.SubTracks.Count == 0 && x.HasValidChildTypes); + } + } + + public bool HasData() + { + return Loop > 0 || TrimStart > 0 || TrimEnd > 0 || FadeIn > 0 || FadeOut > 0 || CrossFade > 0 || PadStart > 0 || + PadEnd > 0 || (Tempo.HasValue && Tempo != 0) || (Normalization.HasValue && Normalization != 0) || + !string.IsNullOrEmpty(File) || SubChannels.Count > 0 || SubTracks.Count > 0; + } + + public bool HasAdvancedData() + { + return FadeIn > 0 || FadeOut > 0 || CrossFade > 0 || PadStart > 0 || PadEnd > 0 || + (Tempo.HasValue && Tempo != 0) || SubChannels.Count > 0 || SubTracks.Count > 0; + } + + public bool HasFiles() + { + return GetFiles().Count > 0; + } + + public int MoveSubInfo(MsuSongMsuPcmInfo info, bool toSubTrack, int index, MsuSongMsuPcmInfo? previousParent) + { + var destination = toSubTrack ? SubTracks : SubChannels; + + if (destination.Contains(info)) + { + var currentIndex = destination.IndexOf(info); + if (index > currentIndex) + { + index--; + } + } + + previousParent?.SubTracks.Remove(info); + previousParent?.SubChannels.Remove(info); + + if (index > destination.Count) + { + destination.Add(info); + } + else + { + destination.Insert(index, info); + } + + return index; + } + + public bool HasChangesSince(DateTime time) + { + if (SubTracks.Any(x => x.HasChangesSince(time))) + return true; + if (SubChannels.Any(x => x.HasChangesSince(time))) + return true; + return LastModifiedDate > time; + } } \ No newline at end of file diff --git a/MSUScripter/Configs/MsuTrackInfo.cs b/MSUScripter/Configs/MsuTrackInfo.cs index 6374060..8c9e901 100644 --- a/MSUScripter/Configs/MsuTrackInfo.cs +++ b/MSUScripter/Configs/MsuTrackInfo.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.IO; +using System.Linq; using MSUScripter.Models; namespace MSUScripter.Configs; @@ -12,5 +14,107 @@ public class MsuTrackInfo public bool IsScratchPad { get; set; } [SkipConvert] - public List Songs { get; set; } = new List(); + public List Songs { get; set; } = []; + + public MsuSongInfo AddSong(MsuProject project, int index = 0, bool advancedMode = false) + { + var msu = new FileInfo(project.MsuPath); + + var newSong = new MsuSongInfo + { + Id = Guid.NewGuid().ToString("N"), + TrackNumber = TrackNumber, + TrackName = TrackName, + DisplayAdvancedMode = advancedMode + }; + + UpdateSongPath(project, newSong, index); + + Songs.Insert(index, newSong); + + for (var i = index + 1; i < Songs.Count; i++) + { + var oldIndex = i - 1; + var oldIsAlt = oldIndex > 0; + string oldDefaultOutputPath; + + if (!oldIsAlt) + { + oldDefaultOutputPath = msu.FullName.Replace(msu.Extension, $"-{TrackNumber}.pcm"); + } + else + { + var altSuffix = oldIndex == 1 ? "alt" : $"alt{oldIndex}"; + oldDefaultOutputPath = msu.FullName.Replace(msu.Extension, $"-{TrackNumber}_{altSuffix}.pcm"); + } + + if (Songs[i].OutputPath == oldDefaultOutputPath) + { + var altSuffix = i == 1 ? "alt" : $"alt{i}"; + var newOutputPath = msu.FullName.Replace(msu.Extension, $"-{TrackNumber}_{altSuffix}.pcm"); + Songs[i].OutputPath = newOutputPath; + Songs[i].MsuPcmInfo.Output = newOutputPath; + } + } + + return newSong; + } + + public void RemoveSong(MsuSongInfo song) + { + var index = Songs.IndexOf(song); + + for (var i = Songs.Count - 1; i >= index + 1; i--) + { + Songs[i].OutputPath = Songs[i - 1].OutputPath; + Songs[i].MsuPcmInfo.Output = Songs[i - 1].MsuPcmInfo.Output; + Songs[i].IsAlt = Songs[i - 1].IsAlt; + } + + Songs.Remove(song); + } + + public void MoveSong(MsuProject project, MsuSongInfo song, int index) + { + var oldTrack = project.Tracks.First(x => x.TrackNumber == song.TrackNumber); + + oldTrack.Songs.Remove(song); + for (var i = 0; i < oldTrack.Songs.Count; i++) + { + UpdateSongPath(project, oldTrack.Songs[i], i); + } + + Songs.Insert(index, song); + for (var i = 0; i < Songs.Count; i++) + { + UpdateSongPath(project, Songs[i], i); + } + + song.TrackName = TrackName; + song.TrackNumber = TrackNumber; + } + + private void UpdateSongPath(MsuProject project, MsuSongInfo song, int? index = null) + { + index ??= Songs.IndexOf(song); + + var msu = new FileInfo(project.MsuPath); + song.IsAlt = index > 0; + + if (song.TrackNumber >= 9999) + { + song.OutputPath = Path.Combine(Directories.TempFolder, project.Id, song.Id, "temp.pcm"); + } + else if (!song.IsAlt) + { + song.OutputPath = msu.FullName.Replace(msu.Extension, $"-{TrackNumber}.pcm"); + } + else + { + var altSuffix = index == 1 ? "alt" : $"alt{index}"; + song.OutputPath = msu.FullName.Replace(msu.Extension, $"-{TrackNumber}_{altSuffix}.pcm"); + } + + song.MsuPcmInfo.Output = song.OutputPath; + } } \ No newline at end of file diff --git a/MSUScripter/Configs/Settings.cs b/MSUScripter/Configs/Settings.cs index 780b29e..48db5b7 100644 --- a/MSUScripter/Configs/Settings.cs +++ b/MSUScripter/Configs/Settings.cs @@ -1,11 +1,12 @@ using System.Collections.Generic; +using MSUScripter.ViewModels; namespace MSUScripter.Configs; public class Settings { public string? MsuPcmPath { get; set; } - public bool PromptOnUpdate { get; set; } = true; + public bool CheckForUpdates { get; set; } = true; public bool PromptOnPreRelease { get; set; } public bool DarkTheme { get; set; } = true; public int LoopDuration { get; set; } = 5; @@ -13,9 +14,20 @@ public class Settings public ICollection RecentProjects { get; set; } = new List(); public double Volume { get; set; } = 1; public string? PreviousPath { get; set; } + public string? PreviousVideoPath { get; set; } public bool HideSubTracksSubChannelsWarning { get; set; } public bool AutomaticallyRunPyMusicLooper { get; set; } = true; public bool RunMsuPcmWithKeepTemps { get; set; } public bool HasDoneFirstTimeSetup { get; set; } - public string? PyMusicLooperPath { get; set; } + public bool IgnoreMissingDependencies { get; set; } + public bool ProjectTreeFilterOnlyTracksMissingSongs { get; set; } + public bool ProjectTreeFilterOnlyIncomplete { get; set; } + public bool ProjectTreeFilterOnlyMissingAudio { get; set; } + public bool ProjectTreeFilterOnlyCopyrightUntested { get; set; } + public bool ProjectTreeDisplayIsCompleteIcon { get; set; } + public bool ProjectTreeDisplayHasSongIcon { get; set; } + public bool ProjectTreeDisplayCheckCopyrightIcon { get; set; } + public bool ProjectTreeDisplayCopyrightSafeIcon { get; set; } + public bool SkipDesktopFile { get; set; } + public DefaultSongPanel DefaultSongPanel { get; set; } = DefaultSongPanel.Prompt; } \ No newline at end of file diff --git a/MSUScripter/Configs/TrackListType.cs b/MSUScripter/Configs/TrackListType.cs index 7ff4628..bfb29d6 100644 --- a/MSUScripter/Configs/TrackListType.cs +++ b/MSUScripter/Configs/TrackListType.cs @@ -1,6 +1,8 @@ -namespace MSUScripter.Configs; +using System.ComponentModel; -public static class TrackListType +namespace MSUScripter.Configs; + +public static class TrackListTypeDeprecated { public const string List = "List"; public const string Table = "Table"; @@ -12,4 +14,14 @@ public static class TrackListType Table, Disabled ]; +} + +public enum TrackList +{ + [Description("List: album - song (artist)")] + ListAlbumFirst, + [Description("List: song by artist (album)")] + ListSongFirst, + Table, + Disabled } \ No newline at end of file diff --git a/MSUScripter/MSUScripter.csproj b/MSUScripter/MSUScripter.csproj index 7c0ff0a..991179c 100644 --- a/MSUScripter/MSUScripter.csproj +++ b/MSUScripter/MSUScripter.csproj @@ -1,66 +1,71 @@  - - WinExe - net9.0 - enable - true - app.manifest - true - MSUScripterIcon.ico - MSUScripterIcon.ico - 4.2.1-beta.1 - 9.0.0 - false - 12 - + + WinExe + net9.0 + enable + true + app.manifest + true + MSUScripterIcon.ico + MSUScripterIcon.ico + 5.0.0 + 9.0.0 + false + 12 + - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + - - - MainWindow.axaml - Code - - - - - - - + + + MainWindow.axaml + Code + + + + + + - - - C:\Users\matte\.nuget\packages\avalonia.themes.simple\11.0.0\lib\net6.0\Avalonia.Themes.Simple.dll - - + + + + C:\Users\matte\.nuget\packages\avalonia.themes.simple\11.0.0\lib\net6.0\Avalonia.Themes.Simple.dll + + - - - - - + + + diff --git a/MSUScripter/Models/AnalysisDataOutput.cs b/MSUScripter/Models/AnalysisDataOutput.cs index f305e1f..aa49532 100644 --- a/MSUScripter/Models/AnalysisDataOutput.cs +++ b/MSUScripter/Models/AnalysisDataOutput.cs @@ -2,6 +2,6 @@ namespace MSUScripter.Models; public class AnalysisDataOutput { - public double? AvgDecibels { get; set; } - public double? MaxDecibels { get; set; } + public double? AvgDecibels { get; init; } + public double? MaxDecibels { get; init; } } \ No newline at end of file diff --git a/MSUScripter/Models/BasicEventArgs.cs b/MSUScripter/Models/BasicEventArgs.cs deleted file mode 100644 index 3d4df16..0000000 --- a/MSUScripter/Models/BasicEventArgs.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace MSUScripter.Models; - -public class BasicEventArgs -{ - public string? Data { get; set; } - - public BasicEventArgs(string? data) - { - Data = data; - } -} \ No newline at end of file diff --git a/MSUScripter/Models/Directories.cs b/MSUScripter/Models/Directories.cs index 34fa0ab..ec827b3 100644 --- a/MSUScripter/Models/Directories.cs +++ b/MSUScripter/Models/Directories.cs @@ -4,12 +4,14 @@ namespace MSUScripter.Models; -public class Directories +public static class Directories { public static string BaseFolder => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "MSUScripter"); public static string LogFolder => Path.Combine(BaseFolder, "logs"); + public static string Dependencies => Path.Combine(BaseFolder, "dependencies"); + public static string CacheFolder { get @@ -35,33 +37,4 @@ public static string TempFolder return path; } } - - public static bool OpenDirectory(string path, bool isFile = false) - { - if (isFile) - { - path = new FileInfo(path).DirectoryName ?? ""; - } - - if (!Directory.Exists(path)) - { - return false; - } - - try - { - Process.Start(new ProcessStartInfo() - { - FileName = path, - UseShellExecute = true, - Verb = "open" - }); - } - catch (Exception) - { - return false; - } - - return true; - } } \ No newline at end of file diff --git a/MSUScripter/Models/FileInputControlType.cs b/MSUScripter/Models/FileInputControlType.cs deleted file mode 100644 index f837ba8..0000000 --- a/MSUScripter/Models/FileInputControlType.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace MSUScripter.Models; - -public enum FileInputControlType -{ - OpenFile, - SaveFile, - Folder -} \ No newline at end of file diff --git a/MSUScripter/Models/MessageWindowResult.cs b/MSUScripter/Models/MessageWindowResult.cs deleted file mode 100644 index 20786ee..0000000 --- a/MSUScripter/Models/MessageWindowResult.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace MSUScripter.Models; - -public enum MessageWindowResult -{ - Ok, - Cancel, - Yes, - No, - DontShow -} \ No newline at end of file diff --git a/MSUScripter/Models/MessageWindowType.cs b/MSUScripter/Models/MessageWindowType.cs deleted file mode 100644 index d40ff34..0000000 --- a/MSUScripter/Models/MessageWindowType.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace MSUScripter.Models; - -public enum MessageWindowType -{ - Basic, - Warning, - Error, - Info, - YesNo, - PcmWarning, - DoNotShowAgain -} \ No newline at end of file diff --git a/MSUScripter/Models/MsuProjectGenerationCache.cs b/MSUScripter/Models/MsuProjectGenerationCache.cs new file mode 100644 index 0000000..a62abd5 --- /dev/null +++ b/MSUScripter/Models/MsuProjectGenerationCache.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Concurrent; + +namespace MSUScripter.Models; + +public class MsuProjectGenerationCache +{ + public ConcurrentDictionary Songs { get; set; } = []; +} + +public class MsuProjectSongCache +{ + public ulong JsonHash { get; init; } + public int JsonLength { get; init; } + public DateTime FileGenerationTime { get; init; } + public long FileLength { get; init; } + + public static bool IsValid(MsuProjectSongCache? a, MsuProjectSongCache? b) + { + if (a is null || b is null) return false; + return a.JsonHash == b.JsonHash && a.JsonLength == b.JsonLength && + a.FileGenerationTime == b.FileGenerationTime & a.FileLength == b.FileLength; + } +} \ No newline at end of file diff --git a/MSUScripter/Models/PcmEventArgs.cs b/MSUScripter/Models/PcmEventArgs.cs deleted file mode 100644 index 838c2ec..0000000 --- a/MSUScripter/Models/PcmEventArgs.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using MSUScripter.Tools; -using MSUScripter.ViewModels; - -namespace MSUScripter.Models; - -public class PcmEventArgs : EventArgs -{ - public MsuSongInfoViewModel Song { get; set; } - public PcmEventType Type { get; set; } - public MsuSongMsuPcmInfoViewModel? PcmInfo { get; set; } - - public PcmEventArgs(MsuSongInfoViewModel song, PcmEventType type, MsuSongMsuPcmInfoViewModel? ppmInfo = null) - { - Song = song; - Type = type; - PcmInfo = ppmInfo; - } -} \ No newline at end of file diff --git a/MSUScripter/Models/PcmEventType.cs b/MSUScripter/Models/PcmEventType.cs deleted file mode 100644 index e7293fe..0000000 --- a/MSUScripter/Models/PcmEventType.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace MSUScripter.Models; - -public enum PcmEventType -{ - Play, - PlayLoop, - Generate, - GenerateAsPrimary, - LoopWindow, - StopMusic, - GenerateEmpty, - StartingSamples, - AddedSubChannelOrSubTrack -} \ No newline at end of file diff --git a/MSUScripter/Models/PythonCompanionServiceModels.cs b/MSUScripter/Models/PythonCompanionServiceModels.cs new file mode 100644 index 0000000..dcd0b5d --- /dev/null +++ b/MSUScripter/Models/PythonCompanionServiceModels.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; +// ReSharper disable UnusedAutoPropertyAccessor.Global +// ReSharper disable CollectionNeverUpdated.Global +// ReSharper disable UnusedMember.Global +// ReSharper disable ClassNeverInstantiated.Global + +namespace MSUScripter.Models; + +public class GetSampleRateRequest +{ + public string Mode => "samples"; + public required string File { get; init; } +} + +public class GetSampleRateResponse : PythonCompanionModeResponse +{ + public double Duration { get; set; } + public int SampleRate { get; init; } = 44100; + public int Channels { get; init; } + public int BitsPerSample { get; init; } + public bool IsBlankSuccess { get; init; } +} + +public class RunPyMusicLooperRequest +{ + public string Mode => "py_music_looper"; + public required string File { get; init; } + public double? MinDurationMultiplier { get; init; } = 0.25f; + public double? MinLoopDuration { get; init; } + public double? MaxLoopDuration { get; init; } + public double? ApproxLoopStart { get; init; } + public double? ApproxLoopEnd { get; init; } + +} + +public class RunPyMusicLooperResponse : PythonCompanionModeResponse +{ + public List Pairs { get; init; } = []; +} + +public class PythonCompanionModeResponse +{ + public bool Successful { get; set; } + public string Error { get; set; } = string.Empty; +} + +public class CreateVideoRequest +{ + public string Mode => "create_video"; + public required string OutputVideo { get; set; } + public string? ProgressFile { get; set; } + public required List Files { get; set; } +} + +public class CreateVideoResponse : PythonCompanionModeResponse; + +public class RunPyResult +{ + public bool Success { get; set; } + public string Result { get; init; } = string.Empty; + public string Error { get; init; } = string.Empty; + public bool IsBlankSuccess => Success && string.IsNullOrEmpty(Result) && string.IsNullOrEmpty(Error); +} +public class PyMusicLooperPair +{ + public int LoopStart { get; set; } + public int LoopEnd { get; set; } + public decimal LoudnessDifference { get; set; } + public decimal NoteDistance { get; set; } + public decimal Score { get; set; } +} + +public class PyMusicLooperCacheKey +{ + private string File { get; } + private DateTime AudioFileModifiedDate { get; } + private long AudioFileLength { get; } + private string MinDurationMultiplier { get; } + private string MinLoopDuration { get; } + private string MaxLoopDuration { get; } + private string ApproxLoopStart { get; } + private string ApproxLoopEnd { get; } + + public PyMusicLooperCacheKey(RunPyMusicLooperRequest request) + { + File = request.File; + + var fileInfo = new FileInfo(request.File); + AudioFileModifiedDate = fileInfo.LastWriteTimeUtc; + AudioFileLength = fileInfo.Length; + + MinDurationMultiplier = Math.Round(request.MinDurationMultiplier ?? -1, 4).ToString(CultureInfo.InvariantCulture); + MinLoopDuration = Math.Round(request.MinLoopDuration ?? -1, 4).ToString(CultureInfo.InvariantCulture); + MaxLoopDuration = Math.Round(request.MaxLoopDuration ?? -1, 4).ToString(CultureInfo.InvariantCulture); + ApproxLoopStart = Math.Round(request.ApproxLoopStart ?? -1, 4).ToString(CultureInfo.InvariantCulture); + ApproxLoopEnd = Math.Round(request.ApproxLoopEnd ?? -1, 4).ToString(CultureInfo.InvariantCulture); + } + + public override string ToString() + { + var inputBytes = Encoding.UTF8.GetBytes(File); + var hashBytes = System.Security.Cryptography.MD5.HashData(inputBytes); + var fileHash = Convert.ToHexString(hashBytes); + + var start = + $"{File}|{AudioFileModifiedDate}|{AudioFileLength}|{MinDurationMultiplier}|{MinLoopDuration}|{MaxLoopDuration}|{ApproxLoopStart}|{ApproxLoopEnd}"; + inputBytes = Encoding.UTF8.GetBytes(start); + hashBytes = System.Security.Cryptography.MD5.HashData(inputBytes); + var keyHash = Convert.ToHexString(hashBytes); + + return $"{fileHash}_{keyHash}"; + } +} diff --git a/MSUScripter/Models/RunMethod.cs b/MSUScripter/Models/RunMethod.cs index 23ce4e5..b936545 100644 --- a/MSUScripter/Models/RunMethod.cs +++ b/MSUScripter/Models/RunMethod.cs @@ -3,6 +3,7 @@ namespace MSUScripter.Models; public enum RunMethod { Unknown, + Installed, Direct, Py, Python3 diff --git a/MSUScripter/Models/SkipConvertAttribute.cs b/MSUScripter/Models/SkipConvertAttribute.cs index c96cea5..dc38bfb 100644 --- a/MSUScripter/Models/SkipConvertAttribute.cs +++ b/MSUScripter/Models/SkipConvertAttribute.cs @@ -1,7 +1,4 @@ namespace MSUScripter.Models; -[System.AttributeUsage(System.AttributeTargets.Property) -] -public class SkipConvertAttribute : System.Attribute -{ -} \ No newline at end of file +[System.AttributeUsage(System.AttributeTargets.Property) ] +public class SkipConvertAttribute : System.Attribute; \ No newline at end of file diff --git a/MSUScripter/Models/SkipLastModifiedAttribute.cs b/MSUScripter/Models/SkipLastModifiedAttribute.cs new file mode 100644 index 0000000..550434f --- /dev/null +++ b/MSUScripter/Models/SkipLastModifiedAttribute.cs @@ -0,0 +1,4 @@ +namespace MSUScripter.Models; + +[System.AttributeUsage(System.AttributeTargets.Property | System.AttributeTargets.Class)] +public class SkipLastModifiedAttribute : System.Attribute; \ No newline at end of file diff --git a/MSUScripter/Models/SongFileEventArgs.cs b/MSUScripter/Models/SongFileEventArgs.cs deleted file mode 100644 index 8d2a687..0000000 --- a/MSUScripter/Models/SongFileEventArgs.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using MSUScripter.ViewModels; - -namespace MSUScripter.Models; - -public class SongFileEventArgs : EventArgs -{ - public SongFileEventArgs(MsuSongInfoViewModel songViewModel, string filePath, bool force) - { - SongViewModel = songViewModel; - FilePath = filePath; - Force = force; - } - - public MsuSongInfoViewModel SongViewModel { get; set; } - public string FilePath { get; set; } - public bool Force { get; set; } - -} \ No newline at end of file diff --git a/MSUScripter/Models/TrackEventArgs.cs b/MSUScripter/Models/TrackEventArgs.cs deleted file mode 100644 index e9919a4..0000000 --- a/MSUScripter/Models/TrackEventArgs.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System; - -namespace MSUScripter.Models; - -public class TrackEventArgs : EventArgs -{ - public int TrackNumber { get; set; } - - public TrackEventArgs(int trackNumber) - { - TrackNumber = trackNumber; - } -} \ No newline at end of file diff --git a/MSUScripter/Models/VideoCreatorServiceEventArgs.cs b/MSUScripter/Models/VideoCreatorServiceEventArgs.cs deleted file mode 100644 index 883f60c..0000000 --- a/MSUScripter/Models/VideoCreatorServiceEventArgs.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; - -namespace MSUScripter.Models; - -public class VideoCreatorServiceEventArgs: EventArgs -{ - public bool Successful { get; set; } - - public string? Message { get; set; } - - public VideoCreatorServiceEventArgs(bool successful, string? message) - { - Successful = successful; - Message = message; - } -} \ No newline at end of file diff --git a/MSUScripter/Models/WindowRestoreDetails.cs b/MSUScripter/Models/WindowRestoreDetails.cs deleted file mode 100644 index 1b3ada1..0000000 --- a/MSUScripter/Models/WindowRestoreDetails.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Avalonia; - -namespace MSUScripter.Models; - -public class WindowRestoreDetails -{ - public int X { get; set; } - public int Y { get; set; } - public double Width { get; set; } - public double Height { get; set; } - public bool IsMaximized { get; set; } - - public PixelPoint GetPosition() => new(X, Y); -} \ No newline at end of file diff --git a/MSUScripter/Program.cs b/MSUScripter/Program.cs index 52e8dd7..a9f91bf 100644 --- a/MSUScripter/Program.cs +++ b/MSUScripter/Program.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.IO; +// ReSharper disable once RedundantUsingDirective using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -15,8 +16,6 @@ using MSURandomizerLibrary; using MSUScripter.Models; using MSUScripter.Services; -using MSUScripter.Services.ControlServices; -using MSUScripter.Views; using Serilog; using Win32RenderingMode = Avalonia.Win32RenderingMode; @@ -62,7 +61,7 @@ public static void Main(string[] args) { StartingProject = args[0]; } - + MainHost = Host.CreateDefaultBuilder(args) .UseSerilog() .ConfigureLogging(logging => @@ -75,7 +74,7 @@ public static void Main(string[] args) }) .Build(); - InitializeServices(args); + InitializeServices(); ExceptionWindow.GitHubUrl = "https://github.com/MattEqualsCoder/MSUScripter/issues"; ExceptionWindow.LogPath = Directories.LogFolder; @@ -89,14 +88,14 @@ public static void Main(string[] args) } catch (Exception e) { - ShowExceptionPopup(e).ContinueWith(t => source.Cancel(), TaskScheduler.FromCurrentSynchronizationContext()); + ShowExceptionPopup(e).ContinueWith(_ => source.Cancel(), TaskScheduler.FromCurrentSynchronizationContext()); Dispatcher.UIThread.MainLoop(source.Token); } } // Avalonia configuration, don't remove; also used by visual designer. - public static AppBuilder BuildAvaloniaApp() + private static AppBuilder BuildAvaloniaApp() { return AppBuilder.Configure() .UsePlatformDetect() @@ -118,28 +117,26 @@ private static IServiceCollection ConfigureServices(IServiceCollection collectio .AddSingleton() .AddSingleton() .AddSingleton() - .AddSingleton() .AddSingleton() .AddSingleton() - .AddTransient() - .AddTransient() - .AddTransient() + .AddSingleton() + .AddSingleton() .AddAvaloniaControlServices() .AddTransient(); if (OperatingSystem.IsWindows()) { - collection.AddSingleton(); + collection.AddSingleton(); } else { - collection.AddSingleton(); + collection.AddSingleton(); } return collection; } - private static void InitializeServices(string[] args) + private static void InitializeServices() { var services = MainHost.Services; services.GetRequiredService(); @@ -147,7 +144,7 @@ private static void InitializeServices(string[] args) services.GetRequiredService(); services.GetRequiredService(); services.GetRequiredService(); - services.GetRequiredService().Initialize(args); + services.GetRequiredService().Initialize(); } private static async Task ShowExceptionPopup(Exception e) diff --git a/MSUScripter/Services/ApplicationInitializationService.cs b/MSUScripter/Services/ApplicationInitializationService.cs index a364932..6ef38e0 100644 --- a/MSUScripter/Services/ApplicationInitializationService.cs +++ b/MSUScripter/Services/ApplicationInitializationService.cs @@ -11,14 +11,14 @@ namespace MSUScripter.Services; public class ApplicationInitializationService(ILogger logger) { - public void Initialize(string[] args) + public void Initialize() { logger.LogInformation("Assembly Location: {Location}", Assembly.GetExecutingAssembly().Location); - logger.LogInformation("Starting MSU Scripter {Version}", GetAppVersion()); + logger.LogInformation("Starting MSU Scripter {Version}", App.Version); var msuInitializationRequest = new MsuRandomizerInitializationRequest { - MsuAppSettingsStream = Assembly.GetExecutingAssembly().GetManifestResourceStream("MSUScripter.msu-randomizer-settings.yaml"), + MsuAppSettingsStream = Assembly.GetExecutingAssembly().GetManifestResourceStream("MSUScripter.Assets.msu-randomizer-settings.yaml"), UserOptionsPath = Path.Combine(Directories.BaseFolder, "msu-user-settings.yml") }; @@ -29,10 +29,4 @@ public void Initialize(string[] args) Program.MainHost.Services.GetRequiredService().Initialize(msuInitializationRequest); } - - private static string GetAppVersion() - { - var version = FileVersionInfo.GetVersionInfo(Assembly.GetEntryAssembly()!.Location); - return (version.ProductVersion ?? "").Split("+")[0]; - } } \ No newline at end of file diff --git a/MSUScripter/Services/AudioAnalysisService.cs b/MSUScripter/Services/AudioAnalysisService.cs index d27a67f..1aafcbc 100644 --- a/MSUScripter/Services/AudioAnalysisService.cs +++ b/MSUScripter/Services/AudioAnalysisService.cs @@ -9,34 +9,49 @@ using MSUScripter.Models; using MSUScripter.ViewModels; using NAudio.Wave; +using SoundFlow.Interfaces; +using SoundFlow.Providers; using File = System.IO.File; namespace MSUScripter.Services; +public class AudioSampleRateResponse +{ + public int SampleRate { get; set; } = 44100; + public bool Successful { get; set; } +} + public class AudioAnalysisService( IAudioPlayerService audioPlayerService, MsuPcmService msuPcmService, StatusBarService statusBarService, - ConverterService converterService, + PythonCompanionService pythonCompanionService, ILogger logger) { public async Task AnalyzePcmFiles(AudioAnalysisViewModel audioAnalysis, CancellationToken ct = new()) { - var project = audioAnalysis.Project == null ? null : converterService.ConvertProject(audioAnalysis.Project); + var project = audioAnalysis.Project; await Parallel.ForEachAsync(audioAnalysis.Rows, new ParallelOptions { MaxDegreeOfParallelism = 10, CancellationToken = ct }, - async (song, token) => + async (song, _) => { - await AnalyzePcmFile(project, song); - audioAnalysis.SongsCompleted++; - }); - } + try + { + await AnalyzePcmFile(project, song); + } + catch (Exception e) + { + logger.LogError(e, "Error analyzing pcm file"); + } - public async Task AnalyzePcmFile(MsuProjectViewModel projectViewModel, AudioAnalysisSongViewModel song) - { - var project = converterService.ConvertProject(projectViewModel); - await AnalyzePcmFile(project, song); + audioAnalysis.UpdateSongsCompleted(); + }); + + if (project?.BasicInfo.IsMsuPcmProject == true) + { + msuPcmService.SaveGenerationCache(project); + } } public async Task AnalyzePcmFile(MsuProject? project, AudioAnalysisSongViewModel song) @@ -51,19 +66,21 @@ public async Task AnalyzePcmFile(MsuProject? project, AudioAnalysisSongViewModel song.WarningMessage = "PCM file missing"; return; } - else if (project?.BasicInfo.IsMsuPcmProject == true && song.OriginalViewModel?.HasFiles() != true && !File.Exists(song.Path)) + else if (project?.BasicInfo.IsMsuPcmProject == true && song.MsuSongInfo == null) { - song.WarningMessage = "No input files specified for PCM file"; + song.WarningMessage = "Song information missing"; return; } // Regenerate the pcm file if it has updates that have been made to it - if (project?.BasicInfo.IsMsuPcmProject == true && song.OriginalViewModel != null && song.OriginalViewModel.HasFiles() && (song.OriginalViewModel.HasChangesSince(song.OriginalViewModel.LastGeneratedDate) || !File.Exists(song.Path))) + if (project?.BasicInfo.IsMsuPcmProject == true && song.MsuSongInfo != null) { - logger.LogInformation("PCM file {File} out of date, regenerating", song.Path); - if (!await GeneratePcmFile(project, song.OriginalViewModel)) + var response = await msuPcmService.CreatePcm(project, song.MsuSongInfo, false, true, true); + if (!response.Successful) { - song.WarningMessage = "Could not generate new PCM file"; + song.WarningMessage = File.Exists(song.Path) + ? "Could not generate new PCM file" + : "PCM file missing and could not be generated"; } } @@ -71,76 +88,92 @@ public async Task AnalyzePcmFile(MsuProject? project, AudioAnalysisSongViewModel song.ApplyAudioAnalysis(data); logger.LogInformation("Analysis for pcm file {File} complete", song.Path); } - - private async Task GeneratePcmFile(MsuProject project, MsuSongInfoViewModel songModel) - { - var song = new MsuSongInfo(); - converterService.ConvertViewModel(songModel, song); - converterService.ConvertViewModel(songModel.MsuPcmInfo, song.MsuPcmInfo); - var response = await msuPcmService.CreatePcm(false, project, song); - if (!response.GeneratedPcmFile) - { - logger.LogInformation("PCM file {File} failed to regenerate: {Error}", song.OutputPath, response.Message); - } - else - { - songModel.LastGeneratedDate = DateTime.Now; - logger.LogInformation("PCM file {File} regenerated successfully", song.OutputPath); - } - return response.GeneratedPcmFile; - } - - public int GetAudioSampleRate(string? path) + public async Task GetAudioSampleRateAsync(string? path) { - if (!OperatingSystem.IsWindows() || string.IsNullOrEmpty(path) || !File.Exists(path)) + if (string.IsNullOrEmpty(path) || !File.Exists(path)) { - return 44100; + return new AudioSampleRateResponse + { + Successful = false, + }; } List incompatibleFileTypes = [".ogg"]; if (incompatibleFileTypes.Contains(new FileInfo(path).Extension.ToLower())) { - logger.LogInformation("AudioSampleRate Incompatible file {File}", path); - return 44100; + logger.LogInformation("AudioSampleRate Incompatible file {File}. Assuming 44100.", path); + return new AudioSampleRateResponse + { + Successful = false, + }; + } + + if (OperatingSystem.IsLinux()) + { + var response = await pythonCompanionService.GetSampleRateAsync(new GetSampleRateRequest() + { + File = path + }); + + if (response.Successful) + { + logger.LogInformation("Successfully retrieved sample rate of {Rate} for {File}", response.SampleRate, path); + } + else + { + logger.LogError("Failed to retrieve sample rate for {File}. Assuming 44100.", path); + } + + return new AudioSampleRateResponse + { + Successful = response.Successful, + SampleRate = response.Successful ? response.SampleRate : 44100, + }; } try { var mp3 = new AudioFileReader(path); - return mp3.WaveFormat.SampleRate; + logger.LogInformation("Successfully retrieved sample rate of {Rate} for {File}", mp3.WaveFormat.SampleRate, path); + return new AudioSampleRateResponse + { + Successful = true, + SampleRate = mp3.WaveFormat.SampleRate + }; } catch (Exception e) { - logger.LogError(e, "Unable to retrieve audio sample rate"); - return 44100; + logger.LogError(e, "Failed to retrieve sample rate for {File}. Assuming 44100.", path); + return new AudioSampleRateResponse + { + Successful = false, + }; } } public int GetAudioStartingSample(string path) { - if (!OperatingSystem.IsWindows()) - { - throw new InvalidOperationException("This is only supported on Windows"); - } - logger.LogInformation("GetAudioStartingSample"); try { + using var reader = new AudioReader(path); + var totalSampleCount = 0; - var samples = 0; + int samples; var readBuffer = new float[10000]; var quit = false; - var mp3 = new AudioFileReader(path); + + var threshold = .0003; do { - samples = mp3.Read(readBuffer, 0, readBuffer.Length); + samples = reader.Read(readBuffer); for (var i = 0; i < samples; i++) { - if (Math.Abs(readBuffer[i]) > .0003) + if (Math.Abs(readBuffer[i]) > threshold) { totalSampleCount += i; quit = true; @@ -156,53 +189,49 @@ public int GetAudioStartingSample(string path) } while (!quit && samples == readBuffer.Length); statusBarService.UpdateStatusBar("Retrieved Starting Samples"); - return totalSampleCount / mp3.WaveFormat.Channels; + return totalSampleCount / reader.Divider; } catch (Exception e) { logger.LogError(e, "Unable to get audio samples for file"); - throw; + return -1; } - } public int GetAudioEndingSample(string path) { - if (!OperatingSystem.IsWindows()) - { - throw new InvalidOperationException("This is only supported on Windows"); - } - logger.LogInformation("GetAudioEndingSample"); try { - var samples = 0; + using var reader = new AudioReader(path); + + int samples; var readBuffer = new float[10000]; - var mp3 = new AudioFileReader(path); - int lastLoudSample = 0; + var lastLoudSample = 0; + + var threshold = .0003; do { - samples = mp3.Read(readBuffer, 0, readBuffer.Length); + samples = reader.Read(readBuffer); for (var i = 0; i < samples; i++) { - if (Math.Abs(readBuffer[i]) > .0003) + if (Math.Abs(readBuffer[i]) > threshold) { lastLoudSample++; } } - + } while (samples == readBuffer.Length); statusBarService.UpdateStatusBar("Retrieved Ending Samples"); - return lastLoudSample / mp3.WaveFormat.Channels; + return lastLoudSample / reader.Divider; } catch (Exception e) { logger.LogError(e, "Unable to get audio samples for file"); - throw; + return -1; } - } public async Task AnalyzeAudio(string path) @@ -237,7 +266,7 @@ public async Task AnalyzeAudio(string path) double sum = 0; var totalSampleCount = 0; - var samples = 0; + int samples; do { samples = sampleProvider.Read(readBuffer, 0, readBuffer.Length); @@ -255,13 +284,58 @@ public async Task AnalyzeAudio(string path) }; } - public double ConvertToDecibel(float value) + private double ConvertToDecibel(float value) { return Math.Round(20 * Math.Log10(Math.Abs(value)), 4); } - - public double ConvertToDecibel(double value) + + private double ConvertToDecibel(double value) { return Math.Round(20 * Math.Log10(Math.Abs(value)), 4); } +} + +public class AudioReader : IDisposable +{ + private readonly AudioFileReader? _windowsReader; + private readonly ISoundDataProvider? _linuxReader; + + public AudioReader(string file) + { + if (OperatingSystem.IsWindows()) + { + _windowsReader = new AudioFileReader(file); + Channels = _windowsReader.WaveFormat.Channels; + BytesPerSample = _windowsReader.WaveFormat.BitsPerSample / 8; + Divider = Channels; + } + else + { + _linuxReader = new StreamDataProvider(File.OpenRead(file)); + Divider = 1; + } + } + + public int Read(float[] buffer) + { + if (OperatingSystem.IsWindows()) + { + return _windowsReader!.Read(buffer, 0, buffer.Length); + } + else + { + return _linuxReader!.ReadBytes(buffer); + } + } + + public int Channels { get; init; } + public int BytesPerSample { get; init; } + public int BytesPerFrame => BytesPerSample * Channels; + public int Divider { get; init; } + + public void Dispose() + { + _windowsReader?.Dispose(); + _linuxReader?.Dispose(); + } } \ No newline at end of file diff --git a/MSUScripter/Services/AudioMetadataService.cs b/MSUScripter/Services/AudioMetadataService.cs index 2311396..163a70c 100644 --- a/MSUScripter/Services/AudioMetadataService.cs +++ b/MSUScripter/Services/AudioMetadataService.cs @@ -9,15 +9,8 @@ namespace MSUScripter.Services; -public class AudioMetadataService +public class AudioMetadataService(ILogger logger) { - private readonly ILogger _logger; - - public AudioMetadataService(ILogger logger) - { - _logger = logger; - } - public AudioMetadata GetAudioMetadata(string file) { if (!File.Exists(file)) @@ -27,7 +20,7 @@ public AudioMetadata GetAudioMetadata(string file) try { - var toReturn = new AudioMetadata() + var toReturn = new AudioMetadata { SongName = Path.GetFileNameWithoutExtension(file), Artist = "", @@ -84,7 +77,7 @@ public AudioMetadata GetAudioMetadata(string file) } catch (Exception e) { - _logger.LogError(e, "Unable to retrieve metadata for {File}", file); + logger.LogError(e, "Unable to retrieve metadata for {File}", file); return new AudioMetadata(); } } diff --git a/MSUScripter/Services/AudioPlayerServiceLinux.cs b/MSUScripter/Services/AudioPlayerServiceLinux.cs deleted file mode 100644 index 178ad25..0000000 --- a/MSUScripter/Services/AudioPlayerServiceLinux.cs +++ /dev/null @@ -1,198 +0,0 @@ -using System; -using System.Diagnostics; -using System.Linq; -using System.Text.RegularExpressions; -using System.Threading.Tasks; -using AvaloniaControls.Services; -using Microsoft.Extensions.Logging; -using MSUScripter.Configs; -using NAudio.Wave; - -namespace MSUScripter.Services; - -public class AudioPlayerServiceLinux : IAudioPlayerService -{ - private readonly ILogger _logger; - private readonly Settings _settings; - private readonly PythonCommandRunnerService _python; - private Process? _process; - private bool _isPlaying; - private bool _isTestingLoop; - private bool _canSetLoopValue; - private const string MinVersionSetLoop = "0.3.0"; - private static readonly Regex digitsOnly = new(@"[^\d.]"); - - public AudioPlayerServiceLinux(ILogger logger, Settings settings, PythonCommandRunnerService python) - { - _logger = logger; - _settings = settings; - _python = python; - if (_python.SetBaseCommand("pcm_player", "--version", out var result, out var error) && - result.StartsWith("pcm_player ")) - { - logger.LogInformation("{Version} found", result); - var version = digitsOnly.Replace(result, "").Split(".").Select(int.Parse).ToList(); - var versionValue = ConvertVersionNumber(version[0], version[1], version[2]); - var minVersionValue = GetMinVersionNumberForSetLoop(); - _canSetLoopValue = versionValue >= minVersionValue; - CanPlayMusic = true; - IAudioPlayerService.CanPlaySongs = true; - } - } - - public string CurrentPlayingFile { get; set; } = ""; - - public bool IsPlaying => _isPlaying; - - public bool IsPaused => false; - - public bool IsStopped => !_isPlaying; - - public void Pause() - { - if (_process?.HasExited == false) - { - _isPlaying = false; - _process.Exited -= ProcessOnExited; - _process.Kill(); - _process = null; - PlayStopped?.Invoke(this, EventArgs.Empty); - } - } - - public void PlayPause() - { - if (_isPlaying) - { - Pause(); - } - else if (!string.IsNullOrEmpty(CurrentPlayingFile)) - { - PlaySongAsync(CurrentPlayingFile, _isTestingLoop); - } - } - - public void Play() - { - // Do nothing - return; - } - - public double? GetCurrentPosition() - { - return null; - } - - public double GetLengthSeconds() - { - // Do nothing - return 0; - } - - public double GetCurrentPositionSeconds() - { - // Do nothing - return 0; - } - - public void SetPosition(double value) - { - // Do nothing - } - - public void JumpToTime(double seconds) - { - // Do nothing - } - - public void SetVolume(double volume) - { - // Do nothing - } - - public Task PlaySongAsync(string path, bool fromEnd) - { - Pause(); - _isTestingLoop = fromEnd; - CurrentPlayingFile = path; - - if (_canSetLoopValue) - { - if (fromEnd) - { - var duration = _settings.LoopDuration; - _process = _python.RunCommandAsync($"-l -s {duration} \"{path}\""); - } - else - { - _process = _python.RunCommandAsync($"\"{path}\""); - } - } - else - { - if (fromEnd) - { - _process = _python.RunCommandAsync($"-f \"{path}\" -l"); - } - else - { - _process = _python.RunCommandAsync($"-f \"{path}\""); - } - } - - - if (_process != null) - { - _isPlaying = true; - _process.Exited += ProcessOnExited; - _process.Disposed += ProcessOnExited; - PlayStarted?.Invoke(this, EventArgs.Empty); - - ITaskService.Run(() => - { - var processToWaitFor = _process; - processToWaitFor.WaitForExit(); - if (_process == processToWaitFor) - { - Pause(); - } - }); - } - - return Task.FromResult(_process != null); - } - - private void ProcessOnExited(object? sender, EventArgs e) - { - _isPlaying = false; - PlayStopped?.Invoke(this, EventArgs.Empty); - } - - private int GetMinVersionNumberForSetLoop() - { - var version = MinVersionSetLoop.Split(".").Select(int.Parse).ToList(); - return ConvertVersionNumber(version[0], version[1], version[2]); - } - - private int ConvertVersionNumber(int a, int b, int c) - { - return a * 10000 + b * 100 + c; - } - - public Task StopSongAsync(string? newSongPath = null, bool waitForFile = false) - { - Pause(); - return Task.FromResult(true); - } - - public EventHandler? PlayStarted { get; set; } - public EventHandler? PlayPaused { get; set; } - public EventHandler? PlayStopped { get; set; } - - public bool CanPlayMusic { get; set; } = false; - - public bool CanSetMusicPosition { get; set; } = false; - - public bool CanChangeVolume { get; set; } = false; - public bool CanPauseMusic { get; set; } = false; -} \ No newline at end of file diff --git a/MSUScripter/Services/AudioPlayerServiceWindows.cs b/MSUScripter/Services/AudioPlayerServiceNAudio.cs similarity index 56% rename from MSUScripter/Services/AudioPlayerServiceWindows.cs rename to MSUScripter/Services/AudioPlayerServiceNAudio.cs index 113c644..7b28a4c 100644 --- a/MSUScripter/Services/AudioPlayerServiceWindows.cs +++ b/MSUScripter/Services/AudioPlayerServiceNAudio.cs @@ -9,19 +9,10 @@ namespace MSUScripter.Services; -public class AudioPlayerServiceWindows : IAudioPlayerService +public class AudioPlayerServiceNAudio(ILogger logger, Settings settings) : IAudioPlayerService { private WaveOutEvent? _waveOutEvent; - private LoopStream? _loopStream; - private readonly ILogger _logger; - private readonly Settings _settings; - - public AudioPlayerServiceWindows(ILogger logger, Settings settings) - { - _logger = logger; - _settings = settings; - IAudioPlayerService.CanPlaySongs = true; - } + private WaveStream? _loopStream; public string CurrentPlayingFile { get; set; } = ""; @@ -81,6 +72,7 @@ public void SetPosition(double value) { if (_waveOutEvent == null || _loopStream == null) return; value = Math.Clamp(value, 0.0, 1.0); + _loopStream.Position = (long)(_loopStream.Length * value + 8.0); } @@ -91,17 +83,21 @@ public void JumpToTime(double seconds) public void SetVolume(double volume) { - if (_waveOutEvent == null || _loopStream == null) return; + if (_waveOutEvent == null) return; volume = Math.Clamp(volume, 0.0, 1.0); _waveOutEvent.Volume = (float)volume; } - + + private WaveStream? _stopStream; + public async Task StopSongAsync(string? newSongPath = null, bool waitForFile = false) { - if (_waveOutEvent?.PlaybackState == PlaybackState.Playing || _waveOutEvent?.PlaybackState == PlaybackState.Paused) + if (_waveOutEvent?.PlaybackState is PlaybackState.Playing or PlaybackState.Paused) { _waveOutEvent?.Stop(); } + + _stopStream = _loopStream; if (waitForFile && !string.IsNullOrEmpty(CurrentPlayingFile)) { @@ -130,7 +126,7 @@ public async Task StopSongAsync(string? newSongPath = null, bool waitForFi } catch { - _logger.LogInformation("Song not accessible"); + logger.LogInformation("Song not accessible"); return false; } } @@ -167,18 +163,18 @@ public async Task StopSongAsync(string? newSongPath = null, bool waitForFi } catch (Exception e) { - _logger.LogError(e, "Error stopping music"); + logger.LogError(e, "Error stopping music"); } - _logger.LogInformation("Song stopped playing {Value}", canPlay ? "successfully" : "unsuccessfully"); + logger.LogInformation("Song stopped playing {Value}", canPlay ? "successfully" : "unsuccessfully"); return canPlay; } - _logger.LogInformation("Song stopped playing successfully"); + logger.LogInformation("Song stopped playing successfully"); return true; } - public async Task PlaySongAsync(string path, bool fromEnd) + public async Task PlaySongAsync(string path, bool fromEnd, bool isLoopingSong) { var canPlay = await StopSongAsync(path); @@ -188,96 +184,124 @@ public async Task PlaySongAsync(string path, bool fromEnd) _ = ITaskService.Run(() => { - _logger.LogInformation("Playing song {Path}", path); + _ = PlaySongInternal(path, fromEnd, isLoopingSong); + }); + + return true; + } + + private async Task PlaySongInternal(string path, bool fromEnd, bool isLoopingSong) + { + logger.LogInformation("Playing song {Path}", path); - var initBytes = new byte[8]; - using (var reader = new BinaryReader(new FileStream(path, FileMode.Open))) + var initBytes = new byte[8]; + using (var reader = new BinaryReader(new FileStream(path, FileMode.Open))) + { + reader.BaseStream.Seek(0, SeekOrigin.Begin); + _ = reader.Read(initBytes, 0, 8); + } + + var replay = false; + + logger.LogInformation("Audio file read"); + + var loopPoint = BitConverter.ToInt32(initBytes, 4) * 1.0; + var totalBytes = new FileInfo(path).Length - 8.0; + var totalSamples = totalBytes / 4.0; + var loopBytes = (long)(loopPoint / totalSamples * totalBytes) + 8; + var startPosition = 8L; + if (fromEnd) + { + var endSamples = totalSamples - 44100 * settings.LoopDuration; + startPosition = (long)(endSamples / totalSamples * totalBytes) + 8; + if (startPosition < 8) { - reader.BaseStream.Seek(0, SeekOrigin.Begin); - reader.Read(initBytes, 0, 8); + startPosition = 8; } + } + + // Fix bad loops to be at the beginning + var enableLoop = loopBytes >= 8 && loopBytes < totalBytes + 8; + + try + { + await using var fs = File.OpenRead(path); + await using var rs = new RawSourceWaveStream(fs, new WaveFormat(44100, 2)); + await using var loop = new NAudioLoopStream(rs); + using var waveOutEvent = new WaveOutEvent(); + + _waveOutEvent = waveOutEvent; + _waveOutEvent.Volume = (float)settings.Volume; - _logger.LogInformation("Audio file read"); - - var loopPoint = BitConverter.ToInt32(initBytes, 4) * 1.0; - var totalBytes = new FileInfo(path).Length - 8.0; - var totalSamples = totalBytes / 4.0; - var loopBytes = (long)(loopPoint / totalSamples * totalBytes) + 8; - var startPosition = 8L; - if (fromEnd) + if (isLoopingSong) { - var endSamples = totalSamples - 44100 * _settings.LoopDuration; - startPosition = (long)(endSamples / totalSamples * totalBytes) + 8; - if (startPosition < 8) - { - startPosition = 8; - } + loop.EnableLooping = enableLoop; + _loopStream = loop; + waveOutEvent.Init(loop); + _loopStream.Position = startPosition; + loop.LoopPosition = loopBytes; + } + else + { + _loopStream = rs; + _loopStream.Position = startPosition; + waveOutEvent.Init(rs); + } + + Play(); + logger.LogInformation("Playing audio file"); + PlayStarted?.Invoke(this, EventArgs.Empty); + Thread.Sleep(200); + while (waveOutEvent.PlaybackState != PlaybackState.Stopped) + { + Thread.Sleep(200); } - // Fix bad loops to be at the beginning - var enableLoop = loopBytes >= 8 && loopBytes < totalBytes + 8; - - try + if (!isLoopingSong && _loopStream == rs && _stopStream != _loopStream) { - using (var fs = File.OpenRead(path)) - using (var rs = new RawSourceWaveStream(fs, new WaveFormat(44100, 2))) - using (var loop = new LoopStream(rs)) - using (var waveOutEvent = new WaveOutEvent()) - { - loop.EnableLooping = enableLoop; - _waveOutEvent = waveOutEvent; - _waveOutEvent.Volume = (float)_settings.Volume; - _loopStream = loop; - waveOutEvent.Init(loop); - loop.Position = startPosition; - loop.LoopPosition = loopBytes; - Play(); - _logger.LogInformation("Playing audio file"); - PlayStarted?.Invoke(this, EventArgs.Empty); - Thread.Sleep(200); - while (waveOutEvent.PlaybackState != PlaybackState.Stopped) - { - Thread.Sleep(200); - } - _waveOutEvent = null; - _loopStream = null; - PlayStopped?.Invoke(this, EventArgs.Empty); - } + replay = true; } - catch (Exception e) + + _waveOutEvent = null; + _loopStream = null; + _stopStream = null; + PlayStopped?.Invoke(this, EventArgs.Empty); + } + catch (Exception e) + { + logger.LogError(e, "Failure playing song"); + CurrentPlayingFile = ""; + + try { - _logger.LogError(e, "Failure playing song"); - CurrentPlayingFile = ""; - - try + if (_loopStream != null) { - if (_loopStream != null) - { - _loopStream.Dispose(); - _loopStream = null; - } - - if (_waveOutEvent != null) - { - _waveOutEvent.Dispose(); - _waveOutEvent = null; - } - - canPlay = true; + await _loopStream.DisposeAsync(); + _loopStream = null; } - catch (Exception e2) + + if (_waveOutEvent != null) { - _logger.LogError(e2, "Error stopping music"); + _waveOutEvent.Dispose(); + _waveOutEvent = null; } } - - }); - - return true; + catch (Exception e2) + { + logger.LogError(e2, "Error stopping music"); + } + } + + if (replay) + { + await Task.Delay(TimeSpan.FromSeconds(2)); + if (_loopStream == null) + { + _ = PlaySongInternal(path, false, false); + } + } } - - public EventHandler? PlayStarted { get; set; } public EventHandler? PlayPaused { get; set; } public EventHandler? PlayStopped { get; set; } @@ -288,4 +312,4 @@ public async Task PlaySongAsync(string path, bool fromEnd) public bool CanChangeVolume { get; set; } = true; public bool CanPauseMusic { get; set; } = true; -} +} \ No newline at end of file diff --git a/MSUScripter/Services/AudioPlayerServiceSoundFlow.cs b/MSUScripter/Services/AudioPlayerServiceSoundFlow.cs new file mode 100644 index 0000000..b98fea6 --- /dev/null +++ b/MSUScripter/Services/AudioPlayerServiceSoundFlow.cs @@ -0,0 +1,235 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using AvaloniaControls.Services; +using Microsoft.Extensions.Logging; +using MSUScripter.Configs; +using SoundFlow.Backends.MiniAudio; +using SoundFlow.Components; +using SoundFlow.Enums; +using SoundFlow.Providers; + +namespace MSUScripter.Services; + +public class AudioPlayerServiceSoundFlow(ILogger logger, Settings settings) + : IAudioPlayerService +{ + private SoundPlayer? _soundPlayer; + + // ReSharper disable once NotAccessedField.Local + private MiniAudioEngine _audioEngine = new(44100, Capability.Playback); + + public string CurrentPlayingFile { get; set; } = ""; + + public bool IsPlaying => _soundPlayer?.State == PlaybackState.Playing; + + public bool IsPaused => _soundPlayer?.State == PlaybackState.Paused; + + public bool IsStopped => !IsPlaying && !IsPaused; + + public void Pause() + { + if (_soundPlayer == null) return; + _soundPlayer?.Pause(); + PlayPaused?.Invoke(this, EventArgs.Empty); + } + + public void PlayPause() + { + if (_soundPlayer == null) return; + if (_soundPlayer.State == PlaybackState.Playing) + { + Pause(); + } + else if (_soundPlayer.State == PlaybackState.Paused) + { + Play(); + } + } + + public void Play() + { + if (_soundPlayer == null) return; + _soundPlayer?.Play(); + PlayStarted?.Invoke(this, EventArgs.Empty); + } + + public double? GetCurrentPosition() + { + if (_soundPlayer == null) return null; + return _soundPlayer.Time / _soundPlayer.Duration; + } + + public double GetLengthSeconds() + { + if (_soundPlayer == null) return 0; + return _soundPlayer.Duration; + } + + public double GetCurrentPositionSeconds() + { + if (_soundPlayer == null) return 0; + return _soundPlayer.Time; + } + + public void SetPosition(double value) + { + if (_soundPlayer == null) return; + var percent = (float)Math.Clamp(value, 0.0, 1.0); + _soundPlayer.Seek(percent * _soundPlayer.Duration); + } + + public void JumpToTime(double seconds) + { + SetPosition(seconds / GetLengthSeconds()); + } + + public void SetVolume(double volume) + { + if (_soundPlayer == null) return; + volume = Math.Clamp(volume, 0.0, 1.0) * 1.5f; + _soundPlayer.Volume = (float)volume; + _soundPlayer.Pan = 0.5f; + } + + public async Task StopSongAsync(string? newSongPath = null, bool waitForFile = false) + { + if (_soundPlayer == null) + { + return true; + } + + if (_soundPlayer.State == PlaybackState.Playing) + { + _soundPlayer.Stop(); + } + + // Wait until the previous song has stopped playing + if (_soundPlayer.State == PlaybackState.Playing) + { + for(var i = 0; i < 30; i++) + { + logger.LogInformation($"{_soundPlayer.State}"); + await Task.Delay(200); + if (_soundPlayer.State != PlaybackState.Playing) + { + break; + } + } + } + + try + { + if (_soundPlayer != null) + { + Mixer.Master.RemoveComponent(_soundPlayer); + _soundPlayer = null; + } + + logger.LogInformation("Song stopped playing successfully"); + + return true; + } + catch (Exception e) + { + logger.LogError(e, "Error stopping music"); + + return false; + } + } + + public async Task PlaySongAsync(string path, bool fromEnd, bool isLoopingSong) + { + var canPlay = await StopSongAsync(path); + + if (!canPlay) return false; + + CurrentPlayingFile = path; + + _ = ITaskService.Run(() => + { + logger.LogInformation("Playing song {Path}", path); + + var initBytes = new byte[8]; + using (var reader = new BinaryReader(new FileStream(path, FileMode.Open))) + { + reader.BaseStream.Seek(0, SeekOrigin.Begin); + if (reader.Read(initBytes, 0, 8) < 8) + { + logger.LogInformation("Invalid file"); + return; + } + } + + logger.LogInformation("Audio file read"); + + var loopSamples = BitConverter.ToInt32(initBytes, 4) * 1; + var totalBytes = new FileInfo(path).Length - 8; + var totalSamples = totalBytes / 4; + var startPosition = 0; + if (fromEnd) + { + var endSamples = totalSamples - 44100 * settings.LoopDuration; + startPosition = (int)endSamples; + if (startPosition < 0) + { + startPosition = 0; + } + } + + // Fix bad loops to be at the beginning + if (loopSamples > totalSamples) + { + loopSamples = 0; + } + + var bytes = File.ReadAllBytes(path).Skip(8); + _soundPlayer = new SoundPlayer(new RawDataProvider(bytes.ToArray(), SampleFormat.S16)); + + if (isLoopingSong) + { + _soundPlayer.IsLooping = true; + _soundPlayer.SetLoopPoints(loopSamples * 2); + } + else + { + _soundPlayer.PlaybackEnded += SoundPlayerOnPlaybackEnded; + } + + // Add the player to the master mixer. This connects the player's output to the audio engine's output. + Mixer.Master.AddComponent(_soundPlayer); + + // Start playback. + _soundPlayer.Play(); + _soundPlayer.Seek(startPosition * 2); + _soundPlayer.Volume = (float)settings.Volume; + _soundPlayer.Pan = 0.5f; + + PlayStarted?.Invoke(this, EventArgs.Empty); + + }); + + return true; + } + + private void SoundPlayerOnPlaybackEnded(object? sender, EventArgs e) + { + ITaskService.Run(async () => + { + await Task.Delay(TimeSpan.FromSeconds(2)); + _soundPlayer?.Seek(0); + _soundPlayer?.Play(); + }); + } + + + public EventHandler? PlayStarted { get; set; } + public EventHandler? PlayPaused { get; set; } + public EventHandler? PlayStopped { get; set; } + + public bool CanPlayMusic { get; set; } = true; + public bool CanSetMusicPosition { get; set; } = true; + public bool CanChangeVolume { get; set; } = true; + public bool CanPauseMusic { get; set; } = true; +} diff --git a/MSUScripter/Services/ControlServices/AddSongWindowService.cs b/MSUScripter/Services/ControlServices/AddSongWindowService.cs deleted file mode 100644 index 40f4e70..0000000 --- a/MSUScripter/Services/ControlServices/AddSongWindowService.cs +++ /dev/null @@ -1,239 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using AvaloniaControls.Controls; -using AvaloniaControls.ControlServices; -using AvaloniaControls.Models; -using AvaloniaControls.Services; -using Microsoft.Extensions.Logging; -using MSUScripter.ViewModels; -using MSUScripter.Views; - -namespace MSUScripter.Services.ControlServices; - -public class AddSongWindowService( - AudioMetadataService audioMetadataService, - AudioAnalysisService audioAnalysisService, - MsuPcmService msuPcmService, - ConverterService converterService, - IAudioPlayerService audioPlayerService, - PyMusicLooperService pyMusicLooperService) : ControlService -{ - private readonly AddSongWindowViewModel _model = new(); - - public AddSongWindowViewModel InitializeModel(MsuProjectViewModel project, int? trackNumber, string? filePath, bool singleMode) - { - _model.MsuProjectViewModel = project; - _model.MsuProject = converterService.ConvertProject(project); - - var addDescriptions = project.Tracks.First().HasDescription; - _model.SingleMode = singleMode; - _model.TrackSearchItems = [new ComboBoxAndSearchItem(null, "Track", addDescriptions ? "Default description" : null)]; - _model.TrackSearchItems.AddRange(project.Tracks.OrderBy(x => x.TrackNumber).Select(x => - new ComboBoxAndSearchItem(x, x.ToString(), x.Description))); - _model.SelectedTrack = trackNumber == null ? null : project.Tracks.FirstOrDefault(x => x.TrackNumber == trackNumber); - - UpdateFilePath(filePath); - - _model.HasBeenModified = false; - - // TODO: Run PyMusicLooper on start if file exists - - return _model; - } - - public void UpdateFilePath(string? path) - { - if (string.IsNullOrEmpty(path)) - { - return; - } - - var metadata = audioMetadataService.GetAudioMetadata(path); - - _model.FilePath = path; - _model.SongName = metadata.SongName ?? _model.SongName; - _model.ArtistName = metadata.Artist ?? _model.ArtistName; - _model.AlbumName = metadata.Album ?? _model.AlbumName; - _model.DisplayHertzWarning = audioAnalysisService.GetAudioSampleRate(path) != 44100; - } - - public void UpdatePyMusicLooperPanel(PyMusicLooperPanel? panel) - { - if (panel == null || !_model.CanEditMainFields) return; - - var pcmData = new MsuSongMsuPcmInfoViewModel - { - File = _model.FilePath - }; - - panel.UpdateModel(_model.MsuProjectViewModel, new MsuSongInfoViewModel - { - MsuPcmInfo = pcmData - }, pcmData); - } - - public async Task PlaySong(bool fromEnd) - { - // Stop the song if it is currently playing - await StopSong(); - - if (string.IsNullOrEmpty(_model.FilePath) || !File.Exists(_model.FilePath)) - { - return; - } - - var outputPath = await CreateTempPcm(); - - if (string.IsNullOrEmpty(outputPath)) - { - return; - } - - await audioPlayerService.PlaySongAsync(outputPath, fromEnd); - } - - public async Task StopSong(bool wait = true) - { - await audioPlayerService.StopSongAsync(null, wait); - } - - public void UpdateFromPyMusicLooper(PyMusicLooperResultViewModel? result) - { - if (result == null) - { - return; - } - - _model.LoopPoint = result.LoopStart; - _model.TrimEnd = result.LoopEnd; - } - - private async Task CreateTempPcm() - { - var response = await msuPcmService.CreateTempPcm(false, _model.MsuProject, _model.FilePath, _model.LoopPoint, - _model.TrimEnd, _model.Normalization ?? _model.MsuProjectViewModel.BasicInfo.Normalization, - _model.TrimStart); - - return response.GeneratedPcmFile ? response.OutputPath : null; - } - - public async Task AddSongToProject(AddSongWindow parent) - { - if (_model.SelectedTrack == null || string.IsNullOrEmpty(_model.FilePath)) - { - return null; - } - - var track = _model.SelectedTrack; - - var response = await msuPcmService.CreateTempPcm(true, _model.MsuProject, _model.FilePath, _model.LoopPoint, - _model.TrimEnd, _model.Normalization ?? _model.MsuProjectViewModel.BasicInfo.Normalization, - _model.TrimStart); - - if (!response.GeneratedPcmFile) - { - await MessageWindow.ShowErrorDialog(response.Message ?? "Unknown error", "Error", parent); - return null; - } - - if (!response.Successful) - { - if (!await MessageWindow.ShowYesNoDialog($"{response.Message}\r\nDo you want to continue adding this song?", - "Continue?", parent)) - { - return null; - } - } - - var isAlt = track.Songs.Any(); - string outputPath; - var msu = new FileInfo(_model.MsuProjectViewModel.MsuPath); - - if (!isAlt) - { - outputPath = msu.FullName.Replace(msu.Extension, $"-{track.TrackNumber}.pcm"); - } - else - { - var altSuffix = track.Songs.Count == 1 ? "alt" : $"alt{track.Songs.Count}"; - outputPath = msu.FullName.Replace(msu.Extension, $"-{track.TrackNumber}_{altSuffix}.pcm"); - } - - var song = new MsuSongInfoViewModel - { - SongName = _model.SongName, - Artist = _model.ArtistName, - Album = _model.AlbumName, - CheckCopyright = _model.CheckCopyright, - IsCopyrightSafe = _model.IsCopyrightSafe, - OutputPath = outputPath, - MsuPcmInfo = new MsuSongMsuPcmInfoViewModel - { - Loop = _model.LoopPoint, - TrimStart = _model.TrimStart, - TrimEnd = _model.TrimEnd, - Normalization = _model.Normalization, - File = _model.FilePath - } - }; - - song.ApplyCascadingSettings(_model.MsuProjectViewModel, track, isAlt, false, true, true); - - track.Songs.Add(song); - - _model.AddSongButtonText = "Added Song"; - _ = ITaskService.Run(() => - { - Thread.Sleep(TimeSpan.FromSeconds(3)); - _model.AddSongButtonText = "Add Song"; - }); - - return song; - } - - public void ClearModel() - { - _model.Clear(); - } - - public void AnalyzeAudio() - { - _model.AverageAudio = "Running"; - _model.PeakAudio = null; - - ITaskService.Run(async () => - { - await StopSong(false); - - var outputPath = await CreateTempPcm(); - - if (!string.IsNullOrEmpty(outputPath)) - { - var output = await audioAnalysisService.AnalyzeAudio(outputPath); - - if (output is { AvgDecibels: not null, MaxDecibels: not null }) - { - _model.AverageAudio = $"Average: {Math.Round(output.AvgDecibels.Value, 2)}db"; - _model.PeakAudio = $"Peak: {Math.Round(output.MaxDecibels.Value, 2)}db"; - } - else - { - _model.AverageAudio = "Error analyzing audio"; - _model.PeakAudio = null; - } - } - else - { - _model.AverageAudio = "Error generating PCM"; - _model.PeakAudio = null; - } - }); - } - - public bool IsPyMusicLooperRunning() => pyMusicLooperService.IsRunning; - - public bool HasChanges => !string.IsNullOrEmpty(_model.FilePath); -} \ No newline at end of file diff --git a/MSUScripter/Services/ControlServices/AudioAnalysisWindowService.cs b/MSUScripter/Services/ControlServices/AudioAnalysisWindowService.cs index 51a5738..b8db9af 100644 --- a/MSUScripter/Services/ControlServices/AudioAnalysisWindowService.cs +++ b/MSUScripter/Services/ControlServices/AudioAnalysisWindowService.cs @@ -4,18 +4,31 @@ using System.Threading; using AvaloniaControls.ControlServices; using AvaloniaControls.Services; +using Microsoft.Extensions.Logging; using MSURandomizerLibrary.Services; +using MSUScripter.Configs; using MSUScripter.ViewModels; namespace MSUScripter.Services.ControlServices; -public class AudioAnalysisWindowService(AudioAnalysisService audioAnalysisService, IMsuLookupService msuLookupService) : ControlService +// ReSharper disable once ClassNeverInstantiated.Global +public class AudioAnalysisWindowService( + AudioAnalysisService audioAnalysisService, + IMsuLookupService msuLookupService, + MsuPcmService msuPcmService, + ILogger logger) : ControlService { private readonly AudioAnalysisViewModel _model = new(); private readonly CancellationTokenSource _cts = new(); - public AudioAnalysisViewModel InitializeModel(MsuProjectViewModel project) + public AudioAnalysisViewModel InitializeModel(MsuProject project) { + if (msuPcmService.IsGeneratingPcm) + { + _model.LoadError = "Another PCM file is currently being generated"; + return _model; + } + _model.Project = project; var msuDirectory = new FileInfo(project.MsuPath).DirectoryName; @@ -31,10 +44,10 @@ public AudioAnalysisViewModel InitializeModel(MsuProjectViewModel project) .Select(x => new AudioAnalysisSongViewModel() { SongName = Path.GetRelativePath(msuDirectory, new FileInfo(x.OutputPath!).FullName), - TrackName = x.TrackName, + TrackName = x.TrackName ?? $"Track {x.TrackNumber}", TrackNumber = x.TrackNumber, Path = x.OutputPath ?? "", - OriginalViewModel = x, + MsuSongInfo = x, }) .ToList(); @@ -46,6 +59,12 @@ public AudioAnalysisViewModel InitializeModel(string msuPath) { _model.ShowCompareButton = false; + if (msuPcmService.IsGeneratingPcm) + { + _model.LoadError = "Another PCM file is currently being generated"; + return _model; + } + var msuDirectory = new FileInfo(msuPath).DirectoryName; if (string.IsNullOrEmpty(msuDirectory)) { @@ -63,13 +82,13 @@ public AudioAnalysisViewModel InitializeModel(string msuPath) var songs = msu.Tracks .OrderBy(x => x.Number) - .Select(x => new AudioAnalysisSongViewModel() + .Select(x => new AudioAnalysisSongViewModel { SongName = Path.GetRelativePath(msuDirectory, new FileInfo(x.Path).FullName), TrackName = x.TrackName, TrackNumber = x.Number, - Path = x.Path ?? "", - OriginalViewModel = null, + Path = x.Path, + MsuSongInfo = null, CanRefresh = false }) .ToList(); @@ -114,7 +133,7 @@ public void RunSong(AudioAnalysisSongViewModel song) _ = ITaskService.Run(async () => { - await audioAnalysisService!.AnalyzePcmFile(_model.Project!, song); + await audioAnalysisService.AnalyzePcmFile(_model.Project, song); CheckSongWarnings(song, GetAverageRms(), GetAveragePeak()); UpdateBottomMessage(); }, _cts.Token); @@ -125,6 +144,11 @@ public void Stop() _cts.Cancel(); } + public void LogError(Exception e, string message) + { + logger.LogError(e, "{Message}", message); + } + public event EventHandler? Completed; private double GetAverageRms() => Math.Round(_model.Rows.Where(x => x.AvgDecibels != null).Average(x => x.AvgDecibels) ?? 0, 4); diff --git a/MSUScripter/Services/ControlServices/AudioControlService.cs b/MSUScripter/Services/ControlServices/AudioControlService.cs index 8d75364..4d21d03 100644 --- a/MSUScripter/Services/ControlServices/AudioControlService.cs +++ b/MSUScripter/Services/ControlServices/AudioControlService.cs @@ -8,6 +8,7 @@ namespace MSUScripter.Services.ControlServices; +// ReSharper disable once ClassNeverInstantiated.Global public class AudioControlService(IAudioPlayerService audioService, SettingsService settingsService) : ControlService { private readonly AudioControlViewModel _model = new(); @@ -45,6 +46,8 @@ public AudioControlViewModel InitializeModel() return _model; } + public EventHandler? OnPlayStarted; + public Task StopAsync() { return audioService.StopSongAsync(); @@ -107,6 +110,7 @@ private void PlayPaused(object? sender, EventArgs e) private void PlayStarted(object? sender, EventArgs e) { + OnPlayStarted?.Invoke(this, EventArgs.Empty); _model.Icon = MaterialIconKind.Pause; _model.CanPlayPause = true; StartTimer(); diff --git a/MSUScripter/Services/ControlServices/CopyMoveTrackWindowService.cs b/MSUScripter/Services/ControlServices/CopyMoveTrackWindowService.cs deleted file mode 100644 index 1424f03..0000000 --- a/MSUScripter/Services/ControlServices/CopyMoveTrackWindowService.cs +++ /dev/null @@ -1,107 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using AvaloniaControls.ControlServices; -using Microsoft.Extensions.Logging; -using MSUScripter.Configs; -using MSUScripter.ViewModels; - -namespace MSUScripter.Services.ControlServices; - -public class CopyMoveTrackWindowService (ConverterService converterService) : ControlService -{ - private readonly CopyMoveTrackWindowViewModel _model = new(); - - public CopyMoveTrackWindowViewModel InitializeModel(MsuProjectViewModel msuProjectViewModel, MsuTrackInfoViewModel trackViewModel, - MsuSongInfoViewModel msuSongInfoViewModel, CopyMoveType type) - { - _model.Project = msuProjectViewModel; - _model.PreviousTrack = trackViewModel; - _model.PreviousSong = msuSongInfoViewModel; - _model.Type = type; - _model.Tracks = msuProjectViewModel.Tracks.OrderBy(x => x.TrackNumber).ToList(); - _model.TargetTrack = _model.PreviousTrack; - _model.OriginalLocation = trackViewModel.Songs.IndexOf(msuSongInfoViewModel); - - return _model; - } - - public void RunCopyMove() - { - if (_model.PreviousTrack == null || _model.PreviousSong == null || _model.Project == null) - { - return; - } - - var songInfo = _model.PreviousSong; - var previousTrack = _model.PreviousTrack; - var destinationTrack = _model.TargetTrack; - - if (_model.Type == CopyMoveType.Move) - { - var targetLocation = _model.TargetLocation; - if (previousTrack == destinationTrack && targetLocation > _model.OriginalLocation) - { - targetLocation--; - } - previousTrack.Songs.Remove(songInfo); - destinationTrack.Songs.Insert(targetLocation, songInfo); - } - else if (_model.Type == CopyMoveType.Copy) - { - var msuSongInfo = new MsuSongInfo(); - converterService.ConvertViewModel(_model.PreviousSong, msuSongInfo); - converterService.ConvertViewModel(_model.PreviousSong.MsuPcmInfo, msuSongInfo.MsuPcmInfo); - - var msuSongInfoCloned = new MsuSongInfoViewModel(); - converterService.ConvertViewModel(msuSongInfo, msuSongInfoCloned); - converterService.ConvertViewModel(msuSongInfo.MsuPcmInfo, msuSongInfoCloned.MsuPcmInfo); - - destinationTrack.Songs.Insert(_model.TargetLocation, msuSongInfoCloned); - } - else if (_model.Type == CopyMoveType.Swap) - { - var originalIndex = previousTrack.Songs.IndexOf(songInfo); - var swapSong = destinationTrack.Songs[_model.TargetLocation]; - previousTrack.Songs.Remove(songInfo); - destinationTrack.Songs.Remove(swapSong); - previousTrack.Songs.Insert(Math.Clamp(originalIndex, 0, previousTrack.Songs.Count), swapSong); - destinationTrack.Songs.Insert(Math.Clamp(_model.TargetLocation, 0, destinationTrack.Songs.Count), songInfo); - } - - previousTrack.FixTrackSuffixes(songInfo.CanPlaySongs); - if (previousTrack != destinationTrack) - { - destinationTrack.FixTrackSuffixes(songInfo.CanPlaySongs); - } - } - - public void UpdateTrackLocations() - { - List locationOptions = []; - - var prefix = _model.Type == CopyMoveType.Swap ? "Song " : "Before song "; - - for (var i = 0; i < _model.TargetTrack.Songs.Count; i++) - { - if (string.IsNullOrEmpty(_model.TargetTrack.Songs[0].SongName)) - { - locationOptions.Add($"{prefix}{i+1}"); - } - else - { - locationOptions.Add($"{prefix}{i+1}: {_model.TargetTrack.Songs[i].SongName}"); - } - } - - if (_model.Type != CopyMoveType.Swap) - { - locationOptions.Add("At the end of the list"); - } - - _model.TargetLocationOptions = locationOptions; - _model.TargetLocation = locationOptions.Count - 1; - _model.IsTargetLocationEnabled = locationOptions.Count > 1; - } -} \ No newline at end of file diff --git a/MSUScripter/Services/ControlServices/CopyProjectWindowService.cs b/MSUScripter/Services/ControlServices/CopyProjectWindowService.cs index b2bd83c..1eeef03 100644 --- a/MSUScripter/Services/ControlServices/CopyProjectWindowService.cs +++ b/MSUScripter/Services/ControlServices/CopyProjectWindowService.cs @@ -4,32 +4,50 @@ using System.Linq; using System.Threading.Tasks; using Avalonia.Platform.Storage; -using AvaloniaControls; using AvaloniaControls.ControlServices; +using Microsoft.Extensions.Logging; using MSUScripter.Configs; using MSUScripter.ViewModels; namespace MSUScripter.Services.ControlServices; -public class CopyProjectWindowService(ConverterService converterService) : ControlService +// ReSharper disable once ClassNeverInstantiated.Global +public class CopyProjectWindowService(ConverterService converterService, ILogger logger) : ControlService { - private CopyProjectWindowViewModel _model = new(); + private readonly CopyProjectWindowViewModel _model = new(); public CopyProjectWindowViewModel InitializeModel() { return _model; } - public void SetProject(MsuProject project) + public void SetProject(MsuProject project, bool isCopy) { - _model.OriginalProject = project; - _model.ProjectViewModel = converterService.ConvertProject(project); + if (isCopy) + { + _model.OriginalProject = project; + _model.NewProject = converterService.CloneProject(project); + } + else + { + _model.OriginalProject = converterService.CloneProject(project); + _model.NewProject = project; + } + + _model.IsCopy = isCopy; + _model.ButtonText = isCopy ? "Copy Project" : "Open Project"; - var paths = new List + var title = string.IsNullOrEmpty(project.BasicInfo.PackName) ? "Project" : project.BasicInfo.PackName; + _model.Title = isCopy ? $"Copy {title}" : $"Update {title}"; + + var paths = new List(); + + if (isCopy) { - new(project.ProjectFilePath), - new(project.MsuPath) - }; + paths.Add(new CopyProjectViewModel(project.ProjectFilePath)); + } + + paths.Add(new CopyProjectViewModel(project.MsuPath)); if (!string.IsNullOrEmpty(project.BasicInfo.ZeldaMsuPath)) { @@ -81,46 +99,53 @@ public async Task UpdatePath(CopyProjectViewModel viewModel, IStorageItem file) public void ImportProject() { - if (_model.ProjectViewModel == null) + if (_model.NewProject == null) { return; } - _model.ProjectViewModel.ProjectFilePath = _model.Paths - .First(x => x.Extension.Equals(".msup", StringComparison.OrdinalIgnoreCase)).NewPath; + if (_model.IsCopy) + { + _model.NewProject.ProjectFilePath = _model.Paths + .First(x => x.Extension.Equals(".msup", StringComparison.OrdinalIgnoreCase)).NewPath; + } foreach (var path in _model.Paths.Where(x => x.Extension.Equals(".msu", StringComparison.OrdinalIgnoreCase))) { - if (_model.ProjectViewModel.MsuPath == path.PreviousPath) + if (_model.NewProject.MsuPath == path.PreviousPath) { - _model.ProjectViewModel.MsuPath = path.NewPath; + _model.NewProject.MsuPath = path.NewPath; } - else if (_model.ProjectViewModel.BasicInfo.MetroidMsuPath == path.PreviousPath) + else if (_model.NewProject.BasicInfo.MetroidMsuPath == path.PreviousPath) { - _model.ProjectViewModel.BasicInfo.MetroidMsuPath = path.NewPath; + _model.NewProject.BasicInfo.MetroidMsuPath = path.NewPath; } - else if (_model.ProjectViewModel.BasicInfo.ZeldaMsuPath == path.PreviousPath) + else if (_model.NewProject.BasicInfo.ZeldaMsuPath == path.PreviousPath) { - _model.ProjectViewModel.BasicInfo.ZeldaMsuPath = path.NewPath; + _model.NewProject.BasicInfo.ZeldaMsuPath = path.NewPath; } } var oldMsuPath = _model.OriginalProject?.MsuPath.Replace(".msu", "", StringComparison.OrdinalIgnoreCase) ?? ""; - var newMsuPath = _model.ProjectViewModel?.MsuPath.Replace(".msu", "", StringComparison.OrdinalIgnoreCase) ?? ""; + var newMsuPath = _model.NewProject?.MsuPath.Replace(".msu", "", StringComparison.OrdinalIgnoreCase) ?? ""; foreach (var path in _model.Paths.Where(x => !x.Extension.Equals(".msu", StringComparison.OrdinalIgnoreCase) && !x.Extension.Equals(".msup", StringComparison.OrdinalIgnoreCase))) { - foreach (var song in _model.ProjectViewModel!.Tracks.SelectMany(x => x.Songs)) + foreach (var song in _model.NewProject!.Tracks.SelectMany(x => x.Songs)) { UpdateSongPaths(song, path, oldMsuPath, newMsuPath); } } + + _model.SavedProject = _model.NewProject; + } - _model.NewProject = converterService.ConvertProject(_model.ProjectViewModel!); - return; + public void LogError(Exception e, string message) + { + logger.LogError(e, "{Message}", message); } - private void UpdateSongPaths(MsuSongInfoViewModel song, CopyProjectViewModel update, string oldMsuPath, string newMsuPath) + private void UpdateSongPaths(MsuSongInfo song, CopyProjectViewModel update, string oldMsuPath, string newMsuPath) { song.OutputPath = song.OutputPath?.Replace(oldMsuPath, newMsuPath); if (song.MsuPcmInfo.HasFiles()) @@ -129,7 +154,7 @@ private void UpdateSongPaths(MsuSongInfoViewModel song, CopyProjectViewModel upd } } - private void UpdateMsuPcmInfo(MsuSongMsuPcmInfoViewModel pcmInfo, CopyProjectViewModel update) + private void UpdateMsuPcmInfo(MsuSongMsuPcmInfo pcmInfo, CopyProjectViewModel update) { pcmInfo.File = pcmInfo.File?.Replace(update.PreviousPath, update.NewPath); foreach (var subchannel in pcmInfo.SubChannels) @@ -159,7 +184,7 @@ private void CheckFiles() } } - _model.IsValid = _model.Paths.All(x => x.IsValid); + _model.IsValid = !_model.IsCopy || _model.Paths.All(x => x.IsValid); } } \ No newline at end of file diff --git a/MSUScripter/Services/ControlServices/EditProjectPanelService.cs b/MSUScripter/Services/ControlServices/EditProjectPanelService.cs deleted file mode 100644 index 6f2179e..0000000 --- a/MSUScripter/Services/ControlServices/EditProjectPanelService.cs +++ /dev/null @@ -1,250 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Timers; -using AvaloniaControls; -using AvaloniaControls.ControlServices; -using AvaloniaControls.Models; -using MSUScripter.Configs; -using MSUScripter.ViewModels; - -namespace MSUScripter.Services.ControlServices; - -public class EditProjectPanelService( - ProjectService projectService, - MsuPcmService msuPcmService, - IAudioPlayerService audioService, - ConverterService converterService, - TrackListService trackListService, - StatusBarService statusBarService, - AudioAnalysisService audioAnalysisService) : ControlService -{ - private EditProjectPanelViewModel _model = new(); - private bool _isFirstInit = true; - private readonly Timer _backupTimer = new(TimeSpan.FromSeconds(60)); - - public EditProjectPanelViewModel InitializeModel(MsuProject project) - { - if (_isFirstInit) - { - statusBarService.StatusBarTextUpdated += (sender, args) => - { - _model.StatusBarText = args.Data; - }; - - _backupTimer.Elapsed += BackupTimerOnElapsed; - } - - var projectModel = converterService.ConvertProject(project); - - foreach (var songViewModel in projectModel.Tracks.SelectMany(x => x.Songs)) - { - songViewModel.MsuPcmInfo.UpdateHertzWarning(audioAnalysisService.GetAudioSampleRate(songViewModel.MsuPcmInfo.File)); - songViewModel.MsuPcmInfo.UpdateMultiWarning(); - songViewModel.MsuPcmInfo.UpdateSubTrackSubChannelWarning(); - } - - _model = new EditProjectPanelViewModel - { - MsuProject = project, - MsuProjectViewModel = projectModel, - Tracks = projectModel.Tracks.OrderBy(x => x.TrackNumber).ToList(), - LastAutoSave = projectModel.LastSaveTime - }; - - List searchItems = - [ - new ComboBoxAndSearchItem(0, "MSU Details"), - new ComboBoxAndSearchItem(1, "Track Overview"), - ]; - searchItems.AddRange(_model.Tracks.Select((t, i) => new ComboBoxAndSearchItem(i + 2, t.ToString()))); - _model.TrackSearchItems = searchItems; - - _backupTimer.Start(); - - if (project.IsNewProject) - { - statusBarService.UpdateStatusBar("Created New Project"); - } - else - { - statusBarService.UpdateStatusBar("Loaded Project"); - } - - return _model; - } - - public void IncrementPage(int mod) - { - _model.PageNumber = Math.Clamp(_model.PageNumber + mod, 0, _model.Tracks.Count + 1); - } - - public void SetPage(int page) - { - _model.PageNumber = Math.Clamp(page, 0, _model.Tracks.Count + 1); - } - - public void SetToTrackPage(int trackNumber) - { - _model.PageNumber = _model.Tracks.IndexOf(_model.Tracks.First(x => x.TrackNumber == trackNumber)) + 2; - } - - public void SaveProject() - { - if (_model.MsuProjectViewModel == null) return; - var project = converterService.ConvertProject(_model.MsuProjectViewModel!); - projectService.SaveMsuProject(project, false); - _model.MsuProjectViewModel.LastSaveTime = project.LastSaveTime; - _model.LastAutoSave = project.LastSaveTime; - } - - public string? ExportYaml(MsuProject? project = null) - { - if (_model.MsuProjectViewModel?.BasicInfo.WriteYamlFile != true) return null; - project ??= converterService.ConvertProject(_model.MsuProjectViewModel); - projectService!.ExportMsuRandomizerYaml(project, out var error); - return error; - } - - public string? ValidateProject() - { - if (_model.MsuProjectViewModel == null) return null; - projectService.ValidateProject(_model.MsuProjectViewModel, out var message); - return message; - } - - public void WriteTrackList(MsuProject? project = null) - { - if (_model.MsuProjectViewModel == null) return; - project ??= converterService.ConvertProject(_model.MsuProjectViewModel); - trackListService.WriteTrackListFile(project); - } - - public void WriteTrackJson() - { - if (_model.MsuProjectViewModel == null) return; - var project = converterService.ConvertProject(_model.MsuProjectViewModel); - msuPcmService.ExportMsuPcmTracksJson(false, project); - } - - public string? WriteSwapperBatchFiles() - { - if (_model.MsuProjectViewModel == null) return null; - var project = converterService.ConvertProject(_model.MsuProjectViewModel); - - var extraProjects = new List(); - - if (project.BasicInfo.CreateSplitSmz3Script) - { - extraProjects = projectService.GetSmz3SplitMsuProjects(project, out _, out var error).ToList(); - if (!string.IsNullOrEmpty(error)) - { - return error; - } - } - - return !projectService.CreateAltSwapperFile(project, extraProjects) - ? "Could not create alt swapper bat file. Project file may be corrupt. Verify output pcm file paths." - : null; - } - - public string? CreateSmz3SplitBatchFile() - { - if (_model.MsuProjectViewModel == null) return null; - var project = converterService.ConvertProject(_model.MsuProjectViewModel); - projectService.GetSmz3SplitMsuProjects(project, out var conversions, out var error); - if (!string.IsNullOrEmpty(error)) - { - return error; - } - - return !projectService.CreateSmz3SplitScript(project, conversions) - ? "Insufficient tracks to create the SMZ3 to ALttP and SM MSUs batch file." - : null; - } - - public string? SetupForMsuGenerationWindow() - { - if (_model.MsuProjectViewModel == null) return null; - var project = converterService.ConvertProject(_model.MsuProjectViewModel); - - if (!projectService.CreateMsuFiles(project)) - { - return "Unable to create MSU files"; - } - - var extraProjects = new List(); - - if (project.BasicInfo.CreateSplitSmz3Script) - { - extraProjects = projectService.GetSmz3SplitMsuProjects(project, out var conversions, out var error).ToList(); - - if (!string.IsNullOrEmpty(error)) - { - return error; - } - - projectService.CreateSmz3SplitScript(project, conversions); - } - - if (project.BasicInfo.CreateAltSwapperScript) - { - if (!projectService.CreateAltSwapperFile(project, extraProjects)) - { - return "Could not create alt swapper bat file. Project file may be corrupt. Verify output pcm file paths."; - } - } - - if (project.BasicInfo.TrackList != TrackListType.Disabled) - { - WriteTrackList(project); - } - - if (!project.BasicInfo.IsMsuPcmProject) - { - return ExportYaml(project); - } - - msuPcmService.ExportMsuPcmTracksJson(false, project); - - return null; - } - - public bool OpenFolder() - { - return _model.MsuProjectViewModel == null || - CrossPlatformTools.OpenDirectory(_model.MsuProjectViewModel.MsuPath, true); - } - - public void UpdateExportMenuOptions() - { - _model.DisplayAltSwapperExportButton = - _model.CreateAltSwapper && _model.MsuProjectViewModel?.Tracks.Any(x => x is { IsScratchPad: false, Songs.Count: > 1 }) == true; - } - - public void Disable() - { - _backupTimer.Stop(); - _ = audioService.StopSongAsync(); - } - - public bool ArePcmFilesUpToDate() - { - return _model.MsuProjectViewModel?.BasicInfo.IsMsuPcmProject == true && _model.MsuProjectViewModel.Tracks - .SelectMany(x => x.Songs).Any(x => x.HasChangesSince(x.LastGeneratedDate)); - } - - public bool HasPendingChanges() => _model.MsuProjectViewModel?.HasPendingChanges() == true; - - public MsuProjectViewModel? MsuProjectViewModel => _model.MsuProjectViewModel; - - private void BackupTimerOnElapsed(object? sender, ElapsedEventArgs e) - { - if (_model.MsuProjectViewModel?.HasChangesSince(_model.LastAutoSave) != true) - return; - var backupProject = converterService.ConvertProject(_model.MsuProjectViewModel); - projectService.SaveMsuProject(backupProject, true); - _model.LastAutoSave = DateTime.Now; - _model.StatusBarText = "Created Project Backup"; - } -} \ No newline at end of file diff --git a/MSUScripter/Services/ControlServices/InstallDependenciesWindowService.cs b/MSUScripter/Services/ControlServices/InstallDependenciesWindowService.cs new file mode 100644 index 0000000..985ec8c --- /dev/null +++ b/MSUScripter/Services/ControlServices/InstallDependenciesWindowService.cs @@ -0,0 +1,146 @@ +using System; +using System.Threading.Tasks; +using AvaloniaControls.ControlServices; +using MSUScripter.ViewModels; + +namespace MSUScripter.Services.ControlServices; + +// ReSharper disable once ClassNeverInstantiated.Global +public class InstallDependenciesWindowService (MsuPcmService msuPcmService, PythonCompanionService pythonCompanionService, SettingsService settingsService) : ControlService +{ + private readonly InstallDependenciesWindowViewModel _viewModel = new(); + + public InstallDependenciesWindowViewModel InitializeModel() + { + _viewModel.MsuPcmState = msuPcmService.IsValid ? InstallState.Valid : InstallState.CanInstall; + _viewModel.FfmpegState = pythonCompanionService.IsFfMpegValid ? InstallState.Valid : InstallState.CanInstall; + _viewModel.PyAppState = pythonCompanionService.IsValid ? InstallState.Valid : InstallState.CanInstall; + _viewModel.InitialDontRemindMeAgain = settingsService.Settings.IgnoreMissingDependencies; + return _viewModel; + } + + public async Task InstallMsuPcm() + { + _viewModel.MsuPcmState = InstallState.InProgress; + _viewModel.MsuPcmInstallProgress = "Starting"; + await Task.Delay(TimeSpan.FromMilliseconds(100)); + var result = await msuPcmService.InstallAsync(progress => + { + _viewModel.MsuPcmInstallProgress = progress; + }); + if (result.Success) + { + _viewModel.MsuPcmState = InstallState.Valid; + } + else + { + _viewModel.MsuPcmState = InstallState.Error; + _viewModel.MsuPcmErrorText = result.MissingSharedLibraries ? "Missing Libraries" : "Install Failed"; + _viewModel.MsuPcmErrorToolTip = result.MissingSharedLibraries + ? "MsuPcm++ is missing some libraries that it is dependent on. Please click to view additional installation instructions." + : "Failed to be able to download and run MsuPcm++. Please click to view additional installation instructions."; + } + } + + public async Task RetryMsuPcm() + { + if ((await msuPcmService.VerifyInstalledAsync()).Successful) + { + _viewModel.MsuPcmState = InstallState.Valid; + return; + } + await InstallMsuPcm(); + } + + public async Task RevalidateMsuPcm() + { + if ((await msuPcmService.VerifyInstalledAsync()).Successful) + { + _viewModel.MsuPcmState = InstallState.Valid; + } + } + + public async Task InstallFfmpeg() + { + _viewModel.FfmpegState = InstallState.InProgress; + _viewModel.FfmpegInstallProgress = "Starting"; + await Task.Delay(TimeSpan.FromMilliseconds(100)); + var result = await pythonCompanionService.InstallFfmpegAsync(progress => + { + _viewModel.FfmpegInstallProgress = progress; + }); + if (result) + { + _viewModel.FfmpegState = InstallState.Valid; + } + else + { + _viewModel.FfmpegState = InstallState.Error; + _viewModel.MsuPcmErrorText = "Install Failed"; + _viewModel.MsuPcmErrorToolTip = "Failed to be able to download and run FFmpeg. Please click to view additional installation instructions."; + } + } + + public async Task RetryFfmpeg() + { + if (await pythonCompanionService.VerifyFfMpegAsync()) + { + _viewModel.FfmpegState = InstallState.Valid; + return; + } + await InstallFfmpeg(); + } + + public async Task RevalidateFfmpeg() + { + if (await pythonCompanionService.VerifyFfMpegAsync()) + { + _viewModel.FfmpegState = InstallState.Valid; + } + } + + public async Task InstallPyApp() + { + _viewModel.PyAppState = InstallState.InProgress; + _viewModel.PyAppInstallProgress = "Starting"; + await Task.Delay(TimeSpan.FromMilliseconds(100)); + var result = await pythonCompanionService.InstallPyApp(progress => + { + _viewModel.PyAppInstallProgress = progress; + }); + if (result) + { + _viewModel.PyAppState = InstallState.Valid; + } + else + { + _viewModel.PyAppState = InstallState.Error; + _viewModel.PyAppErrorText = "Install Failed"; + _viewModel.PyAppErrorToolTip = "Failed to be able to download and run Python Companion App. Please click to view additional installation instructions."; + } + } + + public async Task RetryPyApp() + { + if (await pythonCompanionService.VerifyInstalledAsync()) + { + _viewModel.PyAppState = InstallState.Valid; + return; + } + await InstallPyApp(); + } + + public async Task RevalidatePyApp() + { + if (await pythonCompanionService.VerifyInstalledAsync()) + { + _viewModel.PyAppState = InstallState.Valid; + } + } + + public void SaveSettings() + { + settingsService.Settings.IgnoreMissingDependencies = _viewModel.DontRemindMeAgain; + settingsService.TrySaveSettings(); + } +} \ No newline at end of file diff --git a/MSUScripter/Services/ControlServices/MainWindowService.cs b/MSUScripter/Services/ControlServices/MainWindowService.cs index 62cc90c..f7ef9cc 100644 --- a/MSUScripter/Services/ControlServices/MainWindowService.cs +++ b/MSUScripter/Services/ControlServices/MainWindowService.cs @@ -1,23 +1,37 @@ +using System; using System.IO; +using System.Linq; +using System.Runtime.Versioning; using System.Threading.Tasks; -using AvaloniaControls; using AvaloniaControls.ControlServices; using AvaloniaControls.Services; using GitHubReleaseChecker; +using Microsoft.Extensions.Logging; +using MSURandomizerLibrary.Services; using MSUScripter.Configs; +using MSUScripter.Models; using MSUScripter.ViewModels; namespace MSUScripter.Services.ControlServices; -public class MainWindowService(Settings settings, SettingsService settingsService, MsuPcmService msuPcmService, PyMusicLooperService pyMusicLooperService, ProjectService projectService, IGitHubReleaseCheckerService gitHubReleaseCheckerService) : ControlService +// ReSharper disable once ClassNeverInstantiated.Global +public class MainWindowService( + Settings settings, + SettingsService settingsService, + MsuPcmService msuPcmService, + ProjectService projectService, + IGitHubReleaseCheckerService gitHubReleaseCheckerService, + PythonCompanionService pythonCompanionService, + IMsuTypeService msuTypeService, + ILogger logger) : ControlService { private readonly MainWindowViewModel _model = new(); public MainWindowViewModel InitializeModel() { - _ = CheckForNewRelease(); _ = CleanUpFolders(); - OpenCommandlineArgsProject(); + + _model.InitProject = Program.StartingProject; _model.AppVersion = $" v{App.Version}"; if (!settings.HasDoneFirstTimeSetup && !string.IsNullOrEmpty(settings.MsuPcmPath)) @@ -26,61 +40,105 @@ public MainWindowViewModel InitializeModel() settingsService.SaveSettings(); } - _model.HasDoneFirstTimeSetup = settings.HasDoneFirstTimeSetup; + _model.Settings.LoadSettings(settings); + + _model.MsuTypes = msuTypeService.MsuTypes + .OrderBy(x => x.DisplayName) + .ToList(); + _model.RecentProjects = settings.RecentProjects.ToList(); + + if (_model.RecentProjects.Count != 0) + { + _model.DisplayNewProjectPage = false; + _model.DisplayOpenProjectPage = true; + } + else + { + _model.DisplayNewProjectPage = true; + _model.DisplayOpenProjectPage = false; + } UpdateTitle(); return _model; } - - public void OpenEditProjectPanel(MsuProject project) + + public (MsuProject? mainProject, MsuProject? backupProject, string? error) LoadProject(string? path = null) { - _model.CurrentMsuProject = project; - UpdateTitle(); + path ??= _model.SelectedRecentProject?.ProjectPath; + + if (string.IsNullOrEmpty(path)) + { + return (null, null, "Invalid project path"); + } + + try + { + var project = projectService.LoadMsuProject(path, false); + MsuProject? backupProject = null; + + if (project == null) + { + return (null, null, "Project not found"); + } + + if (!string.IsNullOrEmpty(project.BackupFilePath)) + { + var potentialBackupProject = projectService.LoadMsuProject(project.BackupFilePath, true); + if (potentialBackupProject != null && potentialBackupProject.LastSaveTime > project.LastSaveTime) + { + backupProject = potentialBackupProject; + } + } + + return (project, backupProject, null); + } + catch (Exception ex) + { + logger.LogError(ex, "Error opening project"); + return (null, null, "Error opening project. Please contact MattEqualsCoder or post an issue on GitHub"); + } } - public void CloseEditProjectPanel() + public bool ValidateProjectPaths(MsuProject project) { - _model.CurrentMsuProject = null; - UpdateTitle(); + return project.Tracks.SelectMany(x => x.Songs).All(x => x.MsuPcmInfo.AreFilesValid()); } - public void OpenGitHubReleasePage() + public void UpdateLegacySmz3Project(MsuProject project) { - if (string.IsNullOrEmpty(_model.GitHubReleaseUrl)) return; - CrossPlatformTools.OpenUrl(_model.GitHubReleaseUrl); + projectService.ConvertLegacySmz3Project(project); + projectService.SaveMsuProject(project, false); } - public void CloseNewReleaseBanner(bool permanently) + public bool IsLegacySmz3Project(MsuProject project) { - if (permanently) - { - settings.PromptOnUpdate = false; - settingsService.SaveSettings(); - } - - _model.GitHubReleaseUrl = ""; + return project.MsuType.DisplayName == "SMZ3 Classic (Metroid First)"; } - - public bool ValidateMsuPcm(string msupcmPath) + + public void RefreshRecentProjects() { - return msuPcmService.ValidateMsuPcmPath(msupcmPath, out _); + _model.RecentProjects = settings.RecentProjects.Where(x => File.Exists(x.ProjectPath)).ToList(); + settingsService.TrySaveSettings(); } - public void UpdateHasDoneFirstTimeSetup(string? msupcmPath) + public async Task ValidateDependencies() { - settings.HasDoneFirstTimeSetup = true; - settings.MsuPcmPath = msupcmPath; - settingsService.SaveSettings(); + var isMsuPcmServiceValid = await msuPcmService.VerifyInstalledAsync(); + var isCompanionServiceValid = await pythonCompanionService.VerifyInstalledAsync(); + if (settings.IgnoreMissingDependencies) + { + return true; + } + return isMsuPcmServiceValid.Successful && isCompanionServiceValid; } public void Shutdown() { settingsService.SaveSettings(); - msuPcmService.DeleteTempPcms(); - msuPcmService.DeleteTempJsonFiles(); + CleanDirectory(Directories.TempFolder); } - public void UpdateTitle() + private void UpdateTitle() { if (_model.CurrentMsuProject == null) { @@ -94,49 +152,156 @@ public void UpdateTitle() } } - public bool IsEditPanelDisplayed => _model.DisplayEditPage; - - private void OpenCommandlineArgsProject() + public MsuProject? CreateNewProject() { - if (string.IsNullOrEmpty(Program.StartingProject)) return; + var name = _model.MsuProjectName; + var creator = _model.MsuCreatorName; + var msuPath = _model.MsuPath; + var projectPath = _model.MsuProjectPath; + var msuType = _model.SelectedMsuType; + var msuPcmJson = _model.MsuPcmJsonPath; + var msuPcmWorkingDir = _model.MsuPcmWorkingPath; + + if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(creator) || string.IsNullOrEmpty(msuPath) || + string.IsNullOrEmpty(projectPath) || msuType == null) + { + return null; + } - _model.InitProject = projectService.LoadMsuProject(Program.StartingProject, false); + try + { + logger.LogInformation("Creating new MSU Project"); + return projectService.NewMsuProject(projectPath, msuType, msuPath, msuPcmJson, msuPcmWorkingDir, name, creator); + } + catch (Exception) + { + return null; + } + } - if (_model.InitProject == null) + public void SaveSettings() + { + if (!_model.DisplaySettingsPage) { - _model.InitProjectError = true; return; } + + _model.Settings.SaveChanges(); + settingsService.SaveSettings(); + } + + public void LogError(Exception ex, string message) + { + logger.LogError(ex, "{Message}", message); + } + + private bool CleanDirectory(string path, TimeSpan? timeout = null) + { + timeout ??= TimeSpan.Zero; + var currentDateTime = DateTime.UtcNow; + var isEmpty = true; + foreach (var file in Directory.EnumerateFiles(path)) + { + var fileInfo = new FileInfo(file); + if (currentDateTime - fileInfo.LastWriteTimeUtc > timeout) + { + try + { + fileInfo.Delete(); + } + catch + { + // Do nothing + } + } + else + { + isEmpty = false; + } + } - if (!string.IsNullOrEmpty(_model.InitProject.BackupFilePath)) + foreach (var folder in Directory.EnumerateDirectories(path)) { - _model.InitBackupProject = projectService.LoadMsuProject(_model.InitProject.BackupFilePath, true); + if (CleanDirectory(folder, timeout)) + { + try + { + Directory.Delete(folder); + } + catch + { + // Do nothing + } + } + else + { + isEmpty = false; + } } + + return isEmpty; } - private async Task CheckForNewRelease() + public async Task<(string ReleaseUrl, string? DownloadUrl)?> CheckForNewRelease() { - if (settings.PromptOnUpdate == false) return; + if (!settings.CheckForUpdates) + { + return null; + } var newerGitHubRelease = await gitHubReleaseCheckerService.GetGitHubReleaseToUpdateToAsync("MattEqualsCoder", - "MSUScripter", App.Version, settings.PromptOnPreRelease); + "MSUScripter", App.Version, false); if (newerGitHubRelease != null) { - _model.GitHubReleaseUrl = newerGitHubRelease.Url; + if (OperatingSystem.IsLinux()) + { + return (newerGitHubRelease.Url, + newerGitHubRelease.Asset.FirstOrDefault(x => x.Url.ToLower().EndsWith(".appimage"))?.Url); + } + return (newerGitHubRelease.Url, null); } + + return null; + } + + [SupportedOSPlatform("linux")] + public void CreateDesktopFile() + { + ITaskService.Run(() => + { + var response = App.BuildLinuxDesktopFile(); + + if (response.Success) + { + logger.LogInformation("Created desktop file for AppImage"); + } + else + { + logger.LogError("Error creating desktop fie for AppImage: {Error}", + response.ErrorMessage ?? "Unknown Error"); + } + }); + } + + public void SkipDesktopFile() + { + settingsService.Settings.SkipDesktopFile = true; + settingsService.TrySaveSettings(); + } + + public void IgnoreFutureUpdates() + { + settings.CheckForUpdates = false; + settingsService.SaveSettings(); } private async Task CleanUpFolders() { await ITaskService.Run(() => { - msuPcmService.DeleteTempPcms(); - msuPcmService.DeleteTempJsonFiles(); - msuPcmService.ClearCache(); - pyMusicLooperService.ClearCache(); + CleanDirectory(Directories.CacheFolder, TimeSpan.FromDays(30)); + CleanDirectory(Directories.TempFolder); }); } - - } \ No newline at end of file diff --git a/MSUScripter/Services/ControlServices/MsuGenerationWindowService.cs b/MSUScripter/Services/ControlServices/MsuGenerationWindowService.cs new file mode 100644 index 0000000..dce8643 --- /dev/null +++ b/MSUScripter/Services/ControlServices/MsuGenerationWindowService.cs @@ -0,0 +1,397 @@ +using System; +using System.Collections.Concurrent; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AvaloniaControls.ControlServices; +using AvaloniaControls.Services; +using Microsoft.Extensions.Logging; +using MSUScripter.Configs; +using MSUScripter.Events; +using MSUScripter.ViewModels; + +namespace MSUScripter.Services.ControlServices; + +// ReSharper disable once ClassNeverInstantiated.Global +public class MsuGenerationWindowService( + MsuPcmService msuPcmService, + ProjectService projectService, + StatusBarService statusBarService, + TrackListService trackListService, + ILogger logger) : ControlService +{ + private readonly MsuGenerationViewModel _model = new(); + + private readonly CancellationTokenSource _cts = new(); + + public event EventHandler>? PcmGenerationComplete; + + public MsuGenerationViewModel InitializeModel(MsuProject project) + { + _model.MsuProject = project; + + var msuDirectory = new FileInfo(project.MsuPath).DirectoryName; + if (string.IsNullOrEmpty(msuDirectory)) return _model; + + var rows = project.Tracks + .Where(x => !x.IsScratchPad) + .SelectMany(x => x.Songs) + .OrderBy(x => x.TrackNumber) + .Select(x => new MsuGenerationRowViewModel(x)) + .ToList(); + + rows.Add(new MsuGenerationRowViewModel(MsuGenerationRowType.Msu, project)); + + if (project.BasicInfo.WriteYamlFile) + { + rows.Add(new MsuGenerationRowViewModel(MsuGenerationRowType.Yaml, project)); + } + + if (project.BasicInfo.TrackListType != TrackList.Disabled) + { + rows.Add(new MsuGenerationRowViewModel(MsuGenerationRowType.TrackList, project)); + } + + if (project.BasicInfo.IncludeJson == true) + { + rows.Add(new MsuGenerationRowViewModel(MsuGenerationRowType.MsuPcmJson, project)); + } + + if (project.BasicInfo.CreateAltSwapperScript && project.Tracks.Any(x => x is { IsScratchPad: false, Songs.Count: > 1 })) + { + rows.Add(new MsuGenerationRowViewModel(MsuGenerationRowType.SwapperScript, project)); + } + + if (project.BasicInfo.IsSmz3Project) + { + if (!string.IsNullOrEmpty(project.BasicInfo.ZeldaMsuPath)) + { + rows.Add(new MsuGenerationRowViewModel(MsuGenerationRowType.Smz3Zelda, project)); + } + + if (!string.IsNullOrEmpty(project.BasicInfo.MetroidMsuPath)) + { + rows.Add(new MsuGenerationRowViewModel(MsuGenerationRowType.Smz3Metroid, project)); + } + + if (project.BasicInfo.CreateSplitSmz3Script) + { + rows.Add(new MsuGenerationRowViewModel(MsuGenerationRowType.Smz3Script, project)); + } + + if (project.BasicInfo.WriteYamlFile) + { + rows.Add(new MsuGenerationRowViewModel(MsuGenerationRowType.Smz3MetroidYaml, project)); + rows.Add(new MsuGenerationRowViewModel(MsuGenerationRowType.Smz3ZeldaYaml, project)); + } + } + + if (!string.IsNullOrEmpty(_model.ZipPath)) + { + rows.Add(new MsuGenerationRowViewModel(MsuGenerationRowType.Compress, project, _model.ZipPath)); + } + + _model.Rows = rows; + _model.TotalSongs = rows.Count; + + return _model; + } + + public void SetZipPath(string path) + { + _model.ZipPath = path; + _model.TotalSongs = _model.TotalSongs * 2 + 1; + var rows = _model.Rows.Concat([ + new MsuGenerationRowViewModel(MsuGenerationRowType.Compress, _model.MsuProject, _model.ZipPath) + ]).ToList(); + _model.Rows = rows; + } + + public void RunGeneration() + { + _ = ITaskService.Run(async () => + { + + var start = DateTime.Now; + + // If the yaml file exists, but the user doesn't want to use it, + // then we need to remove it to prevent issues validating the msu + if (!_model.MsuProject.BasicInfo.WriteYamlFile) + { + var yamlPath = Path.ChangeExtension(_model.MsuProject.MsuPath, ".yml"); + if (File.Exists(yamlPath)) + { + try + { + File.Delete(yamlPath); + } + catch (Exception e) + { + logger.LogError(e, "Failed to delete yaml file"); + } + } + } + + ConcurrentBag toRetry = []; + + var generationRows = _model.Rows.Where(x => x.CanParallelize).ToList(); + + await Parallel.ForEachAsync(generationRows, + new ParallelOptions { MaxDegreeOfParallelism = 10, CancellationToken = _cts.Token }, async (model, _) => + { + try + { + await PerformAction(model, toRetry); + } + catch (Exception) + { + // Do nothing + } + }); + + // For retries, try again linearly + foreach (var songDetails in toRetry) + { + logger.LogInformation("Retrying song {File}", songDetails.Path); + await ProcessSong(songDetails, true); + } + + await Task.Delay(TimeSpan.FromSeconds(1)); + + foreach (var details in _model.Rows.Where(x => !x.CanParallelize) + .OrderBy(x => x.Type == MsuGenerationRowType.Compress)) + { + await PerformAction(details); + } + + if (_model.NumErrors > 0) + { + var errorString = _model.NumErrors == 1 ? "was 1 error" : $"were {_model.NumErrors} errors"; + _model.GenerationErrors.Add($" - There were {errorString} when generating the MSU project."); + } + + await Task.Delay(TimeSpan.FromSeconds(1)); + + msuPcmService.SaveGenerationCache(_model.MsuProject); + + if (!projectService.ValidateProject(_model.MsuProject, out var validationError)) + { + _model.GenerationErrors.Add($" - {validationError}"); + } + + var end = DateTime.Now; + var duration = end - start; + _model.GenerationSeconds = Math.Round(duration.TotalSeconds, 2); + _model.IsFinished = true; + _model.ButtonText = "Close"; + _model.SongsCompleted = _model.TotalSongs; + statusBarService.UpdateStatusBar("MSU Generated"); + PcmGenerationComplete?.Invoke(this, new ValueEventArgs(_model)); + + }, _cts.Token); + } + + private async Task PerformAction(MsuGenerationRowViewModel rowDetails, ConcurrentBag? toRetry = null) + { + try + { + if (rowDetails.Type == MsuGenerationRowType.Song) + { + if (!await ProcessSong(rowDetails, false)) + { + toRetry?.Add(rowDetails); + } + } + else if (rowDetails.Type is MsuGenerationRowType.Msu or MsuGenerationRowType.Smz3Metroid + or MsuGenerationRowType.Smz3Zelda) + { + if (!File.Exists(rowDetails.Path)) + { + await using (File.Create(rowDetails.Path)) + { + } + } + + rowDetails.Message = "Generated"; + _model.SongsCompleted++; + } + else if (rowDetails.Type == MsuGenerationRowType.Yaml) + { + projectService.ExportMsuRandomizerYaml(_model.MsuProject, out var error); + rowDetails.Message = string.IsNullOrEmpty(error) ? "Generated" : error; + _model.SongsCompleted++; + } + else if (rowDetails.Type == MsuGenerationRowType.MsuPcmJson) + { + msuPcmService.ExportMsuPcmTracksJson(_model.MsuProject); + rowDetails.Message = + _model.MsuProject.BasicInfo.DitherType is DitherType.DefaultOn or DitherType.DefaultOff + ? "Generated with dither value inconsistencies" + : "Generated"; + _model.SongsCompleted++; + } + else if (rowDetails.Type == MsuGenerationRowType.TrackList) + { + trackListService.WriteTrackListFile(_model.MsuProject); + rowDetails.Message = "Generated"; + _model.SongsCompleted++; + } + else if (rowDetails.Type == MsuGenerationRowType.SwapperScript) + { + rowDetails.Message = !projectService.CreateAltSwapperFile(_model.MsuProject) + ? "Could not create alt swapper bat file. Project file may be corrupt. Verify output pcm file paths." + : "Generated"; + _model.SongsCompleted++; + } + else if (rowDetails.Type == MsuGenerationRowType.Smz3Script) + { + rowDetails.Message = projectService.CreateSmz3SplitScript(_model.MsuProject) + ? "Generated" + : "Could not generate SMZ3 Split Script"; + _model.SongsCompleted++; + } + else if (rowDetails.Type == MsuGenerationRowType.Smz3MetroidYaml) + { + rowDetails.Message = + projectService.CreateSmz3SplitRandomizerYaml(_model.MsuProject, true, false, out var error) + ? "Generated" + : error ?? "Could not generate YAML file"; + _model.SongsCompleted++; + } + else if (rowDetails.Type == MsuGenerationRowType.Smz3ZeldaYaml) + { + rowDetails.Message = + projectService.CreateSmz3SplitRandomizerYaml(_model.MsuProject, false, true, out var error) + ? "Generated" + : error ?? "Could not generate YAML file"; + _model.SongsCompleted++; + } + else if (rowDetails.Type == MsuGenerationRowType.Compress) + { + Compress(rowDetails); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Error running action {Type}", rowDetails.Type); + } + } + + private void Compress(MsuGenerationRowViewModel compressRow) + { + try + { + if (File.Exists(_model.ZipPath)) + { + File.Delete(_model.ZipPath); + } + + using var zip = ZipFile.Open(_model.ZipPath!, ZipArchiveMode.Create); + + foreach (var row in _model.Rows.Where(row => row.Type != MsuGenerationRowType.Compress).TakeWhile(_ => !_cts.Token.IsCancellationRequested)) + { + if (!File.Exists(row.Path)) + { + _model.SongsCompleted++; + continue; + } + + try + { + zip.CreateEntryFromFile(row.Path, Path.GetFileName(row.Path)); + row.Message = "Compressed"; + _model.SongsCompleted++; + } + catch (Exception) + { + row.Message = "Failed to add to zip file"; + _model.SongsCompleted++; + return; + } + } + + compressRow.Message = "Success!"; + _model.SongsCompleted++; + } + catch (Exception) + { + compressRow.HasWarning = true; + compressRow.Message = "Failed to create zip file"; + } + + + } + + public void Cancel() + { + if (!_model.IsFinished) + { + _cts.Cancel(); + } + } + + private async Task ProcessSong(MsuGenerationRowViewModel rowDetails, bool isRetry) + { + if (_cts.IsCancellationRequested) + { + return true; + } + + if (!_model.MsuProject.BasicInfo.IsMsuPcmProject) + { + if (!File.Exists(rowDetails.Path)) + { + rowDetails.HasWarning = true; + rowDetails.Message = "PCM file not found"; + _model.NumErrors++; + } + else + { + rowDetails.Message = "Waiting"; + } + + _model.SongsCompleted++; + return true; + } + + var songInfo = rowDetails.SongInfo!; + var generationResponse = await msuPcmService.CreatePcm(_model.MsuProject, songInfo, false, true, true); + + if (!generationResponse.Successful) + { + // If this is an error for the sox temp file for the first run, ignore so it can be retried + if (!isRetry && generationResponse.Message?.Contains("__sox_wrapper_temp") == true && + generationResponse.Message.Contains("Permission denied")) + { + return false; + } + // Partially ignore empty pcms with no input files + else if (generationResponse.Message?.EndsWith("No input files specified") == true && File.Exists(rowDetails.Path) && new FileInfo(rowDetails.Path).Length <= 44500) + { + rowDetails.HasWarning = true; + rowDetails.Message = generationResponse.Message; + } + else + { + rowDetails.HasWarning = true; + rowDetails.Message = generationResponse.Message ?? "Unknown error"; + _model.NumErrors++; + } + + } + else + { + rowDetails.Message = "Generated"; + } + + _model.SongsCompleted++; + return true; + } + + public void LogError(Exception ex, string message) + { + logger.LogError(ex, "{Message}", message); + } +} \ No newline at end of file diff --git a/MSUScripter/Services/ControlServices/MsuPcmGenerationWindowService.cs b/MSUScripter/Services/ControlServices/MsuPcmGenerationWindowService.cs deleted file mode 100644 index 645c67a..0000000 --- a/MSUScripter/Services/ControlServices/MsuPcmGenerationWindowService.cs +++ /dev/null @@ -1,165 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using AvaloniaControls.ControlServices; -using AvaloniaControls.Services; -using MSUScripter.Configs; -using MSUScripter.Events; -using MSUScripter.ViewModels; - -namespace MSUScripter.Services.ControlServices; - -public class MsuPcmGenerationWindowService(MsuPcmService msuPcmService, ConverterService converterService, ProjectService projectService, StatusBarService statusBarService) : ControlService -{ - private MsuPcmGenerationViewModel _model = new(); - - private readonly CancellationTokenSource _cts = new(); - - public event EventHandler>? PcmGenerationComplete; - - public MsuPcmGenerationViewModel InitializeModel(MsuProjectViewModel project, bool exportYaml) - { - _model.MsuProjectViewModel = project; - _model.MsuProject = converterService.ConvertProject(project); - _model.ExportYaml = exportYaml; - _model.SplitSmz3 = project.BasicInfo.CreateSplitSmz3Script; - - var msuDirectory = new FileInfo(project.MsuPath).DirectoryName; - if (string.IsNullOrEmpty(msuDirectory)) return _model; - - var songs = project.Tracks - .Where(x => !x.IsScratchPad) - .SelectMany(x => x.Songs) - .OrderBy(x => x.TrackNumber) - .Select(x => new MsuPcmGenerationSongViewModel() - { - SongName = Path.GetRelativePath(msuDirectory, new FileInfo(x.OutputPath!).FullName), - TrackName = x.TrackName, - TrackNumber = x.TrackNumber, - Path = x.OutputPath!, - OriginalViewModel = x - }) - .ToList(); - - _model.Rows = songs; - _model.TotalSongs = songs.Count; - - return _model; - } - - public void RunGeneration() - { - _ = ITaskService.Run(async () => { - - var start = DateTime.Now; - - List toRetry = []; - - if (!File.Exists(_model.MsuProjectViewModel.MsuPath)) - { - using (File.Create(_model.MsuProjectViewModel.MsuPath)) { } - } - - Parallel.ForEach(_model.Rows, - new ParallelOptions { MaxDegreeOfParallelism = 10, CancellationToken = _cts.Token }, - ParallelAction); - - // For retries, try again linearly - foreach (var songDetails in toRetry) - { - await ProcessSong(songDetails, true); - } - - if (_model.ExportYaml) - { - projectService.ExportMsuRandomizerYaml(_model.MsuProject, out var error); - - if (!string.IsNullOrEmpty(error)) - { - _model.GenerationErrors.Add($"- YAML file generation failed: {error}"); - } - } - - if (_model.NumErrors > 0) - { - var errorString = _model.NumErrors == 1 ? "was 1 error" : $"were {_model.NumErrors} errors"; - _model.GenerationErrors.Add($"- There {errorString} when running MsuPcm++"); - } - - var end = DateTime.Now; - var duration = end - start; - _model.GenerationSeconds = Math.Round(duration.TotalSeconds, 2); - _model.IsFinished = true; - _model.ButtonText = "Close"; - _model.SongsCompleted = _model.Rows.Count; - statusBarService.UpdateStatusBar("MSU Generated"); - PcmGenerationComplete?.Invoke(this, new ValueEventArgs(_model)); - return; - - async void ParallelAction(MsuPcmGenerationSongViewModel songDetails) - { - if (!await ProcessSong(songDetails, false)) - { - toRetry.Add(songDetails); - } - } - }, _cts.Token); - } - - public void Cancel() - { - if (!_model.IsFinished) - { - _cts.Cancel(); - } - } - - private async Task ProcessSong(MsuPcmGenerationSongViewModel songDetails, bool isRetry) - { - if (_cts.IsCancellationRequested) - { - return true; - } - - var songViewModel = songDetails.OriginalViewModel; - var song = new MsuSongInfo(); - converterService.ConvertViewModel(songViewModel, song); - converterService.ConvertViewModel(songViewModel!.MsuPcmInfo, song.MsuPcmInfo); - - var generationResponse = await msuPcmService.CreatePcm(false, _model.MsuProject, song); - - if (!generationResponse.Successful) - { - // If this is an error for the sox temp file for the first run, ignore so it can be retried - if (!isRetry && generationResponse.Message?.Contains("__sox_wrapper_temp") == true && - generationResponse.Message.Contains("Permission denied")) - { - return false; - } - // Partially ignore empty pcms with no input files - else if (generationResponse.Message?.EndsWith("No input files specified") == true && File.Exists(song.OutputPath) && new FileInfo(song.OutputPath).Length <= 44500) - { - songDetails.HasWarning = true; - songDetails.Message = generationResponse.Message; - } - else - { - songDetails.HasWarning = true; - songDetails.Message = generationResponse.Message ?? "Unknown error"; - _model.NumErrors++; - } - - } - else - { - songViewModel.LastGeneratedDate = DateTime.Now; - songDetails.Message = "Success!"; - } - - _model.SongsCompleted++; - return true; - } -} \ No newline at end of file diff --git a/MSUScripter/Services/ControlServices/MsuProjectWindowService.cs b/MSUScripter/Services/ControlServices/MsuProjectWindowService.cs new file mode 100644 index 0000000..cc1d761 --- /dev/null +++ b/MSUScripter/Services/ControlServices/MsuProjectWindowService.cs @@ -0,0 +1,1190 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Media; +using Avalonia.Threading; +using AvaloniaControls.Controls; +using AvaloniaControls.ControlServices; +using AvaloniaControls.Services; +using DynamicData; +using Material.Icons; +using Microsoft.Extensions.Logging; +using MSUScripter.Configs; +using MSUScripter.Events; +using MSUScripter.Models; +using MSUScripter.ViewModels; + +namespace MSUScripter.Services.ControlServices; + +// ReSharper disable once ClassNeverInstantiated.Global +public class MsuProjectWindowService( + ConverterService converterService, + YamlService yamlService, + StatusBarService statusBarService, + ProjectService projectService, + TrackListService trackListService, + SettingsService settingsService, + IAudioPlayerService audioPlayerService, + MsuPcmService msuPcmService, + PythonCompanionService pythonCompanionService, + ILogger logger) : ControlService +{ + private Settings Settings => settingsService.Settings; + + private MsuProjectWindowViewModel _viewModel = null!; + private MsuProject _project = null!; + private MsuProjectWindowViewModelTreeData? _draggedItem; + private MsuProjectWindowViewModelTreeData? _hoveredItem; + + public MsuProjectWindowViewModel InitViewModel(MsuProject project) + { + _project = project; + + var windowTitle = "MSU Scripter"; + if (!string.IsNullOrEmpty(project.BasicInfo.PackName)) + { + windowTitle = $"{project.BasicInfo.PackName} - MSU Scripter"; + } + else if(!string.IsNullOrEmpty(project.MsuPath)) + { + var baseName = Path.GetFileName(project.MsuPath); + windowTitle = $"{baseName} - MSU Scripter"; + } + + var sidebarItems = new List + { + new() + { + Name = "MSU Details", + CollapseIcon = MaterialIconKind.Note, + LeftSpacing = 0, + ShowCheckbox = false, + SortIndex = -10000, + MsuDetails = true + } + }; + + var totalTracks = 0; + var completedTracks = 0; + var totalSongs = 0; + var completedSongs = 0; + + foreach (var track in _project.Tracks) + { + var trackSortIndex = track.IsScratchPad ? -1000 : track.TrackNumber * 1000; + + if (!track.IsScratchPad) + { + totalTracks++; + } + + var trackTreeData = new MsuProjectWindowViewModelTreeData + { + Name = track.IsScratchPad ? "Scratch Pad" : $"#{track.TrackNumber} {track.TrackName}", + CollapseIcon = MaterialIconKind.MusicNote, + CollapseIconOpacity = 0.4, + TrackInfo = track, + LeftSpacing = 0, + SortIndex = trackSortIndex, + ShowCheckbox = true, + Track = true, + }; + sidebarItems.Add(trackTreeData); + + if (track is { IsScratchPad: false, Songs.Count: > 0 }) + { + completedTracks++; + } + + for (var i = 0; i < track.Songs.Count; i++) + { + var song = track.Songs[i]; + + totalSongs++; + + if (song.IsComplete) + { + completedSongs++; + } + + if (track.Songs.Count > 1) + { + var songTreeData = new MsuProjectWindowViewModelTreeData + { + Name = string.IsNullOrEmpty(song.SongName) ? $"Song {i + 1}" : song.SongName, + CollapseIcon = MaterialIconKind.MusicNote, + CollapseIconOpacity = 0.4, + LeftSpacing = 12, + SortIndex = trackSortIndex + 1 + i, + ParentIndex = trackSortIndex, + ParentTreeData = trackTreeData, + ShowCheckbox = true, + TrackInfo = track, + SongInfo = song, + }; + songTreeData.UpdateCompletedFlag(); + trackTreeData.CollapseIcon = MaterialIconKind.ChevronDown; + trackTreeData.CollapseIconOpacity = 1; + trackTreeData.ChildTreeData.Add(songTreeData); + sidebarItems.Add(songTreeData); + } + else + { + trackTreeData.SongInfo = song; + trackTreeData.CollapseIcon = MaterialIconKind.MusicNote; + trackTreeData.CollapseIconOpacity = 0.4; + } + } + + trackTreeData.UpdateCompletedFlag(); + } + + _viewModel = new MsuProjectWindowViewModel() + { + MsuProject = project, + SongSummary = $"{completedSongs}/{totalSongs} Songs Completed", + TrackSummary = $"{completedTracks}/{totalTracks} Tracks With Songs Added", + WindowTitle = windowTitle, + PreviousVideoPath = settingsService.Settings.PreviousVideoPath + }; + + _viewModel.BasicInfoViewModel.UpdateModel(project); + + _viewModel.TreeItems.AddRange(sidebarItems.OrderBy(x => x.SortIndex)); + + if (Settings.ProjectTreeDisplayIsCompleteIcon) + { + ToggleCompletedIcons(true); + } + + if (Settings.ProjectTreeDisplayCheckCopyrightIcon) + { + ToggleCheckCopyrightIcons(true); + } + + if (Settings.ProjectTreeDisplayCopyrightSafeIcon) + { + ToggleCopyrightStatusIcons(true); + } + + if (Settings.ProjectTreeDisplayHasSongIcon) + { + ToggleHasAudioIcons(true); + } + + _viewModel.FilterOnlyTracksMissingSongs = Settings.ProjectTreeFilterOnlyTracksMissingSongs; + _viewModel.FilterOnlyCopyrightUntested = Settings.ProjectTreeFilterOnlyCopyrightUntested; + _viewModel.FilterOnlyIncomplete = Settings.ProjectTreeFilterOnlyIncomplete; + _viewModel.FilterOnlyMissingAudio = Settings.ProjectTreeFilterOnlyMissingAudio; + FilterTree(); + + statusBarService.StatusBarTextUpdated += StatusBarServiceOnStatusBarTextUpdated; + msuPcmService.GeneratingPcm += MsuPcmServiceOnGeneratingPcm; + + _viewModel.RecentProjects = settingsService.Settings.RecentProjects.Where(x => x.ProjectPath != project.ProjectFilePath) + .ToList(); + + LoadSettings(); + + _viewModel.LastModifiedDate = project.LastSaveTime; + + _viewModel.BasicInfoViewModel.PropertyChanged += (_, args) => + { + if (args.PropertyName == "PackName") + { + if (!string.IsNullOrEmpty(_viewModel.BasicInfoViewModel.PackName)) + { + _viewModel.WindowTitle = $"{_viewModel.BasicInfoViewModel.PackName} - MSU Scripter"; + } + else if(!string.IsNullOrEmpty(project.MsuPath)) + { + var baseName = Path.GetFileName(project.MsuPath); + _viewModel.WindowTitle = $"{baseName} - MSU Scripter"; + } + } + }; + + return _viewModel; + } + + private void MsuPcmServiceOnGeneratingPcm(object? sender, bool e) + { + _viewModel.MsuSongViewModel.IsGeneratingPcmFiles = e; + _viewModel.MsuSongViewModel.AdvancedPanelViewModel.IsGeneratingPcmFile = e; + } + + private void StatusBarServiceOnStatusBarTextUpdated(object? sender, ValueEventArgs e) + { + _viewModel.StatusBarText = e.Data; + } + + public void LoadSettings() + { + _viewModel.DefaultSongPanel = settingsService.Settings.DefaultSongPanel; + _viewModel.MsuSongViewModel.BasicPanelViewModel.PyMusicLooperEnabled = pythonCompanionService.IsValid; + } + + public void UpdateCompletedSummary() + { + var totalTracks = 0; + var completedTracks = 0; + var totalSongs = 0; + var completedSongs = 0; + + foreach (var track in _project.Tracks) + { + if (!track.IsScratchPad) + { + totalTracks++; + if (track.Songs.Count > 0) completedTracks++; + } + + totalSongs += track.Songs.Count; + completedSongs += track.Songs.Count(x => x.IsComplete); + } + + _viewModel.SongSummary = $"{completedSongs}/{totalSongs} Songs Completed"; + _viewModel.TrackSummary = $"{completedTracks}/{totalTracks} Tracks With Songs Added"; + } + + public void ToggleFilterTracksMissingSongs() + { + _viewModel.FilterOnlyTracksMissingSongs = !_viewModel.FilterOnlyTracksMissingSongs; + FilterTree(); + settingsService.Settings.ProjectTreeFilterOnlyTracksMissingSongs = _viewModel.FilterOnlyTracksMissingSongs; + settingsService.TrySaveSettings(); + } + + public void ToggleFilterIncomplete() + { + _viewModel.FilterOnlyIncomplete = !_viewModel.FilterOnlyIncomplete; + FilterTree(); + settingsService.Settings.ProjectTreeFilterOnlyIncomplete = _viewModel.FilterOnlyIncomplete; + settingsService.TrySaveSettings(); + } + + public void ToggleFilterMissingAudio() + { + _viewModel.FilterOnlyMissingAudio = !_viewModel.FilterOnlyMissingAudio; + FilterTree(); + settingsService.Settings.ProjectTreeFilterOnlyMissingAudio = _viewModel.FilterOnlyMissingAudio; + settingsService.TrySaveSettings(); + } + + public void ToggleFilterCopyrightUntested() + { + _viewModel.FilterOnlyCopyrightUntested = !_viewModel.FilterOnlyCopyrightUntested; + FilterTree(); + settingsService.Settings.ProjectTreeFilterOnlyCopyrightUntested = _viewModel.FilterOnlyCopyrightUntested; + settingsService.TrySaveSettings(); + } + + public void FilterTree() + { + var hasFilterToggle = _viewModel.FilterOnlyTracksMissingSongs || _viewModel.FilterOnlyIncomplete || + _viewModel.FilterOnlyMissingAudio || _viewModel.FilterOnlyCopyrightUntested; + _viewModel.FilterEyeIcon = hasFilterToggle ? MaterialIconKind.FilterCheck : MaterialIconKind.Filter; + List parentTreeItems = []; + var filterText = string.IsNullOrEmpty(_viewModel.FilterText) ? null : _viewModel.FilterText.ToLower(); + foreach (var treeData in _viewModel.TreeItems) + { + if (treeData.ChildTreeData.Count > 0) + { + parentTreeItems.Add(treeData); + } + else + { + var matches = treeData.MatchesFilter(filterText, _viewModel.FilterOnlyTracksMissingSongs, _viewModel.FilterOnlyIncomplete, + _viewModel.FilterOnlyMissingAudio, _viewModel.FilterOnlyCopyrightUntested); + treeData.IsFilteredOut = !matches; + } + } + + foreach (var treeData in parentTreeItems) + { + if (treeData.MatchesFilter(filterText, _viewModel.FilterOnlyTracksMissingSongs, _viewModel.FilterOnlyIncomplete, _viewModel.FilterOnlyMissingAudio, _viewModel.FilterOnlyCopyrightUntested)) + { + treeData.IsFilteredOut = false; + foreach (var childData in treeData.ChildTreeData) + { + childData.IsFilteredOut = false; + } + } + else + { + treeData.IsFilteredOut = treeData.ChildTreeData.All(x => x.IsFilteredOut); + } + } + } + + public void SelectedTreeItem(MsuProjectWindowViewModelTreeData treeData, bool isIconClick) + { + if (treeData.ChildTreeData.Count > 0) + { + if (isIconClick) + { + treeData.ToggleAsParent(true, treeData.CollapseIcon == MaterialIconKind.ChevronDown); + } + else + { + SaveCurrentPanel(); + _viewModel.CurrentTreeItem = treeData; + _viewModel.BasicInfoViewModel.IsVisible = false; + _viewModel.MsuSongViewModel.BasicPanelViewModel.PyMusicLooperEnabled = pythonCompanionService.IsValid; + _viewModel.MsuSongViewModel.UpdateViewModel(_project, treeData.TrackInfo!, null, treeData); + } + } + else if (treeData.IsSongOrTrack) + { + SaveCurrentPanel(); + _viewModel.CurrentTreeItem = treeData; + _viewModel.BasicInfoViewModel.IsVisible = false; + _viewModel.MsuSongViewModel.BasicPanelViewModel.PyMusicLooperEnabled = pythonCompanionService.IsValid; + _viewModel.MsuSongViewModel.UpdateViewModel(_project, treeData.TrackInfo!, treeData.SongInfo, treeData); + } + else if (treeData.MsuDetails) + { + if (_viewModel.MsuSongViewModel.IsEnabled) + { + _viewModel.MsuSongViewModel.SaveChanges(); + _viewModel.MsuSongViewModel.IsEnabled = false; + } + _viewModel.CurrentTreeItem = treeData; + _viewModel.BasicInfoViewModel.UpdateModel(_project); + _viewModel.BasicInfoViewModel.IsVisible = true; + } + } + + public bool SaveCurrentPanel() + { + if (_viewModel.MsuSongViewModel.IsEnabled) + { + _viewModel.MsuSongViewModel.SaveChanges(); + if (_viewModel.MsuSongViewModel.LastModifiedDate > _viewModel.LastModifiedDate) + { + _viewModel.LastModifiedDate = _viewModel.MsuSongViewModel.LastModifiedDate; + return true; + } + } + else if (_viewModel.BasicInfoViewModel.IsVisible) + { + _viewModel.BasicInfoViewModel.SaveChanges(); + if (_viewModel.BasicInfoViewModel.LastModifiedDate > _viewModel.LastModifiedDate) + { + _viewModel.LastModifiedDate = _viewModel.BasicInfoViewModel.LastModifiedDate; + return true; + } + } + + return false; + } + + public void SaveProject(bool isBackup = false) + { + SaveCurrentPanel(); + projectService.SaveMsuProject(_project, isBackup); + } + + public bool CreateYamlFile(out string? error) + { + SaveCurrentPanel(); + projectService.ExportMsuRandomizerYaml(_project, out error); + if (!string.IsNullOrEmpty(error)) + { + return false; + } + + return _project.BasicInfo is not { IsSmz3Project: true, CreateSplitSmz3Script: true } || + projectService.CreateSmz3SplitRandomizerYaml(_project, false, false, out error); + } + + public string? CreateTracksJsonFile() + { + SaveCurrentPanel(); + msuPcmService.ExportMsuPcmTracksJson(_project); + + if (_project.BasicInfo.DitherType is DitherType.DefaultOn or DitherType.DefaultOff) + { + return "Generated with dither value inconsistencies"; + } + + return null; + } + + public void CreateTrackList() + { + SaveCurrentPanel(); + trackListService.WriteTrackListFile(_project); + } + + public bool CreateScriptFiles() + { + SaveCurrentPanel(); + if (!projectService.CreateAltSwapperFile(_project)) + { + return false; + } + + if (_project.BasicInfo is { IsSmz3Project: true, CreateSplitSmz3Script: true }) + { + return projectService.CreateSmz3SplitScript(_project); + } + + return true; + } + + public void AddNewSong(MsuProjectWindowViewModelTreeData? treeData = null, bool duplicate = false, bool advancedMode = false, bool rememberSetting = false, string? initialFile = null) + { + if (rememberSetting) + { + settingsService.Settings.DefaultSongPanel = advancedMode ? DefaultSongPanel.Advanced : DefaultSongPanel.Basic; + settingsService.SaveSettings(); + _viewModel.DefaultSongPanel = settingsService.Settings.DefaultSongPanel; + } + + treeData ??= _viewModel.CurrentTreeItem; + if (treeData?.TrackInfo == null || _viewModel.MsuProject == null) + { + throw new InvalidOperationException("Invalid tree data item for adding a song"); + } + + var trackInfo = treeData.TrackInfo; + var index = 0; + if (treeData.SongInfo != null) + { + index = trackInfo.Songs.IndexOf(treeData.SongInfo) + 1; + } + var newSong = trackInfo.AddSong(_viewModel.MsuProject, index, advancedMode); + + if (duplicate && treeData.SongInfo != null) + { + var outputPath = newSong.OutputPath ?? newSong.MsuPcmInfo.Output; + if (converterService.CloneModel(treeData.SongInfo, newSong) && + converterService.CloneModel(treeData.SongInfo.MsuPcmInfo, newSong.MsuPcmInfo)) + { + newSong.Id = Guid.NewGuid().ToString("N"); + newSong.OutputPath = outputPath; + newSong.MsuPcmInfo.Output = outputPath; + } + } + + var parentSortIndex = treeData.ParentIndex != 0 ? treeData.ParentIndex : treeData.SortIndex; + var parentTreeData = _viewModel.TreeItems.First(x => x.SortIndex == parentSortIndex); + var parentIndex = _viewModel.TreeItems.IndexOf(parentTreeData); + + // Added the first song, so just use the same tree data + if (trackInfo.Songs.Count == 1) + { + treeData.SongInfo = newSong; + } + // Added a song to a node that already has multiple songs + else if (parentTreeData.ChildTreeData.Count > 0) + { + parentTreeData.SongInfo = null; + + var songTreeData = new MsuProjectWindowViewModelTreeData + { + Name = string.IsNullOrEmpty(newSong.SongName) ? $"Song {trackInfo.Songs.Count}" : newSong.SongName, + CollapseIcon = MaterialIconKind.MusicNote, + CollapseIconOpacity = 0.4, + LeftSpacing = 12, + SortIndex = parentSortIndex + 1 + index, + ParentIndex = parentSortIndex, + ParentTreeData = parentTreeData, + ShowCheckbox = true, + TrackInfo = trackInfo, + SongInfo = newSong, + }; + + parentTreeData.ToggleAsParent(true, false); + parentTreeData.ChildTreeData.Insert(index, songTreeData); + _viewModel.TreeItems.Insert(parentIndex + index + 1, songTreeData); + + for (var i = index + 1; i < treeData.TrackInfo.Songs.Count; i++) + { + parentTreeData.ChildTreeData[i].SortIndex++; + } + + songTreeData.UpdateCompletedFlag(); + songTreeData.DisplayIsCompleteIcon = _viewModel.DisplayIsCompleteIcon; + songTreeData.DisplayHasSongIcon = _viewModel.DisplayHasSongIcon; + songTreeData.DisplayCopyrightSafeIcon = _viewModel.DisplayCopyrightSafeIcon; + songTreeData.DisplayCheckCopyrightIcon = _viewModel.DisplayCheckCopyrightIcon; + parentTreeData.UpdateCompletedFlag(); + treeData = songTreeData; + } + // Add a second song, meaning we need to switch the track to be a collapsible + else + { + parentTreeData.SongInfo = null; + + for (var i = 0; i < trackInfo.Songs.Count; i++) + { + var song = trackInfo.Songs[i]; + + var songTreeData = new MsuProjectWindowViewModelTreeData + { + Name = string.IsNullOrEmpty(song.SongName) ? $"Song {i + 1}" : song.SongName, + CollapseIcon = MaterialIconKind.MusicNote, + CollapseIconOpacity = 0.4, + LeftSpacing = 12, + SortIndex = parentSortIndex + 1 + i, + ParentIndex = parentSortIndex, + ParentTreeData = parentTreeData, + ShowCheckbox = true, + TrackInfo = trackInfo, + SongInfo = song, + }; + + songTreeData.UpdateCompletedFlag(); + songTreeData.DisplayIsCompleteIcon = _viewModel.DisplayIsCompleteIcon; + songTreeData.DisplayHasSongIcon = _viewModel.DisplayHasSongIcon; + songTreeData.DisplayCopyrightSafeIcon = _viewModel.DisplayCopyrightSafeIcon; + songTreeData.DisplayCheckCopyrightIcon = _viewModel.DisplayCheckCopyrightIcon; + treeData = songTreeData; + + parentTreeData.ToggleAsParent(true, false); + parentTreeData.ChildTreeData.Add(songTreeData); + _viewModel.TreeItems.Insert(parentIndex + i + 1, songTreeData); + } + + parentTreeData.UpdateCompletedFlag(); + } + + _viewModel.MsuSongViewModel.UpdateViewModel(_project, treeData.TrackInfo, newSong, treeData); + _viewModel.SelectedTreeItem = treeData; + treeData.UpdateCompletedFlag(); + UpdateCompletedSummary(); + + if (_viewModel.MsuSongViewModel.BasicPanelViewModel.IsEnabled && !string.IsNullOrEmpty(initialFile)) + { + _viewModel.MsuSongViewModel.BasicPanelViewModel.DragDropFile(initialFile); + } + else if (_viewModel.MsuSongViewModel.AdvancedPanelViewModel.IsEnabled && !string.IsNullOrEmpty(initialFile)) + { + _viewModel.MsuSongViewModel.AdvancedPanelViewModel.DragDropFile(initialFile); + } + + logger.LogInformation("Successfully added new song"); + + _viewModel.LastModifiedDate = DateTime.Now; + } + + public void RemoveSong(MsuProjectWindowViewModelTreeData treeData) + { + if (treeData.SongInfo == null || treeData.TrackInfo == null) + { + throw new InvalidOperationException("Attempted to delete tree data without song information"); + } + + var trackInfo = treeData.TrackInfo; + var songInfo = treeData.SongInfo; + var parentSortIndex = treeData.ParentIndex != 0 ? treeData.ParentIndex : treeData.SortIndex; + var parentTreeData = _viewModel.TreeItems.First(x => x.SortIndex == parentSortIndex); + + trackInfo.RemoveSong(songInfo); + + if (trackInfo.Songs.Count <= 1) + { + var toRemove = _viewModel.TreeItems.Where(x => x.ParentIndex == parentSortIndex).ToList(); + foreach (var itemToRemove in toRemove) + { + _viewModel.TreeItems.Remove(itemToRemove); + } + + parentTreeData.SongInfo = trackInfo.Songs.Count == 1 ? trackInfo.Songs[0] : null; + parentTreeData.ChildTreeData.Clear(); + parentTreeData.ToggleAsParent(false, false); + parentTreeData.UpdateCompletedFlag(); + _viewModel.MsuSongViewModel.UpdateViewModel(_project, trackInfo, parentTreeData.SongInfo, treeData); + _viewModel.SelectedTreeItem = parentTreeData; + } + else + { + _viewModel.TreeItems.Remove(treeData); + parentTreeData.ChildTreeData.Remove(treeData); + parentTreeData.UpdateCompletedFlag(); + _viewModel.MsuSongViewModel.UpdateViewModel(_project, trackInfo, parentTreeData.ChildTreeData.First().SongInfo, treeData); + _viewModel.SelectedTreeItem = parentTreeData.ChildTreeData.First(); + } + + logger.LogInformation("Removed song"); + UpdateCompletedSummary(); + _viewModel.LastModifiedDate = DateTime.Now; + } + + public void UpdateDrag(MsuProjectWindowViewModelTreeData? treeData) + { + if (_hoveredItem != null) + { + _hoveredItem.GridBackground = Brushes.Transparent; + _hoveredItem.BorderColor = Brushes.Transparent; + } + + if (treeData == null) + { + if (_draggedItem != null && _hoveredItem != null && _draggedItem != _hoveredItem && !(_draggedItem.ParentIndex > 0 && _hoveredItem.ParentIndex > 0 && + _draggedItem.ParentIndex == _hoveredItem.ParentIndex && + _draggedItem.SortIndex == _hoveredItem.SortIndex + 1)) + { + HandleDragged(_draggedItem, _hoveredItem); + } + + _hoveredItem = null; + _draggedItem = null; + _viewModel.IsDraggingItem = false; + } + else if (treeData.SongInfo != null) + { + _hoveredItem = null; + _draggedItem = treeData; + _viewModel.IsDraggingItem = true; + } + } + + public void UpdateCompletedFlag(MsuProjectWindowViewModelTreeData treeData) + { + if (treeData.SongInfo == null) + { + return; + } + + if (_viewModel.SelectedTreeItem == treeData && _viewModel.MsuSongViewModel.SongInfo == treeData.SongInfo) + { + _viewModel.MsuSongViewModel.IsComplete = !_viewModel.MsuSongViewModel.IsComplete; + } + else + { + treeData.SongInfo.IsComplete = !treeData.SongInfo.IsComplete; + treeData.UpdateCompletedFlag(); + treeData.ParentTreeData?.UpdateCompletedFlag(); + } + + logger.LogInformation("Updated completed flag"); + _viewModel.LastModifiedDate = DateTime.Now; + } + + public void UpdateCheckCopyright(MsuProjectWindowViewModelTreeData treeData) + { + if (treeData.SongInfo == null) + { + return; + } + + if (_viewModel.SelectedTreeItem == treeData && _viewModel.MsuSongViewModel.SongInfo == treeData.SongInfo) + { + if (_viewModel.MsuSongViewModel.BasicPanelViewModel.IsEnabled) + { + _viewModel.MsuSongViewModel.BasicPanelViewModel.CheckCopyright = + _viewModel.MsuSongViewModel.BasicPanelViewModel.CheckCopyright != true; + } + else if (_viewModel.MsuSongViewModel.AdvancedPanelViewModel.IsEnabled) + { + _viewModel.MsuSongViewModel.AdvancedPanelViewModel.CheckCopyright = + _viewModel.MsuSongViewModel.AdvancedPanelViewModel.CheckCopyright != true; + } + } + else + { + treeData.SongInfo.CheckCopyright = treeData.SongInfo.CheckCopyright != true; + treeData.UpdateCompletedFlag(); + treeData.ParentTreeData?.UpdateCompletedFlag(); + } + + _viewModel.LastModifiedDate = DateTime.Now; + } + + public void UpdateCopyrightSafe(MsuProjectWindowViewModelTreeData treeData) + { + if (treeData.SongInfo == null) + { + return; + } + + if (_viewModel.SelectedTreeItem == treeData && _viewModel.MsuSongViewModel.SongInfo == treeData.SongInfo) + { + if (_viewModel.MsuSongViewModel.BasicPanelViewModel.IsEnabled) + { + _viewModel.MsuSongViewModel.BasicPanelViewModel.IsCopyrightSafe = + _viewModel.MsuSongViewModel.BasicPanelViewModel.IsCopyrightSafe switch + { + true => null, + false => true, + null => false + }; + } + else if (_viewModel.MsuSongViewModel.AdvancedPanelViewModel.IsEnabled) + { + _viewModel.MsuSongViewModel.AdvancedPanelViewModel.IsCopyrightSafe = + _viewModel.MsuSongViewModel.AdvancedPanelViewModel.IsCopyrightSafe switch + { + true => null, + false => true, + null => false + }; + } + } + else + { + if (treeData.SongInfo.IsCopyrightSafe == true) + { + treeData.SongInfo.IsCopyrightSafe = null; + } + else if (treeData.SongInfo.IsCopyrightSafe == false) + { + treeData.SongInfo.IsCopyrightSafe = true; + } + else + { + treeData.SongInfo.IsCopyrightSafe = false; + } + + treeData.UpdateCompletedFlag(); + treeData.ParentTreeData?.UpdateCompletedFlag(); + } + + _viewModel.LastModifiedDate = DateTime.Now; + } + + public async Task CreateVideo(List songs, string videoPath, MessageWindow progressWindow, CancellationToken cancellationToken) + { + await ITaskService.Run(async () => + { + await Parallel.ForEachAsync(songs, + new ParallelOptions { MaxDegreeOfParallelism = 10, CancellationToken = cancellationToken }, async (song, _) => + { + if (cancellationToken.IsCancellationRequested) + { + return; + } + + try + { + await msuPcmService.CreatePcm(_viewModel.MsuProject!, song, false, true, true); + } + catch (Exception) + { + // Do nothing + } + }); + + var pcmFiles = songs.Select(x => x.OutputPath ?? "").Where(x => !string.IsNullOrEmpty(x) && File.Exists(x)).ToList(); + + var response = await pythonCompanionService.CreateVideoAsync(new CreateVideoRequest + { + Files = pcmFiles, + OutputVideo = videoPath + }, progress => + { + Dispatcher.UIThread.Invoke(() => + { + progressWindow.UpdateProgressBar(progress * 100); + }); + }, cancellationToken); + + Dispatcher.UIThread.Invoke(() => + { + progressWindow.UpdateMessageText(response.Successful + ? "Video generation successful" + : "Error generating video"); + progressWindow.UpdatePrimaryButtonText("Close"); + }); + + settingsService.Settings.PreviousVideoPath = videoPath; + settingsService.TrySaveSettings(); + + }, cancellationToken); + } + + private void HandleDragged(MsuProjectWindowViewModelTreeData from, MsuProjectWindowViewModelTreeData to) + { + if (_viewModel.MsuProject == null || from.TrackInfo == null || to.TrackInfo == null || from.SongInfo == null) + { + return; + } + + var currentParent = from.ParentIndex != 0 + ? _viewModel.TreeItems.First(x => x.SortIndex == from.ParentIndex) + : from; + var destinationParent = to.ParentIndex != 0 + ? _viewModel.TreeItems.First(x => x.SortIndex == to.ParentIndex) + : to; + + if (currentParent.TrackInfo == null || destinationParent.TrackInfo == null) + { + return; + } + + var destinationTrack = to.TrackInfo; + int destinationIndex; + if (to.ParentIndex == 0) + { + destinationIndex = to.SongInfo == null ? 0 : 1; + } + else + { + destinationIndex = destinationParent.ChildTreeData.IndexOf(to) + 1; + } + + logger.LogInformation("Dragged song {Name} to {Destination} #{Index}", from.SongInfo.SongName, to.TrackInfo.TrackName, destinationIndex); + + if (currentParent == destinationParent && destinationIndex > destinationParent.ChildTreeData.IndexOf(from)) + { + destinationIndex--; + } + + destinationTrack.MoveSong(_viewModel.MsuProject, from.SongInfo, destinationIndex); + + if (currentParent == destinationParent) + { + var dictionary = destinationParent.ChildTreeData.ToDictionary(x => x.SongInfo!, x => x); + destinationParent.ChildTreeData.Clear(); + var destinationParentIndex = _viewModel.TreeItems.IndexOf(destinationParent); + + for (var i = 0; i < destinationTrack.Songs.Count; i++) + { + var song = destinationTrack.Songs[i]; + var songTreeData = dictionary[song]; + destinationParent.ChildTreeData.Add(songTreeData); + _viewModel.TreeItems.Remove(songTreeData); + _viewModel.TreeItems.Insert(destinationParentIndex + i + 1, songTreeData); + songTreeData.SortIndex = destinationParent.SortIndex + i + 1; + } + + _viewModel.MsuSongViewModel.UpdateViewModel(_project, destinationTrack, from.SongInfo, from); + _viewModel.SelectedTreeItem = from; + } + else + { + var newTreeData = from; + var fromSongInfo = from.SongInfo; + + foreach (var previousTree in currentParent.ChildTreeData.Concat(destinationParent.ChildTreeData)) + { + _viewModel.TreeItems.Remove(previousTree); + } + + currentParent.ChildTreeData.Clear(); + destinationParent.ChildTreeData.Clear(); + + if (currentParent.TrackInfo.Songs.Count <= 1) + { + currentParent.SongInfo = currentParent.TrackInfo.Songs.FirstOrDefault(); + currentParent.UpdateCompletedFlag(); + currentParent.ToggleAsParent(false, false); + } + else + { + for (var i = 0; i < currentParent.TrackInfo.Songs.Count; i++) + { + var song = currentParent.TrackInfo.Songs[i]; + + var songTreeData = new MsuProjectWindowViewModelTreeData + { + Name = string.IsNullOrEmpty(song.SongName) ? $"Song {i + 1}" : song.SongName, + CollapseIcon = MaterialIconKind.MusicNote, + CollapseIconOpacity = 0.4, + LeftSpacing = 12, + SortIndex = currentParent.SortIndex + 1 + i, + ParentIndex = currentParent.SortIndex, + ParentTreeData = currentParent, + ShowCheckbox = true, + TrackInfo = currentParent.TrackInfo, + SongInfo = song, + }; + + songTreeData.UpdateCompletedFlag(); + songTreeData.DisplayIsCompleteIcon = _viewModel.DisplayIsCompleteIcon; + songTreeData.DisplayHasSongIcon = _viewModel.DisplayHasSongIcon; + songTreeData.DisplayCopyrightSafeIcon = _viewModel.DisplayCopyrightSafeIcon; + songTreeData.DisplayCheckCopyrightIcon = _viewModel.DisplayCheckCopyrightIcon; + + currentParent.ChildTreeData.Add(songTreeData); + _viewModel.TreeItems.Insert(_viewModel.TreeItems.IndexOf(currentParent) + i + 1, songTreeData); + } + + currentParent.SongInfo = null; + currentParent.UpdateCompletedFlag(); + currentParent.ToggleAsParent(true, false); + } + + if (destinationParent.TrackInfo.Songs.Count <= 1) + { + destinationParent.SongInfo = destinationParent.TrackInfo.Songs.FirstOrDefault(); + destinationParent.UpdateCompletedFlag(); + destinationParent.ToggleAsParent(false, false); + newTreeData = destinationParent; + } + else + { + for (var i = 0; i < destinationParent.TrackInfo.Songs.Count; i++) + { + var song = destinationParent.TrackInfo.Songs[i]; + + var songTreeData = new MsuProjectWindowViewModelTreeData + { + Name = string.IsNullOrEmpty(song.SongName) ? $"Song {i + 1}" : song.SongName, + CollapseIcon = MaterialIconKind.MusicNote, + CollapseIconOpacity = 0.4, + LeftSpacing = 12, + SortIndex = destinationParent.SortIndex + 1 + i, + ParentIndex = destinationParent.SortIndex, + ParentTreeData = destinationParent, + ShowCheckbox = true, + TrackInfo = destinationParent.TrackInfo, + SongInfo = song, + }; + + if (song == fromSongInfo) + { + newTreeData = songTreeData; + } + + songTreeData.UpdateCompletedFlag(); + songTreeData.DisplayIsCompleteIcon = _viewModel.DisplayIsCompleteIcon; + songTreeData.DisplayHasSongIcon = _viewModel.DisplayHasSongIcon; + songTreeData.DisplayCopyrightSafeIcon = _viewModel.DisplayCopyrightSafeIcon; + songTreeData.DisplayCheckCopyrightIcon = _viewModel.DisplayCheckCopyrightIcon; + + destinationParent.ChildTreeData.Add(songTreeData); + _viewModel.TreeItems.Insert(_viewModel.TreeItems.IndexOf(destinationParent) + i + 1, songTreeData); + } + + destinationParent.SongInfo = null; + destinationParent.UpdateCompletedFlag(); + destinationParent.ToggleAsParent(true, false); + } + + _viewModel.MsuSongViewModel.UpdateViewModel(_project, destinationTrack, fromSongInfo, newTreeData); + _viewModel.SelectedTreeItem = newTreeData; + } + + if (_viewModel.SelectedTreeItem.SongInfo != null) + { + _viewModel.LastModifiedDate = DateTime.Now; + } + + logger.LogInformation("HandleDragged complete"); + } + + public void UpdateHover(MsuProjectWindowViewModelTreeData? treeData) + { + if (_hoveredItem != null) + { + _hoveredItem.GridBackground = Brushes.Transparent; + _hoveredItem.BorderColor = Brushes.Transparent; + } + + _hoveredItem = treeData; + + if (treeData != null) + { + treeData.BorderColor = MsuProjectWindowViewModelTreeData.HighlightColor; + } + } + + public void ToggleCompletedIcons(bool? value = null) + { + bool newValue; + if (value == null) + { + newValue = _viewModel.DisplayIsCompleteIcon = !_viewModel.DisplayIsCompleteIcon; + } + else + { + newValue = _viewModel.DisplayIsCompleteIcon = value.Value; + } + + foreach (var treeData in _viewModel.TreeItems) + { + if (treeData.IsSongOrTrack) + { + treeData.DisplayIsCompleteIcon = newValue; + } + } + + settingsService.Settings.ProjectTreeDisplayIsCompleteIcon = _viewModel.DisplayIsCompleteIcon; + settingsService.TrySaveSettings(); + } + + public void ToggleHasAudioIcons(bool? value = null) + { + bool newValue; + if (value == null) + { + newValue = _viewModel.DisplayHasSongIcon = !_viewModel.DisplayHasSongIcon; + } + else + { + newValue = _viewModel.DisplayHasSongIcon = value.Value; + } + + foreach (var treeData in _viewModel.TreeItems) + { + if (treeData.IsSongOrTrack) + { + treeData.DisplayHasSongIcon = newValue; + } + } + + settingsService.Settings.ProjectTreeDisplayHasSongIcon = _viewModel.DisplayHasSongIcon; + settingsService.TrySaveSettings(); + } + + public void ToggleCheckCopyrightIcons(bool? value = null) + { + bool newValue; + if (value == null) + { + newValue = _viewModel.DisplayCheckCopyrightIcon = !_viewModel.DisplayCheckCopyrightIcon; + } + else + { + newValue = _viewModel.DisplayCheckCopyrightIcon = value.Value; + } + + foreach (var treeData in _viewModel.TreeItems) + { + if (treeData.IsSongOrTrack) + { + treeData.DisplayCheckCopyrightIcon = newValue; + } + } + + settingsService.Settings.ProjectTreeDisplayCheckCopyrightIcon = _viewModel.DisplayCheckCopyrightIcon; + settingsService.TrySaveSettings(); + } + + public void ToggleCopyrightStatusIcons(bool? value = null) + { + bool newValue; + if (value == null) + { + newValue = _viewModel.DisplayCopyrightSafeIcon = !_viewModel.DisplayCopyrightSafeIcon; + } + else + { + newValue = _viewModel.DisplayCopyrightSafeIcon = value.Value; + } + + foreach (var treeData in _viewModel.TreeItems) + { + if (treeData.IsSongOrTrack) + { + treeData.DisplayCopyrightSafeIcon = newValue; + } + } + + settingsService.Settings.ProjectTreeDisplayCopyrightSafeIcon = _viewModel.DisplayCopyrightSafeIcon; + settingsService.TrySaveSettings(); + } + + public string? GetSongCopyDetails(MsuProjectWindowViewModelTreeData treeData) + { + + MsuSongInfo output = new(); + if (treeData.SongInfo == null || !converterService.CloneModel(treeData.SongInfo, output) || !converterService.CloneModel(treeData.SongInfo.MsuPcmInfo, output.MsuPcmInfo)) + { + return null; + } + + output.TrackNumber = 0; + output.TrackName = null; + output.OutputPath = null; + output.LastModifiedDate = new DateTime(); + output.IsComplete = false; + output.MsuPcmInfo.ClearFieldsForYaml(); + output.IsAlt = false; + var yamlText = yamlService.ToYaml(output, YamlType.PascalIgnoreDefaults); + + return + """ + # yaml-language-server: $schema=https://raw.githubusercontent.com/MattEqualsCoder/MSUScripter/main/Schemas/MsuSongInfo.json + # Use Visual Studio Code with the YAML plugin from redhat for schema support (make sure the language is set to YAML) + + """ + yamlText; + } + + public string? PasteSongDetails(MsuProjectWindowViewModelTreeData treeData, string yaml) + { + if (yamlService.FromYaml(yaml, YamlType.PascalIgnoreDefaults, out var songInfo, out var error) && songInfo != null) + { + var originalSongInfo = treeData.SongInfo; + treeData.SongInfo = songInfo; + + if (originalSongInfo != null) + { + songInfo.Id = originalSongInfo.Id; + songInfo.OutputPath = originalSongInfo.OutputPath; + songInfo.MsuPcmInfo.Output = originalSongInfo.MsuPcmInfo.Output; + songInfo.TrackNumber = originalSongInfo.TrackNumber; + songInfo.TrackName = originalSongInfo.TrackName; + + var index = treeData.TrackInfo!.Songs.IndexOf(originalSongInfo); + treeData.TrackInfo.Songs[index] = songInfo; + } + else + { + songInfo.Id = Guid.NewGuid().ToString(); + songInfo.TrackName = treeData.TrackInfo!.TrackName; + songInfo.TrackNumber = treeData.TrackInfo!.TrackNumber; + treeData.TrackInfo.Songs.Add(songInfo); + } + + if (treeData.ParentTreeData != null && !string.IsNullOrEmpty(treeData.SongInfo?.SongName)) + { + treeData.Name = treeData.SongInfo.SongName; + } + + UpdateCompletedSummary(); + + return null; + } + + return error ?? "Unable to copy data"; + } + + public void InputFileUpdated() + { + _viewModel.CurrentTreeItem?.UpdateCompletedFlag(); + } + + public void OnClose() + { + statusBarService.StatusBarTextUpdated -= StatusBarServiceOnStatusBarTextUpdated; + msuPcmService.GeneratingPcm -= MsuPcmServiceOnGeneratingPcm; + _ = audioPlayerService.StopSongAsync(); + } + + public void DragDropFile(string filePath) + { + if (_viewModel.CurrentTreeItem?.TrackInfo == null || _viewModel.CurrentTreeItem?.SongInfo == null) + { + return; + } + + logger.LogInformation("Dragged {File} to {Track}", filePath, _viewModel.MsuSongViewModel.TrackInfo!.TrackNumber); + + if (_viewModel.MsuSongViewModel.BasicPanelViewModel.IsEnabled) + { + _viewModel.MsuSongViewModel.BasicPanelViewModel.DragDropFile(filePath); + } + else if (_viewModel.MsuSongViewModel.AdvancedPanelViewModel is { IsEnabled: true, CurrentTreeItem.MsuPcmInfo: not null }) + { + _viewModel.MsuSongViewModel.AdvancedPanelViewModel.DragDropFile(filePath); + } + } + + public bool CanCreateVideos() + { + return pythonCompanionService.IsValid; + } + + public void LogError(Exception ex, string message) + { + logger.LogError(ex, "{Message}", message); + } +} \ No newline at end of file diff --git a/MSUScripter/Services/ControlServices/MsuSongInfoPanelService.cs b/MSUScripter/Services/ControlServices/MsuSongInfoPanelService.cs deleted file mode 100644 index fbc83b1..0000000 --- a/MSUScripter/Services/ControlServices/MsuSongInfoPanelService.cs +++ /dev/null @@ -1,179 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using System.Threading.Tasks; -using AvaloniaControls.ControlServices; -using AvaloniaControls.Services; -using MSUScripter.Configs; -using MSUScripter.Models; -using MSUScripter.ViewModels; - -namespace MSUScripter.Services.ControlServices; - -public class MsuSongInfoPanelService(SharedPcmService sharedPcmService, Settings settings, AudioMetadataService audioMetadataService, ConverterService converterService, YamlService yamlService, AudioAnalysisService audioAnalysisService) : ControlService -{ - private MsuSongInfoViewModel _model = new(); - - public void InitializeModel(MsuSongInfoViewModel model) - { - _model = model; - _model.Track = _model.Project.Tracks.First(x => x.TrackNumber == model.TrackNumber); - _model.CanPlaySongs = sharedPcmService.CanPlaySongs; - _model.PauseStopIcon = sharedPcmService.CanPauseSongs ? Material.Icons.MaterialIconKind.Pause : Material.Icons.MaterialIconKind.Stop; - _model.PauseStopText = sharedPcmService.CanPauseSongs ? "Pause Music" : "Stop Music"; - } - - public async Task PlaySong(bool testLoop) - { - return await sharedPcmService.PlaySong(_model, testLoop); - } - - public void DeleteSong() - { - _model.Track.Songs.Remove(_model); - _model.Track.FixTrackSuffixes(_model.CanPlaySongs); - } - - public async Task PauseSong() - { - await sharedPcmService.PauseSong(); - } - - public async Task StopSong() - { - await sharedPcmService.StopSong(); - } - - public string? GetOpenMusicFilePath() - { - if (!string.IsNullOrEmpty(_model.MsuPcmInfo.File) && File.Exists(_model.MsuPcmInfo.File)) - { - var file = new FileInfo(_model.MsuPcmInfo.File); - if (file.Directory?.Exists == true) - { - return file.Directory.FullName; - } - } - else if (!string.IsNullOrEmpty(settings.PreviousPath) && Directory.Exists(settings.PreviousPath)) - { - return settings.PreviousPath; - } - - return null; - } - - public void ImportAudioMetadata(string file) - { - if (string.IsNullOrEmpty(file) || !File.Exists(file)) - { - return; - } - - var metadata = audioMetadataService.GetAudioMetadata(file); - _model.ApplyAudioMetadata(metadata, true); - } - - public string? GetCopyDetailsString() - { - MsuSongInfo output = new(); - if (!converterService.ConvertViewModel(_model, output) || !converterService.ConvertViewModel(_model.MsuPcmInfo, output.MsuPcmInfo)) - { - return null; - } - - output.TrackNumber = 0; - output.TrackName = null; - output.OutputPath = null; - output.LastGeneratedDate = new DateTime(); - output.LastModifiedDate = new DateTime(); - output.IsComplete = false; - output.ShowPanel = false; - output.MsuPcmInfo.ClearFieldsForYaml(); - var yamlText = yamlService.ToYaml(output, YamlType.PascalIgnoreDefaults); - - return - """ - # yaml-language-server: $schema=https://raw.githubusercontent.com/MattEqualsCoder/MSUScripter/main/Schemas/MsuSongInfo.json - # Use Visual Studio Code with the YAML plugin from redhat for schema support (make sure the language is set to YAML) - - """ + yamlText; - } - - public string? CopyDetailsFromString(string yamlText) - { - if (!yamlService.FromYaml(yamlText, YamlType.PascalIgnoreDefaults, out var yamlSongDetails, out _) || yamlSongDetails == null) - { - return "Invalid song details"; - } - - var originalProject = _model.Project; - var originalTrack = _model.Track; - var originalTrackName = _model.TrackName; - var originalTrackNumber = _model.TrackNumber; - var originalIsAlt = _model.IsAlt; - var originalCanPlaySongs = _model.CanPlaySongs; - var originalOutputPath = _model.OutputPath; - _model.MsuPcmInfo.SubChannels.Clear(); - _model.MsuPcmInfo.SubTracks.Clear(); - - if (!converterService.ConvertViewModel(yamlSongDetails, _model) || !converterService.ConvertViewModel(yamlSongDetails.MsuPcmInfo, _model.MsuPcmInfo)) - { - return "Invalid song details"; - } - - _model.OutputPath = originalOutputPath; - - _model.ApplyCascadingSettings(originalProject, originalTrack, originalIsAlt, originalCanPlaySongs, true, true); - return null; - } - - public void IgnoreMsuPcmError() - { - if (string.IsNullOrEmpty(_model.OutputPath)) return; - _model.Project.IgnoreWarnings.Add(_model.OutputPath); - } - - public Task GeneratePcmFile(bool asPrimary, bool asEmpty) - { - return sharedPcmService.GeneratePcmFile(_model, asPrimary, asEmpty); - } - - public void AnalyzeAudio() - { - _model.AverageAudio = "Running"; - _model.PeakAudio = null; - - ITaskService.Run(async () => - { - await PauseSong(); - - var generateResponse = await GeneratePcmFile(false, false); - if (!generateResponse.Successful) - { - _model.AverageAudio = "Error generating PCM"; - _model.PeakAudio = null; - } - - if (!string.IsNullOrEmpty(_model.OutputPath)) - { - var output = await audioAnalysisService.AnalyzeAudio(_model.OutputPath); - - if (output is { AvgDecibels: not null, MaxDecibels: not null }) - { - _model.AverageAudio = $"Average: {Math.Round(output.AvgDecibels.Value, 2)}db"; - _model.PeakAudio = $"Peak: {Math.Round(output.MaxDecibels.Value, 2)}db"; - } - else - { - _model.AverageAudio = "Error analyzing audio"; - _model.PeakAudio = null; - } - } - else - { - _model.AverageAudio = "Error generating PCM"; - _model.PeakAudio = null; - } - }); - } -} \ No newline at end of file diff --git a/MSUScripter/Services/ControlServices/MsuSongMsuPcmInfoPanelService.cs b/MSUScripter/Services/ControlServices/MsuSongMsuPcmInfoPanelService.cs deleted file mode 100644 index 3c02b33..0000000 --- a/MSUScripter/Services/ControlServices/MsuSongMsuPcmInfoPanelService.cs +++ /dev/null @@ -1,282 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using AvaloniaControls.ControlServices; -using MSUScripter.Configs; -using MSUScripter.ViewModels; - -namespace MSUScripter.Services.ControlServices; - -public class MsuSongMsuPcmInfoPanelService( - Settings settings, - SettingsService settingsService, - IAudioPlayerService audioPlayerService, - ConverterService converterService, - AudioAnalysisService audioAnalysisService, - AudioMetadataService audioMetadataService, - YamlService yamlService) : ControlService -{ - private MsuSongMsuPcmInfoViewModel _model = new(); - - public void InitializeModel(MsuSongMsuPcmInfoViewModel model) - { - _model = model; - _model.CanPlaySongs = audioPlayerService.CanPlayMusic; - } - - public void AddSubTrack(int? index = null, bool addToParent = false) - { - if (addToParent) - { - if (index is null or -1) - { - _model.ParentMsuPcmInfo?.SubTracks.Add(new MsuSongMsuPcmInfoViewModel - { Project = _model.Project, Song = _model.Song, IsAlt = _model.IsAlt, ParentMsuPcmInfo = _model.ParentMsuPcmInfo }); - } - else - { - _model.ParentMsuPcmInfo?.SubTracks.Insert(index.Value, new MsuSongMsuPcmInfoViewModel - { Project = _model.Project, Song = _model.Song, IsAlt = _model.IsAlt, ParentMsuPcmInfo = _model.ParentMsuPcmInfo }); - } - } - else - { - if (index is null or -1) - { - _model.SubTracks.Add(new MsuSongMsuPcmInfoViewModel - { Project = _model.Project, Song = _model.Song, IsAlt = _model.IsAlt, ParentMsuPcmInfo = _model }); - } - else - { - _model.SubTracks.Insert(index.Value, new MsuSongMsuPcmInfoViewModel - { Project = _model.Project, Song = _model.Song, IsAlt = _model.IsAlt, ParentMsuPcmInfo = _model }); - } - } - - var topLevelPcmInfo = _model.TopLevel; - topLevelPcmInfo.UpdateSubTrackSubChannelWarning(); - } - - public void AddSubChannel(int? index = null, bool addToParent = false) - { - if (addToParent) - { - if (index is null or -1) - { - _model.ParentMsuPcmInfo?.SubChannels.Add(new MsuSongMsuPcmInfoViewModel - { Project = _model.Project, Song = _model.Song, IsAlt = _model.IsAlt, ParentMsuPcmInfo = _model.ParentMsuPcmInfo }); - } - else - { - _model.ParentMsuPcmInfo?.SubChannels.Insert(index.Value, new MsuSongMsuPcmInfoViewModel - { Project = _model.Project, Song = _model.Song, IsAlt = _model.IsAlt, ParentMsuPcmInfo = _model.ParentMsuPcmInfo }); - } - } - else - { - if (index is null or -1) - { - _model.SubChannels.Add(new MsuSongMsuPcmInfoViewModel - { Project = _model.Project, Song = _model.Song, IsAlt = _model.IsAlt, ParentMsuPcmInfo = _model }); - } - else - { - _model.SubChannels.Insert(index.Value, new MsuSongMsuPcmInfoViewModel - { Project = _model.Project, Song = _model.Song, IsAlt = _model.IsAlt, ParentMsuPcmInfo = _model }); - } - } - - var topLevelPcmInfo = _model.TopLevel; - topLevelPcmInfo.UpdateSubTrackSubChannelWarning(); - } - - public void Delete() - { - if (_model.ParentMsuPcmInfo == null) - { - return; - } - - if (_model.IsSubChannel) - { - _model.ParentMsuPcmInfo.SubChannels.Remove(_model); - } - else if (_model.IsSubTrack) - { - _model.ParentMsuPcmInfo.SubTracks.Remove(_model); - } - - var topLevelPcmInfo = _model.TopLevel; - topLevelPcmInfo.UpdateSubTrackSubChannelWarning(); - } - - public bool ShouldShowSubTracksSubChannelsWarningPopup(bool newSubTrack, bool newSubChannel) - { - var numSubTracks = _model.SubTracks.Count + (newSubTrack ? 1 : 0); - var numSubChannels = _model.SubChannels.Count + (newSubChannel ? 1 : 0); - return !settings.HideSubTracksSubChannelsWarning && numSubTracks > 0 && numSubChannels > 0; - } - - public void HideSubTracksSubChannelsWarning() - { - settings.HideSubTracksSubChannelsWarning = true; - settingsService.SaveSettings(); - } - - public string? GetCopyDetailsString() - { - MsuSongMsuPcmInfo output = new(); - if (!converterService.ConvertViewModel(_model, output)) - { - return null; - } - output.ClearFieldsForYaml(); - var yamlText = yamlService.ToYaml(output, YamlType.PascalIgnoreDefaults); - - return - """ - # yaml-language-server: $schema=https://raw.githubusercontent.com/MattEqualsCoder/MSUScripter/main/Schemas/MsuSongMsuPcmInfo.json - # Use Visual Studio Code with the YAML plugin from redhat for schema support (make sure the language is set to YAML) - - """ + yamlText; - } - - public string? CopyDetailsFromString(string yamlText) - { - if (!yamlService.FromYaml(yamlText, YamlType.PascalIgnoreDefaults, out var yamlMsuPcmDetails, out _) || yamlMsuPcmDetails == null) - { - return "Invalid msupcm++ track details"; - } - - var originalProject = _model.Project; - var originalSong = _model.Song; - var originalIsAlt = _model.IsAlt; - var originalParent = _model.ParentMsuPcmInfo; - var originalCanPlaySongs = _model.CanPlaySongs; - - _model.SubTracks.Clear(); - _model.SubChannels.Clear(); - - if (!converterService.ConvertViewModel(yamlMsuPcmDetails, _model)) - { - return "Invalid msupcm++ track details"; - } - - _model.ApplyCascadingSettings(originalProject, originalSong, originalIsAlt, originalParent, originalCanPlaySongs, true, true); - return null; - } - - public void UpdateLoopSettings(PyMusicLooperResultViewModel loopResult) - { - _model.Loop = loopResult.LoopStart; - _model.TrimEnd = loopResult.LoopEnd; - } - - public void UpdateContextMenuOptions() - { - if (_model.IsTopLevel) - { - _model.CanMoveUp = false; - _model.CanMoveDown = false; - return; - } - - var parentObjectList = _model.IsSubChannel - ? _model.ParentMsuPcmInfo!.SubChannels.ToList() - : _model.ParentMsuPcmInfo!.SubTracks.ToList(); - - _model.CanMoveUp = parentObjectList.IndexOf(_model) > 0; - _model.CanMoveDown = parentObjectList.IndexOf(_model) < parentObjectList.Count - 1; - } - - public void MoveUp() - { - if (!_model.CanMoveUp) return; - - var parentObjectList = _model.IsSubChannel - ? _model.ParentMsuPcmInfo!.SubChannels - : _model.ParentMsuPcmInfo!.SubTracks; - - var currentIndex = parentObjectList.IndexOf(_model); - parentObjectList.Remove(_model); - parentObjectList.Insert(currentIndex - 1, _model); - } - - public void MoveDown() - { - if (!_model.CanMoveDown) return; - - var parentObjectList = _model.IsSubChannel - ? _model.ParentMsuPcmInfo!.SubChannels - : _model.ParentMsuPcmInfo!.SubTracks; - - var currentIndex = parentObjectList.IndexOf(_model); - parentObjectList.Remove(_model); - parentObjectList.Insert(currentIndex + 1, _model); - } - - public bool HasLoopDetails() - { - return _model.Loop > 0 || _model.TrimEnd > 0; - } - - public string? GetStartingSamples() - { - var file = _model.GetEffectiveFile(); - if (string.IsNullOrEmpty(file) || !File.Exists(file)) - { - return "No input file selected"; - } - - try - { - var samples = audioAnalysisService.GetAudioStartingSample(file); - _model.TrimStart = samples; - return null; - } - catch - { - return "Unable to get starting samples for file"; - } - } - - public string? GetEndingSamples() - { - var file = _model.GetEffectiveFile(); - if (string.IsNullOrEmpty(file) || !File.Exists(file)) - { - return "No input file selected"; - } - - try - { - var samples = audioAnalysisService.GetAudioEndingSample(file); - _model.TrimEnd = samples; - return null; - } - catch - { - return "Unable to get ending samples for file"; - } - } - - public void ImportAudioMetadata() - { - var topLevelPcmInfo = _model.TopLevel; - - _model.UpdatePyMusicLooperButtonStatus(); - - if (string.IsNullOrEmpty(_model.File) || !File.Exists(_model.File)) - { - topLevelPcmInfo.UpdateHertzWarning(audioAnalysisService.GetAudioSampleRate(_model.File)); - topLevelPcmInfo.UpdateMultiWarning(); - return; - } - - var metadata = audioMetadataService.GetAudioMetadata(_model.File); - _model.Song.ApplyAudioMetadata(metadata, false); - - topLevelPcmInfo.UpdateHertzWarning(audioAnalysisService.GetAudioSampleRate(_model.File)); - topLevelPcmInfo.UpdateMultiWarning(); - } -} \ No newline at end of file diff --git a/MSUScripter/Services/ControlServices/MsuSongPanelService.cs b/MSUScripter/Services/ControlServices/MsuSongPanelService.cs new file mode 100644 index 0000000..4642b4f --- /dev/null +++ b/MSUScripter/Services/ControlServices/MsuSongPanelService.cs @@ -0,0 +1,293 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using AvaloniaControls.ControlServices; +using AvaloniaControls.Services; +using Microsoft.Extensions.Logging; +using MSUScripter.Configs; +using MSUScripter.Models; +using MSUScripter.ViewModels; + +namespace MSUScripter.Services.ControlServices; + +// ReSharper disable once ClassNeverInstantiated.Global +public class MsuSongPanelService( + ConverterService converterService, + YamlService yamlService, + AudioMetadataService audioMetadataService, + MsuPcmService msuPcmService, + IAudioPlayerService audioPlayerService, + AudioAnalysisService audioAnalysisService, + PythonCompanionService pythonCompanionService, + ILogger logger) : ControlService +{ + public string? GetMsuPcmInfoCopyText(MsuSongMsuPcmInfo msuPcmInfo) + { + MsuSongMsuPcmInfo output = new(); + if (!converterService.CloneModel(msuPcmInfo, output)) + { + return null; + } + + Sanitize(output); + + var yamlText = yamlService.ToYaml(output, YamlType.PascalIgnoreDefaults); + + return + """ + # yaml-language-server: $schema=https://raw.githubusercontent.com/MattEqualsCoder/MSUScripter/main/Schemas/MsuSongMsuPcmInfo.json + # Use Visual Studio Code with the YAML plugin from redhat for schema support (make sure the language is set to YAML) + + """ + yamlText; + + void Sanitize(MsuSongMsuPcmInfo subInfo) + { + subInfo.ShowPanel = false; + subInfo.LastModifiedDate = DateTime.MinValue; + subInfo.Output = null; + foreach (var info in subInfo.SubChannels.Concat(subInfo.SubTracks)) + { + Sanitize(info); + } + } + } + + public MsuSongMsuPcmInfo? GetMsuPcmInfoFromText(string yaml) + { + yamlService.FromYaml(yaml, YamlType.PascalIgnoreDefaults, out var data, out _); + return data; + } + + public MsuSongMsuPcmInfo? DuplicateMsuPcmInfo(MsuSongMsuPcmInfo info) + { + MsuSongMsuPcmInfo output = new(); + return !converterService.CloneModel(info, output) ? null : output; + } + + public AudioMetadata GetAudioMetadata(string filename) + { + return audioMetadataService.GetAudioMetadata(filename); + } + + public void CheckSampleRate(MsuSongBasicPanelViewModel model) + { + var path = model.InputFilePath; + if (string.IsNullOrEmpty(path) || !File.Exists(path) || model.Project == null) + { + model.SetSampleRate(44100); + return; + } + + var savedSampleRate = GetSavedSampleInfo(model.Project, path); + if (savedSampleRate != null) + { + model.SetSampleRate(savedSampleRate.Value); + return; + } + + ITaskService.Run(async () => + { + model.SetSampleRate(44100); + var sampleRate = await GetSampleRateAsync(model.Project, path); + if (path == model.InputFilePath) + { + model.SetSampleRate(sampleRate); + } + }); + } + + public void CheckFileErrors(MsuSongAdvancedPanelViewModel model) + { + var project = model.Project; + var song = model.CurrentSongInfo; + + var hasBothSubTracksAndSubChannels = false; + var hasSoloSubChannel = false; + var hasSameParentChildType = false; + var hasIgnoredFile = false; + + List files = []; + foreach (var treeItem in model.TreeItems.Where(x => x.MsuPcmInfo != null)) + { + if (!string.IsNullOrEmpty(treeItem.MsuPcmInfo!.File) && File.Exists(treeItem.MsuPcmInfo!.File)) + { + files.Add(treeItem.MsuPcmInfo.File); + } + + var subTracks = treeItem.ChildrenTreeData.FirstOrDefault(); + var subChannels = treeItem.ChildrenTreeData.LastOrDefault(); + + if (subChannels != null && subTracks != null && subTracks != subChannels && + subTracks.ChildrenTreeData.Count > 0 && subChannels.ChildrenTreeData.Count > 0) + { + hasBothSubTracksAndSubChannels = true; + + if (subChannels.ChildrenTreeData.Count == 1) + { + hasSoloSubChannel = true; + } + } + + if (treeItem is { IsSubChannel: true, ParentTreeData.ParentTreeData.IsSubChannel: true } or { IsSubTrack: true, ParentTreeData.ParentTreeData.IsSubTrack: true }) + { + hasSameParentChildType = true; + } + + if (!string.IsNullOrEmpty(treeItem.MsuPcmInfo.File) && + (subTracks?.ChildrenTreeData.Count > 0 || subChannels?.ChildrenTreeData.Count > 0)) + { + hasIgnoredFile = true; + } + } + + if (files.Count == 0) + { + model.UpdateTrackWarnings(false, false, false, false, false, false); + return; + } + + files = files.Distinct().ToList(); + + if (files.Count > 1) + { + model.UpdateTrackWarnings(false, true, hasBothSubTracksAndSubChannels, hasSoloSubChannel, hasSameParentChildType, hasIgnoredFile); + return; + } + + ITaskService.Run(async() => + { + var path = files.First(); + var sampleRate = GetSavedSampleInfo(project, path) ?? await GetSampleRateAsync(project, path); + if (model.CurrentSongInfo == song) + { + model.UpdateTrackWarnings(sampleRate != 44100, false, hasBothSubTracksAndSubChannels, hasSoloSubChannel, hasSameParentChildType, hasIgnoredFile); + } + }); + } + + private int? GetSavedSampleInfo(MsuProject project, string path) + { + if (project.SampleRates.TryGetValue(path, out var sampleRate)) + { + var fileInfo = new FileInfo(path); + if (fileInfo.Length == sampleRate.FileLength) + { + return sampleRate.SampleRate; + } + } + + return null; + } + + private async Task GetSampleRateAsync(MsuProject project, string path) + { + var response = await audioAnalysisService.GetAudioSampleRateAsync(path); + var fileInfo = new FileInfo(path); + if (response.Successful) + { + project.SampleRates[path] = new FileSampleInfo + { + FileLength = fileInfo.Length, + SampleRate = response.SampleRate + }; + } + return response.SampleRate; + } + + public async Task PlaySong(MsuProject project, MsuSongInfo song, bool testLoop) + { + GeneratePcmFileResponse? generateResponse = null; + + if (song.HasAudioFiles()) + { + generateResponse = await GeneratePcm(project, song, false, false); + if (!generateResponse.GeneratedPcmFile) + { + return generateResponse; + } + + if (string.IsNullOrEmpty(song.OutputPath) || !File.Exists(song.OutputPath)) + { + return generateResponse; + } + } + else if (!File.Exists(song.OutputPath)) + { + return new GeneratePcmFileResponse(false, false, "PCM file not found", song.OutputPath); + } + + var msuTypeTrackInfo = project.MsuType.Tracks.FirstOrDefault(x => x.Number == song.TrackNumber); + + if (await audioPlayerService.PlaySongAsync(song.OutputPath, testLoop, msuTypeTrackInfo?.NonLooping != true)) + { + return new GeneratePcmFileResponse(generateResponse?.Successful ?? true, generateResponse?.GeneratedPcmFile ?? false, generateResponse?.Message ?? string.Empty, song.OutputPath); + } + + return new GeneratePcmFileResponse(false, false, "Error playing PCM file", song.OutputPath); + } + + public async Task GeneratePcm(MsuProject project, MsuSongInfo song, bool asPrimary, bool asEmpty) + { + await audioPlayerService.StopSongAsync(song.OutputPath); + if (asEmpty) + { + return msuPcmService.CreateEmptyPcm(song); + } + return await msuPcmService.CreatePcm(project, song, asPrimary, false, true); + } + + public MsuPcmJsonInfo GenerateSongTracksFile(MsuProject project, MsuSongInfo song, string path) + { + return msuPcmService.ExportMsuPcmTracksJson(project, song, path, song.OutputPath); + } + + public async Task AnalyzeAudio(MsuProject project, MsuSongInfo song) + { + if (string.IsNullOrEmpty(song.OutputPath)) + { + return null; + } + + await audioPlayerService.StopSongAsync(null, true); + var response = await msuPcmService.CreatePcm(project, song, false, false, true); + if (!response.Successful) + { + return null; + } + + var output = await audioAnalysisService.AnalyzeAudio(song.OutputPath); + return output; + } + + public int DetectStartingSamples(string file) + { + return audioAnalysisService.GetAudioStartingSample(file); + } + + public int DetectEndingSamples(string file) + { + return audioAnalysisService.GetAudioEndingSample(file); + } + + public void LogError(Exception ex, string message) + { + logger.LogError(ex, "{Message}", message); + } + + public bool CanGenerateMsuPcmFiles() + { + return msuPcmService.IsValid; + } + + public bool CanGenerateVideos() + { + return pythonCompanionService.IsValid; + } + + public bool CanRunPyMusicLooper() + { + return pythonCompanionService.IsValid; + } +} \ No newline at end of file diff --git a/MSUScripter/Services/ControlServices/MsuTrackInfoPanelService.cs b/MSUScripter/Services/ControlServices/MsuTrackInfoPanelService.cs deleted file mode 100644 index 1bb07fe..0000000 --- a/MSUScripter/Services/ControlServices/MsuTrackInfoPanelService.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.IO; -using AvaloniaControls.ControlServices; -using MSUScripter.ViewModels; - -namespace MSUScripter.Services.ControlServices; - -public class MsuTrackInfoPanelService : ControlService -{ - private MsuTrackInfoViewModel _model = new(); - - public void InitializeModel(MsuTrackInfoViewModel model) - { - _model = model; - } - - public void AddSong() - { - var songInfo = new MsuSongInfoViewModel(); - - var isAlt = _model.Songs.Count > 0; - - var msu = new FileInfo(_model.Project.MsuPath); - if (!isAlt) - { - songInfo.OutputPath = msu.FullName.Replace(msu.Extension, $"-{_model.TrackNumber}.pcm"); - } - else - { - var altSuffix = _model.Songs.Count == 1 ? "alt" : $"alt{_model.Songs.Count}"; - songInfo.OutputPath = msu.FullName.Replace(msu.Extension, $"-{_model.TrackNumber}_{altSuffix}.pcm"); - } - - songInfo.ApplyCascadingSettings(_model.Project, _model, isAlt, true, true, true); - _model.Songs.Add(songInfo); - } -} \ No newline at end of file diff --git a/MSUScripter/Services/ControlServices/NewProjectPanelService.cs b/MSUScripter/Services/ControlServices/NewProjectPanelService.cs deleted file mode 100644 index 053c2b4..0000000 --- a/MSUScripter/Services/ControlServices/NewProjectPanelService.cs +++ /dev/null @@ -1,120 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using AvaloniaControls.Controls; -using AvaloniaControls.ControlServices; -using AvaloniaControls.Models; -using Microsoft.Extensions.Logging; -using MSURandomizerLibrary.Services; -using MSUScripter.Configs; -using MSUScripter.ViewModels; -using MSUScripter.Views; - -namespace MSUScripter.Services.ControlServices; - -public class NewProjectPanelService(IMsuTypeService msuTypeService, ProjectService projectService, Settings settings, ILogger logger) : ControlService -{ - private NewProjectPanelViewModel _model = new(); - - public NewProjectPanelViewModel InitializeModel() - { - _model.MsuTypes = msuTypeService.MsuTypes - .OrderBy(x => x.DisplayName) - .ToList(); - return _model; - } - - public void ResetModel() - { - _model.SelectedMsuType = null; - _model.MsuPath = ""; - _model.MsuPcmTracksJsonPath = ""; - _model.MsuPcmWorkingDirectoryPath = ""; - _model.RecentProjects = settings.RecentProjects.Where(x => File.Exists(x.ProjectPath)).OrderByDescending(x => x.Time).ToList(); - } - - public bool CreateNewProject(string path, out MsuProject? newProject, out bool isLegacySmz3, out string? error) - { - if (string.IsNullOrEmpty(path) || _model.SelectedMsuType == null || - string.IsNullOrEmpty(_model.MsuPath)) - { - newProject = null; - isLegacySmz3 = false; - error = "Missing data. Please enter the project path, msu path, and MSU type."; - return false; - } - - try - { - newProject = projectService.NewMsuProject(path, _model.SelectedMsuType, _model.MsuPath, _model.MsuPcmTracksJsonPath, _model.MsuPcmWorkingDirectoryPath); - isLegacySmz3 = newProject.MsuType == msuTypeService.GetSMZ3LegacyMSUType() && - msuTypeService.GetSMZ3MsuType() != null; - error = null; - return true; - } - catch (Exception exception) - { - logger.LogError(exception, "Unable to create new MSU Scripter project"); - newProject = null; - isLegacySmz3 = false; - error = exception.Message; - return false; - } - } - - public bool UpdateLegacySmz3Msu(MsuProject project) - { - try - { - projectService.ConvertProjectMsuType(project, msuTypeService.GetSMZ3MsuType()!, true); - SaveProject(project); - return true; - } - catch (Exception e) - { - logger.LogError(e, "Unable to update legacy SMZ3 MSU"); - return false; - } - } - - public bool LoadProject(string path, out MsuProject? project, out MsuProject? backupProject, out string? error) - { - try - { - project = projectService.LoadMsuProject(path, false); - backupProject = null; - error = null; - - if (project == null) - { - error = "MSU Project file not found"; - backupProject = null; - return false; - } - - if (!string.IsNullOrEmpty(project.BackupFilePath)) - { - var potentialBackupProject = projectService.LoadMsuProject(project.BackupFilePath, true); - if (potentialBackupProject != null && potentialBackupProject.LastSaveTime > project.LastSaveTime) - { - backupProject = potentialBackupProject; - } - } - - return true; - } - catch (Exception e) - { - logger.LogError(e, "Error opening project"); - project = null; - backupProject = null; - error = "Error opening project. Please contact MattEqualsCoder or post an issue on GitHub"; - return false; - } - } - - public void SaveProject(MsuProject project) - { - projectService.SaveMsuProject(project, false); - } -} \ No newline at end of file diff --git a/MSUScripter/Services/ControlServices/PackageMsuWindowService.cs b/MSUScripter/Services/ControlServices/PackageMsuWindowService.cs deleted file mode 100644 index d9f24b5..0000000 --- a/MSUScripter/Services/ControlServices/PackageMsuWindowService.cs +++ /dev/null @@ -1,143 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.IO.Compression; -using System.Linq; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Avalonia.Platform.Storage; -using AvaloniaControls.ControlServices; -using AvaloniaControls.Services; -using MSUScripter.ViewModels; - -namespace MSUScripter.Services.ControlServices; - -public class PackageMsuWindowService : ControlService -{ - private readonly PackageMsuWindowViewModel _model = new(); - - private readonly HashSet _extensions = - [ - ".txt", - ".pcm", - ".msu", - ".bat", - ".yml" - ]; - - private readonly CancellationTokenSource _cts = new(); - - public PackageMsuWindowViewModel InitializeModel(MsuProjectViewModel project) - { - _model.Project = project; - _model.ValidPcmPaths = project.Tracks.Where(x => !x.IsScratchPad).SelectMany(x => x.Songs) - .Select(x => x.OutputPath).Where(x => !string.IsNullOrEmpty(x)).Cast().ToList(); - return _model; - } - - public void PackageProject(string zipPath) - { - ITaskService.Run(() => PackageProjectTask(zipPath)); - } - - public void PackageProjectTask(string zipPath) - { - _model.IsRunning = true; - - var sb = new StringBuilder(); - sb.AppendLine($"Creating zip file {zipPath}"); - _model.Response = sb.ToString(); - - try - { - if (File.Exists(zipPath)) - { - File.Delete(zipPath); - } - - using var zip = ZipFile.Open(zipPath, ZipArchiveMode.Create); - - foreach (var file in Directory.EnumerateFiles(MsuDirectory, "*.*")) - { - if (_cts.Token.IsCancellationRequested) - { - break; - } - - if (!_extensions.Contains(Path.GetExtension(file))) - { - continue; - } - - if (string.Equals(Path.GetExtension(file), ".pcm", StringComparison.OrdinalIgnoreCase) && - !_model.ValidPcmPaths.Contains(file)) - { - continue; - } - - sb.AppendLine($"... adding {file}"); - _model.Response = sb.ToString(); - - try - { - zip.CreateEntryFromFile(file, Path.GetFileName(file)); - } - catch (Exception e2) - { - sb.AppendLine($"Could not add {file} to zip file: {e2.Message}"); - _model.Response = sb.ToString(); - _model.ButtonText = "Close"; - return; - } - } - - } - catch (Exception e) - { - sb.AppendLine($"Could not create zip file: {e.Message}"); - _model.Response = sb.ToString(); - _model.ButtonText = "Close"; - return; - } - - if (_cts.Token.IsCancellationRequested) - { - try - { - if (File.Exists(zipPath)) - { - File.Delete(zipPath); - } - } - catch - { - // Do nothing - } - } - else - { - _model.IsRunning = false; - sb.AppendLine("Complete!"); - _model.Response = sb.ToString(); - _model.ButtonText = "Close"; - } - } - - public void Cancel() - { - if (_model.IsRunning) - { - _cts.Cancel(); - } - } - - public string MsuDirectory - { - get - { - var msuFileInfo = new FileInfo(_model.Project.MsuPath); - return msuFileInfo.DirectoryName!; - } - } -} \ No newline at end of file diff --git a/MSUScripter/Services/ControlServices/PyMusicLooperPanelService.cs b/MSUScripter/Services/ControlServices/PyMusicLooperPanelService.cs index 0daec01..7b67c37 100644 --- a/MSUScripter/Services/ControlServices/PyMusicLooperPanelService.cs +++ b/MSUScripter/Services/ControlServices/PyMusicLooperPanelService.cs @@ -12,9 +12,19 @@ namespace MSUScripter.Services.ControlServices; +public class PyMusicLooperDetails +{ + public required MsuProject Project { get; init; } + public required string? FilePath { get; init; } + public required int? FilterStart { get; init; } + public double? Normalization { get; init; } + public bool AllowRunByDefault { get; init; } + public bool ForceRun { get; init; } +} + +// ReSharper disable once ClassNeverInstantiated.Global public class PyMusicLooperPanelService( - PyMusicLooperService pyMusicLooperService, - ConverterService converterService, + PythonCompanionService pythonCompanionService, MsuPcmService msuPcmService, IAudioPlayerService audioPlayerService, SettingsService settingsService) : ControlService @@ -23,6 +33,7 @@ public class PyMusicLooperPanelService( private CancellationTokenSource? _cts; private Settings Settings => settingsService.Settings; public event EventHandler? OnUpdated; + public event EventHandler? RunningUpdated; public PyMusicLooperPanelViewModel InitializeModel() { @@ -33,7 +44,8 @@ public PyMusicLooperPanelViewModel InitializeModel() _ = ITaskService.Run(() => { - RunMsuPcm(false); + var cts = _cts = new CancellationTokenSource(); + RunMsuPcm(false, cts); }); }; @@ -41,18 +53,30 @@ public PyMusicLooperPanelViewModel InitializeModel() return _model; } - public void UpdateModel(MsuProjectViewModel msuProjectViewModel, MsuSongInfoViewModel msuSongInfoViewModel, MsuSongMsuPcmInfoViewModel msuSongMsuPcmInfoViewModel) + public void UpdateDetails(PyMusicLooperDetails details) { - _model.MsuProjectViewModel = msuProjectViewModel; - _model.MsuProject = converterService.ConvertProject(_model.MsuProjectViewModel); - _model.MsuSongInfoViewModel = msuSongInfoViewModel; - _model.MsuSongMsuPcmInfoViewModel = msuSongMsuPcmInfoViewModel; - _model.FilterStart = msuSongMsuPcmInfoViewModel.TrimStart; + _model.AutoRun = Settings.AutomaticallyRunPyMusicLooper; + _model.FilePath = details.FilePath; + _model.FilterStart = details.FilterStart; + _model.MsuProject = details.Project; + _model.Normalization = details.Normalization; - if (Settings.AutomaticallyRunPyMusicLooper) + if (string.IsNullOrEmpty(_model.FilePath)) + { + _model.Message = "No file selected. Please select a file and click run."; + _model.CanRun = false; + _model.DisplayAutoRun = true; + } + else if (details.ForceRun || (Settings.AutomaticallyRunPyMusicLooper && details.AllowRunByDefault)) { RunPyMusicLooper(); } + else + { + _model.Message = "Click run to execute PyMusicLooper."; + _model.CanRun = true; + _model.DisplayAutoRun = true; + } } public void UpdateFilterStart(int? filterStart) @@ -74,8 +98,8 @@ public async Task ChangePage(int mod) _ = ITaskService.Run(() => { - RunMsuPcm(); - _model.Message = null; + var cts = _cts = new CancellationTokenSource(); + RunMsuPcm(true, cts); }); } @@ -103,7 +127,7 @@ await ITaskService.Run(async () => if (playSong && !string.IsNullOrEmpty(songPath)) { - _ = audioPlayerService.PlaySongAsync(songPath, true); + _ = audioPlayerService.PlaySongAsync(songPath, true, true); } else { @@ -142,41 +166,40 @@ private void FilterResults() _model.Page = 0; _model.LastPage = _model.FilteredResults.Count / _model.NumPerPage; } - - public void TestPyMusicLooper() + + private void TestPyMusicLooper() { - if (!pyMusicLooperService.TestService(out string message)) + if (pythonCompanionService.IsValid) { - _model.Message = message; - _model.DisplayGitHubLink = true; - OnUpdated?.Invoke(this, new PyMusicLooperPanelUpdatedArgs(null)); return; } - - if (!pyMusicLooperService.CanReturnMultipleResults) - { - _model.DisplayOldVersionWarning = true; - } + + _model.Message = "Companion PyMsuScripterApp is not detected"; + _model.DisplayGitHubLink = true; + OnUpdated?.Invoke(this, new PyMusicLooperPanelUpdatedArgs(null)); } public void RunPyMusicLooper() { + _model.DisplayAutoRun = false; + RunningUpdated?.Invoke(this, true); + if (!_model.HasTestedPyMusicLooper) { TestPyMusicLooper(); if (_model.DisplayGitHubLink) { + RunningUpdated?.Invoke(this, false); return; } - else if (!_model.DisplayOldVersionWarning) - { - _model.HasTestedPyMusicLooper = true; - } + + _model.HasTestedPyMusicLooper = true; } - if (string.IsNullOrEmpty(_model.MsuSongMsuPcmInfoViewModel.GetEffectiveFile())) + if (string.IsNullOrEmpty(_model.FilePath)) { + RunningUpdated?.Invoke(this, false); return; } @@ -184,70 +207,106 @@ public void RunPyMusicLooper() { _model.Message = "Both approximate loop start and end times must be filled out"; OnUpdated?.Invoke(this, new PyMusicLooperPanelUpdatedArgs(null)); + RunningUpdated?.Invoke(this, false); return; } - _cts = new CancellationTokenSource(); + var cts = _cts = new CancellationTokenSource(); - ITaskService.Run(() => + ITaskService.Run(async() => { _model.IsRunning = true; + _model.CanRun = false; _model.Message = "Running PyMusicLooper"; - var inputFile = _model.MsuSongMsuPcmInfoViewModel.GetEffectiveFile()!; - var loopPoints = pyMusicLooperService.GetLoopPoints(inputFile, out string message, - _model.MinDurationMultiplier, - _model.MinLoopDuration, _model.MaxLoopDuration, - _model.ApproximateStart, _model.ApproximateEnd, - _cts.Token); - - if (_cts?.IsCancellationRequested == true) + var inputFile = _model.FilePath; + + var response = await pythonCompanionService.RunPyMusicLooperAsync(new RunPyMusicLooperRequest() + { + File = inputFile, + MinDurationMultiplier = _model.MinDurationMultiplier, + MinLoopDuration = _model.MinLoopDuration, + MaxLoopDuration = _model.MaxLoopDuration, + ApproxLoopStart = _model.ApproximateStart, + ApproxLoopEnd = _model.ApproximateEnd, + }, cts.Token); + + if (cts.IsCancellationRequested) { _model.Message = "PyMusicLooper canceled"; _model.IsRunning = false; + _model.CanRun = true; OnUpdated?.Invoke(this, new PyMusicLooperPanelUpdatedArgs(null)); + RunningUpdated?.Invoke(this, false); return; } - else if (loopPoints?.Any() == true) + else if (response is { Successful: true, Pairs.Count: > 0 }) { _model.PyMusicLooperResults = - loopPoints.Select(x => new PyMusicLooperResultViewModel(x.LoopStart, x.LoopEnd, x.Score)).ToList(); + response.Pairs.Select(x => new PyMusicLooperResultViewModel(x.LoopStart, x.LoopEnd, x.Score)).ToList(); FilterResults(); - _model.SelectedResult = _model.FilteredResults.First(); - _model.SelectedResult.IsSelected = true; - _model.Message = "Generating Preview Files"; - RunMsuPcm(); - if (_cts?.IsCancellationRequested == true) + + if (_model.FilteredResults.Count > 0) { - _model.Message = "PyMusicLooper canceled"; - _model.IsRunning = false; - OnUpdated?.Invoke(this, new PyMusicLooperPanelUpdatedArgs(_model.SelectedResult)); - return; + _model.SelectedResult = _model.FilteredResults.First(); + _model.SelectedResult.IsSelected = true; + _model.Message = "Generating Preview Files"; + RunMsuPcm(true, cts); + if (cts.IsCancellationRequested) + { + _model.Message = "PyMusicLooper canceled"; + _model.IsRunning = false; + _model.CanRun = true; + OnUpdated?.Invoke(this, new PyMusicLooperPanelUpdatedArgs(_model.SelectedResult)); + RunningUpdated?.Invoke(this, false); + return; + } + else + { + _model.Message = null; + _model.IsRunning = false; + _model.CanRun = true; + OnUpdated?.Invoke(this, new PyMusicLooperPanelUpdatedArgs(_model.SelectedResult)); + RunningUpdated?.Invoke(this, false); + return; + } } else { - _model.Message = null; + _model.Message = "No matching results found"; _model.IsRunning = false; - OnUpdated?.Invoke(this, new PyMusicLooperPanelUpdatedArgs(_model.SelectedResult)); + _model.CanRun = true; + OnUpdated?.Invoke(this, new PyMusicLooperPanelUpdatedArgs(null)); + RunningUpdated?.Invoke(this, false); return; } } _model.IsRunning = false; + _model.CanRun = true; OnUpdated?.Invoke(this, new PyMusicLooperPanelUpdatedArgs(_model.SelectedResult)); - _model.Message = message; - }, _cts.Token); + RunningUpdated?.Invoke(this, false); + _model.Message = response.Error; + }, cts.Token); } - + public void StopPyMusicLooper() { _cts?.Cancel(); } - private void RunMsuPcm(bool fullReload = true) + public void SaveAutoRun(bool? value) { - if (_model.CurrentPageResults.All(x => x.Generated)) + Settings.AutomaticallyRunPyMusicLooper = value ?? false; + settingsService.SaveSettings(); + } + + private void RunMsuPcm(bool fullReload, CancellationTokenSource cts) + { + if (_model.CurrentPageResults.All(x => x.Generated && File.Exists(x.TempPath))) { + _model.Message = null; + _model.GeneratingPcms = false; return; } @@ -262,36 +321,47 @@ private void RunMsuPcm(bool fullReload = true) { async void GenerateTempPcm(PyMusicLooperResultViewModel result) { - result.Status = "Generating Preview .pcm File"; + try + { + result.Status = "Generating Preview .pcm File"; - var response = await CreateTempPcm(result, true); + var response = await CreateTempPcm(result, true); - if (!response.Successful) - { - if (response.GeneratedPcmFile) + if (!response.Successful) { - result.Status = $"Generated with message: {response.Message}"; - result.TempPath = response.OutputPath ?? throw new InvalidOperationException("GeneratePcmFileResponse for generated PCM missing output path"); - GetLoopDuration(result); - result.Generated = true; + if (response.GeneratedPcmFile) + { + result.Status = $"Generated with message: {response.Message}"; + result.TempPath = response.OutputPath ?? + throw new InvalidOperationException( + "GeneratePcmFileResponse for generated PCM missing output path"); + GetLoopDuration(result); + result.Generated = true; + } + else + { + result.Status = $"Error: {response.Message}"; + } } else { - result.Status = $"Error: {response.Message}"; + result.Status = "Generated"; + result.TempPath = response.OutputPath ?? + throw new InvalidOperationException( + "GeneratePcmFileResponse for generated PCM missing output path"); + GetLoopDuration(result); + result.Generated = true; } } - else + catch (Exception ex) { - result.Status = "Generated"; - result.TempPath = response.OutputPath ?? throw new InvalidOperationException("GeneratePcmFileResponse for generated PCM missing output path"); - GetLoopDuration(result); - result.Generated = true; + result.Status = $"Error: {ex.Message}"; } } - Parallel.ForEach(_model.CurrentPageResults.Where(x => !x.Generated), new ParallelOptions() + Parallel.ForEach(_model.CurrentPageResults.Where(x => !x.Generated || !File.Exists(x.TempPath)), new ParallelOptions() { - CancellationToken = _cts?.Token ?? CancellationToken.None + CancellationToken = cts.Token }, GenerateTempPcm); } @@ -299,15 +369,30 @@ async void GenerateTempPcm(PyMusicLooperResultViewModel result) { // Do nothing } - + + if (_cts != cts || cts.IsCancellationRequested) + { + _model.PyMusicLooperResults = []; + _model.FilteredResults = []; + _model.Message = "Click run to execute PyMusicLooper."; + _model.CanRun = true; + _model.DisplayAutoRun = true; + } + else + { + _model.Message = null; + } _model.GeneratingPcms = false; } private async Task CreateTempPcm(PyMusicLooperResultViewModel result, bool skipCleanup) { - var normalization = _model.MsuSongMsuPcmInfoViewModel.Normalization ?? - _model.MsuProjectViewModel.BasicInfo.Normalization; - return await msuPcmService.CreateTempPcm(false, _model.MsuProject, _model.MsuSongMsuPcmInfoViewModel.GetEffectiveFile()!, result.LoopStart, result.LoopEnd, normalization, skipCleanup: skipCleanup); + if (string.IsNullOrEmpty(_model.FilePath)) + { + throw new InvalidOperationException("Attempted to create a temp PCM without a file path"); + } + var normalization = _model.Normalization ?? _model.MsuProject.BasicInfo.Normalization; + return await msuPcmService.CreateTempPcm(_model.MsuProject, _model.FilePath, result.LoopStart, result.LoopEnd, normalization, skipCleanup: skipCleanup); } private void GetLoopDuration(PyMusicLooperResultViewModel song) diff --git a/MSUScripter/Services/ControlServices/SettingsWindowService.cs b/MSUScripter/Services/ControlServices/SettingsWindowService.cs index f469039..4072d20 100644 --- a/MSUScripter/Services/ControlServices/SettingsWindowService.cs +++ b/MSUScripter/Services/ControlServices/SettingsWindowService.cs @@ -1,36 +1,22 @@ -using System; -using Avalonia; -using Avalonia.Styling; using AvaloniaControls.ControlServices; using MSUScripter.ViewModels; namespace MSUScripter.Services.ControlServices; -public class SettingsWindowService(SettingsService settingsService, ConverterService converterService, MsuPcmService msuPcmService, PyMusicLooperService pyMusicLooperService) : ControlService +// ReSharper disable once ClassNeverInstantiated.Global +public class SettingsWindowService(SettingsService settingsService) : ControlService { - private SettingsWindowViewModel _model = new(); + private readonly SettingsWindowViewModel _model = new(); public SettingsWindowViewModel InitializeModel() { - converterService.ConvertViewModel(settingsService.Settings, _model); - _model.CanSetPyMusicLooperPath = OperatingSystem.IsWindows(); + _model.SettingsPanelViewModel.LoadSettings(settingsService.Settings); return _model; } public void SaveSettings() { - converterService.ConvertViewModel(_model, settingsService.Settings); + _model.SettingsPanelViewModel.SaveChanges(); settingsService.SaveSettings(); - Application.Current!.RequestedThemeVariant = settingsService.Settings.DarkTheme ? ThemeVariant.Dark : ThemeVariant.Light; - } - - public bool ValidateMsuPcm() - { - return msuPcmService.ValidateMsuPcmPath(_model.MsuPcmPath!, out _); - } - - public bool ValidatePyMusicLooper() - { - return pyMusicLooperService.TestService(out _, _model.PyMusicLooperPath); } } \ No newline at end of file diff --git a/MSUScripter/Services/ControlServices/TrackOverviewPanelService.cs b/MSUScripter/Services/ControlServices/TrackOverviewPanelService.cs deleted file mode 100644 index fe45c8c..0000000 --- a/MSUScripter/Services/ControlServices/TrackOverviewPanelService.cs +++ /dev/null @@ -1,69 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Linq; -using AvaloniaControls.ControlServices; -using MSUScripter.ViewModels; - -namespace MSUScripter.Services.ControlServices; - -public class TrackOverviewPanelService : ControlService -{ - private readonly TrackOverviewPanelViewModel _model = new(); - - public TrackOverviewPanelViewModel InitializeModel(EditProjectPanelViewModel editProjectPanelViewModel) - { - _model.MsuProjectViewModel = editProjectPanelViewModel.MsuProjectViewModel ?? new MsuProjectViewModel(); - RefreshTracks(); - return _model; - } - - public void RefreshTracks() - { - var tracks = _model.MsuProjectViewModel.Tracks; - - _model.Rows.Clear(); - - foreach (var track in tracks.Where(x => !x.IsScratchPad).OrderBy(x => x.TrackNumber)) - { - if (!track.Songs.Any()) - { - _model.Rows.Add(new TrackOverviewPanelViewModel.TrackOverviewRow(track.TrackNumber, track.TrackName)); - } - else - { - foreach (var song in track.Songs) - { - _model.Rows.Add(new TrackOverviewPanelViewModel.TrackOverviewRow(track.TrackNumber, - track.TrackName + (song.IsAlt ? " (Alt)" : ""), song)); - } - - } - } - - UpdateCompletedTrackDetails(); - } - - public void UpdateCompletedTrackDetails() - { - _model.UpdateCompletedTrackDetails(); - } - - public MsuProjectViewModel GetProject() - { - return _model.MsuProjectViewModel; - } - - internal void AddSong(TrackOverviewPanelViewModel.TrackOverviewRow row, MsuSongInfoViewModel newSongInfo) - { - if (row.HasSong) - { - var oldRowIndex = _model.Rows.IndexOf(row); - _model.Rows.Insert(oldRowIndex + 1, new TrackOverviewPanelViewModel.TrackOverviewRow(row.TrackNumber, row.TrackName, newSongInfo)); - } - else - { - row.SongInfo = newSongInfo; - } - } -} \ No newline at end of file diff --git a/MSUScripter/Services/ControlServices/VideoCreatorWindowService.cs b/MSUScripter/Services/ControlServices/VideoCreatorWindowService.cs deleted file mode 100644 index b7e41da..0000000 --- a/MSUScripter/Services/ControlServices/VideoCreatorWindowService.cs +++ /dev/null @@ -1,177 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Management; -using System.Runtime.Versioning; -using System.Text.RegularExpressions; -using AvaloniaControls.ControlServices; -using AvaloniaControls.Services; -using Microsoft.Extensions.Logging; -using MSUScripter.Configs; -using MSUScripter.Models; -using MSUScripter.ViewModels; - -namespace MSUScripter.Services.ControlServices; - -public class VideoCreatorWindowService(ILogger logger, PythonCommandRunnerService python, YamlService yamlService, Settings settings) : ControlService -{ - private Process? _process; - private const string MinVersion = "0.2.0"; - private static readonly Regex DigitsOnly = new(@"[^\d.]"); - private readonly VideoCreatorWindowViewModel _model = new(); - - public VideoCreatorWindowViewModel InitializeModel(MsuProjectViewModel project) - { - _model.PreviousPath = settings.PreviousPath; - - _model.PcmPaths = project.Tracks.Where(x => !x.IsScratchPad).SelectMany(x => x.Songs) - .Where(x => x.CheckCopyright == true && File.Exists(x.OutputPath)).Select(x => x.OutputPath).ToList(); - - if (_model.PcmPaths.Count == 0) - { - _model.DisplayText = "No songs are set to be added to the copyright test"; - return _model; - } - - if (python.SetBaseCommand("msu_test_video_creator", "--version", out var result, out var error) && - result.StartsWith("msu_test_video_creator ")) - { - logger.LogInformation("{Version} found", result); - var version = DigitsOnly.Replace(result, "").Split(".").Select(int.Parse).ToList(); - var currentVersionNumber = ConvertVersionNumber(version[0], version[1], version[2]); - var minVersion = DigitsOnly.Replace(MinVersion, "").Split(".").Select(int.Parse).ToList(); - var minVersionNumber = ConvertVersionNumber(minVersion[0], minVersion[1], minVersion[2]); - - _model.CanRunVideoCreator = currentVersionNumber >= minVersionNumber; - - if (!_model.CanRunVideoCreator) - { - _model.DisplayText = $"msu_test_video_creator is out of date. Please upgrade to version {MinVersion}"; - _model.DisplayGitHubLink = true; - logger.LogWarning("{Warning}", _model.DisplayText); - } - } - else - { - _model.DisplayText = "Unable to run msu_test_video_creator. Make sure you can run it via command line."; - _model.DisplayGitHubLink = true; - logger.LogWarning("Unable to run msu_test_video_creator: {Error}", error); - } - - return _model; - } - - public bool IsRunning { get; private set; } - - public bool CanCreateVideo => _model.CanRunVideoCreator; - - public void CreateVideo(string outputPath) - { - if (!_model.CanRunVideoCreator) return; - - logger.LogInformation("Creating test video"); - _model.DisplayText = "Creating video (this could take a while)"; - - var pcmFilesData = new Dictionary>() - { - { "Files", _model.PcmPaths } - }; - - var yaml = yamlService.ToYaml(pcmFilesData, YamlType.Pascal); - var path = Path.Combine(Directories.TempFolder, "video-creator-list.yml"); - var directory = new FileInfo(path).DirectoryName; - if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) - { - Directory.CreateDirectory(directory); - } - File.WriteAllText(path, yaml); - - _process = python.RunCommandAsync($"-i \"{path}\" -o \"{outputPath}\"", false); - - if (_process == null) - { - _model.DisplayText = "Error calling msu_test_video_creator. Make sure you can call it manually via console."; - _model.DisplayGitHubLink = true; - logger.LogError("Error in VideoCreatorService: {Message}", _model.DisplayText); - return; - } - - ITaskService.Run(() => - { - IsRunning = true; - _process.WaitForExit(); - IsRunning = false; - var code = _process.ExitCode; - if (code != 0) - { - logger.LogError("Error {Code} calling msu_test_video_creator", code); - _model.DisplayText = - "Error calling msu_test_video_creator. Make sure you can call it manually via console."; - } - else - { - _model.DisplayText = "Video generation successful!"; - _model.CloseButtonText = "Close"; - } - }); - } - - public void Cancel() - { - if (_process == null) - { - return; - } - - logger.LogInformation("Video creation cancellation requested"); - - if (OperatingSystem.IsWindows()) - { - KillProcessWindows(_process.Id); - } - else - { - try - { - _process?.Kill(); - } - catch (Exception e) - { - logger.LogError(e, "Unable to kill Video Creator"); - } - } - } - - [SupportedOSPlatform("windows")] - private static void KillProcessWindows(int pid) - { - var processSearch = new ManagementObjectSearcher($"Select * From Win32_Process Where ParentProcessID={pid}"); - var processCollection = processSearch.Get(); - - if (processCollection.Count > 0) - { - foreach (var managementObject in processCollection) - { - KillProcessWindows(Convert.ToInt32(managementObject["ProcessID"])); - } - } - - // Then kill parents. - try - { - Process proc = Process.GetProcessById(pid); - if (!proc.HasExited) proc.Kill(); - } - catch (ArgumentException) - { - // Process already exited. - } - } - - private int ConvertVersionNumber(int a, int b, int c) - { - return a * 10000 + b * 100 + c; - } -} \ No newline at end of file diff --git a/MSUScripter/Services/ConverterService.cs b/MSUScripter/Services/ConverterService.cs index 5215848..9dba65b 100644 --- a/MSUScripter/Services/ConverterService.cs +++ b/MSUScripter/Services/ConverterService.cs @@ -5,25 +5,23 @@ using System.Linq; using System.Reflection; using MSURandomizerLibrary.Configs; -using MSURandomizerLibrary.Services; using MSUScripter.Configs; using MSUScripter.Models; -using MSUScripter.ViewModels; using Track = MSUScripter.Configs.Track; namespace MSUScripter.Services; -public class ConverterService(IMsuTypeService msuTypeService) +public class ConverterService { - public bool ConvertViewModel(A input, B output, bool recursive = true) where B : new() + public bool CloneModel(TA input, TA output, bool recursive = true) where TA : new() { - var propertiesA = typeof(A).GetProperties().Where(x => x.CanWrite && x.PropertyType.Namespace?.Contains("MSU") != true && x.GetCustomAttribute() == null).ToDictionary(x => x.Name, x => x); - var propertiesB = typeof(B).GetProperties().Where(x => x.CanWrite && x.PropertyType.Namespace?.Contains("MSU") != true && x.GetCustomAttribute() == null).ToDictionary(x => x.Name, x => x); + var propertiesA = typeof(TA).GetProperties().Where(x => x.CanWrite && x.PropertyType.Namespace?.Contains("MSU") != true && x.GetCustomAttribute() == null).ToDictionary(x => x.Name, x => x); + var propertiesB = typeof(TA).GetProperties().Where(x => x.CanWrite && x.PropertyType.Namespace?.Contains("MSU") != true && x.GetCustomAttribute() == null).ToDictionary(x => x.Name, x => x); var updated = false; if (propertiesA.Count != propertiesB.Count) { - throw new InvalidOperationException($"Types {typeof(A).Name} and {typeof(B).Name} are not compatible"); + throw new InvalidOperationException($"Types {typeof(TA).Name} and {typeof(TA).Name} are not compatible"); } foreach (var propA in propertiesA.Values) @@ -33,18 +31,18 @@ public class ConverterService(IMsuTypeService msuTypeService) continue; } - if (propA.PropertyType == typeof(List) || propA.PropertyType == typeof(ObservableCollection)) + if (propA.PropertyType == typeof(List) || propA.PropertyType == typeof(ObservableCollection)) { if (recursive) { - IList? aValue = propA.GetValue(input) as IList; - IList bValue = propB.GetValue(output) as IList ?? (propB.PropertyType == typeof(ObservableCollection) ? new ObservableCollection() : new List()); + IList? aValue = propA.GetValue(input) as IList; + IList bValue = propB.GetValue(output) as IList ?? (propB.PropertyType == typeof(ObservableCollection) ? new ObservableCollection() : new List()); if (aValue != null) { foreach (var aSubItem in aValue) { - var bSubItem = new B(); - ConvertViewModel(aSubItem, bSubItem); + var bSubItem = new TA(); + CloneModel(aSubItem, bSubItem); bValue.Add(bSubItem); } } @@ -63,71 +61,33 @@ public class ConverterService(IMsuTypeService msuTypeService) return updated; } - public MsuProjectViewModel ConvertProject(MsuProject project) + public MsuProject CloneProject(MsuProject project) { - var viewModel = new MsuProjectViewModel(); - ConvertViewModel(project, viewModel); - ConvertViewModel(project.BasicInfo, viewModel.BasicInfo); - viewModel.BasicInfo.Project = viewModel; + var newProject = new MsuProject(); + CloneModel(project, newProject); + CloneModel(project.BasicInfo, newProject.BasicInfo); foreach (var track in project.Tracks) { - var msuTypeTrack = project.MsuType.Tracks.FirstOrDefault(x => x.Number == track.TrackNumber); - - var trackViewModel = new MsuTrackInfoViewModel() - { - Project = viewModel, - Description = track.IsScratchPad - ? "Use this page to add songs for keeping and editing without including them in the MSU. All songs included in this will not be included when generating and packaging the MSU." - : msuTypeTrack?.Description - }; - - ConvertViewModel(track, trackViewModel); + var trackViewModel = new MsuTrackInfo(); + CloneModel(track, trackViewModel); foreach (var song in track.Songs) { - var songViewModel = new MsuSongInfoViewModel(); - ConvertViewModel(song, songViewModel); - ConvertViewModel(song.MsuPcmInfo, songViewModel.MsuPcmInfo); - songViewModel.ApplyCascadingSettings(viewModel, trackViewModel, songViewModel.IsAlt, songViewModel.CanPlaySongs, false, false); - trackViewModel.Songs.Add(songViewModel); + var songViewModel = new MsuSongInfo(); + CloneModel(song, songViewModel); + CloneModel(song.MsuPcmInfo, songViewModel.MsuPcmInfo); } - viewModel.Tracks.Add(trackViewModel); + newProject.Tracks.Add(trackViewModel); } - viewModel.LastSaveTime = DateTime.Now; + newProject.MsuType = project.MsuType; + newProject.LastSaveTime = DateTime.Now; - return viewModel; + return newProject; } - public MsuProject ConvertProject(MsuProjectViewModel viewModel) - { - var project = new MsuProject(); - ConvertViewModel(viewModel, project); - ConvertViewModel(viewModel.BasicInfo, project.BasicInfo); - project.MsuType = msuTypeService.GetMsuType(project.MsuTypeName) ?? - throw new InvalidOperationException($"Invalid MSU Type {project.MsuTypeName}"); - - foreach (var trackViewModel in viewModel.Tracks) - { - var track = new MsuTrackInfo(); - ConvertViewModel(trackViewModel, track); - - foreach (var songViewModel in trackViewModel.Songs) - { - var song = new MsuSongInfo(); - ConvertViewModel(songViewModel, song); - ConvertViewModel(songViewModel.MsuPcmInfo, song.MsuPcmInfo); - track.Songs.Add(song); - } - - project.Tracks.Add(track); - } - - return project; - } - public ICollection ConvertMsuPcmTrackInfo(Track_base trackBase, string rootPath) { var outputList = new List(); @@ -226,7 +186,15 @@ public Track_base ConvertMsuPcmTrackInfo(MsuSongMsuPcmInfo trackBase, bool isSub if (!isSubTrack && !isSubChannel && output is Track track) { if (trackBase.SubChannels.Any()) - track.Sub_channels = trackBase.SubChannels.Select(x => ConvertMsuPcmTrackInfo(x, false, true)).Cast().ToList(); + { + if (string.IsNullOrEmpty(track.File)) + { + track.File = trackBase.GetFiles().FirstOrDefault(); + } + track.Sub_channels = trackBase.SubChannels.Select(x => ConvertMsuPcmTrackInfo(x, false, true)) + .Cast().ToList(); + } + if (trackBase.SubTracks.Any()) track.Sub_tracks = trackBase.SubTracks.Select(x => ConvertMsuPcmTrackInfo(x, true, false)).Cast().ToList(); } @@ -236,6 +204,10 @@ public Track_base ConvertMsuPcmTrackInfo(MsuSongMsuPcmInfo trackBase, bool isSub } else if (isSubTrack && output is Sub_track subTrack && trackBase.SubChannels.Any()) { + if (string.IsNullOrEmpty(subTrack.File)) + { + subTrack.File = trackBase.GetFiles().FirstOrDefault(); + } subTrack.Sub_channels = trackBase.SubChannels.Select(x => ConvertMsuPcmTrackInfo(x, false, true)).Cast().ToList(); } diff --git a/MSUScripter/Services/DependencyInstallerService.cs b/MSUScripter/Services/DependencyInstallerService.cs new file mode 100644 index 0000000..c050241 --- /dev/null +++ b/MSUScripter/Services/DependencyInstallerService.cs @@ -0,0 +1,437 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using AvaloniaControls.Services; +using ICSharpCode.SharpZipLib.GZip; +using ICSharpCode.SharpZipLib.Tar; +using ICSharpCode.SharpZipLib.Zip; +using Microsoft.Extensions.Logging; +using MSUScripter.Models; + +namespace MSUScripter.Services; + +public class DependencyInstallerService(ILogger logger) +{ + private const string PythonWindowsDownloadUrl = "https://github.com/astral-sh/python-build-standalone/releases/download/20250828/cpython-3.13.7+20250828-x86_64-pc-windows-msvc-install_only_stripped.tar.gz"; + private const string PythonLinuxDownloadUrl = "https://github.com/astral-sh/python-build-standalone/releases/download/20250828/cpython-3.13.7+20250828-x86_64_v3-unknown-linux-gnu-install_only_stripped.tar.gz"; + private const string MsuPcmWindowsDownloadUrl = "https://github.com/qwertymodo/msupcmplusplus/releases/download/v1.0RC3/msupcm.exe"; + private const string MsuPcmLinuxDownloadUrl = "https://github.com/MattEqualsCoder/msupcmplusplus/releases/download/v1.0RC3/msupcm.AppImage"; + private const string FfmpegWindowsDownloadUrl = "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2024-08-31-12-50/ffmpeg-n7.0.2-6-g7e69129d2f-win64-lgpl-shared-7.0.zip"; + private const string FfmpegWindows32BitDownloadUrl = "https://github.com/defisym/FFmpeg-Builds-Win32/releases/download/latest/ffmpeg-n7.1-latest-win32-gpl-7.1.zip"; + private const string FfmpegLinuxDownloadUrl = "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2024-08-31-12-50/ffmpeg-n7.0.2-6-g7e69129d2f-linux64-lgpl-shared-7.0.tar.xz"; + + public async Task InstallPyApp(Action response, Func> runPyFunc) + { + var successful = false; + + try + { + response.Invoke("Setting up directories"); + var destination = Path.Combine(Directories.Dependencies, "python"); + + if (Directory.Exists(destination)) + { + logger.LogInformation("Deleting prior Python installation"); + try + { + await ITaskService.Run(() => + { + Directory.Delete(destination, true); + }); + } + catch (TaskCanceledException) + { + // Do Nothing + } + } + + EnsureFolders(destination); + + var tempFile = Path.Combine(Directories.TempFolder, "python.tar.gz"); + var url = OperatingSystem.IsWindows() ? PythonWindowsDownloadUrl : PythonLinuxDownloadUrl; + + response.Invoke("Downloading Python"); + if (!await DownloadFileAsync(url, tempFile)) + { + return false; + } + + response.Invoke("Extracting Python files"); + if (!await ExtractTarGzFile(tempFile, Directories.Dependencies)) + { + return false; + } + + var pythonPath = OperatingSystem.IsWindows() + ? Path.Combine(destination, "python.exe") + : Path.Combine(destination, "bin", "python3.13"); + + if (OperatingSystem.IsLinux()) + { + File.SetUnixFileMode(pythonPath, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + response.Invoke("Verifying Python version"); + + var runPyResult = await runPyFunc(pythonPath, "--version"); + if (!runPyResult.Success || !runPyResult.Result.StartsWith("Python 3")) + { + logger.LogError("Python version response incorrect: {Response} | {Error}", runPyResult.Result, + runPyResult.Error); + return false; + } + + response.Invoke("Installing companion app"); + + runPyResult = await runPyFunc(pythonPath, "-m pip install py-msu-scripter-app"); + if (!runPyResult.Success && !runPyResult.Error.StartsWith("[notice]")) + { + logger.LogError("Failed to install Python companion app: {Error}", runPyResult.Error); + return false; + } + + successful = true; + return true; + } + catch (TaskCanceledException) + { + // Do nothing + return successful; + } + catch (Exception ex) + { + logger.LogError(ex, "Unknown error installing Python companion app"); + return false; + } + } + + public async Task InstallMsuPcm(Action progress) + { + var successful = false; + + try + { + progress.Invoke("Setting up directories"); + EnsureFolders(); + + var destination = Path.Combine(Directories.Dependencies, OperatingSystem.IsWindows() ? "msupcm.exe" : "msupcm.AppImage"); + + if (File.Exists(destination)) + { + File.Delete(destination); + } + + var url = OperatingSystem.IsWindows() ? MsuPcmWindowsDownloadUrl : MsuPcmLinuxDownloadUrl; + progress.Invoke("Downloading MsuPcm++"); + if (!await DownloadFileAsync(url, destination)) + { + return false; + } + + if (OperatingSystem.IsLinux()) + { + File.SetUnixFileMode(destination, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + successful = File.Exists(destination); + return successful; + } + catch (TaskCanceledException) + { + // Do nothing + return successful; + } + catch (Exception ex) + { + logger.LogError(ex, "Unknown error installing MsuPcm++"); + return false; + } + } + + public async Task InstallFfmpeg(Action progress) + { + var successful = false; + + try + { + progress.Invoke("Setting up directories"); + var tempExtractionPath = Path.Combine(Directories.TempFolder, "ffmpeg"); + var destination = Path.Combine(Directories.Dependencies, "ffmpeg"); + + if (Directory.Exists(tempExtractionPath)) + { + logger.LogInformation("Deleting prior FFmpeg installation"); + + try + { + await ITaskService.Run(() => + { + Directory.Delete(tempExtractionPath, true); + }); + } + catch (TaskCanceledException) + { + // Do Nothing + } + } + + if (Directory.Exists(destination)) + { + logger.LogInformation("Deleting prior FFmpeg installation"); + + try + { + await ITaskService.Run(() => + { + Directory.Delete(destination, true); + }); + } + catch (TaskCanceledException) + { + // Do Nothing + } + } + + EnsureFolders(tempExtractionPath, destination); + + var tempFileName = OperatingSystem.IsWindows() ? "ffmpeg.zip" : "ffmpeg.tar.xz"; + var tempFilePath = Path.Combine(Directories.TempFolder, tempFileName); + var url = OperatingSystem.IsLinux() + ? FfmpegLinuxDownloadUrl + : Environment.Is64BitOperatingSystem + ? FfmpegWindowsDownloadUrl + : FfmpegWindows32BitDownloadUrl; + + progress.Invoke("Downloading FFmpeg"); + if (!await DownloadFileAsync(url, tempFilePath)) + { + return false; + } + + progress.Invoke("Extracting files"); + if (OperatingSystem.IsWindows() && !await ExtractZipFile(tempFilePath, tempExtractionPath)) + { + return false; + } + if (OperatingSystem.IsLinux() && !await ExtractTarXzFile(tempFilePath, tempExtractionPath)) + { + return false; + } + + var subDirectory = Directory.GetDirectories(tempExtractionPath).FirstOrDefault(); + if (!Directory.Exists(subDirectory)) + { + return false; + } + + progress.Invoke("Copying files"); + successful = await CopyDirectory(subDirectory, destination); + + if (OperatingSystem.IsLinux()) + { + var executablePath = Path.Combine(destination, "bin", "ffmpeg"); + File.SetUnixFileMode(executablePath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + executablePath = Path.Combine(destination, "bin", "ffprobe"); + File.SetUnixFileMode(executablePath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + executablePath = Path.Combine(destination, "bin", "ffplay"); + File.SetUnixFileMode(executablePath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + return successful; + + } + catch (TaskCanceledException) + { + // Do nothing + return successful; + } + catch (Exception ex) + { + logger.LogError(ex, "Unknown error installing FFmpeg"); + return false; + } + } + + private async Task ExtractTarGzFile(string inputPath, string outputDirectory) + { + logger.LogInformation("Extracting files from {File} to {Destination}", inputPath, outputDirectory); + + var success = false; + try + { + await ITaskService.Run(() => + { + try + { + using var inStream = File.OpenRead(inputPath); + using var gzipStream = new GZipInputStream(inStream); + var tarArchive = TarArchive.CreateInputTarArchive(gzipStream, Encoding.UTF8); + tarArchive.ExtractContents(outputDirectory); + tarArchive.Close(); + success = true; + } + catch (Exception e) + { + logger.LogError(e, "Error extracting files"); + } + }); + } + catch (TaskCanceledException) + { + // Do nothing + } + + return success; + } + + private async Task ExtractTarXzFile(string inputPath, string outputDirectory) + { + logger.LogInformation("Extracting files from {File} to {Destination}", inputPath, outputDirectory); + var procStartInfo = new ProcessStartInfo("tar") + { + Arguments = $"xf \"{inputPath}\"", + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = outputDirectory + }; + using var process = new Process(); + process.StartInfo = procStartInfo; + process.Start(); + await process.WaitForExitAsync(); + return process.ExitCode == 0; + } + + private async Task ExtractZipFile(string inputPath, string outputDirectory) + { + logger.LogInformation("Extracting files from {File} to {Destination}", inputPath, outputDirectory); + + var success = false; + try + { + await ITaskService.Run(() => + { + try + { + var fastZip = new FastZip(); + fastZip.ExtractZip(inputPath, outputDirectory, null); + success = true; + } + catch (Exception e) + { + logger.LogError(e, "Error extracting files"); + } + }); + } + catch (TaskCanceledException) + { + // Do nothing + } + + return success; + } + + private async Task CopyDirectory(string sourceDir, string targetDir) + { + logger.LogInformation("Copying {Source} to {Destination}", sourceDir, targetDir); + + var success = false; + try + { + await ITaskService.Run(() => + { + try + { + CopyDirectoryInternal(sourceDir, targetDir); + logger.LogInformation("Successfully copied files"); + success = true; + } + catch (Exception e) + { + logger.LogError(e, "Error copying directory"); + } + }); + } + catch (TaskCanceledException) + { + // Do nothing + } + + return success; + } + + private void CopyDirectoryInternal(string sourceDir, string targetDir) + { + Directory.CreateDirectory(targetDir); + + foreach(var file in Directory.GetFiles(sourceDir)) + File.Copy(file, Path.Combine(targetDir, Path.GetFileName(file))); + + foreach(var directory in Directory.GetDirectories(sourceDir)) + CopyDirectoryInternal(directory, Path.Combine(targetDir, Path.GetFileName(directory))); + } + + private async Task DownloadFileAsync(string url, string target, int attempts = 3) + { + logger.LogInformation("Downloading {Url} to {Target}", url, target); + for (var i = 0; i < attempts; i++) + { + var result = await DownloadFileAsyncAttempt(url, target); + if (result) + { + return true; + } + + await Task.Delay(TimeSpan.FromSeconds(15)); + } + + return false; + } + + private async Task DownloadFileAsyncAttempt(string url, string target) + { + + using var httpClient = new HttpClient(); + + try + { + await using (var downloadStream = await httpClient.GetStreamAsync(url)) + await using (var fileStream = new FileStream(target, FileMode.Create)) + { + await downloadStream.CopyToAsync(fileStream); + } + logger.LogInformation("Successfully downloaded file"); + return true; + } + catch (Exception ex) + { + logger.LogInformation(ex, "Error downloading file"); + return false; + } + } + + private void EnsureFolders(params string[] additionalFolders) + { + if (!Directory.Exists(Directories.Dependencies)) + { + Directory.CreateDirectory(Directories.Dependencies); + } + + if (!Directory.Exists(Directories.TempFolder)) + { + Directory.CreateDirectory(Directories.TempFolder); + } + + foreach (var additionalFolder in additionalFolders) + { + if (!string.IsNullOrEmpty(additionalFolder) && !Directory.Exists(additionalFolder)) + { + Directory.CreateDirectory(additionalFolder); + } + } + } +} \ No newline at end of file diff --git a/MSUScripter/Services/IAudioPlayerService.cs b/MSUScripter/Services/IAudioPlayerService.cs index e7aacfe..257fb6f 100644 --- a/MSUScripter/Services/IAudioPlayerService.cs +++ b/MSUScripter/Services/IAudioPlayerService.cs @@ -5,10 +5,6 @@ namespace MSUScripter.Services; public interface IAudioPlayerService { - public static bool CanPlaySongs { get; protected set; } - - public string CurrentPlayingFile { get; protected set; } - public void Pause(); public void PlayPause(); @@ -33,7 +29,7 @@ public interface IAudioPlayerService public bool IsStopped { get; } - public Task PlaySongAsync(string path, bool fromEnd); + public Task PlaySongAsync(string path, bool fromEnd, bool isLoopingSong); public Task StopSongAsync(string? newSongPath = null, bool waitForFile = false); diff --git a/MSUScripter/Services/MsuPcmService.cs b/MSUScripter/Services/MsuPcmService.cs index 7cbd802..b34319d 100644 --- a/MSUScripter/Services/MsuPcmService.cs +++ b/MSUScripter/Services/MsuPcmService.cs @@ -5,10 +5,9 @@ using System.Linq; using System.Reflection; using System.Runtime.InteropServices; -using System.Security.Cryptography; using System.Text; -using System.Text.RegularExpressions; using System.Threading.Tasks; +using K4os.Hash.xxHash; using Microsoft.Extensions.Logging; using MSUScripter.Configs; using MSUScripter.Models; @@ -16,26 +15,57 @@ namespace MSUScripter.Services; -public class MsuPcmService +public class MsuPcmInstallResponse { - private readonly StatusBarService _statusBarService; - private readonly ConverterService _converterService; - private readonly ILogger _logger; - private readonly Settings _settings; - private readonly IAudioPlayerService _audioPlayerService; - private readonly string _cacheFolder; - - public MsuPcmService(ILogger logger, Settings settings, StatusBarService statusBarService, ConverterService converterService, IAudioPlayerService audioPlayerService) + public bool Success { get; init; } + public bool MissingSharedLibraries { get; init; } +} + +public class MsuPcmJsonInfo +{ + // ReSharper disable once UnusedAutoPropertyAccessor.Global + public required string JsonFilePath { get; init; } + public required string JsonText { get; init; } + public string? ErrorMessage { get; init; } +} + +public class MsuPcmResult +{ + public bool Successful { get; set; } + public string Result { get; set; } = string.Empty; + public string Error { get; set; } = string.Empty; +} + +public class MsuPcmService( + ILogger logger, + Settings settings, + StatusBarService statusBarService, + ConverterService converterService, + YamlService yamlService, + IAudioPlayerService audioPlayerService, + DependencyInstallerService dependencyInstallerService) +{ + private string _cacheFolder2 = ""; + private string _msuPcmPath = string.Empty; + + public bool IsGeneratingPcm { get; private set; } + + public event EventHandler? GeneratingPcm; + + private string CacheFolder { - _logger = logger; - _settings = settings; - _statusBarService = statusBarService; - _converterService = converterService; - _audioPlayerService = audioPlayerService; - _cacheFolder = Path.Combine(Directories.CacheFolder, "msupcm"); - if (!Directory.Exists(_cacheFolder)) + get { - Directory.CreateDirectory(_cacheFolder); + if (string.IsNullOrEmpty(_cacheFolder2)) + { + _cacheFolder2 = Path.Combine(Directories.CacheFolder, "msupcm"); + if (!Directory.Exists(_cacheFolder2)) + { + Directory.CreateDirectory(_cacheFolder2); + } + } + + return _cacheFolder2; } } @@ -53,7 +83,7 @@ public void DeleteTempPcms(int capPcms = -1) } catch (Exception) { - _logger.LogWarning("Could not delete {File}", tempPcm.FullName); + logger.LogWarning("Could not delete {File}", tempPcm.FullName); } } } @@ -70,13 +100,15 @@ public void DeleteTempPcms(int capPcms = -1) } catch (Exception) { - _logger.LogWarning("Could not delete {File}", tempPcm.FullName); + logger.LogWarning("Could not delete {File}", tempPcm.FullName); } } } } + + public bool IsValid { get; private set; } - public async Task CreateTempPcm(bool standAlone, MsuProject project, string inputFile, int? loop = null, int? trimEnd = null, double? normalization = -25, int? trimStart = null, bool skipCleanup = false) + public async Task CreateTempPcm(MsuProject project, string inputFile, int? loop = null, int? trimEnd = null, double? normalization = -25, int? trimStart = null, bool skipCleanup = false) { if (!skipCleanup) { @@ -87,9 +119,10 @@ public async Task CreateTempPcm(bool standAlone, MsuPro { File.Delete(outputPath); } - var result = await CreatePcm(standAlone, project, new MsuSongInfo() + var result = await CreatePcm(project, new MsuSongInfo() { - TrackNumber = project.MsuType.Tracks.First().Number, + Id = Guid.NewGuid().ToString("N"), + TrackNumber = -1, OutputPath = outputPath, MsuPcmInfo = new MsuSongMsuPcmInfo() { @@ -100,208 +133,233 @@ public async Task CreateTempPcm(bool standAlone, MsuPro TrimEnd = trimEnd, Normalization = normalization } - }, false); + }, false, false, false); if (result is { Successful: true, GeneratedPcmFile: true }) { - _logger.LogInformation("Temp PCM {Path} created successfully", outputPath); + logger.LogInformation("Temp PCM {Path} created successfully", outputPath); } else if (result.GeneratedPcmFile) { - _logger.LogInformation("Temp PCM {Path} created with warning: {Warning}", outputPath, result.Message); + logger.LogInformation("Temp PCM {Path} created with warning: {Warning}", outputPath, result.Message); } else { - _logger.LogInformation("Temp PCM {Path} had an error: {Error}", outputPath, result.Message); + logger.LogInformation("Temp PCM {Path} had an error: {Error}", outputPath, result.Message); } return result; } - public void ClearCache() + public async Task CreatePcm(MsuProject project, MsuSongInfo song, bool asPrimary, bool isBulkGeneration, bool cacheResults) { - var cacheDirectory = new DirectoryInfo(_cacheFolder); - foreach (var file in cacheDirectory.EnumerateFiles()) + if (!IsValid) { - if (file.CreationTime < DateTime.Now.AddMonths(-1)) - { - try - { - file.Delete(); - } - catch - { - _logger.LogWarning("Could not delete {File}", file.FullName); - } - } + return new GeneratePcmFileResponse(false, false, + "MsuPcm++ is not installed and configured. Please install MsuPcm++ and reverify in the MSU Scripter settings.", + null); } - } - public void DeleteTempJsonFiles() - { - var jsonDirectory = Path.Combine(Directories.TempFolder, "msupcm"); - var tempDirectory = new DirectoryInfo(jsonDirectory); - if (tempDirectory.Exists) + GeneratingPcm?.Invoke(this, true); + + if (!audioPlayerService.IsStopped) { - foreach (var tempPcm in tempDirectory.EnumerateFiles("*.json")) - { - try - { - tempPcm.Delete(); - } - catch - { - _logger.LogWarning("Could not delete {File}", tempPcm.FullName); - } - } + await audioPlayerService.StopSongAsync(song.OutputPath); } - } - - public async Task CreatePcm(bool standAlone, MsuProject project, MsuSongInfo song, bool addTrackDetailsToMessage = true) - { - if (!_audioPlayerService.IsStopped) + + if (song.TrackNumber < 0) { - await _audioPlayerService.StopSongAsync(song.OutputPath); + logger.LogInformation("Generating PCM file for song {Song}", string.IsNullOrEmpty(song.SongName) ? song.TrackName : song.SongName); + } + else + { + logger.LogInformation("Generating temp PCM file for {SongFile}", song.MsuPcmInfo.File); + } + + var tempPath = project.GetMsuGenerationTempFilePath(song); + if (!Directory.Exists(tempPath)) + { + Directory.CreateDirectory(tempPath); + } + var tempJsonPath = Path.Combine(tempPath, "temp.json"); + var tempPcmPath = Path.Combine(tempPath, "temp.pcm"); + var jsonResponse = ExportMsuPcmTracksJson(project, song, tempJsonPath, tempPcmPath); + + if (string.IsNullOrEmpty(jsonResponse.JsonText)) + { + WriteFailureToStatusBar(); + GeneratingPcm?.Invoke(this, false); + return new GeneratePcmFileResponse(false, false, jsonResponse.ErrorMessage ?? "Failed to generate MsuPcm++ JSON file", null); } - var message = ""; - _statusBarService.UpdateStatusBar("Generating PCM"); - - var hasAlts = project.Tracks.First(x => x.TrackNumber == song.TrackNumber).Songs.Count > 1; + var outputPath = song.OutputPath; + if (asPrimary && song.IsAlt) + { + outputPath = project.Tracks.First(x => x.TrackNumber == song.TrackNumber).Songs.First(x => !x.IsAlt).OutputPath; + } + else if (song.TrackNumber is >= 1000 or < 0) + { + song.OutputPath = tempPcmPath; + outputPath = tempPcmPath; + } - if (string.IsNullOrEmpty(song.OutputPath)) + if (string.IsNullOrEmpty(outputPath)) { WriteFailureToStatusBar(); + GeneratingPcm?.Invoke(this, false); return new GeneratePcmFileResponse(false, false, $"Track #{song.TrackNumber} - Missing output PCM path", null); } + + project.GenerationCache.Songs.TryGetValue(song.Id, out var previousCache); + var currentCache = cacheResults ? GetSongCacheData(jsonResponse.JsonText, outputPath) : null; - if (song.MsuPcmInfo.HasBothSubTracksAndSubChannels) + if (MsuProjectSongCache.IsValid(previousCache, currentCache)) { - message = $"Track #{song.TrackNumber} - Subtracks and subchannels can't be at the same level and be generated by msupcm++."; - WriteFailureToStatusBar(); - return new GeneratePcmFileResponse(false, false, message, null); + logger.LogInformation("Song {SongId} matches cached data", outputPath); + statusBarService.UpdateStatusBar("PCM Generated"); + GeneratingPcm?.Invoke(this, false); + return new GeneratePcmFileResponse(true, true, null, outputPath); } - var jsonDirectory = Path.Combine(Directories.TempFolder, "msupcm"); - if (!Directory.Exists(jsonDirectory)) + logger.LogInformation("Generating PCM at {Path}", tempPcmPath); + statusBarService.UpdateStatusBar("Generating PCM"); + + // Generate the file and make sure the file exists + var msuPcmResult = await RunMsuPcmAsync(tempJsonPath, tempPcmPath); + if (!msuPcmResult.Successful && !File.Exists(tempPcmPath)) { - Directory.CreateDirectory(jsonDirectory); + logger.LogError("MsuPcm++ returned the following error: {Error}", msuPcmResult.Error); + GeneratingPcm?.Invoke(this, false); + WriteFailureToStatusBar(); + return new GeneratePcmFileResponse(false, false, msuPcmResult.Error, outputPath); } - - var msu = new FileInfo(project.MsuPath); - var guid = Guid.NewGuid().ToString("N"); - var jsonPath = Path.Combine(jsonDirectory, msu.Name.Replace(msu.Extension, $"-msupcm-temp-{guid}.json")); - try + logger.LogInformation("MsuPcm++ generated PCM file {File}", tempPcmPath); + + // Clean up msupcm++ errors + bool msuPcmSuccessful = msuPcmResult.Successful; + var msuPcmMessage = msuPcmResult.Error; + if (!msuPcmResult.Successful && project.IgnoreWarnings.Contains(msuPcmResult.Error)) { - ExportMsuPcmTracksJson(standAlone, project, song, jsonPath); - - var msuPath = new FileInfo(project.MsuPath).DirectoryName; - var relativePath = Path.GetRelativePath(msuPath!, song.OutputPath); + logger.LogWarning("Ignoring MsuPcm++ warning: {Warning}", msuPcmResult.Error); + msuPcmSuccessful = true; + msuPcmMessage = ""; + } - if (!File.Exists(jsonPath)) - { - message = "Valid MsuPcm++ json was not able to be created"; - if (addTrackDetailsToMessage) - message = $"Track #{song.TrackNumber} - {relativePath} - {message}"; - WriteFailureToStatusBar(); - return new GeneratePcmFileResponse(false, false, message, null); - } + // Validate the MsuPcm++ header + var validationSuccessful = ValidatePcm(tempPcmPath, out var validationResponse); + if (!validationSuccessful) + { + logger.LogError("Generated PCM file has an error: {Error}", validationResponse); + GeneratingPcm?.Invoke(this, false); + WriteFailureToStatusBar(); + return new GeneratePcmFileResponse(false, false, validationResponse, outputPath); + } - if (!ValidateMsuPcmInfo(song.MsuPcmInfo, out message, out var numFiles)) + // Move the file to the target location + if (outputPath != tempPcmPath) + { + try { - if (addTrackDetailsToMessage) - message = $"Track #{song.TrackNumber} - {relativePath} - {message}"; - File.Delete(jsonPath); - WriteFailureToStatusBar(); - return new GeneratePcmFileResponse(false, false, message, null); - } + if (File.Exists(outputPath)) + { + File.Delete(outputPath); + } + + var directory = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } - if (numFiles == 0) + logger.LogInformation("Moving generated PCM to {Path}", outputPath); + File.Move(tempPcmPath, outputPath); + } + catch (Exception ex) { - message = "No input files specified"; - if (addTrackDetailsToMessage) - message = $"Track #{song.TrackNumber} - {relativePath} - {message}"; + logger.LogError(ex, "Error while generating PCM file {Path}", outputPath); + GeneratingPcm?.Invoke(this, false); WriteFailureToStatusBar(); - File.Delete(jsonPath); - return new GeneratePcmFileResponse(false, false, message, null); + return new GeneratePcmFileResponse(false, false, "MsuPcm++ succeeded, but could not move generated file", outputPath); } + } - var file = new FileInfo(song.OutputPath); - - if (IsCached(song.MsuPcmInfo.GetFiles(), file.FullName, jsonPath)) - { - message = "Success!"; - if (addTrackDetailsToMessage) - message = $"Track #{song.TrackNumber} - {relativePath} - {message}"; - _statusBarService.UpdateStatusBar(hasAlts ? "PCM Generated - YAML Regeneration Needed" : "PCM Generated"); - return new GeneratePcmFileResponse(true, true, message, song.OutputPath); - } - - var lastModifiedDate = file.Exists ? file.LastWriteTime : DateTime.MinValue; + // Move to the cache + if (cacheResults) + { + currentCache = GetSongCacheData(jsonResponse.JsonText, outputPath); - if (RunMsuPcm(jsonPath, song.OutputPath, out message)) + if (currentCache != null) { - if (!ValidatePcm(song.OutputPath, out message)) + project.GenerationCache.Songs[song.Id] = currentCache; + + if (!isBulkGeneration) { - if (addTrackDetailsToMessage) - message = $"Track #{song.TrackNumber} - {relativePath} - {message}"; - File.Delete(jsonPath); - return new GeneratePcmFileResponse(false, false, message, null); + SaveGenerationCache(project); } - Cache(song.MsuPcmInfo.GetFiles(), file.FullName, jsonPath); - message = "Success!"; - if (addTrackDetailsToMessage) - message = $"Track #{song.TrackNumber} - {relativePath} - {message}"; - File.Delete(jsonPath); - - _statusBarService.UpdateStatusBar(hasAlts ? "PCM Generated - YAML Regeneration Needed" : "PCM Generated"); - return new GeneratePcmFileResponse(true, true, message, song.OutputPath); } + } - file = new FileInfo(song.OutputPath); - var newModifiedDate = file.Exists ? file.LastWriteTime : DateTime.MinValue; - var generated = newModifiedDate > lastModifiedDate; - - if (generated) - { - message = $"PCM Generated with msupcm++ warning: {CleanMsuPcmResponse(message)}"; - if (addTrackDetailsToMessage) - message = $"Track #{song.TrackNumber} - {relativePath} - {message}"; - _statusBarService.UpdateStatusBar("PCM Generated with Warning"); - } - else - { - message = CleanMsuPcmResponse(message); - if (addTrackDetailsToMessage) - message = $"Track #{song.TrackNumber} - {relativePath} - {message}"; - WriteFailureToStatusBar(); - } - - File.Delete(jsonPath); - - return new GeneratePcmFileResponse(false, generated, message, generated ? song.OutputPath : null); + if (!asPrimary) + { + song.LastGeneratedDate = DateTime.Now; } - catch (Exception e) + + var hasAlts = song.TrackNumber < 1000 && + project.Tracks.FirstOrDefault(x => x.TrackNumber == song.TrackNumber)?.Songs.Count > 1; + + if (msuPcmSuccessful && validationSuccessful) { - _logger.LogError(e, "Error creating PCM file for Track #{TrackNum} - {SongPath}", song.TrackNumber, song.OutputPath); - message = "Unknown error"; - if (addTrackDetailsToMessage) - message = $"Track #{song.TrackNumber} - {song.OutputPath} - {message}"; - if (File.Exists(jsonPath)) - { - File.Delete(jsonPath); - } - - WriteFailureToStatusBar(); - return new GeneratePcmFileResponse(false, false, message, null); + logger.LogInformation("Generated PCM file {File} successfully", outputPath); + } + else + { + logger.LogWarning("Generated PCM file {File} with warnings: {Warning}", outputPath, msuPcmMessage); + } + + statusBarService.UpdateStatusBar(hasAlts ? "PCM Generated - YAML Regeneration Needed" : "PCM Generated"); + GeneratingPcm?.Invoke(this, false); + return new GeneratePcmFileResponse(msuPcmSuccessful && validationSuccessful, true, msuPcmMessage, outputPath); + } + + public void SaveGenerationCache(MsuProject project) + { + var generationCacheFile = project.GetMsuGenerationCacheFilePath(); + var cacheYaml = yamlService.ToYaml(project.GenerationCache, YamlType.Pascal); + try + { + File.WriteAllText(generationCacheFile, cacheYaml); + logger.LogInformation("Saved project msupcm++ generation cache to {Path}", generationCacheFile); + } + catch (Exception ex) + { + logger.LogError(ex, "Error while saving generation cache"); } } - public bool CreateEmptyPcm(MsuSongInfo song) + private MsuProjectSongCache? GetSongCacheData(string json, string outputPath) + { + if (!File.Exists(outputPath)) + { + return null; + } + + var data = Encoding.UTF8.GetBytes(json); + var hash = XXH64.DigestOf(data); + var fileInfo = new FileInfo(outputPath); + return new MsuProjectSongCache + { + JsonHash = hash, + JsonLength = json.Length, + FileGenerationTime = fileInfo.LastWriteTime, + FileLength = fileInfo.Length, + }; + } + + public GeneratePcmFileResponse CreateEmptyPcm(MsuSongInfo song) { if (string.IsNullOrEmpty(song.OutputPath)) { - return false; + return new GeneratePcmFileResponse(false, false, "Missing song output path", null); } try @@ -313,14 +371,16 @@ public bool CreateEmptyPcm(MsuSongInfo song) } catch (Exception e) { - _logger.LogError(e, "Could not delete output file"); - _statusBarService.UpdateStatusBar("Error Creating Empty PCM File"); - return false; + logger.LogError(e, "Could not delete output file"); + statusBarService.UpdateStatusBar("Error Creating Empty PCM File"); + return new GeneratePcmFileResponse(false, false, "Could not delete output file", null); } - var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream("MSUScripter.empty.pcm"); + var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream("MSUScripter.Assets.empty.pcm"); if (stream == null) - return false; + { + return new GeneratePcmFileResponse(false, false, "Error Creating Empty PCM File", null); + } try { @@ -329,67 +389,28 @@ public bool CreateEmptyPcm(MsuSongInfo song) } catch (Exception e) { - _logger.LogError(e, "Could not copy empty pcm file"); - _statusBarService.UpdateStatusBar("Error Creating Empty PCM File"); - return false; + logger.LogError(e, "Could not copy empty pcm file"); + statusBarService.UpdateStatusBar("Error Creating Empty PCM File"); + return new GeneratePcmFileResponse(false, false, "Error Creating Empty PCM File", null); } - _statusBarService.UpdateStatusBar("Generated Empty PCM File"); - return true; + statusBarService.UpdateStatusBar("Generated Empty PCM File"); + return new GeneratePcmFileResponse(true, true, "Error Creating Empty PCM File", song.OutputPath); } - public bool ValidateMsuPcmInfo(MsuSongMsuPcmInfo info, out string? error, out int numFiles) + private bool ValidatePcm(string path, out string validationError) { - numFiles = 0; - - if (!string.IsNullOrEmpty(info.File)) - { - if (!File.Exists(info.File)) - { - error = $"{info.File} not found"; - return false; - } - - numFiles = 1; - } - - foreach (var subTrack in info.SubTracks) - { - if (!ValidateMsuPcmInfo(subTrack, out error, out var numSubFiles)) - return false; - numFiles += numSubFiles; - } - - foreach (var subChannel in info.SubChannels) - { - if (!ValidateMsuPcmInfo(subChannel, out error, out var numSubFiles)) - return false; - numFiles += numSubFiles; - } - - error = null; - return true; - } - - public bool ValidatePcm(string path, out string? error) - { - if (!File.Exists(path)) - { - error = "msupcm++ did not create the file, but did not return an error."; - return false; - } - var testBytes = new byte[8]; using (var reader = new BinaryReader(new FileStream(path, FileMode.Open))) { reader.BaseStream.Seek(0, SeekOrigin.Begin); - reader.Read(testBytes, 0, 8); + _ = reader.Read(testBytes, 0, 8); } if (Encoding.UTF8.GetString(testBytes, 0, 4) != "MSU1") { - error = "Bad Header"; + validationError = "MsuPcm++ generated the file with an invalid header."; return false; } @@ -398,90 +419,88 @@ public bool ValidatePcm(string path, out string? error) if (loop < totalSamples) { - error = null; + validationError = ""; return true; } - else - { - error = "Bad loop point specified"; - return false; - } + + validationError = "Bad loop point specified. Continuing could cause some emulators to crash."; + return false; } - public bool RunMsuPcm(string trackJson, string expectedOutputPath, out string error) + private async Task RunMsuPcmAsync(string trackJson, string expectedOutputPath) { IsGeneratingPcm = true; - var toReturn = RunMsuPcmInternal("\"" + trackJson + "\"", expectedOutputPath, out _, out error); + logger.LogInformation("Running MsuPcm++ for file {File}", trackJson); + var toReturn = RunMsuPcmInternalAsync(trackJson, expectedOutputPath); IsGeneratingPcm = false; - return toReturn; + return await toReturn; } - public bool ValidateMsuPcmPath(string msuPcmPath, out string error) + public async Task VerifyInstalledAsync() { - var successful = RunMsuPcmInternal("-v", "", out var result, out error, msuPcmPath); - return successful && result.StartsWith("msupcm v"); - } - - private bool IsCached(ICollection inputPaths, string outputPath, string jsonPath) - { - if (!File.Exists(outputPath)) + var fileName = OperatingSystem.IsWindows() ? "msupcm.exe" : "msupcm.AppImage"; + _msuPcmPath = Path.Combine(Directories.Dependencies, fileName); + var result = await RunMsuPcmInternalAsync("-v", ""); + IsValid = result.Successful && result.Result.StartsWith("msupcm v"); + + if (!IsValid) { - return false; + logger.LogError("msupcm++ could not be validated at path {Path}: {Error}", _msuPcmPath, result.Error); } - - var expectedCache = GetCacheDetails(inputPaths, outputPath, jsonPath); - - if (!File.Exists(expectedCache.CachePath)) + else { - return false; + logger.LogInformation("msupcm++ validated successfully at {Path}: {Result}", _msuPcmPath, result.Result); } - var currentCache = File.ReadAllText(expectedCache.CachePath); - - return currentCache == expectedCache.CacheValue; - } - - private void Cache(ICollection inputPaths, string outputPath, string jsonPath) - { - var cache = GetCacheDetails(inputPaths, outputPath, jsonPath); - File.WriteAllText(cache.CachePath, cache.CacheValue); + return new MsuPcmResult() + { + Successful = IsValid, + Result = result.Result, + Error = result.Error + }; } - private (string CachePath, string CacheValue) GetCacheDetails(ICollection inputPaths, string outputPath, - string jsonPath) + public async Task InstallAsync(Action progress) { - using var sha1 = SHA1.Create(); + var response = await dependencyInstallerService.InstallMsuPcm(progress); - var key = BitConverter.ToString(sha1.ComputeHash(Encoding.UTF8.GetBytes(outputPath))).Replace("-", ""); - - var paths = new List(); - paths.Add(jsonPath); - paths.Add(outputPath); - paths.AddRange(inputPaths); - - var hashes = new List(); - foreach (var inputPath in paths) + if (!response) { - using var stream = File.OpenRead(inputPath); - hashes.Add(BitConverter.ToString(sha1.ComputeHash(stream)).Replace("-", "")); + return new MsuPcmInstallResponse + { + Success = false, + }; } - var value = string.Join("|", hashes); + var verified = await VerifyInstalledAsync(); - var filePath = Path.Combine(_cacheFolder, key); - - return (filePath, value); + if (verified.Error.Contains("error while loading shared libraries")) + { + return new MsuPcmInstallResponse + { + Success = false, + MissingSharedLibraries = true + }; + } + + return new MsuPcmInstallResponse + { + Success = verified.Successful, + MissingSharedLibraries = false + }; } - - private bool RunMsuPcmInternal(string innerCommand, string expectedOutputPath, out string result, out string error, string? msuPcmPath = null) + + private async Task RunMsuPcmInternalAsync(string innerCommand, string expectedOutputPath) { - msuPcmPath ??= _settings.MsuPcmPath; - if (string.IsNullOrEmpty(msuPcmPath) || - !File.Exists(msuPcmPath)) + if (string.IsNullOrEmpty(_msuPcmPath) || + !File.Exists(_msuPcmPath)) { - result = ""; - error = "MsuPcm++ path not specified or is invalid"; - return false; + return new MsuPcmResult() + { + Successful = false, + Result = "", + Error = "MsuPcm++ path not specified or is invalid" + }; } try @@ -493,16 +512,28 @@ private bool RunMsuPcmInternal(string innerCommand, string expectedOutputPath, o modifiedTime = fileInfo.LastWriteTime; } - var msuPcmFile = new FileInfo(msuPcmPath); + var msuPcmFile = new FileInfo(_msuPcmPath); + + var workingDirectory = msuPcmFile.DirectoryName; + var command = msuPcmFile.FullName; + var arguments = innerCommand; + + if (innerCommand != "-v" && File.Exists(innerCommand)) + { + workingDirectory = Path.GetDirectoryName(innerCommand); + arguments = "\"" + Path.GetFileName(innerCommand) + "\""; + } ProcessStartInfo procStartInfo; + + logger.LogInformation("Running msupcm++ command: {Command} {Arguments}", command, arguments); if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - var command = msuPcmFile.Name + " " + innerCommand; - procStartInfo= new ProcessStartInfo("cmd", "/c " + command) + procStartInfo= new ProcessStartInfo(command) { - WorkingDirectory = msuPcmFile.DirectoryName, + Arguments = arguments, + WorkingDirectory = workingDirectory, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, @@ -511,10 +542,10 @@ private bool RunMsuPcmInternal(string innerCommand, string expectedOutputPath, o } else { - procStartInfo= new ProcessStartInfo(msuPcmFile.FullName) + procStartInfo= new ProcessStartInfo(command) { - Arguments = innerCommand, - WorkingDirectory = msuPcmFile.DirectoryName, + Arguments = arguments, + WorkingDirectory = workingDirectory, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, @@ -528,16 +559,26 @@ private bool RunMsuPcmInternal(string innerCommand, string expectedOutputPath, o process.Start(); // Add this: wait until process does its work - process.WaitForExit(); + await process.WaitForExitAsync(); // and only then read the result - result = process.StandardOutput.ReadToEnd().Replace("\0", "").Trim(); - error = process.StandardError.ReadToEnd().Replace("\0", "").Trim(); + var result = (await process.StandardOutput.ReadToEndAsync()).Replace("\0", "").Trim(); + var error = (await process.StandardError.ReadToEndAsync()).Replace("\0", "").Trim(); if (!string.IsNullOrEmpty(error)) { - _logger.LogError("Error running MsuPcm++: {Error}", error); - return false; + logger.LogError("Error running MsuPcm++: {Error}", error); + if (error.Contains("decrease volume?")) + { + error = + "MsuPcm settings for audio file caused audio clipping. Consider lowering the normalization value."; + } + return new MsuPcmResult() + { + Successful = false, + Result = result, + Error = error + }; } // Validate that the file generated if applicable @@ -548,14 +589,24 @@ private bool RunMsuPcmInternal(string innerCommand, string expectedOutputPath, o if (!string.IsNullOrEmpty(result)) { error = $"MsuPcm++ did not create the expected file and returned with the following Message: {result}"; - _logger.LogError("Error running MsuPcm++: {Error}", error); - return false; + logger.LogError("Error running MsuPcm++: {Error}", error); + return new MsuPcmResult() + { + Successful = false, + Result = result, + Error = error + }; } else { error = "$MsuPcm++ ran but did not create the expected file or return an error message."; - _logger.LogError("Error running MsuPcm++: {Error}", error); - return false; + logger.LogError("Error running MsuPcm++: {Error}", error); + return new MsuPcmResult() + { + Successful = false, + Result = result, + Error = error + }; } } else @@ -566,40 +617,120 @@ private bool RunMsuPcmInternal(string innerCommand, string expectedOutputPath, o if (!string.IsNullOrEmpty(result)) { error = $"MsuPcm++ did not create the expected file and returned with the following Message: {result}"; - _logger.LogError("Error running MsuPcm++: {Error}", error); - return false; + logger.LogError("Error running MsuPcm++: {Error}", error); + return new MsuPcmResult() + { + Successful = false, + Result = result, + Error = error + }; } else { error = "$MsuPcm++ ran but did not create the expected file or return an error message."; - _logger.LogError("Error running MsuPcm++: {Error}", error); - return false; + logger.LogError("Error running MsuPcm++: {Error}", error); + return new MsuPcmResult() + { + Successful = false, + Result = result, + Error = error + }; } } } } - return true; + return new MsuPcmResult() + { + Successful = true, + Result = result, + Error = error + }; } catch (Exception e) { - result = ""; - error = "Unknown error running MsuPcm++"; - _logger.LogError(e, "Unknown error running MsuPcm++"); - return false; + var result = ""; + var error = "Unknown error running MsuPcm++"; + logger.LogError(e, "Unknown error running MsuPcm++"); + return new MsuPcmResult() + { + Successful = false, + Result = result, + Error = error + }; } } + + private string? CheckSongForErrors(MsuSongInfo song) + { + var audioFiles = song.MsuPcmInfo.GetFiles(); + + if (audioFiles.Count == 0) + { + return "No input files selected."; + } + + foreach (var path in audioFiles.Where(path => !File.Exists(path))) + { + return $"Audio file {path} is not found."; + } + + if (song.MsuPcmInfo.HasBothSubTracksAndSubChannels) + { + return "Subtracks and subchannels can't be at the same level and be generated by msupcm++."; + } + + if (!song.MsuPcmInfo.HasValidSubChannelCount) + { + return "If subchannels are to be used, you need at least two of them."; + } + + if (!song.MsuPcmInfo.HasValidChildTypes) + { + return "Subchannels cannot contain more subchannels and subtracks cannot contain more subtracks. You must alternate between subchannel and subtracks when creating multiple layers."; + } + + return null; + } - public string? ExportMsuPcmTracksJson(bool standAlone, MsuProject project, MsuSongInfo? singleSong = null, string? exportPath = null) + public MsuPcmJsonInfo ExportMsuPcmTracksJson(MsuProject project, MsuSongInfo? singleSong = null, string? exportPath = null, string? pcmPath = null) { + if (singleSong != null) + { + var error = CheckSongForErrors(singleSong); + if (!string.IsNullOrEmpty(error)) + { + return new MsuPcmJsonInfo + { + JsonText = "", + JsonFilePath = "", + ErrorMessage = error, + }; + } + } + var msu = new FileInfo(project.MsuPath); if (string.IsNullOrEmpty(exportPath)) { - exportPath = msu.FullName.Replace(msu.Extension, "-tracks.json"); + exportPath = project.GetTracksJsonPath(); } + bool? ditherValue = project.BasicInfo.DitherType switch + { + DitherType.Default => null, + DitherType.All => true, + DitherType.None => false, + DitherType.DefaultOn => singleSong?.MsuPcmInfo.Dither ?? true, + DitherType.DefaultOff => singleSong?.MsuPcmInfo.Dither ?? false, + _ => null + }; + + var songs = singleSong == null + ? project.Tracks.Where(x => !x.IsScratchPad).SelectMany(x => x.Songs).ToList() + : [singleSong]; + var output = new MsuPcmPlusPlusConfig() { Game = project.BasicInfo.Game, @@ -607,22 +738,18 @@ private bool RunMsuPcmInternal(string innerCommand, string expectedOutputPath, o Artist = project.BasicInfo.Artist, Output_prefix = msu.FullName.Replace(msu.Extension, ""), Normalization = project.BasicInfo.Normalization, - Dither = project.BasicInfo.Dither, + Dither = ditherValue, Verbosity = 2, - Keep_temps = standAlone && _settings.RunMsuPcmWithKeepTemps, - First_track = singleSong?.TrackNumber ?? project.Tracks.Min(x => x.TrackNumber), - Last_track = singleSong?.TrackNumber ?? project.Tracks.Where(x => !x.IsScratchPad).Max(x => x.TrackNumber) + Keep_temps = settings.RunMsuPcmWithKeepTemps, + First_track = songs.Min(x => x.TrackNumber), + Last_track = songs.Max(x => x.TrackNumber) }; - var tracks = new List(); - - var songs = singleSong == null - ? project.Tracks.Where(x => !x.IsScratchPad).SelectMany(x => x.Songs).ToList() - : new List() { singleSong }; + var tracks = new List(); foreach (var song in songs) { - if (_converterService.ConvertMsuPcmTrackInfo(song.MsuPcmInfo, false, false) is not Track track) continue; - track.Output = song.OutputPath; + if (converterService.ConvertMsuPcmTrackInfo(song.MsuPcmInfo, false, false) is not Track track) continue; + track.Output = pcmPath ?? song.OutputPath; track.Track_number = song.TrackNumber; track.Title = song.TrackName ?? ""; tracks.Add(track); @@ -631,21 +758,16 @@ private bool RunMsuPcmInternal(string innerCommand, string expectedOutputPath, o output.Tracks = tracks; var json = JsonConvert.SerializeObject(output, Formatting.Indented); File.WriteAllText(exportPath, json); - _statusBarService.UpdateStatusBar("Json File Written"); - return exportPath; + statusBarService.UpdateStatusBar("Json File Written"); + return new MsuPcmJsonInfo + { + JsonFilePath = exportPath, + JsonText = json + }; } private void WriteFailureToStatusBar() { - _statusBarService.UpdateStatusBar("PCM Generation Failed"); - } - - public bool IsGeneratingPcm { get; private set; } - - private string TempFilePath => Path.Combine(Directories.BaseFolder, "tmp-pcm.pcm"); - - private string CleanMsuPcmResponse(string input) - { - return Regex.Replace(input.ReplaceLineEndings(""), @"\s[`'][^`']+\.pcm[`']\s", " "); + statusBarService.UpdateStatusBar("PCM Generation Failed"); } } \ No newline at end of file diff --git a/MSUScripter/Services/LoopStream.cs b/MSUScripter/Services/NAudioLoopStream.cs similarity index 96% rename from MSUScripter/Services/LoopStream.cs rename to MSUScripter/Services/NAudioLoopStream.cs index 744d745..7d6e566 100644 --- a/MSUScripter/Services/LoopStream.cs +++ b/MSUScripter/Services/NAudioLoopStream.cs @@ -6,7 +6,7 @@ namespace MSUScripter.Services; /// /// Stream for looping playback /// -public class LoopStream (WaveStream sourceStream) : WaveStream +public class NAudioLoopStream (WaveStream sourceStream) : WaveStream { /// /// Use this to turn looping on or off diff --git a/MSUScripter/Services/ProjectService.cs b/MSUScripter/Services/ProjectService.cs index 5c79cd1..fcee8d8 100644 --- a/MSUScripter/Services/ProjectService.cs +++ b/MSUScripter/Services/ProjectService.cs @@ -3,13 +3,11 @@ using System.IO; using System.Linq; using System.Text; -using DynamicData; using Microsoft.Extensions.Logging; using MSURandomizerLibrary.Configs; using MSURandomizerLibrary.Services; using MSUScripter.Configs; using MSUScripter.Models; -using MSUScripter.ViewModels; using Newtonsoft.Json; namespace MSUScripter.Services; @@ -27,6 +25,8 @@ public class ProjectService( { public void SaveMsuProject(MsuProject project, bool isBackup) { + var previousLastSaveTime = project.LastSaveTime; + project.LastSaveTime = DateTime.Now; if (!isBackup) @@ -34,6 +34,7 @@ public void SaveMsuProject(MsuProject project, bool isBackup) project.BackupFilePath = GetProjectBackupFilePath(project.ProjectFilePath); settingsService.AddRecentProject(project); SaveMsuProject(project, true); + project.LastSaveTime = DateTime.Now; } var yaml = yamlService.ToYaml(project, YamlType.Pascal); @@ -77,7 +78,13 @@ public void SaveMsuProject(MsuProject project, bool isBackup) if (!isBackup) { logger.LogInformation("Saved project"); - statusBarService?.UpdateStatusBar("Project Saved"); + statusBarService.UpdateStatusBar("Project Saved"); + } + else + { + project.LastSaveTime = previousLastSaveTime; + logger.LogInformation("Saved project backup"); + statusBarService.UpdateStatusBar("Backup Created"); } } @@ -99,7 +106,12 @@ public void SaveMsuProject(MsuProject project, bool isBackup) project.ProjectFilePath = path; project.BackupFilePath = GetProjectBackupFilePath(path); } - + + if (string.IsNullOrEmpty(project.Id)) + { + project.Id = Guid.NewGuid().ToString("N"); + } + project.MsuType = msuTypeService.GetMsuType(project.MsuTypeName) ?? throw new InvalidOperationException(); // Whoops, I screwed up. Fix up broken tracks. @@ -126,9 +138,17 @@ public void SaveMsuProject(MsuProject project, bool isBackup) duplicate.IsAlt = true; } } + + song.DisplayAdvancedMode ??= song.MsuPcmInfo.HasAdvancedData(); + + if (string.IsNullOrEmpty(song.Id)) + { + song.Id = Guid.NewGuid().ToString("N"); + } } } + // Add the scratch pad if one doesn't exist already if (!project.Tracks.Any(x => x.IsScratchPad)) { project.Tracks.Add(new MsuTrackInfo() @@ -139,43 +159,77 @@ public void SaveMsuProject(MsuProject project, bool isBackup) }); } + // Save whether it's an SMZ3 MSU if (project.MsuType == msuTypeService.GetSMZ3LegacyMSUType() || project.MsuType == msuTypeService.GetSMZ3MsuType()) { project.BasicInfo.IsSmz3Project = true; } + + // Convert track list + if (!string.IsNullOrEmpty(project.BasicInfo.TrackList)) + { + if (project.BasicInfo.TrackList == TrackListTypeDeprecated.List) + { + project.BasicInfo.TrackListType = TrackList.ListAlbumFirst; + } + else if (project.BasicInfo.TrackList == TrackListTypeDeprecated.Table) + { + project.BasicInfo.TrackListType = TrackList.Table; + } + } + + if (project.BasicInfo.Dither != null) + { + project.BasicInfo.DitherType = project.BasicInfo.Dither == true ? DitherType.All : DitherType.None; + project.BasicInfo.Dither = null; + } if (!isBackup) { settingsService.AddRecentProject(project); } + + var generationCacheFile = project.GetMsuGenerationCacheFilePath(); + var cacheFolder = Path.Combine(Directories.CacheFolder, "Generation"); + logger.LogInformation("Attempting to load PCM cache from {Path}", generationCacheFile); + if (File.Exists(generationCacheFile)) + { + var cacheYaml = File.ReadAllText(generationCacheFile); + if (yamlService.FromYaml(cacheYaml, YamlType.Pascal, out var cacheObject, out _) && cacheObject != null) + { + project.GenerationCache = cacheObject; + } + } + else if (!Directory.Exists(cacheFolder)) + { + Directory.CreateDirectory(cacheFolder); + } + statusBarService.UpdateStatusBar("Project Loaded"); return project; } - public MsuProject NewMsuProject(string projectPath, string msuTypeName, string msuPath, string? msuPcmTracksJsonPath, string? msuPcmWorkingDirectory) - { - var msuType = msuTypeService.GetMsuType(msuTypeName) ?? throw new InvalidOperationException("Invalid MSU Type"); - - return NewMsuProject(projectPath, msuType, msuPath, msuPcmTracksJsonPath, msuPcmWorkingDirectory); - } - - public MsuProject NewMsuProject(string projectPath, MsuType msuType, string msuPath, string? msuPcmTracksJsonPath, string? msuPcmWorkingDirectory) + public MsuProject NewMsuProject(string projectPath, MsuType msuType, string msuPath, string? msuPcmTracksJsonPath, string? msuPcmWorkingDirectory, string? projectName, string? creatorName) { var project = new MsuProject() { + Id = Guid.NewGuid().ToString("N"), ProjectFilePath = projectPath, BackupFilePath = GetProjectBackupFilePath(projectPath), MsuType = msuType, MsuTypeName = msuType.DisplayName, MsuPath = msuPath, IsNewProject = true, + BasicInfo = new MsuBasicInfo + { + PackName = projectName, + PackCreator = creatorName, + PackVersion = "", + } }; - project.BasicInfo.MsuType = project.MsuType.Name; - project.BasicInfo.Game = project.MsuType.Name; - foreach (var track in project.MsuType.Tracks.OrderBy(x => x.Number)) { project.Tracks.Add(new MsuTrackInfo() @@ -232,18 +286,27 @@ public MsuProject NewMsuProject(string projectPath, MsuType msuType, string msuP return project; } - public void ImportMsu(MsuProject project, string msuPath) + public void ConvertLegacySmz3Project(MsuProject msuProject) + { + try + { + var legacyMsuType = + msuTypeService.MsuTypes.First(x => x.DisplayName == "SMZ3 Combo Randomizer (Zelda First)"); + ConvertProjectMsuType(msuProject, legacyMsuType, true); + } + catch (Exception e) + { + logger.LogError(e, "Error updating legacy SMZ3 project"); + } + } + + private void ImportMsu(MsuProject project, string msuPath) { var msu = msuLookupService.LoadMsu(msuPath, project.MsuType); if (msu == null) return; - if (msu.MsuType != project.MsuType && msu.MsuType != null) - { - ConvertProjectMsuType(project, msu.MsuType); - } - project.BasicInfo.PackName = msu.Name; project.BasicInfo.PackCreator = msu.Creator; project.BasicInfo.PackVersion = msu.Version; @@ -269,25 +332,24 @@ public void ImportMsu(MsuProject project, string msuPath) var projectTrack = project.Tracks.FirstOrDefault(x => x.TrackNumber == track.Number); if (projectTrack == null) continue; var song = projectTrack.Songs.FirstOrDefault(x => x.OutputPath == track.Path); - if (song == null) + if (song != null) continue; + song = new MsuSongInfo() { - song = new MsuSongInfo() - { - TrackNumber = track.Number, - TrackName = track.TrackName, - SongName = track.SongName, - Artist = track.Artist, - Album = track.Album, - Url = track.Url, - OutputPath = track.Path, - IsAlt = track.IsAlt - }; - projectTrack.Songs.Add(song); - } + TrackNumber = track.Number, + TrackName = track.TrackName, + SongName = track.SongName, + Artist = track.Artist, + Album = track.Album, + Url = track.Url, + OutputPath = track.Path, + IsAlt = track.IsAlt, + Id = Guid.NewGuid().ToString("N") + }; + projectTrack.Songs.Add(song); } } - public void ConvertProjectMsuType(MsuProject project, MsuType newMsuType, bool swapPcmFiles = false) + private void ConvertProjectMsuType(MsuProject project, MsuType newMsuType, bool swapPcmFiles = false) { if (!project.MsuType.IsCompatibleWith(newMsuType) && project.MsuType != newMsuType) return; @@ -303,7 +365,7 @@ public void ConvertProjectMsuType(MsuProject project, MsuType newMsuType, bool s var msu = new FileInfo(project.MsuPath); var baseName = msu.Name.Replace(msu.Extension, ""); - HashSet swappedFiles = new HashSet(); + HashSet swappedFiles = []; var newTracks = new List(); foreach (var oldTrack in project.Tracks) { @@ -352,7 +414,8 @@ public void ConvertProjectMsuType(MsuProject project, MsuType newMsuType, bool s Url = oldSong.Url, OutputPath = Path.Combine(songDirectory, newSongName), IsAlt = oldSong.IsAlt, - MsuPcmInfo = oldSong.MsuPcmInfo + MsuPcmInfo = oldSong.MsuPcmInfo, + Id = Guid.NewGuid().ToString("N") }; newSongs.Add(newSong); @@ -388,7 +451,7 @@ public void ConvertProjectMsuType(MsuProject project, MsuType newMsuType, bool s project.Tracks = newTracks; } - public ICollection GetSmz3SplitMsuProjects(MsuProject project, out Dictionary convertedPaths, out string? error) + private ICollection GetSmz3SplitMsuProjects(MsuProject project, out Dictionary convertedPaths, out string? error) { var toReturn = new List(); convertedPaths = new Dictionary(); @@ -428,7 +491,7 @@ public ICollection GetSmz3SplitMsuProjects(MsuProject project, out D private MsuProject InternalGetSmz3MsuProject(MsuProject project, MsuType msuType, string newMsuPath, Dictionary convertedPaths) { var basicInfo = new MsuBasicInfo(); - converterService.ConvertViewModel(project.BasicInfo, basicInfo); + converterService.CloneModel(project.BasicInfo, basicInfo); var conversion = msuType.Conversions[project.MsuType]; @@ -461,7 +524,7 @@ private MsuProject InternalGetSmz3MsuProject(MsuProject project, MsuType msuType foreach (var song in project.Tracks.First(x => x.TrackNumber == oldTrackNumber).Songs) { var newSong = new MsuSongInfo(); - converterService.ConvertViewModel(song, newSong); + converterService.CloneModel(song, newSong); newSong.TrackNumber = newTrackNumber; newSong.TrackName = trackName; newSong.OutputPath = @@ -490,8 +553,10 @@ private MsuProject InternalGetSmz3MsuProject(MsuProject project, MsuType msuType }; } - public bool CreateSmz3SplitScript(MsuProject smz3Project, Dictionary convertedPaths) + public bool CreateSmz3SplitScript(MsuProject smz3Project) { + GetSmz3SplitMsuProjects(smz3Project, out var convertedPaths, out _); + var testTrack = smz3Project.Tracks.FirstOrDefault(x => x.TrackNumber > 100 && x.Songs.Any())?.TrackNumber; if (testTrack == null) @@ -525,14 +590,14 @@ public bool CreateSmz3SplitScript(MsuProject smz3Project, Dictionary(data); @@ -551,7 +616,15 @@ public void ImportMsuPcmTracksJson(MsuProject project, string jsonPath, string? project.BasicInfo.Artist = msuPcmData.Artist; project.BasicInfo.Game = msuPcmData.Game; project.BasicInfo.Normalization = msuPcmData.Normalization; - project.BasicInfo.Dither = msuPcmData.Dither; + + if (msuPcmData.Dither == true) + { + project.BasicInfo.DitherType = DitherType.All; + } + else if (msuPcmData.Dither == false) + { + project.BasicInfo.DitherType = DitherType.None; + } var msuFileInfo = new FileInfo(project.MsuPath); var msuDirectory = msuFileInfo.DirectoryName!; @@ -637,13 +710,19 @@ public void ImportMsuPcmTracksJson(MsuProject project, string jsonPath, string? } } - public bool CreateSmz3SplitRandomizerYaml(MsuProject project, out string? error) + public bool CreateSmz3SplitRandomizerYaml(MsuProject project, bool metroidOnly, bool zeldaOnly, out string? error) { - var data = new List<(MsuType?, string?)>() + var data = new List<(MsuType?, string?)>(); + + if (!metroidOnly) { - (msuTypeService.GetMsuType("Super Metroid"), project.BasicInfo.MetroidMsuPath), - (msuTypeService.GetMsuType("The Legend of Zelda: A Link to the Past"), project.BasicInfo.ZeldaMsuPath) - }; + data.Add((msuTypeService.GetMsuType("The Legend of Zelda: A Link to the Past"), project.BasicInfo.ZeldaMsuPath)); + } + + if (!zeldaOnly) + { + data.Add((msuTypeService.GetMsuType("Super Metroid"), project.BasicInfo.MetroidMsuPath)); + } var msu = new FileInfo(project.MsuPath); var yamlPath = msu.FullName.Replace(msu.Extension, ".yml"); @@ -718,6 +797,8 @@ public void ExportMsuRandomizerYaml(MsuProject project, out string? error) var msuFile = new FileInfo(project.MsuPath); var msuDirectory = msuFile.Directory!; + CreateMsuFiles(project); + var tracks = new List(); foreach (var projectTrack in project.Tracks.Where(x => !x.IsScratchPad)) @@ -762,25 +843,27 @@ public void ExportMsuRandomizerYaml(MsuProject project, out string? error) statusBarService.UpdateStatusBar("YAML File Write Failed"); } - if (project.BasicInfo.CreateSplitSmz3Script) - { - if (CreateSmz3SplitRandomizerYaml(project, out error)) - { - statusBarService.UpdateStatusBar("YAML File Written"); - } - else - { - statusBarService.UpdateStatusBar("YAML File Write Failed"); - } - } - else - { - statusBarService.UpdateStatusBar("YAML File Written"); - } + statusBarService.UpdateStatusBar("YAML File Written"); + // TODO: See if this works? + // if (project.BasicInfo.CreateSplitSmz3Script) + // { + // if (CreateSmz3SplitRandomizerYaml(project, out error)) + // { + // statusBarService.UpdateStatusBar("YAML File Written"); + // } + // else + // { + // statusBarService.UpdateStatusBar("YAML File Write Failed"); + // } + // } + // else + // { + // statusBarService.UpdateStatusBar("YAML File Written"); + // } } - public bool CreateMsuFiles(MsuProject project) + private void CreateMsuFiles(MsuProject project) { try { @@ -810,21 +893,29 @@ public bool CreateMsuFiles(MsuProject project) { } } - - return true; } catch (Exception e) { logger.LogError(e, "Unable to create msu file"); - return false; } } - public bool CreateAltSwapperFile(MsuProject project, ICollection? otherProjects) + public bool CreateAltSwapperFile(MsuProject project) { if (project.Tracks.All(x => x.Songs.Count <= 1)) return true; + ICollection otherProjects = new List(); + + if (project.BasicInfo.CreateSplitSmz3Script) + { + otherProjects = GetSmz3SplitMsuProjects(project, out _, out var error).ToList(); + if (!string.IsNullOrEmpty(error)) + { + return false; + } + } + var msuPath = new FileInfo(project.MsuPath).DirectoryName; if (string.IsNullOrEmpty(msuPath)) return true; @@ -836,13 +927,10 @@ public bool CreateAltSwapperFile(MsuProject project, ICollection? ot var trackCombos = project.Tracks.Where(t => t is { IsScratchPad: false, Songs.Count: > 1 }) .Select(t => (t.Songs.First(s => !s.IsAlt), t.Songs.First(s => s.IsAlt))).ToList(); - if (otherProjects != null) + foreach (var otherProject in otherProjects) { - foreach (var otherProject in otherProjects) - { - trackCombos.AddRange(otherProject.Tracks.Where(t => t is { IsScratchPad: false, Songs.Count: > 1 }) - .Select(t => (t.Songs.First(s => !s.IsAlt), t.Songs.First(s => s.IsAlt)))); - } + trackCombos.AddRange(otherProject.Tracks.Where(t => t is { IsScratchPad: false, Songs.Count: > 1 }) + .Select(t => (t.Songs.First(s => !s.IsAlt), t.Songs.First(s => s.IsAlt)))); } foreach (var combo in trackCombos) @@ -863,7 +951,7 @@ public bool CreateAltSwapperFile(MsuProject project, ICollection? ot } var text = sb.ToString(); - File.WriteAllText(Path.Combine(msuPath, "!Swap_Alt_Tracks.bat"), text); + File.WriteAllText(project.GetAltSwapperPath(), text); return true; } catch (Exception e) @@ -872,21 +960,22 @@ public bool CreateAltSwapperFile(MsuProject project, ICollection? ot return false; } } - - public bool ValidateProject(MsuProjectViewModel project, out string message) + + public bool ValidateProject(MsuProject project, out string message) { var msuPath = project.MsuPath; var msu = msuLookupService.LoadMsu(msuPath, saveToCache: false, ignoreCache: true, forceLoad: true); - + if (msu == null) { message = "Could not load MSU."; + logger.LogWarning("Project validation failed: {Error}", message); statusBarService.UpdateStatusBar("YAML File Validation Failed"); return false; } var projectSongs = project.Tracks.Where(x => !x.IsScratchPad).SelectMany(x => x.Songs).ToList(); - + var projectTrackNumbers = projectSongs.Select(x => x.TrackNumber).Order().ToList(); var msuTrackNumbers = msu.Tracks.Where(x => !x.IsCopied).Select(x => x.Number).Order().ToList(); if (!projectTrackNumbers.SequenceEqual(msuTrackNumbers)) @@ -897,49 +986,55 @@ public bool ValidateProject(MsuProjectViewModel project, out string message) .Select(x => x.OutputPath) .ToList(); var msuPaths = msu.Tracks.Where(x => x.Number == trackNumber).Select(x => x.Path).ToList(); - - var missingMsuPaths = projPaths.Where(x => !msuPaths.Contains(x ?? "")).ToList(); + + var missingMsuPaths = projPaths.Where(x => !msuPaths.Contains(x ?? "")).Select(Path.GetFileName).ToList(); if (missingMsuPaths.Any()) { - message = $"{string.Join(", ", missingMsuPaths)} found in the project but not the generated MSU YAML file"; + message = $"{string.Join(", ", missingMsuPaths)} found in the project settings, but appears to be missing or not loading successfully."; + logger.LogWarning("Project validation failed: {Error}", message); statusBarService.UpdateStatusBar("YAML File Validation Failed"); return false; } - var missingProjPaths = msuPaths.Where(x => !projPaths.Contains(x ?? "")).ToList(); + var missingProjPaths = msuPaths.Where(x => !projPaths.Contains(x)).Select(Path.GetFileName).ToList(); if (missingProjPaths.Any()) { message = $"{string.Join(", ", missingProjPaths)} found in the generated MSU YAML file but not the project"; + logger.LogWarning("Project validation failed: {Error}", message); statusBarService.UpdateStatusBar("YAML File Validation Failed"); return false; } } message = "Could not load all tracks from the YAML file."; + logger.LogWarning("Project validation failed: {Error}", message); statusBarService.UpdateStatusBar("YAML File Validation Failed"); return false; } - + foreach (var projectSong in projectSongs) { var filename = new FileInfo(projectSong.OutputPath!).Name; var msuTrack = msu.Tracks.FirstOrDefault(x => x.Path.EndsWith(filename)); - + if (msuTrack == null) { message = $"Could not find track for song {projectSong.SongName} in the YAML file."; + logger.LogWarning("Project validation failed: {Error}", message); statusBarService.UpdateStatusBar("YAML File Validation Failed"); return false; } - else if ((projectSong.SongName ?? "") != msuTrack.SongName || (projectSong.Album ?? "") != (msuTrack.Album ?? "") || - (projectSong.Artist ?? "") != (msuTrack.Artist ?? "") || (projectSong.Url ?? "") != (msuTrack.Url ?? "")) + else if (project.BasicInfo.WriteYamlFile && ((projectSong.SongName ?? "") != msuTrack.SongName || (projectSong.Album ?? "") != (msuTrack.Album ?? "") || + (projectSong.Artist ?? "") != (msuTrack.Artist ?? "") || (projectSong.Url ?? "") != (msuTrack.Url ?? ""))) { message = $"Detail mismatch for song {projectSong.SongName} under track #{projectSong.TrackNumber}."; + logger.LogWarning("Project validation failed: {Error}", message); statusBarService.UpdateStatusBar("YAML File Validation Failed"); return false; } } message = ""; + logger.LogInformation("Project validated successfully"); statusBarService.UpdateStatusBar("YAML File Validated Successfully"); return true; } @@ -947,11 +1042,11 @@ public bool ValidateProject(MsuProjectViewModel project, out string message) private string GetProjectBackupFilePath(string projectFilePath) { var file = new FileInfo(projectFilePath); - byte[] inputBytes = Encoding.ASCII.GetBytes(file.FullName); - byte[] hashBytes = System.Security.Cryptography.MD5.HashData(inputBytes); + var inputBytes = Encoding.ASCII.GetBytes(file.FullName); + var hashBytes = System.Security.Cryptography.MD5.HashData(inputBytes); return Path.Combine(GetBackupDirectory(), $"{Convert.ToHexString(hashBytes)}_{file.Name}"); } - + private string GetBackupDirectory() { return Path.Combine(Directories.BaseFolder, "backups"); diff --git a/MSUScripter/Services/PyMusicLooperService.cs b/MSUScripter/Services/PyMusicLooperService.cs deleted file mode 100644 index d8864cc..0000000 --- a/MSUScripter/Services/PyMusicLooperService.cs +++ /dev/null @@ -1,323 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Security.Cryptography; -using System.Text; -using System.Text.RegularExpressions; -using System.Threading; -using Microsoft.Extensions.Logging; -using MSUScripter.Models; -using YamlDotNet.Serialization; -using YamlDotNet.Serialization.NamingConventions; -using Settings = MSUScripter.Configs.Settings; - -namespace MSUScripter.Services; - -public class PyMusicLooperService -{ - private readonly ILogger _logger; - private readonly PythonCommandRunnerService _python; - private readonly YamlService _yamlService; - private static readonly Regex digitsOnly = new(@"[^\d.]"); - private bool _hasValidated; - private const string MinVersion = "3.0.0"; - private const string MinVersionMultipleResults = "3.2.0"; - private bool _canReturnMultipleResults; - private readonly string _cachePath; - private int _currentVersion; - private string _pyMusicLooperCommand = "pymusiclooper"; - private readonly Settings _settings; - - public PyMusicLooperService(ILogger logger, PythonCommandRunnerService python, YamlService yamlService, Settings settings) - { - _logger = logger; - _python = python; - _yamlService = yamlService; - _settings = settings; - _cachePath = Path.Combine(Directories.CacheFolder, "pymusiclooper"); - if (!string.IsNullOrEmpty(settings.PyMusicLooperPath) && File.Exists(settings.PyMusicLooperPath)) - { - _pyMusicLooperCommand = settings.PyMusicLooperPath; - } - if (!Directory.Exists(_cachePath)) - { - Directory.CreateDirectory(_cachePath); - } - } - - public bool CanReturnMultipleResults => _canReturnMultipleResults; - - public bool IsRunning { get; private set; } - - public void ClearCache() - { - var cacheDirectory = new DirectoryInfo(_cachePath); - foreach (var file in cacheDirectory.EnumerateFiles()) - { - if (file.CreationTime < DateTime.Now.AddMonths(-1)) - { - try - { - file.Delete(); - } - catch - { - _logger.LogWarning("Could not delete {File}", file.FullName); - } - } - } - } - - public List<(int LoopStart, int LoopEnd, decimal Score)>? GetLoopPoints(string filePath, out string message, double minDurationMultiplier = 0.25, int? minLoopDuration = null, int? maxLoopDuration = null, int? approximateLoopStart = null, int? approximateLoopEnd = null, CancellationToken? cancellationToken = null) - { - IsRunning = true; - - if (!_hasValidated) - { - if (!TestService(out message)) - { - IsRunning = false; - return null; - } - } - - if (minLoopDuration != null && minLoopDuration < 1) - { - minLoopDuration = 1; - } - - var file = new FileInfo(filePath); - - var path = GetCacheFilePath(file.FullName, minDurationMultiplier, minLoopDuration, maxLoopDuration, approximateLoopStart, approximateLoopEnd); - if (File.Exists(path)) - { - var ymlText = File.ReadAllText(path); - - if (_yamlService.FromYaml>(ymlText, YamlType.UnderscoreIgnoreDefaults, out var result, out _)) - { - message = ""; - IsRunning = false; - return result; - } - } - - var arguments = GetArguments(file.FullName, minDurationMultiplier, minLoopDuration, maxLoopDuration, approximateLoopStart, approximateLoopEnd); - List<(int, int, decimal)>? loopPoints; - - if (!_canReturnMultipleResults) - { - loopPoints = GetLoopPointsSingle(arguments, out message, cancellationToken ?? CancellationToken.None); - } - else - { - loopPoints = GetLoopPointsMulti(arguments, out message, cancellationToken ?? CancellationToken.None); - } - - if (loopPoints != null) - { - try - { - var ymlText = _yamlService.ToYaml(loopPoints, YamlType.UnderscoreIgnoreDefaults); - File.WriteAllText(path, ymlText); - } - catch (Exception e) - { - _logger.LogError(e, "Error saving PyMusicLooper cache"); - } - - } - - IsRunning = false; - return loopPoints; - } - - public bool TestService(out string message, string? testPath = null) - { - if (_hasValidated && testPath == null) - { - message = ""; - return true; - } - - if (testPath != null) - { - _settings.PyMusicLooperPath = testPath; - _pyMusicLooperCommand = string.Empty == testPath ? "pymusiclooper" : testPath; - } - else if (!string.IsNullOrEmpty(_settings.PyMusicLooperPath) && File.Exists(_settings.PyMusicLooperPath)) - { - _pyMusicLooperCommand = _settings.PyMusicLooperPath; - } - else - { - _pyMusicLooperCommand = "pymusiclooper"; - } - - if (!_python.SetBaseCommand(_pyMusicLooperCommand, "--version", out var result, out _) || !result.StartsWith("pymusiclooper ", StringComparison.OrdinalIgnoreCase)) - { - message = "Could not run PyMusicLooper. Make sure it's installed and executable in command line."; - return false; - } - - _logger.LogInformation("{Version} found", result); - var version = digitsOnly.Replace(result, "").Split(".").Select(int.Parse).ToList(); - _currentVersion = ConvertVersionNumber(version[0], version[1], version[2]); - _hasValidated = _currentVersion >= GetMinVersionNumber(); - _canReturnMultipleResults = _currentVersion >= GetMinVersionNumberForMultipleResults(); - message = _hasValidated ? "" : $"Minimum required PyMusicLooper version is {MinVersion}"; - return _hasValidated; - } - - private List<(int, int, decimal)>? GetLoopPointsSingle(string arguments, out string message, CancellationToken cancellationToken) - { - _logger.LogInformation("Executing PyMusicLooper: {Command}", arguments); - - var successful = _python.RunCommand(arguments, out var result, out var error, true, cancellationToken); - - if (!successful || !result.Contains("LOOP_START: ") || !result.Contains("LOOP_END: ")) - { - message = CleanPyMusicLooperError(string.IsNullOrEmpty(error) ? result : error); - return null; - } - - var loopStart = -1; - var loopEnd = -1; - - var regex = new Regex(@"LOOP_START: (\d)+"); - var match = regex.Match(result); - if (match.Success) - { - loopStart = int.Parse(match.Groups[0].Value.Split(" ")[1], CultureInfo.InvariantCulture); - } - - regex = new Regex(@"LOOP_END: (\d)+"); - match = regex.Match(result); - if (match.Success) - { - loopEnd = int.Parse(match.Groups[0].Value.Split(" ")[1], CultureInfo.InvariantCulture); - } - - if (loopStart == -1 || loopEnd == -1) - { - message = "Invalid loop found"; - return null; - } - else - { - message = ""; - return new List<(int, int, decimal)>() { (loopStart, loopEnd, 0) }; - } - } - - private List<(int, int, decimal)>? GetLoopPointsMulti(string arguments, out string message, CancellationToken cancellationToken) - { - arguments += " --alt-export-top -1"; - - _logger.LogInformation("Executing PyMusicLooper: {Command}", arguments); - - var successful = _python.RunCommand(arguments, out var result, out var error, true, cancellationToken); - - var regexValid = new Regex("^[0-9- .-nae\r\n]+$"); - if (!successful || !regexValid.IsMatch(result)) - { - message = CleanPyMusicLooperError(string.IsNullOrEmpty(error) ? result : error); - return null; - } - - message = ""; - - return result.Split("\n") - .Select(ParsePyMusicLooperLine) - .Where(x => x != null) - .Cast<(int, int, decimal)>() - .ToList(); - } - - private (int, int, decimal)? ParsePyMusicLooperLine(string input) - { - var parts = input.Split(" "); - if (parts.Length < 5) - { - return null; - } - int.TryParse(parts[0], CultureInfo.InvariantCulture, out var loopStart); - int.TryParse(parts[1], CultureInfo.InvariantCulture, out var loopEnd); - decimal.TryParse(parts[4], CultureInfo.InvariantCulture, out var score); - return (loopStart, loopEnd, score); - } - - private string GetArguments(string filePath, double minDurationMultiplier = 0.25, int? minLoopDuration = null, int? maxLoopDuration = null, int? approximateLoopStart = null, int? approximateLoopEnd = null) - { - var arguments = string.Create(CultureInfo.InvariantCulture, $"export-points --min-duration-multiplier {minDurationMultiplier} --path \"{filePath}\""); - - if (minLoopDuration != null) - { - arguments += string.Create(CultureInfo.InvariantCulture, $" --min-loop-duration {minLoopDuration}"); - } - - if (maxLoopDuration != null) - { - arguments += string.Create(CultureInfo.InvariantCulture, $" --max-loop-duration {maxLoopDuration}"); - } - - if (approximateLoopStart != null && approximateLoopEnd != null) - { - arguments += string.Create(CultureInfo.InvariantCulture, $" --approx-loop-position {approximateLoopStart} {approximateLoopEnd}"); - } - - return arguments; - } - - private int GetMinVersionNumber() - { - var version = MinVersion.Split(".").Select(int.Parse).ToList(); - return ConvertVersionNumber(version[0], version[1], version[2]); - } - - private int GetMinVersionNumberForMultipleResults() - { - var version = MinVersionMultipleResults.Split(".").Select(int.Parse).ToList(); - return ConvertVersionNumber(version[0], version[1], version[2]); - } - - private int ConvertVersionNumber(int a, int b, int c) - { - return a * 10000 + b * 100 + c; - } - - private string GetCacheFilePath(string path, double minDurationMultiplier = 0.25, int? minLoopDuration = null, int? maxLoopDuration = null, int? approximateLoopStart = null, int? approximateLoopEnd = null) - { - using var md5 = MD5.Create(); - using var stream = File.OpenRead(path); - var pathHash = GetHexString(md5.ComputeHash(Encoding.Default.GetBytes(path))); - var fileHash = GetHexString(md5.ComputeHash(stream)); - var fileName = string.Create(CultureInfo.InvariantCulture, $"{pathHash}_{fileHash}_{_currentVersion}_{Math.Round(minDurationMultiplier, 2)}_{minLoopDuration}_{maxLoopDuration}_{approximateLoopStart}_{approximateLoopEnd}.yml"); - return Path.Combine(_cachePath, fileName); - } - - private string GetHexString(byte[] bytes) - { - return BitConverter.ToString(bytes).Replace("-", string.Empty); - } - - private string CleanPyMusicLooperError(string message) - { - _logger.LogError("PyMusicLooper Error: {Message}", message); - if (message.Contains("\u2502")) - { - message = message.Split("\u2502")[1]; - return message.Trim(); - } - else - { - message = Regex.Replace(Regex.Replace(Regex.Replace(Regex.Replace(message, @"\s\s+", " "), "@__+", "_"), @"[─╭╮╯╰│]+", ""), @"---+", "-"); - if (message.Contains("+- Error -+")) - { - message = "PyMusicLooper Error: " + message.Substring(message.IndexOf("+- Error -+", StringComparison.OrdinalIgnoreCase)); - } - return message; - } - } -} diff --git a/MSUScripter/Services/PythonCommandRunnerService.cs b/MSUScripter/Services/PythonCommandRunnerService.cs deleted file mode 100644 index 02f0baf..0000000 --- a/MSUScripter/Services/PythonCommandRunnerService.cs +++ /dev/null @@ -1,248 +0,0 @@ -using System; -using System.Diagnostics; -using System.IO; -using System.Runtime.InteropServices; -using System.Text; -using System.Threading; -using Microsoft.Extensions.Logging; -using MSUScripter.Models; - -namespace MSUScripter.Services; - -public class PythonCommandRunnerService -{ - private ILogger _logger; - private RunMethod _runMethod; - private string _baseCommand = ""; - - public PythonCommandRunnerService(ILogger logger) - { - _logger = logger; - } - - public bool SetBaseCommand(string baseCommand, string testCommand, out string testResult, out string testError) - { - _baseCommand = baseCommand; - return RunCommand(testCommand, out testResult, out testError); - } - - public bool RunCommand(string command, out string result, out string error, bool redirectOutput = true, CancellationToken? cancellationToken = null) - { - result = ""; - error = "Unknown error"; - - switch (_runMethod) - { - case RunMethod.Unknown when RunInternalDirect(command, out result, out error, redirectOutput, cancellationToken): - _runMethod = RunMethod.Direct; - return true; - case RunMethod.Unknown when RunInternalPy(command, out result, out error, redirectOutput, cancellationToken): - _runMethod = RunMethod.Py; - return true; - case RunMethod.Unknown when RunInternalPython3(command, out result, out error, redirectOutput, cancellationToken): - _runMethod = RunMethod.Python3; - return true; - case RunMethod.Direct: - return RunInternalDirect(command, out result, out error, redirectOutput, cancellationToken); - case RunMethod.Py: - return RunInternalPy(command, out result, out error, redirectOutput, cancellationToken); - case RunMethod.Python3: - return RunInternalPython3(command, out result, out error, redirectOutput, cancellationToken); - default: - return false; - } - } - - public Process? RunCommandAsync(string command, bool redirectOutput = true) - { - switch (_runMethod) - { - case RunMethod.Direct: - return RunInternalDirectAsync(command, redirectOutput); - case RunMethod.Py: - return RunInternalPyAsync(command, redirectOutput); - case RunMethod.Python3: - return RunInternalPython3Async(command, redirectOutput); - default: - return null; - } - } - - private bool RunInternalDirect(string command, out string result, out string error, bool redirectOutput, CancellationToken? cancellationToken = null) - { - return RunInternal(_baseCommand, command, out result, out error, redirectOutput, cancellationToken); - } - - private bool RunInternalPy(string command, out string result, out string error, bool redirectOutput, CancellationToken? cancellationToken = null) - { - return RunInternal("py", $"-m {_baseCommand} {command}", out result, out error, redirectOutput, cancellationToken); - } - - private bool RunInternalPython3(string command, out string result, out string error, bool redirectOutput, CancellationToken? cancellationToken = null) - { - return RunInternal("python3", $"-m {_baseCommand} {command}", out result, out error, redirectOutput, cancellationToken); - } - - private Process? RunInternalDirectAsync(string command, bool redirectOutput) - { - return RunInternalAsync(_baseCommand, command, redirectOutput); - } - - private Process? RunInternalPyAsync(string command, bool redirectOutput) - { - return RunInternalAsync("py", $"-m {_baseCommand} {command}", redirectOutput); - } - - private Process? RunInternalPython3Async(string command, bool redirectOutput) - { - return RunInternalAsync("python3", $"-m {_baseCommand} {command}", redirectOutput); - } - - private bool RunInternal(string command, string arguments, out string result, out string error, bool redirectOutput, CancellationToken? cancellationToken = null) - { - try - { - ProcessStartInfo procStartInfo; - - var innerCommand = $"{command} {arguments}"; - _logger.LogInformation("Executing python command: {Command}", innerCommand); - - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - var workingDirectory = ""; - if (System.IO.File.Exists(command)) - { - workingDirectory = Directory.GetParent(command)?.FullName; - if (!string.IsNullOrEmpty(workingDirectory)) - { - var file = Path.GetFileName(command); - innerCommand = $"{file} {arguments}"; - } - } - - procStartInfo= new ProcessStartInfo("cmd", "/c " + innerCommand) - { - RedirectStandardOutput = redirectOutput, - RedirectStandardError = redirectOutput, - UseShellExecute = false, - CreateNoWindow = true, - WorkingDirectory = workingDirectory - }; - } - else - { - procStartInfo= new ProcessStartInfo(command) - { - Arguments = arguments, - RedirectStandardOutput = redirectOutput, - RedirectStandardError = redirectOutput, - UseShellExecute = false, - CreateNoWindow = true - }; - } - - using var process = new Process(); - process.StartInfo = procStartInfo; - - var resultBuilder = new StringBuilder(); - var errorBuilder = new StringBuilder(); - process.OutputDataReceived += (_, e) => - { - resultBuilder.AppendLine(e.Data); - }; - process.ErrorDataReceived += (_, e) => - { - errorBuilder.AppendLine(e.Data); - }; - - process.Start(); - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - while (cancellationToken?.IsCancellationRequested != true) - { - if (process.WaitForExit(TimeSpan.FromMilliseconds(100))) - { - break; - } - _logger.LogDebug("Waiting for response from {Command}", innerCommand); - } - - if (cancellationToken?.IsCancellationRequested == true) - { - try - { - process.Kill(); - } - catch - { - // Do nothing - } - - result = ""; - error = ""; - return false; - } - - result = resultBuilder.ToString().Trim(); - error = errorBuilder.ToString().Trim(); - - if (string.IsNullOrEmpty(error)) return true; - _logger.LogError("Error running {Command}: {Error}", _baseCommand, error); - return false; - } - catch (Exception e) - { - result = ""; - error = $"Unknown error running {_baseCommand}"; - _logger.LogError(e, "Unknown error running {Command}", _baseCommand); - return false; - } - } - - private Process? RunInternalAsync(string command, string arguments, bool redirectOutput) - { - try - { - ProcessStartInfo procStartInfo; - - var innerCommand = $"{command} {arguments}"; - _logger.LogInformation("Executing async python command: {Command}", innerCommand); - - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - procStartInfo= new ProcessStartInfo("cmd", "/c " + innerCommand) - { - RedirectStandardOutput = redirectOutput, - RedirectStandardError = redirectOutput, - UseShellExecute = false, - CreateNoWindow = true - }; - } - else - { - procStartInfo= new ProcessStartInfo(command) - { - Arguments = arguments, - RedirectStandardOutput = redirectOutput, - RedirectStandardError = redirectOutput, - UseShellExecute = false, - CreateNoWindow = true - }; - } - - var process = new Process(); - process.StartInfo = procStartInfo; - if (process.Start()) - { - return process; - } - - return null; - } - catch (Exception e) - { - _logger.LogError(e, "Unknown error running {Command}", _baseCommand); - return null; - } - } -} \ No newline at end of file diff --git a/MSUScripter/Services/PythonCompanionService.cs b/MSUScripter/Services/PythonCompanionService.cs new file mode 100644 index 0000000..b65cf45 --- /dev/null +++ b/MSUScripter/Services/PythonCompanionService.cs @@ -0,0 +1,645 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using AvaloniaControls.Services; +using Microsoft.Extensions.Logging; +using MSUScripter.Models; + +namespace MSUScripter.Services; + +public class PythonCompanionService(ILogger logger, YamlService yamlService, DependencyInstallerService dependencyInstallerService) +{ + private const string BaseCommand = "py_msu_scripter_app"; + private const string MinVersion = "v0.1.5"; + private RunMethod _runMethod = RunMethod.Unknown; + private string? _pythonExecutablePath; + private string? _ffmpegPath; + + public bool IsValid { get; private set; } + public bool IsFfMpegValid { get; private set; } + + public async Task VerifyInstalledAsync() + { + if (!await VerifyFfMpegAsync()) + { + IsValid = false; + return false; + } + + _runMethod = RunMethod.Unknown; + _pythonExecutablePath = null; + var response = await RunCommandAsync("--version"); + + IsValid = response.Success && response.Result.EndsWith(MinVersion) && + !response.Error.Contains("Couldn't find ffmpeg"); + + if (IsValid) + { + logger.LogInformation("Companion PyMsuScripterApp validated successfully: {Result}", response.Result); + } + else + { + logger.LogError("Failed to validate companion PyMsuScripterApp: {Result} | {Error}", response.Result, response.Error); + } + + return IsValid; + } + + public async Task VerifyFfMpegAsync() + { + var ffmpegFolder = Path.Combine(Directories.Dependencies, "ffmpeg", "bin"); + var ffmpegAppName = OperatingSystem.IsWindows() ? "ffmpeg.exe" : "ffmpeg"; + var ffmpegPath = Path.Combine(ffmpegFolder, ffmpegAppName); + + logger.LogInformation("Checking for FFmpeg at {Path}", ffmpegPath); + + if (File.Exists(ffmpegPath)) + { + var installedResult = await ValidateInstalledFfmpegAsync(ffmpegPath, 3); + if (installedResult.Success && installedResult.Result.StartsWith("ffmpeg version ")) + { + var parts = installedResult.Result.Split(" ", 4); + logger.LogInformation("FFmpeg validated successfully at {Path}: {Result}", ffmpegPath, $"{parts[0]} {parts[1]} {parts[2]}"); + IsFfMpegValid = true; + _ffmpegPath = ffmpegFolder; + return true; + } + } + + var pathResult = await RunInternalAsync(ffmpegAppName, "-version"); + IsFfMpegValid = pathResult.Success && pathResult.Result.StartsWith("ffmpeg version "); + + if (IsFfMpegValid) + { + var parts = pathResult.Result.Split(" ", 4); + logger.LogInformation("FFmpeg validated successfully from environment: {Version}", $"{parts[0]} {parts[1]} {parts[2]}"); + } + else + { + logger.LogError("Failed to validate companion FFmpeg: {Result} | {Error}", pathResult.Result, pathResult.Error); + } + + return IsFfMpegValid; + } + + private async Task ValidateInstalledFfmpegAsync(string ffmpegPath, int attempts) + { + for (var i = 0; i < attempts; i++) + { + var currentResult = await RunInternalAsync(ffmpegPath, "-version"); + if (currentResult.Success && currentResult.Result.StartsWith("ffmpeg version")) + { + return currentResult; + } + + if (currentResult.IsBlankSuccess) continue; + currentResult.Success = false; + return currentResult; + } + + return new RunPyResult + { + Success = false, + Error = "Unable to run ffmpeg" + }; + } + + public async Task InstallPyApp(Action response) + { + var result = await dependencyInstallerService.InstallPyApp(response, + async (application, arguments) => await RunInternalAsync(application, arguments)); + return result && await VerifyInstalledAsync(); + } + + public async Task InstallFfmpegAsync(Action response) + { + var result = await dependencyInstallerService.InstallFfmpeg(response); + return result && await VerifyFfMpegAsync(); + } + + public async Task GetSampleRateAsync(GetSampleRateRequest request, CancellationToken? cancellationToken = null) + { + if (!IsFfMpegValid) + { + return new GetSampleRateResponse() + { + Successful = false, + Error = "Companion PyMsuScripterApp not validated" + }; + } + + var ffprobeResponse = await GetSampleRateViaFfprobeAsync(request.File, 3); + if (ffprobeResponse.Successful) + { + return ffprobeResponse; + } + + return await RunCommandAsync(request, cancellationToken); + } + + private async Task GetSampleRateViaFfprobeAsync(string file, int numAttempts) + { + for (var i = 0; i < numAttempts; i++) + { + var response = await GetSampleRateViaFfprobeAsync(file); + if (!response.IsBlankSuccess) + { + return response; + } + } + + return new GetSampleRateResponse() + { + Successful = false, + Error = "Unable to get results from FFprobe" + }; + } + + private async Task GetSampleRateViaFfprobeAsync(string file) + { + var ffprobePath = string.IsNullOrEmpty(_ffmpegPath) ? "ffprobe" : Path.Combine(_ffmpegPath, "ffprobe"); + var ffprobeResponse = await RunInternalAsync(ffprobePath, $"-v quiet -show_streams \"{file}\""); + if (ffprobeResponse.Success && ffprobeResponse.Result.StartsWith("[STREAM]")) + { + try + { + var sampleRate = -1; + var duration = -1d; + var channels = 2; + int bitsPerSample = 2; + var lines = ffprobeResponse.Result.Split(["\r\n", "\r", "\n"], StringSplitOptions.None); + foreach (var line in lines.Where(x => x.Contains('='))) + { + var parts = line.Split('='); + if (parts[0] == "sample_rate") + { + sampleRate = int.Parse(parts[1]); + } + else if (parts[0] == "duration") + { + duration = double.Parse(parts[1]); + } + else if (parts[0] == "channels") + { + channels = int.Parse(parts[1]); + } + else if (parts[0] == "bits_per_sample") + { + bitsPerSample = int.Parse(parts[1]); + } + } + + if (sampleRate > 0 && duration > 0) + { + return new GetSampleRateResponse + { + Successful = true, + SampleRate = sampleRate, + Duration = duration, + Channels = channels, + BitsPerSample = bitsPerSample + }; + } + else + { + logger.LogError("Failed getting valid sample rate and duration: {SampleRate} | {Duration}", sampleRate, duration); + } + } + catch (Exception e) + { + logger.LogError(e.Message, "Error running FFprobe"); + } + } + else + { + logger.LogError("Invalid response from FFprobe: Result: {Result} | Error: {Error}", ffprobeResponse.Result, ffprobeResponse.Error); + } + + return new GetSampleRateResponse + { + Successful = false, + Error = "Unable to get sample rate via ffprobe", + IsBlankSuccess = ffprobeResponse.Success && string.IsNullOrEmpty(ffprobeResponse.Result) && string.IsNullOrEmpty(ffprobeResponse.Error) + }; + } + + public async Task RunPyMusicLooperAsync(RunPyMusicLooperRequest request, CancellationToken? cancellationToken = null) + { + if (!IsValid) + { + return new RunPyMusicLooperResponse() + { + Successful = false, + Error = "Companion PyMsuScripterApp not validated" + }; + } + + var cachePath = GetPyMusicLooperCachePath(request); + + logger.LogInformation("PyMusicLooper checking cache at {Path}", cachePath); + + var savedCacheData = GetCacheFromFile(cachePath); + + if (savedCacheData?.Successful == true) + { + logger.LogInformation("PyMusicLooper cache found at {Path}", cachePath); + return savedCacheData; + } + + var result = await RunCommandAsync(request, cancellationToken); + + if (result.Successful) + { + logger.LogInformation("PyMusicLooper completed for {File} with {PairCount} results", request.File, result.Pairs.Count); + SaveCacheToFile(result, cachePath); + } + + return result; + } + + private string GetPyMusicLooperCachePath(RunPyMusicLooperRequest request) + { + var directory = Path.Combine(Directories.CacheFolder, "PyMusicLooper"); + if (!Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + var cacheKey = new PyMusicLooperCacheKey(request).ToString(); + return Path.Combine(directory, $"{cacheKey}.yml"); + } + + private RunPyMusicLooperResponse? GetCacheFromFile(string cachePath) + { + if (!File.Exists(cachePath)) + { + return null; + } + + var text = File.ReadAllText(cachePath); + if (yamlService.FromYaml(text, YamlType.Pascal, out var cacheObject, out _) && cacheObject != null) + { + return cacheObject; + } + + return null; + } + + private void SaveCacheToFile(RunPyMusicLooperResponse cache, string cachePath) + { + var text = yamlService.ToYaml(cache, YamlType.Pascal); + File.WriteAllText(cachePath, text); + logger.LogInformation("Saved PyMusicLooper results to {CachePath}", cachePath); + } + + public async Task CreateVideoAsync(CreateVideoRequest request, Action? updateCallback = null, CancellationToken? cancellationToken = null) + { + if (!IsValid) + { + return new CreateVideoResponse() + { + Successful = false, + Error = "Companion PyMsuScripterApp not validated" + }; + } + + if (!string.IsNullOrEmpty(request.ProgressFile) && File.Exists(request.ProgressFile) && updateCallback != null) + { + File.Delete(request.ProgressFile); + } + else if (string.IsNullOrEmpty(request.ProgressFile) && updateCallback != null) + { + request.ProgressFile = Path.Combine(Directories.TempFolder, $"{Guid.NewGuid().ToString()}_progress.txt"); + } + + CancellationTokenSource cts = new(); + + if (updateCallback != null && !string.IsNullOrEmpty(request.ProgressFile)) + { + _ = ITaskService.Run(async () => + { + while (!cts.IsCancellationRequested) + { + try + { + await using var fs = new FileStream(request.ProgressFile, FileMode.Open, FileAccess.Read, + FileShare.ReadWrite); + using var reader = new StreamReader(fs); + + var text = await reader.ReadToEndAsync(cts.Token); + if (text.Contains('|')) + { + var parts = text.Split("|"); + var section = int.Parse(parts[0]); + var percentage = float.Parse(parts[1]); + var totalPercentage = (section * 0.33f) + (percentage / 100 * 0.33); + updateCallback(totalPercentage); + } + // Do nothing + } + catch + { + // Do nothing + } + + await Task.Delay(TimeSpan.FromSeconds(1), cts.Token); + } + }, cts.Token); + } + + var response = await RunCommandAsync(request, cancellationToken); + + var successful = response.Successful && !cts.IsCancellationRequested; + + await cts.CancelAsync(); + + if (updateCallback != null) + { + updateCallback(1); + } + + if (successful) + { + logger.LogInformation("Video creation successful"); + } + else + { + logger.LogError("Error generating video: {Response}", response.Error); + } + + return response; + } + + private async Task RunCommandAsync(TRequest request, CancellationToken? cancellationToken) where TResponse : PythonCompanionModeResponse + { + var guid = Guid.NewGuid().ToString(); + var inputFile = Path.Combine(Directories.TempFolder, $"{guid}_in.yml"); + var outputFile = Path.Combine(Directories.TempFolder, $"{guid}_out.yml"); + try + { + await File.WriteAllTextAsync(inputFile, yamlService.ToYaml(request!, YamlType.Pascal), cancellationToken ?? CancellationToken.None); + + var runResponse = await RunCommandAsync($"--input \"{inputFile}\" --output \"{outputFile}\"", cancellationToken); + if (!runResponse.Success) + { + var response = Activator.CreateInstance(); + response.Successful = false; + response.Error = runResponse.Error; + return response; + } + + if (cancellationToken?.IsCancellationRequested == true) + { + var response = Activator.CreateInstance(); + response.Successful = false; + response.Error = "Request cancelled"; + return response; + } + + var outputYaml = await File.ReadAllTextAsync(outputFile); + + if (yamlService.FromYaml(outputYaml, YamlType.Pascal, out var result, out var error)) + { + return result!; + } + else + { + var response = Activator.CreateInstance(); + response.Successful = false; + response.Error = error ?? "Failed running PythonCompanionService"; + return response; + } + } + catch (Exception e) + { + logger.LogError(e, "Failed running PythonCompanionService"); + var response = Activator.CreateInstance(); + response.Successful = false; + response.Error = "Failed running PythonCompanionService"; + return response; + } + } + + private async Task RunCommandAsync(string command, CancellationToken? cancellationToken = null) + { + switch (_runMethod) + { + case RunMethod.Unknown: + var result = await RunInternalInstalledAsync(command, cancellationToken); + if (result.Success) + { + _runMethod = RunMethod.Installed; + return result; + } + + result = await RunInternalDirectAsync(command, cancellationToken); + if (result.Success) + { + _runMethod = RunMethod.Direct; + return result; + } + + result = await RunInternalPyAsync(command, cancellationToken); + if (result.Success) + { + _runMethod = RunMethod.Py; + return result; + } + + result = await RunInternalPython3Async(command, cancellationToken); + if (result.Success) + { + _runMethod = RunMethod.Python3; + return result; + } + + return new RunPyResult + { + Success = false, + Error = "No valid run type found" + }; + + case RunMethod.Installed: + return await RunInternalInstalledAsync(command, cancellationToken); + case RunMethod.Direct: + return await RunInternalDirectAsync(command, cancellationToken); + case RunMethod.Py: + return await RunInternalPyAsync(command, cancellationToken); + case RunMethod.Python3: + return await RunInternalPython3Async(command, cancellationToken); + default: + return new RunPyResult + { + Success = false, + Error = "Invalid run type" + }; + } + } + + private async Task RunInternalDirectAsync(string command, CancellationToken? cancellationToken = null) + { + return await RunInternalAsync(BaseCommand, command, cancellationToken); + } + + private async Task RunInternalInstalledAsync(string command, CancellationToken? cancellationToken = null) + { + if (!string.IsNullOrEmpty(_pythonExecutablePath)) + { + return await RunInternalAsync(_pythonExecutablePath, $"-m {BaseCommand} {command}", cancellationToken); + } + + var exePath = Directories.Dependencies; + + if (!string.IsNullOrEmpty(exePath) && Directory.Exists(Path.Combine(exePath, "python"))) + { + _pythonExecutablePath = OperatingSystem.IsLinux() + ? Path.Combine(exePath, "python", "bin", "python3.13") + : Path.Combine(exePath, "python", "python.exe"); + + if (!File.Exists(_pythonExecutablePath)) + { + _pythonExecutablePath = ""; + return new RunPyResult + { + Success = false, + Error = "Python executable not found" + }; + } + } + else + { + return new RunPyResult + { + Success = false, + Error = "Invalid run type" + }; + } + + return await RunInternalAsync(_pythonExecutablePath, $"-m {BaseCommand} {command}", cancellationToken); + } + + private async Task RunInternalPyAsync(string command, CancellationToken? cancellationToken = null) + { + return await RunInternalAsync("py", $"-m {BaseCommand} {command}", cancellationToken); + } + + private async Task RunInternalPython3Async(string command, CancellationToken? cancellationToken = null) + { + return await RunInternalAsync("python3", $"-m {BaseCommand} {command}", cancellationToken); + } + + private async Task RunInternalAsync(string command, string arguments, CancellationToken? cancellationToken = null) + { + try + { + ProcessStartInfo procStartInfo; + + var innerCommand = $"{command} {arguments}"; + logger.LogInformation("Executing python command: {Command}", innerCommand); + + var workingDirectory = ""; + if (!string.IsNullOrEmpty(_ffmpegPath)) + { + workingDirectory = _ffmpegPath; + } else if (File.Exists(command)) + { + workingDirectory = Directory.GetParent(command)?.FullName; + } + + var isPipInstall = arguments.StartsWith("-m pip"); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + string fileName; + string argumentString; + + if (!string.IsNullOrEmpty(workingDirectory) && command.StartsWith(workingDirectory, StringComparison.OrdinalIgnoreCase)) + { + var file = Path.GetFileName(command); + fileName = "cmd"; + argumentString = $"/c {file} {arguments}"; + } + else if (Path.IsPathRooted(command)) + { + fileName = command; + argumentString = arguments; + } + else + { + var file = Path.GetFileName(command); + fileName = "cmd"; + argumentString = $"/c {file} {arguments}"; + } + + procStartInfo= new ProcessStartInfo(fileName, argumentString) + { + RedirectStandardOutput = !isPipInstall, + RedirectStandardError = !isPipInstall, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = workingDirectory + }; + } + else + { + procStartInfo= new ProcessStartInfo(command) + { + Arguments = arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = workingDirectory + }; + } + + using var process = new Process(); + process.StartInfo = procStartInfo; + process.Start(); + await process.WaitForExitAsync(cancellationToken ?? CancellationToken.None); + + var resultText = ""; + var errorText = ""; + + if (isPipInstall) + { + errorText = process.ExitCode != 0 ? $"pipx completed with error code {process.ExitCode}" : ""; + } + else + { + resultText = (await process.StandardOutput.ReadToEndAsync()).Replace("\0", "").Trim(); + errorText = (await process.StandardError.ReadToEndAsync()).Replace("\0", "").Trim(); + } + + if (!string.IsNullOrEmpty(errorText)) + { + logger.LogError("Error running {Command}: {Error}", BaseCommand, errorText); + } + + if (cancellationToken?.IsCancellationRequested == true) + { + logger.LogError("Cancellation requested of command {Command}", BaseCommand); + } + + return new RunPyResult + { + Success = true, + Result = resultText, + Error = errorText + }; + } + catch (Exception e) + { + logger.LogError(e, "Unknown error running {Command}", BaseCommand); + return new RunPyResult + { + Success = false, + Error = $"Unknown error running {BaseCommand}" + }; + } + } +} diff --git a/MSUScripter/Services/SettingsService.cs b/MSUScripter/Services/SettingsService.cs index a5e4bd4..d80203c 100644 --- a/MSUScripter/Services/SettingsService.cs +++ b/MSUScripter/Services/SettingsService.cs @@ -2,6 +2,7 @@ using System.IO; using System.Linq; using AvaloniaControls.Controls; +using AvaloniaControls.Services; using Microsoft.Extensions.Logging; using MSUScripter.Configs; using MSUScripter.Models; @@ -12,16 +13,18 @@ namespace MSUScripter.Services; public class SettingsService { private readonly YamlService _yamlService; + private readonly ILogger _logger; - public Settings Settings { get; set; } = null!; + public Settings Settings { get; private set; } = null!; - public SettingsService(YamlService yamlService) + public SettingsService(YamlService yamlService, ILogger logger) { _yamlService = yamlService; + _logger = logger; LoadSettings(); } - public void LoadSettings() + private void LoadSettings() { var settingsPath = GetSettingsPath(); if (!File.Exists(settingsPath)) @@ -43,6 +46,8 @@ public void LoadSettings() Settings = settingsObject; } + Settings.RecentProjects = Settings.RecentProjects.Where(x => File.Exists(x.ProjectPath)).ToList(); + ScalableWindow.GlobalScaleFactor = decimal.ToDouble(Settings.UiScaling); } @@ -60,6 +65,21 @@ public void SaveSettings() ScalableWindow.GlobalScaleFactor = decimal.ToDouble(Settings.UiScaling); } + public void TrySaveSettings() + { + _ = ITaskService.Run(() => + { + try + { + SaveSettings(); + } + catch (Exception e) + { + _logger.LogError(e, "Error saving settings"); + } + }); + } + public void AddRecentProject(MsuProject project) { var projectFile = new FileInfo(project.ProjectFilePath); diff --git a/MSUScripter/Services/SharedPcmService.cs b/MSUScripter/Services/SharedPcmService.cs deleted file mode 100644 index 97b8a2c..0000000 --- a/MSUScripter/Services/SharedPcmService.cs +++ /dev/null @@ -1,105 +0,0 @@ -using System; -using System.IO; -using System.Threading.Tasks; -using MSUScripter.Configs; -using MSUScripter.Models; -using MSUScripter.ViewModels; - -namespace MSUScripter.Services; - -public class SharedPcmService(MsuPcmService msuPcmService, IAudioPlayerService audioPlayerService, ConverterService converterService) -{ - public async Task GeneratePcmFile(MsuSongInfoViewModel songInfo, bool asPrimary, bool asEmpty) - { - if (msuPcmService.IsGeneratingPcm) - { - return new GeneratePcmFileResponse(false, false, "Currently generating another file", null); - } - - if (songInfo.Track.IsScratchPad && songInfo.OutputPath?.StartsWith(Directories.TempFolder) != true) - { - var msuFile = new FileInfo(songInfo.Project.MsuPath); - var pcmFileName = msuFile.Name.Replace(msuFile.Extension, $"-{Guid.NewGuid()}.pcm"); - songInfo.OutputPath = Path.Combine(Directories.TempFolder, pcmFileName); - } - - await audioPlayerService.StopSongAsync(null, true); - - if (asEmpty) - { - var emptySong = new MsuSongInfo(); - converterService.ConvertViewModel(songInfo, emptySong); - var successful = msuPcmService.CreateEmptyPcm(emptySong); - - return !successful - ? new GeneratePcmFileResponse(false, false, "Currently generating another file", null) - : new GeneratePcmFileResponse(true, true, "Successful", songInfo.OutputPath); - } - - if (!songInfo.HasFiles()) - { - return new GeneratePcmFileResponse(false, false, "No files specified to generate into a pcm file", null); - } - - var song = new MsuSongInfo(); - converterService.ConvertViewModel(songInfo, song); - converterService.ConvertViewModel(songInfo.MsuPcmInfo, song.MsuPcmInfo); - var tempProject = converterService.ConvertProject(songInfo.Project); - - if (asPrimary) - { - var msu = new FileInfo(songInfo.Project.MsuPath); - var path = msu.FullName.Replace(msu.Extension, $"-{song.TrackNumber}.pcm"); - song.OutputPath = path; - } - - var response = await msuPcmService.CreatePcm(true, tempProject, song); - if (!response.Successful) - { - if (response.GeneratedPcmFile && songInfo.Project.IgnoreWarnings.Contains(song.OutputPath ?? "")) - { - songInfo.LastGeneratedDate = DateTime.Now; - return new GeneratePcmFileResponse(true, true, null, song.OutputPath); - } - else - { - return response; - } - } - - songInfo.LastGeneratedDate = DateTime.Now; - return response; - } - - public async Task PlaySong(MsuSongInfoViewModel songInfo, bool testLoop) - { - var generateResponse = await GeneratePcmFile(songInfo, false, false); - if (!generateResponse.Successful) - return generateResponse.Message; - - if (string.IsNullOrEmpty(songInfo.OutputPath) || !File.Exists(songInfo.OutputPath)) - { - return "No pcm file detected"; - } - - await audioPlayerService.PlaySongAsync(songInfo.OutputPath, testLoop); - return null; - } - - public async Task PauseSong() - { - if (CanPauseSongs) - audioPlayerService.Pause(); - else - await audioPlayerService.StopSongAsync(null, true); - } - - public async Task StopSong() - { - await audioPlayerService.StopSongAsync(null, true); - } - - public bool CanPlaySongs => audioPlayerService.CanPlayMusic; - - public bool CanPauseSongs => audioPlayerService.CanPauseMusic; -} \ No newline at end of file diff --git a/MSUScripter/Services/StatusBarService.cs b/MSUScripter/Services/StatusBarService.cs index e7fb658..53c451b 100644 --- a/MSUScripter/Services/StatusBarService.cs +++ b/MSUScripter/Services/StatusBarService.cs @@ -9,12 +9,12 @@ public class StatusBarService public StatusBarService(IAudioPlayerService audioPlayerService) { - audioPlayerService.PlayStarted += (sender, args) => + audioPlayerService.PlayStarted += (_, _) => { UpdateStatusBar("Playing Song"); }; - audioPlayerService.PlayStopped += (sender, args) => + audioPlayerService.PlayStopped += (_, _) => { UpdateStatusBar("Stopped Song"); }; diff --git a/MSUScripter/Services/TrackListService.cs b/MSUScripter/Services/TrackListService.cs index 0fb9f1a..00265a8 100644 --- a/MSUScripter/Services/TrackListService.cs +++ b/MSUScripter/Services/TrackListService.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using System.Text; @@ -14,8 +15,7 @@ public class TrackListService(ILogger logger, IMsuTypeService { public void WriteTrackListFile(MsuProject project) { - var msuFileInfo = new FileInfo(project.MsuPath); - var tracklistPath = Path.Combine(msuFileInfo.DirectoryName!, "Track List.txt"); + var tracklistPath = project.GetTracksTextPath(); var sb = new StringBuilder(); var title = $"{project.BasicInfo.PackName}"; @@ -59,31 +59,31 @@ public void WriteTrackListFile(MsuProject project) !(t.TrackNumber >= metroidTrackRange.Item1 && t.TrackNumber <= metroidTrackRange.Item2) && t.Songs.Count != 0); - if (project.BasicInfo.TrackList == TrackListType.List) + if (project.BasicInfo.TrackListType is TrackList.ListAlbumFirst or TrackList.ListSongFirst) { sb.AppendLine("Zelda Tracks:"); sb.AppendLine(); - AppendTrackList(zeldaTracks, sb, zeldaTrackModifier); + AppendTrackList(zeldaTracks, sb, zeldaTrackModifier, project.BasicInfo.TrackListType == TrackList.ListSongFirst); sb.AppendLine("--------------------------------------------------"); sb.AppendLine(); sb.AppendLine("Metroid Tracks:"); sb.AppendLine(); - AppendTrackList(metroidTracks, sb, metroidTrackModifier); + AppendTrackList(metroidTracks, sb, metroidTrackModifier, project.BasicInfo.TrackListType == TrackList.ListSongFirst); sb.AppendLine("--------------------------------------------------"); sb.AppendLine(); sb.AppendLine("SMZ3 Tracks:"); sb.AppendLine(); - AppendTrackList(smz3Tracks, sb, 0); + AppendTrackList(smz3Tracks, sb, 0, project.BasicInfo.TrackListType == TrackList.ListSongFirst); } else { var songs = project.Tracks.Where(x => !x.IsScratchPad).SelectMany(x => x.Songs).ToList(); var numberLength = songs.Any(x => x.IsAlt) ? 12 : 6; - var trackLength = project.Tracks.Where(x => !x.IsScratchPad).Max(x => x.TrackName.Length) + 4; + var trackLength = project.Tracks.Where(x => !x.IsScratchPad).Max(x => new StringInfo(x.TrackName).LengthInTextElements) + 4; var albumLength = songs.Max(x => string.IsNullOrEmpty(x.Album) ? 0 : x.Album.CleanString().Length + 4); var songLength = songs.Max(x => string.IsNullOrEmpty(x.SongName) ? 0 : x.SongName.CleanString().Length + 4); var artistLength = songs.Max(x => string.IsNullOrEmpty(x.Artist) ? 0 : x.Artist.CleanString().Length + 4); @@ -109,18 +109,18 @@ public void WriteTrackListFile(MsuProject project) { var allTracks = project.Tracks.Where(t => !t.IsScratchPad && t.Songs.Count != 0); - if (project.BasicInfo.TrackList == TrackListType.List) + if (project.BasicInfo.TrackListType is TrackList.ListAlbumFirst or TrackList.ListSongFirst) { - AppendTrackList(allTracks, sb, 0); + AppendTrackList(allTracks, sb, 0, project.BasicInfo.TrackListType == TrackList.ListSongFirst); } else { var songs = project.Tracks.SelectMany(x => x.Songs).ToList(); var numberLength = songs.Any(x => x.IsAlt) ? 12 : 6; - var trackLength = project.Tracks.Max(x => x.TrackName.Length) + 3; - var albumLength = songs.Max(x => string.IsNullOrEmpty(x.Album) ? 0 : x.Album.CleanString().Length + 3); - var songLength = songs.Max(x => string.IsNullOrEmpty(x.SongName) ? 0 : x.SongName.CleanString().Length + 3); - var artistLength = songs.Max(x => string.IsNullOrEmpty(x.Artist) ? 0 : x.Artist.CleanString().Length + 3); + var trackLength = project.Tracks.Max(x => x.TrackName.GetUnicodeLength()) + 3; + var albumLength = songs.Max(x => string.IsNullOrEmpty(x.Album) ? 0 : x.Album.GetUnicodeLength() + 3); + var songLength = songs.Max(x => string.IsNullOrEmpty(x.SongName) ? 0 : x.SongName.GetUnicodeLength() + 3); + var artistLength = songs.Max(x => string.IsNullOrEmpty(x.Artist) ? 0 : x.Artist.GetUnicodeLength() + 3); AppendTrackTable(allTracks, sb, 0, numberLength, trackLength, albumLength, songLength, artistLength); } } @@ -137,7 +137,7 @@ public void WriteTrackListFile(MsuProject project) } } - private void AppendTrackList(IEnumerable tracks, StringBuilder sb, int trackModifier) + private void AppendTrackList(IEnumerable tracks, StringBuilder sb, int trackModifier, bool isVariant) { foreach (var track in tracks.Where(x => !x.IsScratchPad).OrderBy(x => x.TrackNumber)) { @@ -145,7 +145,7 @@ private void AppendTrackList(IEnumerable tracks, StringBuilder sb, foreach (var song in track.Songs.OrderBy(x => x.IsAlt)) { - sb.AppendLine(GetTrackListSongInfo(song)); + sb.AppendLine(isVariant ? GetTrackListSongInfoVariant(song) : GetTrackListSongInfo(song)); } sb.AppendLine(); @@ -176,6 +176,30 @@ private string GetTrackListSongInfo(MsuSongInfo song) return songInfo; } + private string GetTrackListSongInfoVariant(MsuSongInfo song) + { + var songInfo = ""; + + songInfo += song.SongName?.CleanString(); + + if (!string.IsNullOrEmpty(song.Artist)) + { + songInfo += $" by {song.Artist?.CleanString()}"; + } + + if (!string.IsNullOrEmpty(song.Album)) + { + songInfo += $" ({song.Album?.CleanString()})"; + } + + if (song.IsAlt) + { + songInfo += " (Alt)"; + } + + return songInfo; + } + private void AppendTrackTable(IEnumerable tracks, StringBuilder sb, int trackModifier, int numberLength, int trackLength, int albumLength, int songLength, int artistLength) { var headerNumber = "###".PadRight(numberLength); @@ -208,17 +232,23 @@ private string GetTrackTableSongInfo(MsuTrackInfo track, MsuSongInfo song, int t if (albumLength > 0) { - songInfo += (song.Album?.CleanString() ?? "").PadRight(albumLength); + var item = song.Album?.CleanString() ?? ""; + var spaces = new string(' ', albumLength - item.GetUnicodeLength()); + songInfo += item + spaces; } if (songLength > 0) { - songInfo += (song.SongName?.CleanString() ?? "").PadRight(songLength); + var item = song.SongName?.CleanString() ?? ""; + var spaces = new string(' ', songLength - item.GetUnicodeLength()); + songInfo += item + spaces; } if (artistLength > 0) { - songInfo += (song.Artist?.CleanString() ?? "").PadRight(artistLength); + var item = song.Artist?.CleanString() ?? ""; + var spaces = new string(' ', artistLength - item.GetUnicodeLength()); + songInfo += item + spaces; } return songInfo; diff --git a/MSUScripter/Text/ApplicationText.cs b/MSUScripter/Text/ApplicationText.cs new file mode 100644 index 0000000..925bf81 --- /dev/null +++ b/MSUScripter/Text/ApplicationText.cs @@ -0,0 +1,199 @@ +// ReSharper disable ReplaceAutoPropertyWithComputedProperty + +using System; + +namespace MSUScripter.Text; + +public class ApplicationText +{ + public static ApplicationText CurrentLanguageText { get; set; } = new(); + + public static event EventHandler? LanguageChanged; + + public static void SetLanguage(ApplicationText newLanguage) + { + CurrentLanguageText = newLanguage; + LanguageChanged?.Invoke(null, newLanguage); + } + + public string MainWindowApplicationName { get; } = "MSU Scripter"; + public string MainWindowNewProject { get; } = "New Project"; + public string MainWindowOpenProject { get; } = "Open Project"; + public string MainWindowSettings { get; } = "Settings"; + public string MainWindowAbout { get; } = "About"; + + public string ProjectWindowMainMenu { get; } = "Main Menu"; + public string ProjectWindowSaveProject { get; } = "Save Project"; + public string ProjectWindowOpenMsuFolder { get; } = "Open MSU Folder"; + public string ProjectWindowAnalyzeAudio { get; } = "Analyze Audio..."; + public string ProjectWindowExportProject { get; } = "Generate MSU..."; + public string ProjectWindowFilterOnlyTracksMissingSongs { get; } = "Show only tracks with no added songs"; + public string ProjectWindowFilterOnlyIncomplete { get; } = "Show only incomplete songs"; + public string ProjectWindowFilterOnlyMissingAudio { get; } = "Show only songs missing audio files"; + public string ProjectWindowFilterOnlyCopyrightUntested { get; } = "Show only songs untested for copyright strikes"; + public string ProjectWindowDisplayIsCompleteIcon { get; } = "Display song completed flag icon"; + public string ProjectWindowDisplayHasSongIcon { get; } = "Display has audio files icon"; + public string ProjectWindowDisplayCheckCopyrightIcon { get; } = "Display add to copyright test video icon"; + public string ProjectWindowDisplayCopyrightSafeIcon { get; } = "Display copyright strike status icon"; + + public string MsuBasicInfoMsuDetailsHeader { get; } = "MSU Details"; + public string MsuBasicInfoGenerationSettingsHeader { get; } = "Generation Settings"; + public string MsuBasicInfoPackNameLabel { get; } = "Pack Name"; + public string MsuBasicInfoPackNameToolTip { get; } = "A friendly display name of the MSU pack. Added to the MSU Randomizer YAML file."; + public string MsuBasicInfoPackCreatorLabel { get; } = "Pack Creator"; + public string MsuBasicInfoPackCreatorToolTip { get; } = "Who created the MSU pack. Added to the MSU Randomizer YAML file."; + public string MsuBasicInfoMsuTypeLabel { get; } = "MSU Type"; + public string MsuBasicInfoMsuTypeToolTip { get; } = "The randomizer or game you are making to use this MSU with."; + public string MsuBasicInfoMsuPathLabel { get; } = "MSU Path"; + public string MsuBasicInfoMsuPathToolTip { get; } = "The path the of the .msu file to generate and create the MSU Randomizer YAML file for."; + public string MsuBasicInfoMsuProjectPathLabel { get; } = "MSU Project Path"; + public string MsuBasicInfoMsuProjectPathToolTip { get; } = "The path the of the .msup project file used by the MSU Scripter application."; + + public string MsuBasicInfoImportJsonLabel { get; } = "Import MsuPcm++ JSON File (Optional)"; + public string MsuBasicInfoImportJsonToolTip { get; } = "If you are importing a previously created MSU, you can select the MsuPcm++ tracks JSON to automatically import some of the details from the JSON file."; + public string MsuBasicInfoImportWorkingDirectoryLabel { get; } = "MsuPcm++ Working Directory (Optional)"; + public string MsuBasicInfoImportWorkingDirectoryToolTip { get; } = "If you are importing a previously created MSU and MsuPcm++ tracks JSON file and have relative file paths in the JSON file, you can specify the folder you ran MsuPcm++ from to determine the full file paths."; + public string MsuBasicInfoPackVersionLabel { get; } = "Pack Version"; + public string MsuBasicInfoPackVersionToolTip { get; } = "Current version of the track. Used by the MSU Randomizer to recache MSU data."; + public string MsuBasicInfoDefaultArtistLabel { get; } = "Default Artist"; + public string MsuBasicInfoDefaultArtistToolTip { get; } = "Name of the artist to use for songs where an artist is not entered."; + public string MsuBasicInfoDefaultAlbumLabel { get; } = "Default Album"; + public string MsuBasicInfoDefaultAlbumToolTip { get; } = "Name of the album to use for songs where an album is not entered."; + public string MsuBasicInfoDefaultUrlLabel { get; } = "Default URL"; + public string MsuBasicInfoDefaultUrlToolTip { get; } = "URL to retrieve the album or support the artist for songs where a URL is not entered."; + public string MsuBasicInfoIsMsuPcmPackLabel { get; } = "Generate PCM Files via MsuPcm++"; + public string MsuBasicInfoIsMsuPcmPackToolTip { get; } = "Add additional fields to enter information to use for generating the PCM files via the MsuPcm++ application."; + public string MsuBasicInfoMsuPcmNormalizationLabel { get; } = "Default Normalization"; + public string MsuBasicInfoMsuPcmNormalizationToolTip { get; } = "The default RMS normalization level, in dBFS (decibels), to be applied to the entire pack. Should be a negative value, typically less than -10 as values approaching 0 will likely cause audio clipping."; + public string MsuBasicInfoMsuPcmDitherLabel { get; } = "Dither"; + public string MsuBasicInfoMsuPcmDitherToolTip { get; } = "Whether or not to apply audio dither to the final output"; + public string MsuBasicInfoWriteYamlLabel { get; } = "Create YAML File"; + public string MsuBasicInfoWriteYamlToolTip { get; } = "Generate a YAML with track information that can be read by a user, the MSU Randomizer application, or some other application that supports it."; + public string MsuBasicInfoWriteTrackListLabel { get; } = "Track List Text File Type"; + public string MsuBasicInfoWriteTrackListToolTip { get; } = "Structure of the text file that lists basic information about the tracks and artists."; + public string MsuBasicInfoIncludeJsonLabel { get; } = "Bundle MsuPcm++ tracks.json File"; + public string MsuBasicInfoIncludeJsonToolTip { get; } = "General a full tracks.json file that can be used by others to re-generate the MSU provided they have the correct input files."; + public string MsuBasicInfoWriteAltSwapperLabel { get; } = "Create Alt Track Swapper Script"; + public string MsuBasicInfoWriteAltSwapperToolTip { get; } = "Generate a PowerShell script to swap between primary and the first alt tracks, if any alt tracks are available."; + public string MsuBasicInfoCreateSplitSmz3MsuCheckbox { get; } = ""; + public string MsuBasicInfoCreateSplitSmz3Label { get; } = "Create Separate ALttP & SM MSUs"; + public string MsuBasicInfoCreateSplitSmz3ToolTip { get; } = "Create Separate A Link to the Past and Super Metroid MSUs and Create PowerShell Script to Split MSU."; + public string MsuBasicInfoZeldaMsuPathLabel { get; } = "A Link to the Past MSU Path"; + public string MsuBasicInfoZeldaMsuPathToolTip { get; } = "The path to the separate ALttP MSU that users can split the SMZ3 MSU into."; + public string MsuBasicInfoMetroidMsuPathLabel { get; } = "Super Metroid MSU Path"; + public string MsuBasicInfoMetroidMsuPathToolTip { get; } = "The path to the separate Super Metroid MSU that users can split the SMZ3 MSU into."; + + public string InputAudioFileLabel { get; } = "Input Audio File"; + public string InputAudioFileToolTip { get; } = "Input audio file used by MsuPcm++ to generate the PCM file."; + public string InputAudioFileFilter { get; } = "Supported audio files:*.wav,*.mp3,*.flac,*.ogg;All files:*.*"; + + public string OutputAudioFileLabel { get; } = "Output Audio File"; + public string OutputAudioFileToolTip { get; } = "Output PCM file generated by MsuPcm++. Can only be modified for alt songs."; + public string OutputAudioFileFilter { get; } = "PCM audio files:*.pcm"; + + public string MetadataSongNameLabel { get; } = "Song Name"; + public string MetadataSongNameToolTip { get; } = "Song name used in the track list and the MSU Randomizer YAML file. The MSU Scripter will attempt to populate this automatically when you select an input audio file."; + public string MetadataArtistNameLabel { get; } = "Artist Name"; + public string MetadataArtistNameToolTip { get; } = "Artist name used in the track list and the MSU Randomizer YAML file. The MSU Scripter will attempt to populate this automatically when you select an input audio file."; + public string MetadataAlbumNameLabel { get; } = "Album Name"; + public string MetadataAlbumNameToolTip { get; } = "Album name used in the track list and the MSU Randomizer YAML file. The MSU Scripter will attempt to populate this automatically when you select an input audio file."; + public string MetadataUrlLabel { get; } = "Url"; + public string MetadataUrlToolTip { get; } = "Url to retrieve the song or view the artist's library. Added to the YAML file."; + + public string MsuPcmHeader { get; } = "MsuPcm++ Details"; + public string MsuPcmTrimStartLabel { get; } = "Trim Start"; + public string MsuPcmTrimStartToolTip { get; } = "Trim the start of the current track at the specified sample."; + public string MsuPcmTrimEndLabel { get; } = "Trim End"; + public string MsuPcmTrimEndToolTip { get; } = "Trim the end of the current track at the specified sample."; + public string MsuPcmLoopLabel { get; } = "Loop Point"; + public string MsuPcmLoopToolTip { get; } = "The loop point of the current track, relative to this track/sub-track/sub-channel, in samples."; + public string MsuPcmNormalizationLabel { get; } = "Normalization"; + public string MsuPcmNormalizationToolTip { get; } = "Normalize the current track to the specified RMS level in dBFS (decibels). The value overrides the default global normalization value. Should be a negative value, typically less than -10 as values approaching 0 will likely cause audio clipping."; + public string MsuPcmFadeInLabel { get; } = "Fade In"; + public string MsuPcmFadeInToolTip { get; } = "Apply a fade in effect to the current track lasting a specified number of samples."; + public string MsuPcmFadeOutLabel { get; } = "Fade Out"; + public string MsuPcmFadeOutToolTip { get; } = "Apply a fade out effect to the current track lasting a specified number of samples."; + public string MsuPcmCrossFadeLabel { get; } = "Cross Fade"; + public string MsuPcmCrossFadeToolTip { get; } = "Apply a cross fade effect from the end of the current track to its loop point lasting a specified number of samples."; + public string MsuPcmPaddingStartLabel { get; } = "Padding Start"; + public string MsuPcmPaddingStartToolTip { get; } = "Pad the beginning of the current track with a specified number of silent samples."; + public string MsuPcmPaddingEndLabel { get; } = "Padding End"; + public string MsuPcmPaddingEndToolTip { get; } = "Pad the end of the current track with a specified number of silent samples."; + public string MsuPcmTempoLabel { get; } = "Tempo"; + public string MsuPcmTempoToolTip { get; } = "Alter the tempo of the current track by a specified ratio."; + public string MsuPcmCompressionLabel { get; } = "Compression"; + public string MsuPcmCompressionToolTip { get; } = "Apply dynamic range compression to the current track. Helps to minimize very loud and very quiet portions of the track."; + public string MsuPcmDitherLabel { get; } = "Dither"; + public string MsuPcmDitherToolTip { get; } = "Whether or not to apply audio dither to the final output. If set, overrides the default value."; + + + public string CheckCopyrightCheckedText { get; } = "Add to copyright test video"; + public string CheckCopyrightUncheckedText { get; } = "Do not add to copyright test video"; + public string CheckCopyrightToolTipText { get; } = "If this track will be added to the video to upload to YouTube to check for potential copyright strikes."; + public string IsCopyrightSafeCheckedText { get; } = "Verified to be safe from copyright strikes"; + public string IsCopyrightSafeUncheckedText { get; } = "Verified to not be safe from copyright strikes"; + public string IsCopyrightSafeNullText { get; } = "Not tested for copyright strike safety"; + public string IsCopyrightSafeToolTipText { get; } = "If this song has been tested and is known to be safe from potential copyright strikes."; + + public string SongPanelBasicHeader { get; } = "Song Details"; + public string SongPanelAdvancedModeCheckBox { get; } = "Advanced View"; + public string SongPanelBasicModeCheckBox { get; } = "Advanced View"; + public string SongPanelAdvancedModeToolTip { get; } = "Toggle advanced mode where you can set all msupcm++ settings, including adding subtracks and subchannels."; + public string SongPanelBasicMetadataHeader { get; } = "Song Metadata Details"; + + public string PyMusicLooperHeader { get; } = "PyMusicLooper"; + public string PyMusicLooperMinDurationMultiplierLabel { get; } = "Min Length Mult."; + public string PyMusicLooperMinDurationMultiplierToolTip { get; } = "Minimum length/duration multiplier, or the minimum percentage of the source audio's length that the duration of the looped portion should last. For example, a multiplier of 0.25 of a 60 second audio file would mean the looped portion would need to be at least 15 seconds long."; + public string PyMusicLooperDurationLimitInSecondsLabel { get; } = "Duration Limit in Seconds"; + public string PyMusicLooperDurationLimitInSecondsToolTip { get; } = "Minimum and maximum lengths of the looped audio in seconds"; + public string PyMusicLooperApproximateTimesLabel { get; } = "Approximate Loop Time in Seconds"; + public string PyMusicLooperApproximateTimesToolTip { get; } = "Approximate times for the start and stop times of the looped audio within 2 seconds."; + public string PyMusicLooperFilterSamplesLabel { get; } = "Filter Samples"; + public string PyMusicLooperFilterSamplesToolTip { get; } = "Sample values to filter out results. If entered, the trim start value will be used as the starting sample filter."; + public string PyMusicLooperRunButton { get; } = "Run"; + public string PyMusicLooperAutoRunCheckBox { get; } = "Automatically Run PyMusicLooper After Selecting File"; + public string PyMusicLooperStopButton { get; } = "Stop PyMusicLooper"; + public string PyMusicLooperPrevButton { get; } = "Previous Page"; + public string PyMusicLooperNextButton { get; } = "Next Page"; + public string PyMusicLooperGridLoopStartHeader { get; } = "Loop Start Sample"; + public string PyMusicLooperGridLoopEndHeader { get; } = "Loop End Sample"; + public string PyMusicLooperGridScoreHeader { get; } = "Score"; + public string PyMusicLooperGridLoopDurationHeader { get; } = "Loop Duration"; + public string PyMusicLooperGridStatusHeader { get; } = "Status"; + public string PyMusicLooperOpenWindowButton { get; } = "Run PyMusicLooper Application"; + + public string MenuItemCopyToClipboard { get; } = "Copy to Clipboard"; + public string MenuItemPasteFromClipboard { get; } = "Paste from Clipboard"; + public string MenuItemDuplicateMsuPcmDetails { get; } = "Duplicate MsuPcm++ Details"; + public string MenuItemDeleteMsuPcmDetails { get; } = "Delete MsuPcm++ Details"; + public string MenuItemNewProject { get; } = "_New Project..."; + public string MenuItemOpenProject { get; } = "_Open Project"; + public string MenuItemOpenFromFile { get; } = "From File..."; + public string MenuItemSaveProject { get; } = "_Save Project"; + public string MenuItemCloseProject { get; } = "_Close Project"; + public string MenuItemSettings { get; } = "Se_ttings..."; + public string MenuItemExitApp { get; } = "E_xit MSU Scripter"; + public string MenuItemDuplicateSongDetails { get; } = "Duplicate Song Details"; + public string MenuItemDeleteSongDetails { get; } = "Delete Song Details"; + + public string MenuItemGenerateMsuLabel { get; } = "Generate MSU..."; + public string MenuItemGenerateMsuToolTip { get; } = "Generate all PCM files if needed along with all other files for the MSU and (optionally) package the files into a zip file."; + public string MenuItemCreateYouTubeVideoDetailsLabel { get; } = "Create Copyright Test YouTube Video..."; + public string MenuItemCreateYouTubeVideoDetailsToolTip { get; } = "Create a video of all of the marked songs to compile into a single video that you can upload to YouTube to see if it'll get copyright strikes."; + public string MenuItemCreateYamlLabel { get; } = "Create MSU YAML File"; + public string MenuItemCreateYamlToolTip { get; } = "Create a YAML file with all track details that can be read by users or applications like the MSU Randomizer. Must be re-generated when you change any track metadata or if you re-generate PCM files where you have alt tracks."; + public string MenuItemCreateTrackListLabel { get; } = "Create Track List File"; + public string MenuItemCreateTrackListToolTip { get; } = "Create a track list text file with all track details that can be read by users."; + public string MenuItemCreateSwapScriptsLabel { get; } = "Create Track Swapping Script File(s)"; + public string MenuItemCreateSwapScriptsToolTip { get; } = "Create a .bat script file for swapping between primary tracks and, if applicable, a file for switching between SMZ3 and split SM and ALttP MSUs."; + public string MenuItemCreateTracksJsonLabel { get; } = "Create MsuPcm++ Tracks File"; + public string MenuItemCreateTracksJsonToolTip { get; } = "Create the MsuPcm++ tracks.json file that can be sent to others to generate the Msu themselves."; + public string MsuGenerationSettingsGenerateButton { get; } = "Generate"; + public string MsuGenerationSettingsCancelButton { get; } = "Cancel"; + + public string GenericErrorTitle { get; } = + "Unexpected Error"; + public string GenericError { get; } = + "There was an unexpected error, please try again. If the problem persists, please post an Issue on GitHub."; + +} \ No newline at end of file diff --git a/MSUScripter/Tools/ActionExtensions.cs b/MSUScripter/Tools/ActionExtensions.cs new file mode 100644 index 0000000..cfdf287 --- /dev/null +++ b/MSUScripter/Tools/ActionExtensions.cs @@ -0,0 +1,29 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace MSUScripter.Tools; + +public static class ActionExtensions +{ + public static Action Debounce(this Action func, int milliseconds = 300) + { + CancellationTokenSource? cancelTokenSource = null; + + return () => + { + cancelTokenSource?.Cancel(); + cancelTokenSource = new CancellationTokenSource(); + + Task.Delay(milliseconds, cancelTokenSource.Token) + .ContinueWith(t => + { + if (t.IsCompletedSuccessfully) + { + func(); + } + }, TaskScheduler.Default); + }; + } + +} \ No newline at end of file diff --git a/MSUScripter/Tools/ControlExtensions.cs b/MSUScripter/Tools/ControlExtensions.cs index 15c68a9..c64343b 100644 --- a/MSUScripter/Tools/ControlExtensions.cs +++ b/MSUScripter/Tools/ControlExtensions.cs @@ -9,12 +9,12 @@ public static class ControlExtensions public static async Task GetDocumentsFolderPath(this Control control) { var topLevel = TopLevel.GetTopLevel(control) ?? App.MainWindow; - if (topLevel == null) - { - return null; - } - var location = await topLevel.StorageProvider.TryGetWellKnownFolderAsync(WellKnownFolder.Documents); return location?.Path.LocalPath; } + + public static Window GetTopLevelWindow(this Control control) + { + return TopLevel.GetTopLevel(control) as Window ?? App.MainWindow; + } } \ No newline at end of file diff --git a/MSUScripter/Tools/ReactiveObjectExtensions.cs b/MSUScripter/Tools/ReactiveObjectExtensions.cs new file mode 100644 index 0000000..3c2d0b7 --- /dev/null +++ b/MSUScripter/Tools/ReactiveObjectExtensions.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.Linq; +using MSUScripter.Models; +using ReactiveUI; + +namespace MSUScripter.Tools; + +public static class ReactiveObjectExtensions +{ + public static HashSet GetSkipLastModifiedPropertyNames(this ReactiveObject reactiveObject) + { + var toReturn = new HashSet(); + + foreach (var property in reactiveObject.GetType().GetProperties()) + { + if (property.GetCustomAttributes(true).Any(x => x is SkipLastModifiedAttribute)) + { + toReturn.Add(property.Name); + } + } + + return toReturn; + } +} \ No newline at end of file diff --git a/MSUScripter/Tools/StringExtensions.cs b/MSUScripter/Tools/StringExtensions.cs index 0aef939..1b339b7 100644 --- a/MSUScripter/Tools/StringExtensions.cs +++ b/MSUScripter/Tools/StringExtensions.cs @@ -1,4 +1,5 @@ -using System.Linq; +using System.Globalization; +using System.Linq; namespace MSUScripter.Tools; @@ -6,6 +7,11 @@ public static class StringExtensions { public static string CleanString(this string str) { - return new string(str.Where(c => (int)c >= 0x1F).ToArray()); + return new string(str.Where(c => c >= 0x1F).ToArray()); + } + + public static int GetUnicodeLength(this string str) + { + return new StringInfo(str.CleanString()).LengthInTextElements; } } \ No newline at end of file diff --git a/MSUScripter/ViewModels/AddSongWindowViewModel.cs b/MSUScripter/ViewModels/AddSongWindowViewModel.cs deleted file mode 100644 index c6ba09e..0000000 --- a/MSUScripter/ViewModels/AddSongWindowViewModel.cs +++ /dev/null @@ -1,113 +0,0 @@ -using System; -using System.Collections.Generic; -using Avalonia.Media; -using AvaloniaControls.Models; -using Material.Icons; -using MSUScripter.Configs; -using ReactiveUI.Fody.Helpers; -#pragma warning disable CS0067 // Event is never used - -namespace MSUScripter.ViewModels; - -public class AddSongWindowViewModel : ViewModelBase -{ - [Reactive] public string? SongName { get; set; } - [Reactive] public bool DisplayHertzWarning { get; set; } - [Reactive] public string? ArtistName { get; set; } - [Reactive] public string? AlbumName { get; set; } - [Reactive, ReactiveLinkedEvent(nameof(TrimStartUpdated))] public int? TrimStart { get; set; } - [Reactive] public int? TrimEnd { get; set; } - [Reactive] public int? LoopPoint { get; set; } - [Reactive] public double? Normalization { get; set; } - [Reactive] public string? AverageAudio { get; set; } - [Reactive] public string AddSongButtonText { get; set; } = "Add Song"; - [Reactive] public bool RunningPyMusicLooper { get; set; } - [Reactive] public bool SingleMode { get; set; } - public bool ShowPyMusicLooper => !string.IsNullOrEmpty(FilePath); - - [Reactive, ReactiveLinkedProperties(nameof(HasAudioAnalysis))] - public string? PeakAudio { get; set; } - - [Reactive, ReactiveLinkedProperties(nameof(CanEditMainFields), nameof(CanAddSong), nameof(ShowPyMusicLooper))] - public string FilePath { get; set; } = ""; - - [Reactive, ReactiveLinkedProperties(nameof(CanAddSong))] - public MsuTrackInfoViewModel? SelectedTrack { get; set; } - - [Reactive, ReactiveLinkedProperties(nameof(CopyrightIconKind), nameof(CopyrightIconBrush), nameof(CopyrightSafeText))] - public bool? IsCopyrightSafe { get; set; } - - [Reactive, ReactiveLinkedProperties(nameof(CheckCopyrightIconKind))] - public bool CheckCopyright { get; set; } = true; - - public MaterialIconKind CheckCopyrightIconKind => CheckCopyright switch - { - true => MaterialIconKind.CheckboxOutline, - false => MaterialIconKind.CheckboxBlankOutline, - }; - - public MaterialIconKind CopyrightIconKind => IsCopyrightSafe switch - { - true => MaterialIconKind.CheckboxOutline, - false => MaterialIconKind.CancelBoxOutline, - _ => MaterialIconKind.QuestionBoxOutline - }; - - public IBrush CopyrightIconBrush => IsCopyrightSafe switch - { - true => Brushes.LimeGreen, - false => Brushes.IndianRed, - _ => Brushes.Goldenrod - }; - - public string CopyrightSafeText => IsCopyrightSafe switch - { - true => "Safe", - false => "Unsafe", - _ => "Untested" - }; - - public List TrackSearchItems { get; set; } = []; - - public bool HasAudioAnalysis => !string.IsNullOrEmpty(PeakAudio); - - public bool CanEditMainFields => !string.IsNullOrEmpty(FilePath); - - public bool CanAddSong => !string.IsNullOrEmpty(FilePath) && SelectedTrack != null && !RunningPyMusicLooper; - - public MsuProjectViewModel MsuProjectViewModel { get; set; } = new(); - - public MsuProject MsuProject { get; set; } = new(); - - public event EventHandler? TrimStartUpdated; - - public void Clear() - { - FilePath = ""; - SongName = ""; - ArtistName = ""; - AlbumName = ""; - TrimStart = null; - TrimEnd = null; - LoopPoint = null; - Normalization = null; - AverageAudio = null; - PeakAudio = null; - HasBeenModified = false; - } - - public override ViewModelBase DesignerExample() - { - FilePath = "C:\\Test.mp3"; - SongName = "Test Song Name"; - ArtistName = "Test Song Artist"; - TrimStart = 10000; - TrimEnd = 1000000; - LoopPoint = 50000; - TrackSearchItems = - [ - new ComboBoxAndSearchItem(null, "Track", "Track Description") - ]; - return this; - } -} \ No newline at end of file diff --git a/MSUScripter/ViewModels/AudioAnalysisSongViewModel.cs b/MSUScripter/ViewModels/AudioAnalysisSongViewModel.cs index cfb86a0..2f1874b 100644 --- a/MSUScripter/ViewModels/AudioAnalysisSongViewModel.cs +++ b/MSUScripter/ViewModels/AudioAnalysisSongViewModel.cs @@ -1,4 +1,5 @@ using AvaloniaControls.Models; +using MSUScripter.Configs; using MSUScripter.Models; using ReactiveUI.Fody.Helpers; @@ -6,7 +7,7 @@ namespace MSUScripter.ViewModels; public class AudioAnalysisSongViewModel : ViewModelBase { - public MsuSongInfoViewModel? OriginalViewModel { get; set; } + public MsuSongInfo? MsuSongInfo { get; init; } [Reactive] public string SongName { get; set; } = ""; @@ -14,7 +15,7 @@ public class AudioAnalysisSongViewModel : ViewModelBase [Reactive] public string TrackName { get; set; } = ""; - [Reactive] public string Path { get; set; } = ""; + [Reactive] public string Path { get; init; } = ""; [Reactive] public double? AvgDecibels { get; set; } diff --git a/MSUScripter/ViewModels/AudioAnalysisViewModel.cs b/MSUScripter/ViewModels/AudioAnalysisViewModel.cs index 2a63d8c..8e5af2f 100644 --- a/MSUScripter/ViewModels/AudioAnalysisViewModel.cs +++ b/MSUScripter/ViewModels/AudioAnalysisViewModel.cs @@ -1,15 +1,17 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; +using System.Linq; using AvaloniaControls.Models; +using MSUScripter.Configs; +using ReactiveUI; using ReactiveUI.Fody.Helpers; namespace MSUScripter.ViewModels; -public class AudioAnalysisViewModel : ViewModelBase +public class AudioAnalysisViewModel : TranslatedViewModelBase { - public MsuProjectViewModel? Project { get; set; } - - [Reactive] public int SongsCompleted { get; set; } + public MsuProject? Project { get; set; } + + public int SongsCompleted => Rows.Count(x => x.HasLoaded); [Reactive] public string BottomBar { get; set; } = ""; @@ -17,16 +19,19 @@ public class AudioAnalysisViewModel : ViewModelBase public List Rows { get; set; } = []; [Reactive] public bool CompareEnabled { get; set; } = true; - - + public double Duration { get; set; } public int TotalSongs => Rows.Count; public string? LoadError { get; set; } public bool ShowCompareButton { get; set; } = true; + public void UpdateSongsCompleted() + { + this.RaisePropertyChanged(nameof(SongsCompleted)); + } + public override ViewModelBase DesignerExample() { - SongsCompleted = 2; BottomBar = "Test Message"; Rows = [ diff --git a/MSUScripter/ViewModels/AudioControlViewModel.cs b/MSUScripter/ViewModels/AudioControlViewModel.cs index 269e4e6..fa66255 100644 --- a/MSUScripter/ViewModels/AudioControlViewModel.cs +++ b/MSUScripter/ViewModels/AudioControlViewModel.cs @@ -1,8 +1,10 @@ using Material.Icons; +using MSUScripter.Models; using ReactiveUI.Fody.Helpers; namespace MSUScripter.ViewModels; +[SkipLastModified] public class AudioControlViewModel : ViewModelBase { [Reactive] public MaterialIconKind Icon { get; set; } = MaterialIconKind.Stop; @@ -14,9 +16,7 @@ public class AudioControlViewModel : ViewModelBase [Reactive] public bool CanPlayPause { get; set; } [Reactive] public bool CanChangeVolume { get; set; } [Reactive] public int? JumpToSeconds { get; set; } - [Reactive] public bool CanPopout { get; set; } [Reactive] public bool CanSetTimeSeconds { get; set; } - [Reactive] public bool CanPressPopoutButton { get; set; } = true; public double PreviousPosition { get; set; } public int IconSize => 16; @@ -26,7 +26,6 @@ public override ViewModelBase DesignerExample() CanChangePosition = true; CanPlayPause = true; CanChangeVolume = true; - CanPopout = true; CanSetTimeSeconds = true; return this; } diff --git a/MSUScripter/ViewModels/CopyMoveTrackWindowViewModel.cs b/MSUScripter/ViewModels/CopyMoveTrackWindowViewModel.cs deleted file mode 100644 index d211825..0000000 --- a/MSUScripter/ViewModels/CopyMoveTrackWindowViewModel.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using AvaloniaControls.Models; -using ReactiveUI.Fody.Helpers; - -namespace MSUScripter.ViewModels; - -public enum CopyMoveType -{ - Copy, - Move, - Swap -} - -public class CopyMoveTrackWindowViewModel : ViewModelBase -{ - [Reactive] public MsuProjectViewModel? Project { get; set; } - [Reactive] public MsuTrackInfoViewModel? PreviousTrack { get; set; } - [Reactive] public MsuSongInfoViewModel? PreviousSong { get; set; } - [Reactive] public CopyMoveType Type { get; set; } - [Reactive] public List Tracks { get; set; } = []; - [Reactive] public bool IsTargetLocationEnabled { get; set; } - [Reactive] public int OriginalLocation { get; set; } - - [Reactive, ReactiveLinkedProperties(nameof(CanPressButton))] - public MsuTrackInfoViewModel TargetTrack { get; set; } = new(); - - [Reactive, ReactiveLinkedProperties(nameof(CanPressButton))] - public List TargetLocationOptions { get; set; } = []; - - [Reactive, ReactiveLinkedProperties(nameof(CanPressButton))] - public int TargetLocation { get; set; } - - public bool CanPressButton => Type != CopyMoveType.Swap || (TargetLocationOptions.Count > 0 && - (PreviousTrack != TargetTrack || - TargetLocation != OriginalLocation)); - - public string MainText => - Type switch - { - CopyMoveType.Move => "Select a track to move song to", - CopyMoveType.Copy => "Select a track to copy song to", - _ => "Select a track to swap with" - }; - - public string LocationText => - Type switch - { - CopyMoveType.Swap => "Song to swap with", - _ => "Song placement" - }; - - public override ViewModelBase DesignerExample() - { - Tracks = - [ - new MsuTrackInfoViewModel - { - TrackNumber = 5, - TrackName = "Fifth Track" - } - ]; - TargetTrack = Tracks.First(); - return this; - } -} \ No newline at end of file diff --git a/MSUScripter/ViewModels/CopyProjectWindowViewModel.cs b/MSUScripter/ViewModels/CopyProjectWindowViewModel.cs index ae676b7..af264da 100644 --- a/MSUScripter/ViewModels/CopyProjectWindowViewModel.cs +++ b/MSUScripter/ViewModels/CopyProjectWindowViewModel.cs @@ -1,24 +1,34 @@ using System; using System.Collections.Generic; using System.IO; -using Avalonia.Platform.Storage; using MSUScripter.Configs; using ReactiveUI.Fody.Helpers; namespace MSUScripter.ViewModels; -public class CopyProjectWindowViewModel : ViewModelBase +public class CopyProjectWindowViewModel : TranslatedViewModelBase { [Reactive] public MsuProject? OriginalProject { get; set; } - [Reactive] public MsuProjectViewModel? ProjectViewModel { get; set; } - [Reactive] public MsuProject? NewProject { get; set; } - [Reactive] public List Paths { get; set; } = new(); + [Reactive] public MsuProject? SavedProject { get; set; } + + [Reactive] public List Paths { get; set; } = []; [Reactive] public bool IsValid { get; set; } + [Reactive] public string ButtonText { get; set; } = "Update Project"; + + [Reactive] public string Title { get; set; } = "Update Project"; + + public string TopText => + IsCopy + ? "Update the paths below as desired for the new project." + : "One or more input files are missing. Update them below or continue opening the project."; + + public bool IsCopy { get; set; } + public override ViewModelBase DesignerExample() { Paths = @@ -45,9 +55,8 @@ public CopyProjectViewModel(string? path) NewPath = path ?? ""; if (!string.IsNullOrEmpty(PreviousPath)) { - var file = new FileInfo(PreviousPath); - BaseFileName = file.Name; - Extension = file.Extension; + BaseFileName = GetFileNameFromAnyPath(PreviousPath); + Extension = Path.GetExtension(PreviousPath); } } @@ -65,16 +74,26 @@ public CopyProjectViewModel(string? path) public bool IsSongFile => !Extension.Equals(".msup", StringComparison.OrdinalIgnoreCase) && !Extension.Equals(".msu", StringComparison.OrdinalIgnoreCase); - public List? FileTypePatterns => - string.IsNullOrEmpty(Extension) - ? null - : new List - { - new($"{Extension} File") { Patterns = new List { $"*{Extension}" } } - }; public override ViewModelBase DesignerExample() { return this; } + + static string GetFileNameFromAnyPath(string path) + { + if (string.IsNullOrEmpty(path)) + return string.Empty; + + // Normalize to system-independent form + path = path.Replace('\\', '/'); + + // Extract after last '/' + int lastSlash = path.LastIndexOf('/'); + if (lastSlash >= 0 && lastSlash < path.Length - 1) + return path.Substring(lastSlash + 1); + + // Fall back to entire string if no slash + return path; + } } \ No newline at end of file diff --git a/MSUScripter/ViewModels/EditProjectPanelViewModel.cs b/MSUScripter/ViewModels/EditProjectPanelViewModel.cs deleted file mode 100644 index c391da0..0000000 --- a/MSUScripter/ViewModels/EditProjectPanelViewModel.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using AvaloniaControls.Models; -using MSUScripter.Configs; -using ReactiveUI.Fody.Helpers; - -namespace MSUScripter.ViewModels; - -public class EditProjectPanelViewModel : ViewModelBase -{ - [Reactive, - ReactiveLinkedProperties(nameof(DisplayBasicInfoPanel), nameof(DisplayTrackOverviewPanel), - nameof(DisplayTrackInfoPanel), nameof(SelectedTrack), nameof(CanClickPrev), nameof(CanClickNext))] - public int PageNumber { get; set; } = 0; - - [Reactive] public string StatusBarText { get; set; } = ""; - - public MsuProject? MsuProject { get; set; } - - [Reactive, - ReactiveLinkedProperties(nameof(IsMsuPcmProject), nameof(WriteYamlFile), nameof(WriteTrackList), - nameof(CreateAltSwapper), nameof(CreateSplitSmz3Script))] - public MsuProjectViewModel? MsuProjectViewModel { get; set; } - - public List Tracks { get; set; } = []; - public MsuTrackInfoViewModel? SelectedTrack => PageNumber < 2 ? null : Tracks[PageNumber - 2]; - - public MsuBasicInfoViewModel MsuBasicInfoViewModel => MsuProjectViewModel?.BasicInfo ?? new MsuBasicInfoViewModel(); - public bool CanClickPrev => PageNumber > 0; - public bool CanClickNext => PageNumber < Tracks.Count + 1; - public bool IsMsuPcmProject => MsuProjectViewModel?.BasicInfo.IsMsuPcmProject == true; - public bool WriteYamlFile => MsuProjectViewModel?.BasicInfo.WriteYamlFile == true; - public bool WriteTrackList => MsuProjectViewModel?.BasicInfo.TrackList != TrackListType.Disabled; - public bool CreateAltSwapper => MsuProjectViewModel?.BasicInfo.CreateAltSwapperScript == true; - public bool CreateSplitSmz3Script => MsuProjectViewModel?.BasicInfo.CreateSplitSmz3Script == true; - public bool DisplayBasicInfoPanel => PageNumber == 0; - public bool DisplayTrackOverviewPanel => PageNumber == 1; - public bool DisplayTrackInfoPanel => PageNumber >= 2; - public DateTime LastAutoSave = DateTime.MaxValue; - public object? NullValue => null; - - [Reactive] public bool DisplayAltSwapperExportButton { get; set; } - - [Reactive] public List TrackSearchItems { get; set; } = []; - - public override ViewModelBase DesignerExample() - { - StatusBarText = "Project Loaded"; - MsuProjectViewModel = new MsuProjectViewModel() - { - BasicInfo = new MsuBasicInfoViewModel() - { - IsMsuPcmProject = true - } - }; - return this; - } -} \ No newline at end of file diff --git a/MSUScripter/ViewModels/InstallDependenciesWindowViewModel.cs b/MSUScripter/ViewModels/InstallDependenciesWindowViewModel.cs new file mode 100644 index 0000000..4829b5c --- /dev/null +++ b/MSUScripter/ViewModels/InstallDependenciesWindowViewModel.cs @@ -0,0 +1,57 @@ +using AvaloniaControls.Models; +using ReactiveUI.Fody.Helpers; + +namespace MSUScripter.ViewModels; + +public class InstallDependenciesWindowViewModel : ViewModelBase +{ + [Reactive] public bool DontRemindMeAgain { get; set; } = true; + public bool InitialDontRemindMeAgain { get; set; } + public bool CanClickInstallButton => !ShowMsuPcmProgress && !ShowFfmpegProgress && !ShowPyAppProgress; + + + [Reactive, ReactiveLinkedProperties(nameof(ShowInstallMsuPcmButton), nameof(ShowMsuPcmVerifiedText), nameof(ShowMsuPcmProgress), nameof(ShowMsuPcmError), nameof(CanClickInstallButton))] + public InstallState MsuPcmState { get; set; } + public bool ShowInstallMsuPcmButton => MsuPcmState == InstallState.CanInstall; + public bool ShowMsuPcmVerifiedText => MsuPcmState == InstallState.Valid; + public bool ShowMsuPcmProgress => MsuPcmState == InstallState.InProgress; + public bool ShowMsuPcmError => MsuPcmState == InstallState.Error; + [Reactive] public string MsuPcmInstallProgress { get; set; } = string.Empty; + [Reactive] public string MsuPcmErrorText { get; set; } = "Install Failed"; + [Reactive] public string MsuPcmErrorToolTip { get; set; } = "Install Failed"; + + + [Reactive, ReactiveLinkedProperties(nameof(ShowInstallFfmpegButton), nameof(ShowFfmpegVerifiedText), nameof(ShowFfmpegProgress), nameof(ShowFfmpegError), nameof(CanClickInstallButton))] + public InstallState FfmpegState { get; set; } + public bool ShowInstallFfmpegButton => FfmpegState == InstallState.CanInstall; + public bool ShowFfmpegVerifiedText => FfmpegState == InstallState.Valid; + public bool ShowFfmpegProgress => FfmpegState == InstallState.InProgress; + public bool ShowFfmpegError => FfmpegState == InstallState.Error; + [Reactive] public string FfmpegInstallProgress { get; set; } = string.Empty; + [Reactive] public string FfmpegErrorText { get; set; } = "Install Failed"; + [Reactive] public string FfmpegErrorToolTip { get; set; } = "Install Failed"; + + + [Reactive, ReactiveLinkedProperties(nameof(ShowInstallPyAppButton), nameof(ShowPyAppVerifiedText), nameof(ShowPyAppProgress), nameof(ShowPyAppError), nameof(CanClickInstallButton))] + public InstallState PyAppState { get; set; } + public bool ShowInstallPyAppButton => PyAppState == InstallState.CanInstall; + public bool ShowPyAppVerifiedText => PyAppState == InstallState.Valid; + public bool ShowPyAppProgress => PyAppState == InstallState.InProgress; + public bool ShowPyAppError => PyAppState == InstallState.Error; + [Reactive] public string PyAppInstallProgress { get; set; } = string.Empty; + [Reactive] public string PyAppErrorText { get; set; } = "Install Failed"; + [Reactive] public string PyAppErrorToolTip { get; set; } = "Install Failed"; + + public override ViewModelBase DesignerExample() + { + return new InstallDependenciesWindowViewModel(); + } +} + +public enum InstallState +{ + Valid, + CanInstall, + InProgress, + Error +} \ No newline at end of file diff --git a/MSUScripter/ViewModels/MainWindowViewModel.cs b/MSUScripter/ViewModels/MainWindowViewModel.cs index c4f4ce3..e17ded6 100644 --- a/MSUScripter/ViewModels/MainWindowViewModel.cs +++ b/MSUScripter/ViewModels/MainWindowViewModel.cs @@ -1,34 +1,90 @@ using System; +using System.Collections.Generic; +using Avalonia.Media; using AvaloniaControls.Models; +using MSURandomizerLibrary.Configs; using MSUScripter.Configs; using ReactiveUI.Fody.Helpers; #pragma warning disable CS0067 // Event is never used namespace MSUScripter.ViewModels; -public class MainWindowViewModel : ViewModelBase +public class MsuTypeDropdownOption +{ + public required string DisplayName { get; init; } + public required MsuType MsuType { get; init; } +} + +public class MainWindowViewModel : TranslatedViewModelBase { [Reactive] public string Title { get; set; } = "MSU Scripter"; - [Reactive, ReactiveLinkedProperties(nameof(DisplayNewPage), nameof(DisplayEditPage)), ReactiveLinkedEvent(nameof(CurrentMsuProjectChanged))] + [Reactive, ReactiveLinkedEvent(nameof(CurrentMsuProjectChanged))] public MsuProject? CurrentMsuProject { get; set; } - public MsuProject? InitProject { get; set; } - public MsuProject? InitBackupProject { get; set; } + public string? InitProject { get; set; } public bool InitProjectError { get; set; } - - [Reactive, ReactiveLinkedProperties(nameof(DisplayNewVersionBanner))] - public string? GitHubReleaseUrl { get; set; } - public bool HasDoneFirstTimeSetup { get; set; } - public bool DisplayNewPage => CurrentMsuProject == null; - public bool DisplayEditPage => CurrentMsuProject != null; public string AppVersion { get; set; } = ""; - public bool DisplayNewVersionBanner => !string.IsNullOrEmpty(GitHubReleaseUrl); - public object? NullValue => null; public event EventHandler? CurrentMsuProjectChanged; + + [Reactive, ReactiveLinkedProperties(nameof(CanCreateProject))] + public string MsuProjectName { get; set; } = string.Empty; + [Reactive, ReactiveLinkedProperties(nameof(CanCreateProject))] + public string MsuCreatorName { get; set; } = string.Empty; + [Reactive, ReactiveLinkedProperties(nameof(CanCreateProject))] + public string MsuPath { get; set; } = string.Empty; + [Reactive, ReactiveLinkedProperties(nameof(CanCreateProject))] + public string MsuProjectPath { get; set; } = string.Empty; + [Reactive, ReactiveLinkedProperties(nameof(CanSetMsuPcmWorkingPath))] + public string MsuPcmJsonPath { get; set; } = string.Empty; + [Reactive] public string MsuPcmWorkingPath { get; set; } = string.Empty; + public bool CanSetMsuPcmWorkingPath => !string.IsNullOrEmpty(MsuPcmJsonPath); + public bool CanCreateProject => !string.IsNullOrEmpty(MsuProjectName) && !string.IsNullOrEmpty(MsuCreatorName) && !string.IsNullOrEmpty(MsuPath) && !string.IsNullOrEmpty(MsuProjectPath) && SelectedMsuType != null; + [Reactive] public List MsuTypes { get; set; } = []; + [Reactive, ReactiveLinkedProperties(nameof(CanCreateProject))] + public MsuType? SelectedMsuType { get; set; } + [Reactive] public bool DisplayNewProjectPage { get; set; } = false; + [Reactive] public bool DisplayOpenProjectPage { get; set; } = true; + [Reactive] public bool DisplaySettingsPage { get; set; } + [Reactive] public bool DisplayAboutPage { get; set; } + [Reactive] public bool ValidatedDependencies { get; set; } + [Reactive] public List RecentProjects { get; set; } = []; + [Reactive] public RecentProject? SelectedRecentProject { get; set; } + [Reactive] public IBrush NewProjectBackground { get; set; } = Brushes.Transparent; + [Reactive] public IBrush OpenProjectBackground { get; set; } = Brushes.Transparent; + [Reactive] public IBrush SettingsBackground { get; set; } = Brushes.Transparent; + [Reactive] public IBrush AboutBackground { get; set; } = Brushes.Transparent; + public SettingsPanelViewModel Settings { get; set; } = new(); + + public IBrush ActiveTabBackground = Brushes.Transparent; + public override ViewModelBase DesignerExample() { - GitHubReleaseUrl = "a"; + AppVersion = "v4.0.3"; + MsuTypes = + [ + new MsuType() + { + Name = "SMZ3", + DisplayName = "SMZ3", + RequiredTrackNumbers = [], + ValidTrackNumbers = [], + Tracks = [] + } + ]; + RecentProjects = + [ + new RecentProject() + { + ProjectName = "Project 1", + ProjectPath = "/home/matt/Documents/MSUProjects/When The Fates Cry/WhenTheFatesCry.msup" + }, + new RecentProject() + { + ProjectName = "Project 2", + ProjectPath = "C:\\User\\Test\\Documents\\test2.msup" + }, + ]; return this; } -} \ No newline at end of file +} diff --git a/MSUScripter/ViewModels/MsuBasicInfoViewModel.cs b/MSUScripter/ViewModels/MsuBasicInfoViewModel.cs index 9d6e929..43e4693 100644 --- a/MSUScripter/ViewModels/MsuBasicInfoViewModel.cs +++ b/MSUScripter/ViewModels/MsuBasicInfoViewModel.cs @@ -1,48 +1,64 @@ using System; -using AvaloniaControls.Models; -using Material.Icons; using MSUScripter.Configs; -using MSUScripter.Models; using ReactiveUI.Fody.Helpers; namespace MSUScripter.ViewModels; -public class MsuBasicInfoViewModel : ViewModelBase +public class MsuBasicInfoViewModel : SavableViewModelBase { - public MsuBasicInfoViewModel() - { - PropertyChanged += (sender, args) => - { - if (args.PropertyName != nameof(LastModifiedDate) && args.PropertyName != nameof(HasBeenModified)) - { - LastModifiedDate = DateTime.Now; - } - }; - } - - [SkipConvert] public MsuProjectViewModel Project { get; set; } = null!; + public MsuProject? Project { get; set; } [Reactive] public string MsuType { get; set; } = ""; - - [Reactive] public string Game { get; set; } = ""; [Reactive] public string PackName { get; set; } = ""; [Reactive] public string PackCreator { get; set; } = ""; [Reactive] public string PackVersion { get; set; } = ""; [Reactive] public string Artist { get; set; } = ""; [Reactive] public string Album { get; set; } = ""; [Reactive] public string Url { get; set; } = ""; - [Reactive] public double? Normalization { get; set; } - [Reactive] public bool? Dither { get; set; } + [Reactive] public bool IsMsuPcmProject { get; set; } + [Reactive] public double? Normalization { get; set; } + [Reactive] public DitherType DitherType { get; set; } + public bool HasSeenDitherWarning { get; set; } + [Reactive] public bool IncludeJson { get; set; } + + [Reactive] public TrackList TrackList { get; set; } + [Reactive] public bool WriteYamlFile { get; set; } [Reactive] public bool CreateAltSwapperScript { get; set; } - [Reactive] public bool CreateSplitSmz3Script { get; set; } + [Reactive] public bool IsSmz3Project { get; set; } + [Reactive] public bool CreateSplitSmz3Script { get; set; } [Reactive] public string? ZeldaMsuPath { get; set; } [Reactive] public string? MetroidMsuPath { get; set; } + [Reactive] public bool IsVisible { get; set; } = true; + + public void UpdateModel(MsuProject project) + { + Project = project; + MsuType = project.MsuTypeName; + + PackName = project.BasicInfo.PackName ?? ""; + PackCreator = project.BasicInfo.PackCreator ?? ""; + PackVersion = project.BasicInfo.PackVersion ?? ""; + Artist = project.BasicInfo.Artist ?? ""; + Album = project.BasicInfo.Album ?? ""; + Url = project.BasicInfo.Url ?? ""; + + CreateAltSwapperScript = project.BasicInfo.CreateAltSwapperScript; + CreateSplitSmz3Script = project.BasicInfo.CreateSplitSmz3Script; + TrackList = project.BasicInfo.TrackListType; + IsSmz3Project = project.BasicInfo.IsSmz3Project; + ZeldaMsuPath = project.BasicInfo.ZeldaMsuPath; + MetroidMsuPath = project.BasicInfo.MetroidMsuPath; + WriteYamlFile = project.BasicInfo.WriteYamlFile; + + IsMsuPcmProject = project.BasicInfo.IsMsuPcmProject; + Normalization = project.BasicInfo.Normalization; + DitherType = project.BasicInfo.DitherType; + HasSeenDitherWarning = project.BasicInfo.HasSeenDitherWarning; + IncludeJson = project.BasicInfo.IncludeJson ?? false; - [Reactive, ReactiveLinkedProperties(nameof(WriteTrackList))] public string TrackList { get; set; } = ""; - [Reactive] public bool WriteYamlFile { get; set; } - public DateTime LastModifiedDate { get; set; } - public bool WriteTrackList => TrackList != TrackListType.Disabled; + LastModifiedDate = project.BasicInfo.LastModifiedDate; + } public bool HasChangesSince(DateTime time) { @@ -51,8 +67,36 @@ public bool HasChangesSince(DateTime time) public override ViewModelBase DesignerExample() { - TrackList = TrackListType.List; + TrackList = TrackList.ListAlbumFirst; IsMsuPcmProject = true; + IsSmz3Project = true; return this; } + + public override void SaveChanges() + { + if (Project == null) return; + + Project.BasicInfo.PackName = PackName; + Project.BasicInfo.PackCreator = PackCreator; + Project.BasicInfo.PackVersion = PackVersion; + + Project.BasicInfo.Artist = Artist; + Project.BasicInfo.Album = Album; + Project.BasicInfo.Url = Url; + + Project.BasicInfo.CreateAltSwapperScript = CreateAltSwapperScript; + Project.BasicInfo.CreateSplitSmz3Script = CreateSplitSmz3Script; + Project.BasicInfo.TrackListType = TrackList; + Project.BasicInfo.IsSmz3Project = IsSmz3Project; + Project.BasicInfo.ZeldaMsuPath = ZeldaMsuPath; + Project.BasicInfo.MetroidMsuPath = MetroidMsuPath; + Project.BasicInfo.WriteYamlFile = WriteYamlFile; + + Project.BasicInfo.IsMsuPcmProject = IsMsuPcmProject; + Project.BasicInfo.Normalization = Normalization; + Project.BasicInfo.DitherType = DitherType; + Project.BasicInfo.HasSeenDitherWarning = HasSeenDitherWarning; + Project.BasicInfo.IncludeJson = IncludeJson; + } } \ No newline at end of file diff --git a/MSUScripter/ViewModels/MsuGenerationViewModel.cs b/MSUScripter/ViewModels/MsuGenerationViewModel.cs new file mode 100644 index 0000000..70ae53b --- /dev/null +++ b/MSUScripter/ViewModels/MsuGenerationViewModel.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using MSUScripter.Configs; +using ReactiveUI.Fody.Helpers; + +namespace MSUScripter.ViewModels; + +public class MsuGenerationViewModel : TranslatedViewModelBase +{ + [Reactive] public MsuProject MsuProject { get; set; } = new(); + [Reactive] public List Rows { get; set; } = []; + [Reactive] public int TotalSongs { get; set; } + [Reactive] public int SongsCompleted { get; set; } + [Reactive] public string ButtonText { get; set; } = "Cancel"; + [Reactive] public bool IsFinished { get; set; } + [Reactive] public int NumErrors { get; set; } + [Reactive] public List GenerationErrors { get; set; } = []; + public double GenerationSeconds { get; set; } + public string? ZipPath { get; set; } + + public override ViewModelBase DesignerExample() + { + return this; + } +} + +public enum MsuGenerationRowType +{ + Msu, + Song, + Yaml, + MsuPcmJson, + TrackList, + SwapperScript, + Smz3Zelda, + Smz3Metroid, + Smz3Script, + Smz3ZeldaYaml, + Smz3MetroidYaml, + Compress +} + +public class MsuGenerationRowViewModel : ViewModelBase +{ + [Reactive] public string Title { get; set; } + + [Reactive] public MsuSongInfo? SongInfo { get; set; } + + [Reactive] public string Path { get; set; } + [Reactive] public string PathDisplay { get; set; } = ""; + + [Reactive] public bool HasLoaded { get; set; } + + [Reactive] public bool HasWarning { get; set; } + + [Reactive] public string Message { get; set; } + + [Reactive] public bool CanParallelize { get; set; } = true; + + public MsuGenerationRowType Type { get; } + + public MsuGenerationRowViewModel(MsuSongInfo songInfo) + { + Type = MsuGenerationRowType.Song; + SongInfo = songInfo; + Title = $"Track #{songInfo.TrackNumber} - {System.IO.Path.GetFileName(songInfo.OutputPath)}"; + Path = songInfo.OutputPath ?? ""; + Message = "Waiting"; + SetPathDisplay(); + } + + public MsuGenerationRowViewModel(MsuGenerationRowType type, MsuProject project, string? path = null) + { + Type = type; + Title = type switch + { + MsuGenerationRowType.Msu => "MSU File", + MsuGenerationRowType.Yaml => "MSU Randomizer YAML", + MsuGenerationRowType.MsuPcmJson => "MsuPcm++ Tracks JSON File", + MsuGenerationRowType.TrackList => "Track List", + MsuGenerationRowType.SwapperScript => "Alt Track Swapper Script", + MsuGenerationRowType.Smz3Zelda => "A Link to the Past MSU", + MsuGenerationRowType.Smz3ZeldaYaml => "A Link to the Past MSU YAML", + MsuGenerationRowType.Smz3Metroid => "Super Metroid MSU", + MsuGenerationRowType.Smz3MetroidYaml => "Super Metroid MSU YAML", + MsuGenerationRowType.Smz3Script => "SMZ3 to Split ALttP & SM MSUs Script", + MsuGenerationRowType.Compress => "Compressing to ZIP File", + _ => throw new ArgumentOutOfRangeException(nameof(type), type, null) + }; + + Path = type switch + { + MsuGenerationRowType.Msu => project.MsuPath, + MsuGenerationRowType.Yaml => project.GetYamlPath(), + MsuGenerationRowType.MsuPcmJson => project.GetTracksJsonPath(), + MsuGenerationRowType.TrackList => project.GetTracksTextPath(), + MsuGenerationRowType.SwapperScript => project.GetAltSwapperPath(), + MsuGenerationRowType.Smz3Zelda => project.GetZeldaMsuPath(), + MsuGenerationRowType.Smz3ZeldaYaml => project.GetZeldaMsuYamlPath(), + MsuGenerationRowType.Smz3Metroid => project.GetMetroidMsuPath(), + MsuGenerationRowType.Smz3MetroidYaml => project.GetMetroidMsuYamlPath(), + MsuGenerationRowType.Smz3Script => project.GetSmz3SwapperPath(), + MsuGenerationRowType.Compress => path ?? "", + _ => throw new ArgumentOutOfRangeException(nameof(type), type, null) + }; + + if (type is MsuGenerationRowType.Compress or MsuGenerationRowType.Yaml or MsuGenerationRowType.Smz3MetroidYaml or MsuGenerationRowType.Smz3ZeldaYaml) + { + CanParallelize = false; + } + + Message = "Waiting"; + + SetPathDisplay(); + } + + private void SetPathDisplay() + { + if (Path.Length > 50) + { + PathDisplay = "..." + Path[^47..]; + } + else + { + PathDisplay = Path; + } + } + + public override ViewModelBase DesignerExample() + { + return this; + } +} \ No newline at end of file diff --git a/MSUScripter/ViewModels/MsuPcmGenerationViewModel.cs b/MSUScripter/ViewModels/MsuPcmGenerationViewModel.cs deleted file mode 100644 index 05d076d..0000000 --- a/MSUScripter/ViewModels/MsuPcmGenerationViewModel.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System.Collections.Generic; -using MSUScripter.Configs; -using ReactiveUI.Fody.Helpers; - -namespace MSUScripter.ViewModels; - -public class MsuPcmGenerationViewModel : ViewModelBase -{ - [Reactive] public MsuProjectViewModel MsuProjectViewModel { get; set; } = new(); - [Reactive] public MsuProject MsuProject { get; set; } = new(); - [Reactive] public List Rows { get; set; } = []; - [Reactive] public int TotalSongs { get; set; } - [Reactive] public int SongsCompleted { get; set; } - [Reactive] public string ButtonText { get; set; } = "Cancel"; - [Reactive] public bool IsFinished { get; set; } - [Reactive] public int NumErrors { get; set; } - [Reactive] public List GenerationErrors { get; set; } = []; - public bool ExportYaml { get; set; } - public bool SplitSmz3 { get; set; } - public double GenerationSeconds { get; set; } - - public override ViewModelBase DesignerExample() - { - return this; - } -} - -public class MsuPcmGenerationSongViewModel : ViewModelBase -{ - [Reactive] public string SongName { get; set; } = ""; - - [Reactive] public MsuSongInfoViewModel? OriginalViewModel { get; set; } - - [Reactive] public int TrackNumber { get; set; } - - [Reactive] public string TrackName { get; set; } = ""; - - [Reactive] public string Path { get; set; } = ""; - - [Reactive] public bool HasLoaded { get; set; } - - [Reactive] public bool HasWarning { get; set; } - - [Reactive] public string Message { get; set; } = ""; - public override ViewModelBase DesignerExample() - { - return this; - } -} \ No newline at end of file diff --git a/MSUScripter/ViewModels/MsuProjectViewModel.cs b/MSUScripter/ViewModels/MsuProjectViewModel.cs deleted file mode 100644 index 0dd1c5a..0000000 --- a/MSUScripter/ViewModels/MsuProjectViewModel.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Runtime.InteropServices; -using MSUScripter.Models; -using MSUScripter.Tools; - -namespace MSUScripter.ViewModels; - -public class MsuProjectViewModel -{ - public string ProjectFilePath { get; set; } = ""; - public string BackupFilePath { get; set; } = ""; - public string MsuPath { get; set; } = ""; - public string MsuTypeName { get; set; } = ""; - public DateTime LastSaveTime { get; set; } - public List IgnoreWarnings { get; set; } = new List(); - [SkipConvert] - public MsuBasicInfoViewModel BasicInfo { get; set; } = new(); - [SkipConvert] - public List Tracks { get; set; } = new(); - - public bool HasPendingChanges() - { - return HasChangesSince(LastSaveTime); - } - - public bool HasChangesSince(DateTime time) - { - return BasicInfo.HasChangesSince(time) || Tracks.Any(x => x.HasChangesSince(time)); - } -} \ No newline at end of file diff --git a/MSUScripter/ViewModels/MsuProjectWindowViewModel.cs b/MSUScripter/ViewModels/MsuProjectWindowViewModel.cs new file mode 100644 index 0000000..59ccb9a --- /dev/null +++ b/MSUScripter/ViewModels/MsuProjectWindowViewModel.cs @@ -0,0 +1,419 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using Avalonia.Media; +using AvaloniaControls.Models; +using Material.Icons; +using MSUScripter.Configs; +using MSUScripter.Models; +using ReactiveUI.Fody.Helpers; + +namespace MSUScripter.ViewModels; + +[SkipLastModified] +public class MsuProjectWindowViewModelTreeData : TranslatedViewModelBase +{ + public static IBrush HighlightColor { get; set; } = Brushes.SlateGray; + + [Reactive, SkipLastModified] public required MaterialIconKind CollapseIcon { get; set; } + [Reactive, SkipLastModified] public double CollapseIconOpacity { get; set; } = 1; + [Reactive, SkipLastModified] public MaterialIconKind CompletedIconKind { get; set; } = MaterialIconKind.FlagOutline; + [Reactive, SkipLastModified] public IBrush CompletedIconColor { get; set; } = Brushes.DimGray; + [Reactive, SkipLastModified] public MaterialIconKind HasSongIconKind { get; set; } = MaterialIconKind.VolumeSource; + [Reactive, SkipLastModified] public IBrush HasSongIconColor { get; set; } = Brushes.DimGray; + [Reactive, SkipLastModified] public MaterialIconKind CheckCopyrightIconKind { get; set; } = MaterialIconKind.Video; + [Reactive, SkipLastModified] public IBrush CheckCopyrightIconColor { get; set; } = Brushes.DimGray; + [Reactive, SkipLastModified] public MaterialIconKind CopyrightSafeIconKind { get; set; } = MaterialIconKind.Copyright; + [Reactive, SkipLastModified] public IBrush CopyrightSafeIconColor { get; set; } = Brushes.DimGray; + + [Reactive] public required string Name { get; set; } + public required int LeftSpacing { get; init; } + public required bool ShowCheckbox { get; init; } + [Reactive, SkipLastModified] public IBrush GridBackground { get; set; } = Brushes.Transparent; + [Reactive, SkipLastModified] public IBrush BorderColor { get; set; } = Brushes.Transparent; + + [Reactive, ReactiveLinkedProperties(nameof(IsVisible)), SkipLastModified] + public bool IsCollapsed { get; set; } + + [Reactive, ReactiveLinkedProperties(nameof(IsVisible)), SkipLastModified] + public bool IsFilteredOut { get; set; } + + public bool IsVisible => !IsCollapsed && !IsFilteredOut; + public int SortIndex { get; set; } + public int ParentIndex { get; init; } + + public bool MsuDetails { get; set; } + public bool Track { get; set; } + public bool Song => SongInfo != null; + public bool IsSongOrTrack => TrackInfo != null; + + [Reactive] public bool DisplayHasSongIcon { get; set; } + [Reactive] public bool DisplayCheckCopyrightIcon { get; set; } + [Reactive] public bool DisplayCopyrightSafeIcon { get; set; } + [Reactive] public bool DisplayIsCompleteIcon { get; set; } + [Reactive] public bool CanDelete { get; set; } + + [Reactive] public bool ShowAddButton { get; set; } + [Reactive] public bool ShowMenuButton { get; set; } + [Reactive] public bool IsComplete { get; set; } + [Reactive] public bool IsInTestVideo { get; set; } + [Reactive] public bool IsNotCopyrightTested { get; set; } + [Reactive] public bool IsNotCopyrightSafe { get; set; } + [Reactive] public bool IsCopyrightSafe { get; set; } + + public MsuTrackInfo? TrackInfo { get; set; } + public MsuSongInfo? SongInfo { get; set; } + public MsuProjectWindowViewModelTreeData? ParentTreeData { get; set; } + public List ChildTreeData { get; set; } = []; + + public override ViewModelBase DesignerExample() + { + throw new System.NotImplementedException(); + } + + public void ToggleAsParent(bool isParent, bool isCollapsed) + { + if (isParent) + { + CollapseIcon = isCollapsed ? MaterialIconKind.ChevronRight : MaterialIconKind.ChevronDown; + CollapseIconOpacity = 1; + + foreach (var item in ChildTreeData) + { + item.IsCollapsed = isCollapsed; + } + } + else + { + CollapseIcon = MaterialIconKind.MusicNote; + CollapseIconOpacity = 0.4; + } + } + + public void UpdateCompletedFlag() + { + if (ChildTreeData.Count > 0) + { + var numComplete = 0; + var numHasAudio = 0; + var numCopyrightSafe = 0; + var numCopyrightUnsafe = 0; + var numCheckCopyright = 0; + + foreach (var data in ChildTreeData.Select(x => x.SongInfo)) + { + if (data!.IsComplete) + { + numComplete++; + } + + if (data.HasAudioFiles()) + { + numHasAudio++; + } + + if (data.IsCopyrightSafe == true) + { + numCopyrightSafe++; + } + else if (data.IsCopyrightSafe == false) + { + numCopyrightUnsafe++; + } + + if (data.CheckCopyright == true) + { + numCheckCopyright++; + } + } + + if (numComplete == 0) + { + CompletedIconColor = Brushes.IndianRed; + CompletedIconKind = MaterialIconKind.FlagOutline; + } + else if (numComplete == ChildTreeData.Count) + { + CompletedIconColor = Brushes.LimeGreen; + CompletedIconKind = MaterialIconKind.Flag; + } + else + { + CompletedIconColor = Brushes.Goldenrod; + CompletedIconKind = MaterialIconKind.FlagOutline; + } + + if (numHasAudio == 0) + { + HasSongIconColor = Brushes.IndianRed; + HasSongIconKind = MaterialIconKind.VolumeMute; + } + else if (numHasAudio == ChildTreeData.Count) + { + HasSongIconColor = Brushes.LimeGreen; + HasSongIconKind = MaterialIconKind.VolumeSource; + } + else + { + HasSongIconColor = Brushes.Goldenrod; + HasSongIconKind = MaterialIconKind.VolumeMute; + } + + if (numCopyrightSafe == ChildTreeData.Count) + { + CopyrightSafeIconColor = Brushes.LimeGreen; + CopyrightSafeIconKind = MaterialIconKind.Copyright; + } + else if (numCopyrightUnsafe == ChildTreeData.Count) + { + CopyrightSafeIconColor = Brushes.IndianRed; + CopyrightSafeIconKind = MaterialIconKind.CloseCircleOutline; + } + else if (numCopyrightUnsafe + numCopyrightSafe == ChildTreeData.Count) + { + CopyrightSafeIconColor = Brushes.Goldenrod; + CopyrightSafeIconKind = MaterialIconKind.Copyright; + } + else + { + CopyrightSafeIconColor = Brushes.Goldenrod; + CopyrightSafeIconKind = MaterialIconKind.QuestionMarkCircleOutline; + } + + if (numCheckCopyright == 0) + { + CheckCopyrightIconColor = Brushes.IndianRed; + CheckCopyrightIconKind = MaterialIconKind.VideoOutline; + } + else if (numCheckCopyright == ChildTreeData.Count) + { + CheckCopyrightIconColor = Brushes.LimeGreen; + CheckCopyrightIconKind = MaterialIconKind.Video; + } + else + { + CheckCopyrightIconColor = Brushes.Goldenrod; + CheckCopyrightIconKind = MaterialIconKind.VideoOutline; + } + + IsComplete = false; + IsInTestVideo = false; + IsNotCopyrightTested = false; + IsNotCopyrightSafe = false; + IsCopyrightSafe = false; + } + else if (SongInfo == null) + { + CompletedIconColor = Brushes.DimGray; + CompletedIconKind = MaterialIconKind.FlagOutline; + HasSongIconColor = Brushes.DimGray; + HasSongIconKind = MaterialIconKind.VolumeMute; + CheckCopyrightIconColor = Brushes.DimGray; + CheckCopyrightIconKind = MaterialIconKind.VideoOutline; + CopyrightSafeIconColor = Brushes.DimGray; + CopyrightSafeIconKind = MaterialIconKind.Copyright; + + IsComplete = false; + IsInTestVideo = false; + IsNotCopyrightTested = false; + IsNotCopyrightSafe = false; + IsCopyrightSafe = false; + } + else + { + if (SongInfo.IsComplete) + { + CompletedIconColor = Brushes.LimeGreen; + CompletedIconKind = MaterialIconKind.Flag; + IsComplete = true; + } + else + { + CompletedIconColor = Brushes.IndianRed; + CompletedIconKind = MaterialIconKind.FlagOutline; + IsComplete = false; + } + + if (SongInfo.HasAudioFiles()) + { + HasSongIconColor = Brushes.LimeGreen; + HasSongIconKind = MaterialIconKind.VolumeSource; + } + else + { + HasSongIconColor = Brushes.IndianRed; + HasSongIconKind = MaterialIconKind.VolumeMute; + } + + if (SongInfo.CheckCopyright == true) + { + CheckCopyrightIconColor = Brushes.LimeGreen; + CheckCopyrightIconKind = MaterialIconKind.Video; + IsInTestVideo = true; + } + else + { + CheckCopyrightIconColor = Brushes.IndianRed; + CheckCopyrightIconKind = MaterialIconKind.VideoOutline; + IsInTestVideo = false; + } + + if (SongInfo.IsCopyrightSafe == true) + { + CopyrightSafeIconColor = Brushes.LimeGreen; + CopyrightSafeIconKind = MaterialIconKind.Copyright; + IsCopyrightSafe = false; + IsNotCopyrightSafe = false; + IsNotCopyrightTested = false; + } + else if (SongInfo.IsCopyrightSafe == false) + { + CopyrightSafeIconColor = Brushes.IndianRed; + CopyrightSafeIconKind = MaterialIconKind.CloseCircleOutline; + IsCopyrightSafe = false; + IsNotCopyrightSafe = true; + IsNotCopyrightTested = false; + } + else + { + CopyrightSafeIconColor = Brushes.Goldenrod; + CopyrightSafeIconKind = MaterialIconKind.QuestionMarkCircleOutline; + IsCopyrightSafe = false; + IsNotCopyrightSafe = false; + IsNotCopyrightTested = true; + } + } + } + + public bool MatchesFilter(string? filterText, bool filterOnlyTracksMissingSongs, bool filterOnlyIncomplete, bool filterOnlyMissingAudio, bool filterOnlyCopyrightUntested) + { + var matches = true; + + if (matches && filterText != null) + { + matches = Name.Contains(filterText, System.StringComparison.CurrentCultureIgnoreCase); + } + + if (matches && filterOnlyTracksMissingSongs) + { + matches = TrackInfo != null && SongInfo == null && ChildTreeData.Count == 0; + } + + if (matches && filterOnlyIncomplete) + { + matches = SongInfo != null && !IsComplete; + } + + if (matches && filterOnlyMissingAudio) + { + matches = SongInfo != null && !SongInfo.HasAudioFiles(); + } + + if (matches && filterOnlyCopyrightUntested) + { + matches = SongInfo != null && IsNotCopyrightTested; + } + + return matches; + } +} + +[SkipLastModified] +public class MsuProjectWindowViewModel : TranslatedViewModelBase +{ + public ObservableCollection TreeItems { get; set; } = []; + + [Reactive] public string SongSummary { get; set; } = ""; + [Reactive] public string TrackSummary { get; set; } = ""; + public string FilterText { get; set; } = ""; + public bool IsDraggingItem { get; set; } + + public bool IsViewingSongData => MsuSongViewModel.IsEnabled; + [Reactive, ReactiveLinkedProperties(nameof(HasSongButtonOpacity))] public bool DisplayHasSongIcon { get; set; } + [Reactive, ReactiveLinkedProperties(nameof(CheckCopyrightButtonOpacity))] public bool DisplayCheckCopyrightIcon { get; set; } + [Reactive, ReactiveLinkedProperties(nameof(CopyrightSafeButtonOpacity))] public bool DisplayCopyrightSafeIcon { get; set; } + [Reactive, ReactiveLinkedProperties(nameof(IsCompleteButtonOpacity))] public bool DisplayIsCompleteIcon { get; set; } + public double IsCompleteButtonOpacity => DisplayIsCompleteIcon ? 1 : 0.5; + public double HasSongButtonOpacity => DisplayHasSongIcon ? 1 : 0.5; + public double CheckCopyrightButtonOpacity => DisplayCheckCopyrightIcon ? 1 : 0.5; + public double CopyrightSafeButtonOpacity => DisplayCopyrightSafeIcon ? 1 : 0.5; + [Reactive] public MsuProjectWindowViewModelTreeData? SelectedTreeItem { get; set; } + [Reactive] public bool FilterOnlyTracksMissingSongs { get; set; } + [Reactive] public bool FilterOnlyIncomplete { get; set; } + [Reactive] public bool FilterOnlyMissingAudio { get; set; } + [Reactive] public bool FilterOnlyCopyrightUntested { get; set; } + [Reactive] public MaterialIconKind FilterEyeIcon { get; set; } = MaterialIconKind.Filter; + [Reactive] public string StatusBarText { get; set; } = "Loaded Project"; + [Reactive] public string WindowTitle { get; set; } = "MSU Scripter"; + + public MsuProjectWindowViewModelTreeData? CurrentTreeItem { get; set; } + public MsuProject? MsuProject { get; set; } + public MsuSongOuterPanelViewModel MsuSongViewModel { get; set; } = new(); + public MsuBasicInfoViewModel BasicInfoViewModel { get; set; } = new(); + [Reactive] public bool IsBusy { get; set; } = false; + public List RecentProjects { get; set; } = []; + public DefaultSongPanel DefaultSongPanel { get; set; } + public string? PreviousVideoPath { get; set; } + + public override ViewModelBase DesignerExample() + { + SongSummary = "2/5 Songs Complete"; + TrackSummary = "1/102 Tracks Complete"; + TreeItems = + [ + new MsuProjectWindowViewModelTreeData + { + CollapseIcon = MaterialIconKind.Note, + Name = "MSU Details", + LeftSpacing = 0, + ShowCheckbox = false, + }, + new MsuProjectWindowViewModelTreeData + { + CollapseIcon = MaterialIconKind.ChevronRight, + Name = "Scratch Pad", + LeftSpacing = 0, + ShowCheckbox = true, + TrackInfo = new MsuTrackInfo(), + }, + new MsuProjectWindowViewModelTreeData() + { + CollapseIcon = MaterialIconKind.ChevronDown, + Name = "#1 With a Really Long Name", + LeftSpacing = 0, + ShowCheckbox = true, + + TrackInfo = new MsuTrackInfo(), + }, + new MsuProjectWindowViewModelTreeData() + { + CollapseIcon = MaterialIconKind.MusicNote, + Name = "Song 1", + LeftSpacing = 12, + ShowCheckbox = true, + TrackInfo = new MsuTrackInfo(), + }, + ]; + + for (var i = 2; i < 102; i++) + { + TreeItems.Add(new MsuProjectWindowViewModelTreeData + { + CollapseIcon = MaterialIconKind.ChevronDown, + Name = $"#{i} Eastern Palace", + LeftSpacing = 0, + ShowCheckbox = true, + TrackInfo = new MsuTrackInfo(), + }); + } + + return this; + } + + public List GetSongsForVideo() + { + return MsuProject?.Tracks.Where(x => !x.IsScratchPad).SelectMany(x => x.Songs) + .Where(x => x.CheckCopyright == true).ToList() ?? []; + } +} \ No newline at end of file diff --git a/MSUScripter/ViewModels/MsuSongAdvancedPanelViewModel.cs b/MSUScripter/ViewModels/MsuSongAdvancedPanelViewModel.cs new file mode 100644 index 0000000..6be9ee1 --- /dev/null +++ b/MSUScripter/ViewModels/MsuSongAdvancedPanelViewModel.cs @@ -0,0 +1,846 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.IO; +using System.Linq; +using Avalonia.Controls; +using Avalonia.Media; +using AvaloniaControls.Models; +using Material.Icons; +using MSUScripter.Configs; +using MSUScripter.Models; +using ReactiveUI.Fody.Helpers; + +namespace MSUScripter.ViewModels; + +public class MsuSongAdvancedPanelViewModel : SavableViewModelBase +{ + [Reactive, SkipLastModified] public bool IsScratchPad { get; set; } + + [Reactive] public string? SongName { get; set; } + + [Reactive] public string? ArtistName { get; set; } + + [Reactive] public string? Album { get; set; } + + [Reactive] public string? Url { get; set; } + + [Reactive] public bool IsAlt { get; set; } + [Reactive] public bool? CheckCopyright { get; set; } + + [Reactive] public bool? IsCopyrightSafe { get; set; } + + [Reactive, SkipLastModified] public bool IsEnabled { get; set; } + [Reactive, SkipLastModified] public bool IsAdvancedMode { get; set; } = true; + + [Reactive] public int? Loop { get; set; } + + [Reactive] public int? TrimStart { get; set; } + + [Reactive] public int? TrimEnd { get; set; } + + [Reactive] public int? FadeIn { get; set; } + + [Reactive] public int? FadeOut { get; set; } + + [Reactive] public int? CrossFade { get; set; } + + [Reactive] public int? PadStart { get; set; } + + [Reactive] public int? PadEnd { get; set; } + + [Reactive] public double? Tempo { get; set; } + + [Reactive] public double? Normalization { get; set; } + + [Reactive] public bool? Compression { get; set; } + + [Reactive] public bool? Dither { get; set; } + [Reactive] public bool ShowDither { get; set; } + + [Reactive] public string? Output { get; set; } + + [Reactive, ReactiveLinkedProperties(nameof(CanPressPyMusicLooperButton))] public string? Input { get; set; } + + [Reactive, SkipLastModified] public bool DisplayWarning { get; set; } + [Reactive, SkipLastModified] public string WarningText { get; set; } = string.Empty; + [Reactive, SkipLastModified] public string WarningToolTip { get; set; } = string.Empty; + [Reactive, SkipLastModified] public bool DisplayError { get; set; } + [Reactive, SkipLastModified] public string ErrorText { get; set; } = string.Empty; + [Reactive, SkipLastModified] public string ErrorToolTip { get; set; } = string.Empty; + + [Reactive, SkipLastModified] public bool DisplayOutputPcmFile { get; set; } + [Reactive, SkipLastModified] public bool CanUpdateOutputPcmFile { get; set; } + [Reactive, SkipLastModified, ReactiveLinkedProperties(nameof(CanPressPyMusicLooperButton))] public bool IsGeneratingPcmFile { get; set; } + [Reactive, SkipLastModified] public bool ShowMsuPcmInfo { get; set; } + public MsuProject Project { get; set; } = null!; + + public MsuSongAdvancedPanelViewModelModelTreeData CurrentTreeItem { get; set; } = null!; + + public MsuSongAdvancedPanelViewModelModelTreeData? DraggedItem => _draggedItem; + public bool IsDraggingItem => _draggedItem != null; + + public ObservableCollection TreeItems { get; set; } = []; + [Reactive, SkipLastModified] public MsuSongAdvancedPanelViewModelModelTreeData? SelectedTreeItem { get; set; } + public bool CanPressPyMusicLooperButton => !string.IsNullOrEmpty(Input) && !IsGeneratingPcmFile; + + public ContextMenu? CurrentContextMenu { get; set; } + + public event EventHandler? ViewModelUpdated; + public event EventHandler? FileDragDropped; + + public MsuSongInfo? CurrentSongInfo { get; private set; } + private MsuSongMsuPcmInfo? _currentSongMsuPcmInfo; + private MsuProjectWindowViewModelTreeData? _treeData; + private MsuSongAdvancedPanelViewModelModelTreeData? _hoveredItem; + private MsuSongAdvancedPanelViewModelModelTreeData? _draggedItem; + private bool _isTopLevelMsuPcmInfo; + private bool _updatingModel; + + public MsuSongAdvancedPanelViewModel() + { + PropertyChanged += OnPropertyChanged; + return; + + void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (_updatingModel) return; + if (e.PropertyName == nameof(SongName) && _treeData is { ParentTreeData: not null } && !string.IsNullOrEmpty(SongName)) + { + _treeData.Name = SongName ?? "Test"; + } + else if (e.PropertyName is nameof(CheckCopyright) or nameof(IsCopyrightSafe)) + { + SaveChanges(); + _treeData?.UpdateCompletedFlag(); + _treeData?.ParentTreeData?.UpdateCompletedFlag(); + } + } + } + + public void UpdateViewModel(MsuProject project, MsuTrackInfo trackInfo, MsuSongInfo songInfo, MsuProjectWindowViewModelTreeData treeData) + { + Project = project; + _currentSongMsuPcmInfo = null; + _updatingModel = true; + CurrentSongInfo = songInfo; + _treeData = treeData; + + SongName = songInfo.SongName; + ArtistName = songInfo.Artist; + Album = songInfo.Album; + Url = songInfo.Url; + CheckCopyright = songInfo.CheckCopyright; + IsCopyrightSafe = songInfo.IsCopyrightSafe; + IsScratchPad = trackInfo.IsScratchPad; + Dither = songInfo.MsuPcmInfo.Dither; + + TreeItems.Clear(); + AddTreeItem(songInfo.MsuPcmInfo, 0, false, 0, 0, -1, null); + SetSelectedTreeData(TreeItems[0]); + + IsAdvancedMode = true; + IsEnabled = true; + HasBeenModified = false; + _updatingModel = false; + LastModifiedDate = songInfo.LastModifiedDate; + ViewModelUpdated?.Invoke(this, EventArgs.Empty); + } + + private int AddTreeItem(MsuSongMsuPcmInfo msuPcmInfo, int level, bool isChannel, int index, int sortIndex, int parentSortIndex, MsuSongAdvancedPanelViewModelModelTreeData? parentTreeData) + { + var currentIndex = sortIndex; + + string name; + if (level == 0) + { + name = "Top Level"; + } + else if (isChannel) + { + var fileName = string.IsNullOrEmpty(msuPcmInfo.File) ? "" : Path.GetFileName(msuPcmInfo.File); + name = $"SC{index + 1} {fileName}"; + } + else + { + var fileName = string.IsNullOrEmpty(msuPcmInfo.File) ? "" : Path.GetFileName(msuPcmInfo.File); + name = $"ST{index + 1} {fileName}"; + } + + var mainTreeData = new MsuSongAdvancedPanelViewModelModelTreeData() + { + Name = name, + Level = level, + IsVisible = true, + ChevronIcon = MaterialIconKind.ChevronDown, + SortIndex = currentIndex, + MsuPcmInfo = msuPcmInfo, + ParentIndex = parentSortIndex, + ShowOutput = level == 0, + SongInfo = CurrentSongInfo, + ParentTreeData = parentTreeData, + IsSubChannel = parentTreeData != null && isChannel, + IsSubTrack = parentTreeData != null && !isChannel, + }; + + TreeItems.Insert(sortIndex, mainTreeData); + if (TreeItems.Count == 1) + { + SelectedTreeItem = TreeItems[0]; + } + if (parentTreeData != null) + { + parentTreeData.ChildrenTreeData.Insert(index, mainTreeData); + } + + currentIndex++; + + var subTrackTreeData = new MsuSongAdvancedPanelViewModelModelTreeData() + { + Name = "Sub Tracks", + Level = level + 1, + IsVisible = true, + ChevronIcon = MaterialIconKind.ChevronDown, + SortIndex = currentIndex, + ParentIndex = sortIndex, + ParentTreeData = mainTreeData, + IsSubTrack = true, + }; + + mainTreeData.ChildrenTreeData.Add(subTrackTreeData); + TreeItems.Insert(currentIndex, subTrackTreeData); + + currentIndex++; + + for (var i = 0; i < msuPcmInfo.SubTracks.Count; i++) + { + currentIndex = AddTreeItem(msuPcmInfo.SubTracks[i], level + 2, false, i, currentIndex, sortIndex, subTrackTreeData); + } + + var subChannelTreeData = new MsuSongAdvancedPanelViewModelModelTreeData() + { + Name = "Sub Channels", + Level = level + 1, + IsVisible = true, + ChevronIcon = MaterialIconKind.ChevronDown, + SortIndex = currentIndex, + ParentIndex = sortIndex, + ParentTreeData = mainTreeData, + IsSubChannel = true, + }; + + mainTreeData.ChildrenTreeData.Add(subChannelTreeData); + TreeItems.Insert(currentIndex, subChannelTreeData); + + currentIndex++; + + for (var i = 0; i < msuPcmInfo.SubChannels.Count; i++) + { + currentIndex = AddTreeItem(msuPcmInfo.SubChannels[i], level + 2, true, i, currentIndex, sortIndex, subChannelTreeData); + } + + return currentIndex; + } + + public void SetSelectedTreeData(MsuSongAdvancedPanelViewModelModelTreeData treeData) + { + if (_currentSongMsuPcmInfo != null) + { + SaveChanges(); + } + + if (treeData.MsuPcmInfo == null) + { + _currentSongMsuPcmInfo = null; + ShowMsuPcmInfo = false; + return; + } + + ShowMsuPcmInfo = true; + + var currentLastModifiedTime = LastModifiedDate; + + Loop = treeData.MsuPcmInfo.Loop; + TrimStart = treeData.MsuPcmInfo.TrimStart; + TrimEnd = treeData.MsuPcmInfo.TrimEnd; + FadeIn = treeData.MsuPcmInfo.FadeIn; + FadeOut = treeData.MsuPcmInfo.FadeOut; + CrossFade = treeData.MsuPcmInfo.CrossFade; + PadStart = treeData.MsuPcmInfo.PadStart; + PadEnd = treeData.MsuPcmInfo.PadEnd; + Tempo = treeData.MsuPcmInfo.Tempo; + Normalization = treeData.MsuPcmInfo.Normalization; + Compression = treeData.MsuPcmInfo.Compression; + _isTopLevelMsuPcmInfo = treeData.ParentIndex < 0; + if (_isTopLevelMsuPcmInfo) + { + Output = CurrentSongInfo?.OutputPath ?? treeData.MsuPcmInfo.Output; + ShowDither = Project.BasicInfo.DitherType is DitherType.DefaultOff or DitherType.DefaultOn; + } + else + { + Output = ""; + ShowDither = false; + } + Input = treeData.MsuPcmInfo.File; + DisplayOutputPcmFile = treeData.ShowOutput && !IsScratchPad; + CanUpdateOutputPcmFile = treeData.SongInfo?.IsAlt == true; + _currentSongMsuPcmInfo = treeData.MsuPcmInfo; + CurrentTreeItem = treeData; + + LastModifiedDate = currentLastModifiedTime; + } + + public override void SaveChanges() + { + if (CurrentSongInfo == null || _currentSongMsuPcmInfo == null) return; + CurrentSongInfo.SongName = SongName; + CurrentSongInfo.Artist = ArtistName; + CurrentSongInfo.Album = Album; + CurrentSongInfo.Url = Url; + CurrentSongInfo.CheckCopyright = CheckCopyright; + CurrentSongInfo.IsCopyrightSafe = IsCopyrightSafe; + _currentSongMsuPcmInfo.Loop = Loop; + _currentSongMsuPcmInfo.TrimStart = TrimStart; + _currentSongMsuPcmInfo.TrimEnd = TrimEnd; + _currentSongMsuPcmInfo.FadeIn = FadeIn; + _currentSongMsuPcmInfo.FadeOut = FadeOut; + _currentSongMsuPcmInfo.CrossFade = CrossFade; + _currentSongMsuPcmInfo.PadStart = PadStart; + _currentSongMsuPcmInfo.PadEnd = PadEnd; + _currentSongMsuPcmInfo.Tempo = Tempo; + _currentSongMsuPcmInfo.Normalization = Normalization; + _currentSongMsuPcmInfo.Compression = Compression; + _currentSongMsuPcmInfo.Dither = Dither; + + if (_isTopLevelMsuPcmInfo) + { + CurrentSongInfo.OutputPath = CurrentSongInfo.OutputPath; + _currentSongMsuPcmInfo.Output = CurrentSongInfo.OutputPath; + } + + _currentSongMsuPcmInfo.File = Input; + HasBeenModified = false; + } + + public bool UpdateDrag(MsuSongAdvancedPanelViewModelModelTreeData? treeData) + { + var dragCompleted = false; + + if (_hoveredItem != null) + { + _hoveredItem.GridBackground = Brushes.Transparent; + _hoveredItem.BorderColor = Brushes.Transparent; + } + + if (treeData == null) + { + if (_draggedItem != null && _hoveredItem != null && _draggedItem != _hoveredItem && !(_draggedItem.ParentIndex > 0 && _hoveredItem.ParentIndex > 0 && + _draggedItem.ParentIndex == _hoveredItem.ParentIndex && + _draggedItem.SortIndex == _hoveredItem.SortIndex + 1)) + { + dragCompleted = HandleDragged(_draggedItem, _hoveredItem); + } + + _hoveredItem = null; + _draggedItem = null; + } + else if (treeData.SongInfo != null) + { + _hoveredItem = null; + _draggedItem = treeData; + } + + return dragCompleted; + } + + public void UpdateHover(MsuSongAdvancedPanelViewModelModelTreeData? treeData) + { + if (_hoveredItem != null) + { + _hoveredItem.GridBackground = Brushes.Transparent; + _hoveredItem.BorderColor = Brushes.Transparent; + } + + _hoveredItem = treeData; + + if (treeData != null) + { + treeData.BorderColor = MsuSongAdvancedPanelViewModelModelTreeData.HighlightColor; + } + } + + public void RemoveMsuPcmInfo(MsuSongAdvancedPanelViewModelModelTreeData treeData) + { + if (treeData.ParentTreeData != null) + { + RemoveFromTree(treeData); + treeData.ParentTreeData?.ChildrenTreeData.Remove(treeData); + if (treeData is { IsSubChannel: true, MsuPcmInfo: not null }) + { + treeData.ParentTreeData?.ParentTreeData?.MsuPcmInfo?.SubChannels.Remove(treeData.MsuPcmInfo); + } + else if (treeData is { IsSubTrack: true, MsuPcmInfo: not null }) + { + treeData.ParentTreeData?.ParentTreeData?.MsuPcmInfo?.SubTracks.Remove(treeData.MsuPcmInfo); + } + UpdateSortIndexes(); + } + else + { + var newMsuPcmInfo = new MsuSongMsuPcmInfo() + { + Output = treeData.MsuPcmInfo?.Output + }; + TreeItems.Clear(); + AddTreeItem(newMsuPcmInfo, 0, false, 0, 0, -1, null); + SetSelectedTreeData(TreeItems[0]); + } + } + + public void ReplaceMsuPcmInfo(MsuSongAdvancedPanelViewModelModelTreeData treeData, MsuSongMsuPcmInfo pcmInfo) + { + var index = treeData.ParentTreeData?.ChildrenTreeData.IndexOf(treeData) ?? 0; + RemoveFromTree(treeData); + + if (treeData.ParentTreeData?.ParentTreeData?.MsuPcmInfo != null) + { + if (treeData.ParentTreeData.Name == "Sub Channels") + { + if (treeData.MsuPcmInfo != null) + { + treeData.ParentTreeData.ParentTreeData.MsuPcmInfo.SubChannels.Remove(treeData.MsuPcmInfo); + } + treeData.ParentTreeData.ParentTreeData.MsuPcmInfo.SubChannels.Insert(0, pcmInfo); + } + else + { + if (treeData.MsuPcmInfo != null) + { + treeData.ParentTreeData.ParentTreeData.MsuPcmInfo.SubTracks.Remove(treeData.MsuPcmInfo); + } + treeData.ParentTreeData.ParentTreeData.MsuPcmInfo.SubTracks.Insert(0, pcmInfo); + } + treeData.ParentTreeData?.ChildrenTreeData.Remove(treeData); + } + else if (CurrentSongInfo != null) + { + CurrentSongInfo.MsuPcmInfo = pcmInfo; + _currentSongMsuPcmInfo = pcmInfo; + } + + _currentSongMsuPcmInfo = null; + AddTreeItem(pcmInfo, treeData.Level, treeData.ParentTreeData?.Name == "Sub Channels", index, treeData.SortIndex, treeData.ParentIndex, treeData.ParentTreeData); + UpdateSortIndexes(); + SetSelectedTreeData(TreeItems.First(x => x.MsuPcmInfo == pcmInfo)); + } + + public void DragDropFile(string fileName) + { + Input = fileName; + FileDragDropped?.Invoke(this, EventArgs.Empty); + } + + private bool HandleDragged(MsuSongAdvancedPanelViewModelModelTreeData from, MsuSongAdvancedPanelViewModelModelTreeData to) + { + var parentTreeData = to.MsuPcmInfo == null ? to : to.ParentTreeData; + var parent = parentTreeData?.ParentTreeData?.MsuPcmInfo; + var destinationIndex = to.MsuPcmInfo == null ? 0 : to.ParentTreeData?.ChildrenTreeData.IndexOf(to) + 1; + + if (parentTreeData == null || parent == null || from.MsuPcmInfo == null) + { + return false; + } + + var currentToNode = to.ParentTreeData; + while (currentToNode != null) + { + if (currentToNode == from) + { + return false; + } + + currentToNode = currentToNode.ParentTreeData; + } + + var updatedIndex = parent.MoveSubInfo(from.MsuPcmInfo, parentTreeData.IsSubTrack, destinationIndex ?? 0, from.ParentTreeData?.ParentTreeData?.MsuPcmInfo); + + RemoveFromTree(from); + InsertAfter(from, to); + + from.ParentTreeData?.ChildrenTreeData.Remove(from); + parentTreeData.ChildrenTreeData.Insert(updatedIndex, from); + from.ParentTreeData = parentTreeData; + from.IsSubChannel = parentTreeData.IsSubChannel; + from.IsSubTrack = parentTreeData.IsSubTrack; + + UpdateTabbing(from); + UpdateSortIndexes(); + + SelectedTreeItem = from; + LastModifiedDate = DateTime.Now; + return true; + } + + private void UpdateTabbing(MsuSongAdvancedPanelViewModelModelTreeData data) + { + data.Level = (data.ParentTreeData?.Level ?? 0) + 1; + foreach (var child in data.ChildrenTreeData) + { + UpdateTabbing(child); + } + } + + private void RemoveFromTree(MsuSongAdvancedPanelViewModelModelTreeData data) + { + TreeItems.Remove(data); + foreach (var item in data.ChildrenTreeData) + { + RemoveFromTree(item); + } + } + + private void InsertAfter(MsuSongAdvancedPanelViewModelModelTreeData from, + MsuSongAdvancedPanelViewModelModelTreeData to) + { + var insertIndex = TreeItems.IndexOf(to) + 1; + if (to.MsuPcmInfo != null) + { + while (insertIndex < TreeItems.Count && TreeItems[insertIndex].Level > to.Level) + { + insertIndex++; + } + } + + InsertAt(insertIndex, from); + + return; + + int InsertAt(int index, MsuSongAdvancedPanelViewModelModelTreeData item) + { + TreeItems.Insert(index, item); + index++; + + foreach (var child in item.ChildrenTreeData) + { + index = InsertAt(index, child); + } + + return index; + + } + } + + private void UpdateSortIndexes() + { + var index = 0; + foreach (var item in TreeItems) + { + item.SortIndex = index; + item.ParentIndex = item.ParentTreeData?.SortIndex ?? -1; + UpdateTreeItemName(item); + index++; + } + } + + public void UpdateTreeItemName(MsuSongAdvancedPanelViewModelModelTreeData? item = null) + { + item ??= CurrentTreeItem; + if (item.ParentTreeData?.IsSubTrack == true && item.MsuPcmInfo != null) + { + var subTrackIndex = item.ParentTreeData.ChildrenTreeData.IndexOf(item) + 1; + var fileName = string.IsNullOrEmpty(item.MsuPcmInfo?.File) + ? "" + : Path.GetFileName(item.MsuPcmInfo?.File); + item.Name = $"ST{subTrackIndex} {fileName}"; + } + else if (item.ParentTreeData?.IsSubChannel == true && item.MsuPcmInfo != null) + { + var subTrackIndex = item.ParentTreeData.ChildrenTreeData.IndexOf(item) + 1; + var fileName = string.IsNullOrEmpty(item.MsuPcmInfo?.File) + ? "" + : Path.GetFileName(item.MsuPcmInfo?.File); + item.Name = $"SC{subTrackIndex} {fileName}"; + } + } + + public MsuSongAdvancedPanelViewModelModelTreeData AddMsuPcmInfo(MsuSongAdvancedPanelViewModelModelTreeData to) + { + var parentTreeData = to.MsuPcmInfo == null ? to : to.ParentTreeData; + var destinationIndex = to.MsuPcmInfo == null ? 0 : to.ParentTreeData?.ChildrenTreeData.IndexOf(to) + 1; + + if (parentTreeData == null || destinationIndex == null) + { + throw new InvalidOperationException(); + } + + var newMsuPcmInfo = new MsuSongMsuPcmInfo(); + + var newData = new MsuSongAdvancedPanelViewModelModelTreeData() + { + Name = "Temp", + Level = parentTreeData.Level + 1, + IsVisible = true, + ChevronIcon = MaterialIconKind.ChevronDown, + SortIndex = parentTreeData.SortIndex + destinationIndex.Value, + MsuPcmInfo = newMsuPcmInfo, + ParentIndex = parentTreeData.SortIndex, + ShowOutput = false, + SongInfo = CurrentSongInfo, + ParentTreeData = parentTreeData, + IsSubChannel = parentTreeData.IsSubChannel, + IsSubTrack = parentTreeData.IsSubTrack, + }; + + newData.ChildrenTreeData.Add(new MsuSongAdvancedPanelViewModelModelTreeData() + { + Name = "Sub Tracks", + Level = parentTreeData.Level + 2, + IsVisible = true, + ChevronIcon = MaterialIconKind.ChevronDown, + SortIndex = parentTreeData.SortIndex + destinationIndex.Value + 1, + ParentIndex = parentTreeData.SortIndex + destinationIndex.Value, + ParentTreeData = newData, + IsSubTrack = true, + }); + + newData.ChildrenTreeData.Add(new MsuSongAdvancedPanelViewModelModelTreeData() + { + Name = "Sub Channels", + Level = parentTreeData.Level + 2, + IsVisible = true, + ChevronIcon = MaterialIconKind.ChevronDown, + SortIndex = parentTreeData.SortIndex + destinationIndex.Value + 1, + ParentIndex = parentTreeData.SortIndex + destinationIndex.Value, + ParentTreeData = newData, + IsSubChannel = true + }); + + InsertAfter(newData, to); + + parentTreeData.ChildrenTreeData.Insert(destinationIndex.Value, newData); + newData.ParentTreeData = parentTreeData; + + UpdateTabbing(newData); + UpdateSortIndexes(); + + if (parentTreeData.IsSubTrack) + { + parentTreeData.ParentTreeData?.MsuPcmInfo?.SubTracks.Insert(destinationIndex.Value, newMsuPcmInfo); + } + else if (parentTreeData.IsSubChannel) + { + parentTreeData.ParentTreeData?.MsuPcmInfo?.SubChannels.Insert(destinationIndex.Value, newMsuPcmInfo); + } + + SelectedTreeItem = newData; + + return newData; + } + + public void UpdateTrackWarnings(bool sampleRateWarning, bool multiWarning, bool dualTypes, bool soloSubChannel, bool hasSameParentChildType, bool hasIgnoredFile) + { + List<(string, string)> warnings = []; + List<(string, string)> errors = []; + + if (sampleRateWarning) + { + warnings.Add(("Non-44100Hz File", "This is a non-44100Hz file. If using Audacity or another editor, make sure the project sample rate matches the audio source rate.")); + } + + if (multiWarning) + { + warnings.Add(("Multiple Input Files", "When there are multiple input files via sub tracks or sub channels, combined audio operations are in a 44100Hz sample rate.")); + } + + if (hasIgnoredFile) + { + warnings.Add(("Ignored File Found", "When a level has sub tracks or sub channels, that level's file is ignored.")); + } + + if (dualTypes) + { + errors.Add(("Simultaneous Sub Channel and Sub Track", "There is at least one sub channel and subtrack on the same level.")); + } + + if (soloSubChannel) + { + errors.Add(("Solo Sub Channel", "When using sub channels, you must have at least two of them.")); + } + + if (hasSameParentChildType) + { + errors.Add(("Same Parent/Child Types", "You must alternate between sub channel and sub tracks when having multiple levels.")); + } + + DisplayWarning = warnings.Count > 0; + WarningText = warnings.Count == 1 ? warnings[0].Item1 : $"{warnings.Count} Warnings"; + WarningToolTip = string.Join("\n", warnings.Select(w => $"{w.Item1} - {w.Item2}")); + + DisplayError = errors.Count > 0; + ErrorText = errors.Count == 1 ? errors[0].Item1 : $"{errors.Count} Errors"; + ErrorToolTip = string.Join("\n", errors.Select(w => $"{w.Item1} - {w.Item2}")); + } + + public override ViewModelBase DesignerExample() + { + return new MsuSongAdvancedPanelViewModel() + { + DisplayWarning = true, + DisplayError = true, + WarningText = "2 Warnings", + ErrorText = "2 Errors", + TreeItems = + [ + new MsuSongAdvancedPanelViewModelModelTreeData() + { + Name = "Top Level", + Level = 0, + IsVisible = true, + ChevronIcon = MaterialIconKind.ChevronDown, + }, + new MsuSongAdvancedPanelViewModelModelTreeData() + { + Name = "Sub Tracks", + Level = 1, + IsVisible = true, + ChevronIcon = MaterialIconKind.ChevronDown, + }, + new MsuSongAdvancedPanelViewModelModelTreeData() + { + Name = "test.mp4", + Level = 2, + IsVisible = true, + ChevronIcon = MaterialIconKind.ChevronDown, + }, + new MsuSongAdvancedPanelViewModelModelTreeData() + { + Name = "Sub Tracks", + Level = 3, + IsVisible = true, + ChevronIcon = MaterialIconKind.ChevronDown, + }, + new MsuSongAdvancedPanelViewModelModelTreeData() + { + Name = "Sub Channels", + Level = 3, + IsVisible = true, + ChevronIcon = MaterialIconKind.ChevronDown, + }, + new MsuSongAdvancedPanelViewModelModelTreeData() + { + Name = "test.mp4", + Level = 2, + IsVisible = true, + ChevronIcon = MaterialIconKind.ChevronDown, + }, + new MsuSongAdvancedPanelViewModelModelTreeData() + { + Name = "Sub Tracks", + Level = 3, + IsVisible = true, + ChevronIcon = MaterialIconKind.ChevronDown, + }, + new MsuSongAdvancedPanelViewModelModelTreeData() + { + Name = "Sub Channels", + Level = 3, + IsVisible = true, + ChevronIcon = MaterialIconKind.ChevronDown, + }, + new MsuSongAdvancedPanelViewModelModelTreeData() + { + Name = "Sub Channels", + Level = 1, + IsVisible = true, + ChevronIcon = MaterialIconKind.ChevronDown, + }, + new MsuSongAdvancedPanelViewModelModelTreeData() + { + Name = "1 test.mp4", + Level = 2, + IsVisible = true, + ChevronIcon = MaterialIconKind.ChevronDown, + }, + new MsuSongAdvancedPanelViewModelModelTreeData() + { + Name = "Sub Tracks", + Level = 3, + IsVisible = true, + ChevronIcon = MaterialIconKind.ChevronDown, + }, + new MsuSongAdvancedPanelViewModelModelTreeData() + { + Name = "Sub Channels", + Level = 3, + IsVisible = true, + ChevronIcon = MaterialIconKind.ChevronDown, + }, + ] + }; + } +} + +[SkipLastModified] +public class MsuSongAdvancedPanelViewModelModelTreeData : TranslatedViewModelBase +{ + public static IBrush HighlightColor { get; set; } = Brushes.SlateGray; + + [Reactive] public required MaterialIconKind ChevronIcon { get; set; } + + [Reactive] public required string Name { get; set; } + + [ReactiveLinkedProperties(nameof(LeftSpacing))] + public required int Level { get; set; } + + [Reactive] public IBrush GridBackground { get; set; } = Brushes.Transparent; + [Reactive] public IBrush BorderColor { get; set; } = Brushes.Transparent; + [Reactive] public bool IsVisible { get; set; } = true; + [Reactive] public bool ShowAddButton { get; set; } + [Reactive] public bool ShowMenuButton { get; set; } + [Reactive] public bool EnableMenuItems { get; set; } + public int LeftSpacing => Level * 12; + public int SortIndex { get; set; } + public int ParentIndex { get; set; } + public bool IsSubTrack { get; set; } + public bool IsSubChannel { get; set; } + public MsuSongMsuPcmInfo? MsuPcmInfo { get; set; } + + public MsuTrackInfo? TrackInfo { get; set; } + public MsuSongInfo? SongInfo { get; set; } + + public bool IsCollapsed { get; set; } + public bool ShowOutput { get; set; } + + + public MsuSongAdvancedPanelViewModelModelTreeData? ParentTreeData { get; set; } + public List ChildrenTreeData { get; set; } = []; + + public override ViewModelBase DesignerExample() + { + return new MsuSongAdvancedPanelViewModel(); + } + + public void ToggleCollapsed(bool? newVal = null) + { + IsCollapsed = newVal ?? !IsCollapsed; + + foreach (var child in ChildrenTreeData) + { + child.UpdateVisibility(!IsCollapsed); + } + } + + private void UpdateVisibility(bool newValue) + { + IsVisible = newValue; + + var childVisibility = IsVisible && !IsCollapsed; + + foreach (var child in ChildrenTreeData) + { + child.UpdateVisibility(childVisibility); + } + } +} \ No newline at end of file diff --git a/MSUScripter/ViewModels/MsuSongBasicPanelViewModel.cs b/MSUScripter/ViewModels/MsuSongBasicPanelViewModel.cs new file mode 100644 index 0000000..e75ba51 --- /dev/null +++ b/MSUScripter/ViewModels/MsuSongBasicPanelViewModel.cs @@ -0,0 +1,189 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using AvaloniaControls.Models; +using MSUScripter.Configs; +using MSUScripter.Models; +using MSUScripter.Text; +using ReactiveUI.Fody.Helpers; + +namespace MSUScripter.ViewModels; + +public class MsuSongBasicPanelViewModel : SavableViewModelBase +{ + [Reactive, SkipLastModified] public bool IsScratchPad { get; set; } + + [Reactive] public string? SongName { get; set; } + + [Reactive] public string? ArtistName { get; set; } + + [Reactive] public string? Album { get; set; } + + [Reactive] public string? Url { get; set; } + + [Reactive] public string? OutputFilePath { get; set; } + + [Reactive] public bool IsAlt { get; set; } + + [Reactive, ReactiveLinkedProperties(nameof(HasSelectedInputFile))] public string? InputFilePath { get; set; } + + [Reactive, SkipLastModified] public bool PyMusicLooperRunning { get; set; } + [Reactive, SkipLastModified] public bool CanUpdatePcmFile { get; set; } + [Reactive] public int? TrimStart { get; set; } + [Reactive] public int? TrimEnd { get; set; } + [Reactive] public int? LoopPoint { get; set; } + [Reactive] public double? Normalization { get; set; } + + [Reactive] public bool? CheckCopyright { get; set; } + + [Reactive] public bool? IsCopyrightSafe { get; set; } + [Reactive, SkipLastModified] public bool DisplayOutputFile { get; set; } = true; + [Reactive, SkipLastModified] public bool DisplayInputFile { get; set; } = true; + [Reactive, SkipLastModified] public bool IsEnabled { get; set; } = true; + [Reactive, SkipLastModified] public bool IsAdvancedMode { get; set; } + [Reactive, SkipLastModified, ReactiveLinkedProperties(nameof(DisplayPyMusicLooperPanel))] public bool EnableMsuPcm { get; set; } = true; + [Reactive, SkipLastModified, ReactiveLinkedProperties(nameof(DisplayPyMusicLooperPanel))] public bool PyMusicLooperEnabled { get; set; } = true; + [Reactive, SkipLastModified] public int InputColumnSpan { get; set; } = 2; + [Reactive, SkipLastModified] public int OutputColumn { get; set; } = 2; + [Reactive, SkipLastModified] public int OutputColumnSpan { get; set; } = 2; + [Reactive, SkipLastModified] public bool DisplaySampleRateWarning { get; set; } + public bool DisplayPyMusicLooperPanel => EnableMsuPcm && PyMusicLooperEnabled; + public MsuProject? Project { get; set; } + public bool HasSelectedInputFile => !string.IsNullOrEmpty(InputFilePath); + + private MsuSongInfo? _currentSongInfo; + private MsuProjectWindowViewModelTreeData? _treeData; + private bool _updatingModel; + + public event EventHandler? ViewModelUpdated; + public event EventHandler? FileDragDropped; + + public MsuSongBasicPanelViewModel() : base() + { + PropertyChanged += OnPropertyChanged; + return; + + void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (_updatingModel) return; + if (e.PropertyName == nameof(SongName) && _treeData is { ParentTreeData: not null } && !string.IsNullOrEmpty(SongName)) + { + _treeData.Name = SongName ?? "Test"; + } + else if (e.PropertyName is nameof(CheckCopyright) or nameof(IsCopyrightSafe)) + { + SaveChanges(); + _treeData?.UpdateCompletedFlag(); + _treeData?.ParentTreeData?.UpdateCompletedFlag(); + } + } + } + + public void UpdateViewModel(MsuProject project, MsuTrackInfo trackInfo, MsuSongInfo songInfo, MsuProjectWindowViewModelTreeData treeData) + { + Project = project; + + _updatingModel = true; + _currentSongInfo = songInfo; + _treeData = treeData; + + SongName = songInfo.SongName; + ArtistName = songInfo.Artist; + Album = songInfo.Album; + Url = songInfo.Url; + InputFilePath = songInfo.MsuPcmInfo.File; + OutputFilePath = songInfo.OutputPath; + IsScratchPad = trackInfo.IsScratchPad; + TrimStart = songInfo.MsuPcmInfo.TrimStart; + TrimEnd = songInfo.MsuPcmInfo.TrimEnd; + LoopPoint = songInfo.MsuPcmInfo.Loop; + Normalization = songInfo.MsuPcmInfo.Normalization; + CheckCopyright = songInfo.CheckCopyright; + IsCopyrightSafe = songInfo.IsCopyrightSafe; + CanUpdatePcmFile = songInfo.IsAlt; + DisplayOutputFile = !IsScratchPad; + EnableMsuPcm = project.BasicInfo.IsMsuPcmProject; + + if (EnableMsuPcm && IsScratchPad) + { + DisplayInputFile = true; + InputColumnSpan = 4; + + DisplayOutputFile = false; + OutputColumn = 2; + OutputColumnSpan = 2; + } + else if (EnableMsuPcm && !IsScratchPad) + { + DisplayInputFile = true; + InputColumnSpan = 2; + + DisplayOutputFile = true; + OutputColumn = 2; + OutputColumnSpan = 2; + } + else if (!EnableMsuPcm && IsScratchPad) + { + DisplayInputFile = false; + DisplayOutputFile = false; + } + else if (!EnableMsuPcm && !IsScratchPad) + { + DisplayInputFile = false; + InputColumnSpan = 2; + + DisplayOutputFile = true; + OutputColumn = 0; + OutputColumnSpan = 4; + } + + IsAdvancedMode = false; + IsEnabled = true; + HasBeenModified = false; + _updatingModel = false; + LastModifiedDate = songInfo.LastModifiedDate; + ViewModelUpdated?.Invoke(this, EventArgs.Empty); + } + + public override void SaveChanges() + { + if (_currentSongInfo == null || !HasBeenModified) return; + _currentSongInfo.SongName = SongName; + _currentSongInfo.Artist = ArtistName; + _currentSongInfo.Album = Album; + _currentSongInfo.Url = Url; + _currentSongInfo.CheckCopyright = CheckCopyright; + _currentSongInfo.IsCopyrightSafe = IsCopyrightSafe; + _currentSongInfo.OutputPath = OutputFilePath; + _currentSongInfo.MsuPcmInfo.File = InputFilePath; + _currentSongInfo.MsuPcmInfo.TrimStart = TrimStart; + _currentSongInfo.MsuPcmInfo.TrimEnd = TrimEnd; + _currentSongInfo.MsuPcmInfo.Loop = LoopPoint; + _currentSongInfo.MsuPcmInfo.Normalization = Normalization; + HasBeenModified = false; + } + + public override ViewModelBase DesignerExample() + { + return new MsuSongBasicPanelViewModel() + { + SongName = "Test Song", + ArtistName = "Test Song Artist", + Album = "Test Song Album", + Url = "https://www.google.com", + IsAlt = false, + }; + } + + public void SetSampleRate(int sampleRate) + { + DisplaySampleRateWarning = sampleRate != 44100; + } + + public void DragDropFile(string fileName) + { + if (!DisplayInputFile) return; + InputFilePath = fileName; + FileDragDropped?.Invoke(this, EventArgs.Empty); + } +} \ No newline at end of file diff --git a/MSUScripter/ViewModels/MsuSongInfoViewModel.cs b/MSUScripter/ViewModels/MsuSongInfoViewModel.cs deleted file mode 100644 index 95de7ea..0000000 --- a/MSUScripter/ViewModels/MsuSongInfoViewModel.cs +++ /dev/null @@ -1,162 +0,0 @@ -using System; -using Avalonia.Media; -using AvaloniaControls.Models; -using Material.Icons; -using MSUScripter.Configs; -using MSUScripter.Models; -using ReactiveUI.Fody.Helpers; - -namespace MSUScripter.ViewModels; - -public class MsuSongInfoViewModel : ViewModelBase -{ - public MsuSongInfoViewModel() - { - PropertyChanged += (sender, args) => - { - if (args.PropertyName != nameof(LastModifiedDate) && args.PropertyName != nameof(HasBeenModified)) - { - LastModifiedDate = DateTime.Now; - } - }; - } - - public int TrackNumber { get; set; } - - public string TrackName { get; set; } = ""; - - [Reactive] public string? SongName { get; set; } - - [Reactive] public string? Artist { get; set; } - - [Reactive] public string? Album { get; set; } - - [Reactive] public string? Url { get; set; } - - [Reactive] public string? OutputPath { get; set; } - - [Reactive] public bool IsAlt { get; set; } - - public bool DisplayPcmFile => !Track.IsScratchPad; - - [Reactive, ReactiveLinkedProperties(nameof(CompleteIconKind), nameof(CompleteIconBrush))] - public bool IsComplete { get; set; } - - [Reactive, ReactiveLinkedProperties(nameof(CheckCopyrightIconKind))] - public bool? CheckCopyright { get; set; } = true; - - [Reactive, ReactiveLinkedProperties(nameof(CopyrightIconKind), nameof(CopyrightIconBrush), nameof(CopyrightSafeText))] - public bool? IsCopyrightSafe { get; set; } - - [Reactive] public DateTime LastModifiedDate { get; set; } - - [Reactive] public DateTime LastGeneratedDate { get; set; } - - [SkipConvert] public MsuProjectViewModel Project { get; set; } = null!; - - [SkipConvert] public MsuTrackInfoViewModel Track { get; set; } = null!; - - [SkipConvert] public bool CanPlaySongs { get; set; } - [Reactive, SkipConvert] public MaterialIconKind PauseStopIcon { get; set; } - [Reactive, SkipConvert] public string PauseStopText { get; set; } = "Pause Song"; - - [Reactive, SkipConvert] public string? AverageAudio { get; set; } - - [Reactive, ReactiveLinkedProperties(nameof(HasAudioAnalysis)), SkipConvert] - public string? PeakAudio { get; set; } - - [SkipConvert] public bool HasAudioAnalysis => !string.IsNullOrEmpty(PeakAudio); - - [Reactive] public bool ShowPanel { get; set; } = true; - public bool ShowCreatePcmSection => Project.BasicInfo.IsMsuPcmProject && !Track.IsScratchPad; - public MsuSongMsuPcmInfoViewModel MsuPcmInfo { get; set; } = new(); - - public MaterialIconKind CompleteIconKind => IsComplete switch - { - true => MaterialIconKind.CheckboxOutline, - false => MaterialIconKind.CheckboxBlankOutline, - }; - - public IBrush CompleteIconBrush => IsComplete switch - { - true => Brushes.LimeGreen, - false => Brushes.DarkGray, - }; - - public MaterialIconKind CheckCopyrightIconKind => CheckCopyright switch - { - true => MaterialIconKind.CheckboxOutline, - false => MaterialIconKind.CheckboxBlankOutline, - null => MaterialIconKind.CheckboxBlankOutline - }; - - public MaterialIconKind CopyrightIconKind => IsCopyrightSafe switch - { - true => MaterialIconKind.CheckboxOutline, - false => MaterialIconKind.CancelBoxOutline, - _ => MaterialIconKind.QuestionBoxOutline - }; - - public IBrush CopyrightIconBrush => IsCopyrightSafe switch - { - true => Brushes.LimeGreen, - false => Brushes.IndianRed, - _ => Brushes.Goldenrod - }; - - public string CopyrightSafeText => IsCopyrightSafe switch - { - true => "Verified to be safe from copyright strikes", - false => "Verified to not be safe from copyright strikes", - _ => "Not tested for copyright strike safety" - }; - - public bool HasChangesSince(DateTime time) - { - return MsuPcmInfo.HasChangesSince(time) || LastModifiedDate > time; - } - - public bool HasFiles() - { - return MsuPcmInfo.HasFiles(); - } - - public void ApplyAudioMetadata(AudioMetadata metadata, bool force) - { - if (metadata.HasData != true) return; - if (force || string.IsNullOrEmpty(SongName) || SongName.StartsWith("Track #")) - SongName = metadata.SongName; - if (force || (string.IsNullOrEmpty(Artist) && !string.IsNullOrEmpty(metadata.Artist))) - Artist = metadata.Artist; - if (force || (string.IsNullOrEmpty(Album) && !string.IsNullOrEmpty(metadata.Album))) - Album = metadata.Album; - if (force || (string.IsNullOrEmpty(Url) && !string.IsNullOrEmpty(metadata.Url))) - Url = metadata.Url; - } - - public void ApplyCascadingSettings(MsuProjectViewModel projectModel, MsuTrackInfoViewModel track, bool isAlt, bool canPlaySongs, bool updateLastModified, bool forceOpen) - { - var lastModified = updateLastModified ? DateTime.Now : LastModifiedDate; - Project = projectModel; - Track = track; - TrackName = track.TrackName; - TrackNumber = track.TrackNumber; - IsAlt = isAlt; - CanPlaySongs = canPlaySongs; - - if (forceOpen) - { - ShowPanel = true; - } - - MsuPcmInfo.ApplyCascadingSettings(Project, this, isAlt, null, canPlaySongs, updateLastModified, forceOpen); - - LastModifiedDate = lastModified; - } - - - public override ViewModelBase DesignerExample() - { - return this; - } -} \ No newline at end of file diff --git a/MSUScripter/ViewModels/MsuSongMsuPcmInfoViewModel.cs b/MSUScripter/ViewModels/MsuSongMsuPcmInfoViewModel.cs deleted file mode 100644 index bddd4e0..0000000 --- a/MSUScripter/ViewModels/MsuSongMsuPcmInfoViewModel.cs +++ /dev/null @@ -1,254 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Linq; -using AvaloniaControls.Models; -using MSUScripter.Models; -using ReactiveUI; -using ReactiveUI.Fody.Helpers; - -namespace MSUScripter.ViewModels; - -public class MsuSongMsuPcmInfoViewModel : ViewModelBase -{ - - public MsuSongMsuPcmInfoViewModel() - { - PropertyChanged += (sender, args) => - { - if (args.PropertyName != nameof(LastModifiedDate) && args.PropertyName != nameof(HasBeenModified)) - { - LastModifiedDate = DateTime.Now; - } - }; - - SubTracks.CollectionChanged += (sender, args) => - { - this.RaisePropertyChanged(nameof(SubTracks)); - }; - - SubChannels.CollectionChanged += (sender, args) => - { - this.RaisePropertyChanged(nameof(SubChannels)); - }; - } - - [Reactive] public int? Loop { get; set; } - - [Reactive] public int? TrimStart { get; set; } - - [Reactive] public int? TrimEnd { get; set; } - - [Reactive] public int? FadeIn { get; set; } - - [Reactive] public int? FadeOut { get; set; } - - [Reactive] public int? CrossFade { get; set; } - - [Reactive] public int? PadStart { get; set; } - - [Reactive] public int? PadEnd { get; set; } - - [Reactive] public double? Tempo { get; set; } - - [Reactive] public double? Normalization { get; set; } - - [Reactive] public bool? Compression { get; set; } - - [Reactive] public string? Output { get; set; } - - [Reactive] public DateTime LastModifiedDate { get; set; } - - [Reactive] public ObservableCollection SubTracks { get; init; } = []; - - [Reactive] public ObservableCollection SubChannels { get; init; } = []; - - [SkipConvert] public bool IsPyMusicLooperButtonEnabled => (!string.IsNullOrEmpty(File) && System.IO.File.Exists(File)) || ParentMsuPcmInfo?.IsPyMusicLooperButtonEnabled == true; - - [SkipConvert, Reactive] - public bool DisplayHertzWarning { get; set; } - - [SkipConvert, Reactive] - public bool DisplayMultiWarning { get; set; } - - [SkipConvert, Reactive] - public bool DisplaySubTrackSubChannelWarning { get; set; } - - [Reactive, ReactiveLinkedProperties(nameof(IsPyMusicLooperButtonEnabled))] - public string? File { get; set; } - - [Reactive] public bool ShowPanel { get; set; } = true; - - public bool CanDisplayTrimStartButton => OperatingSystem.IsWindows(); - - [SkipConvert] - public MsuProjectViewModel Project { get; set; } = null!; - - [SkipConvert] - public MsuSongInfoViewModel Song { get; set; } = null!; - - [SkipConvert] - public bool IsAlt { get; set; } - - [SkipConvert] - public bool IsTopLevel => ParentMsuPcmInfo == null; - - [SkipConvert] - public MsuSongMsuPcmInfoViewModel? ParentMsuPcmInfo { get; set; } - - [SkipConvert] - public MsuSongMsuPcmInfoViewModel TopLevel - { - get - { - if (IsTopLevel) return this; - var topModel = ParentMsuPcmInfo; - while (topModel?.ParentMsuPcmInfo != null) - topModel = topModel.ParentMsuPcmInfo; - return topModel ?? this; - } - } - - [SkipConvert, Reactive] public bool CanMoveUp { get; set; } - [SkipConvert, Reactive] public bool CanMoveDown { get; set; } - - public bool CanDelete => !IsTopLevel; - - public bool CanEditFile => !SubTracks.Any() && !SubChannels.Any(); - - public bool HasSubChannels => SubChannels.Any(); - - public bool HasSubTracks => SubTracks.Any(); - - public bool HasChangesSince(DateTime time) - { - if (SubTracks.Any(x => x.HasChangesSince(time))) - return true; - if (SubChannels.Any(x => x.HasChangesSince(time))) - return true; - return LastModifiedDate > time; - } - - public bool HasFiles() - { - return !string.IsNullOrEmpty(File) || SubTracks.Any(x => x.HasFiles()) || SubChannels.Any(x => x.HasFiles()); - } - - public int GetFileCount() - { - var fileCount = !string.IsNullOrEmpty(File) ? 1 : 0; - return fileCount + SubTracks.Sum(x => x.GetFileCount()) + SubChannels.Sum(x => x.GetFileCount()); - } - - public void ApplyCascadingSettings(MsuProjectViewModel projectModel, MsuSongInfoViewModel songModel, bool isAlt, MsuSongMsuPcmInfoViewModel? parent, bool canPlaySongs, bool updateLastModified, bool forceOpen) - { - var lastModified = updateLastModified ? DateTime.Now : LastModifiedDate; - Project = projectModel; - Song = songModel; - IsAlt = isAlt; - ParentMsuPcmInfo = parent; - CanPlaySongs = canPlaySongs; - - if (forceOpen) - { - ShowPanel = forceOpen; - } - - foreach (var subItem in SubChannels.Concat(SubTracks)) - { - subItem.ApplyCascadingSettings(projectModel, songModel, isAlt, this, canPlaySongs, updateLastModified, forceOpen); - } - - LastModifiedDate = lastModified; - } - - public void UpdatePyMusicLooperButtonStatus() - { - this.RaisePropertyChanged(nameof(IsPyMusicLooperButtonEnabled)); - foreach (var subTrackChannel in SubChannels.Concat(SubTracks)) - { - subTrackChannel.UpdatePyMusicLooperButtonStatus(); - } - } - - public string? GetEffectiveFile() - { - var model = this; - - while (model != null && string.IsNullOrEmpty(model.File)) - { - model = model.ParentMsuPcmInfo; - } - - return model?.File; - } - - [SkipConvert] - public bool IsSubChannel => !IsTopLevel && ParentMsuPcmInfo?.SubChannels.Contains(this) == true; - - [SkipConvert] - public bool IsSubTrack => !IsTopLevel && ParentMsuPcmInfo?.SubTracks.Contains(this) == true; - - [SkipConvert] - public string InsertText => IsSubChannel ? "Insert New Sub Channel Before This" : "Insert New Sub Track Before This"; - - [SkipConvert] - public string HeaderText => - IsTopLevel ? "MsuPcm++ Details" : IsSubChannel ? "Sub Channel Details" : "Sub Track Details"; - - [SkipConvert] - public string RemoveText => - IsSubChannel ? "Remove Sub Channel" : "Remove Sub Track"; - - [SkipConvert] public bool CanPlaySongs { get; set; } - - public void UpdateHertzWarning(int? sampleRate) - { - DisplayHertzWarning = sampleRate != 44100; - } - - public void UpdateMultiWarning() - { - DisplayMultiWarning = GetFiles().Distinct().Count() > 1; - } - - public void UpdateSubTrackSubChannelWarning() - { - DisplaySubTrackSubChannelWarning = HasBothSubTracksAndChannels; - } - - private List GetFiles() - { - List toReturn = []; - - if (!string.IsNullOrEmpty(File)) - { - toReturn.Add(File); - } - - toReturn.AddRange(SubChannels.Concat(SubTracks).SelectMany(x => x.GetFiles())); - - return toReturn; - } - - private bool HasBothSubTracksAndChannels - { - get - { - if (HasSubChannels && HasSubTracks) - { - return true; - } - - return SubChannels.Concat(SubTracks).Any(x => x.HasBothSubTracksAndChannels); - } - - } - - public override ViewModelBase DesignerExample() - { - Song.CheckCopyright = true; - Song.IsCopyrightSafe = true; - return this; - } -} \ No newline at end of file diff --git a/MSUScripter/ViewModels/MsuSongOuterPanelViewModel.cs b/MSUScripter/ViewModels/MsuSongOuterPanelViewModel.cs new file mode 100644 index 0000000..3e90967 --- /dev/null +++ b/MSUScripter/ViewModels/MsuSongOuterPanelViewModel.cs @@ -0,0 +1,169 @@ +using System.ComponentModel; +using System.Linq; +using AvaloniaControls.Models; +using MSUScripter.Configs; +using MSUScripter.Models; +using ReactiveUI.Fody.Helpers; + +namespace MSUScripter.ViewModels; + +public class MsuSongOuterPanelViewModel : SavableViewModelBase +{ + [Reactive, SkipLastModified] public string TrackTitleText { get; set; } = ""; + + [Reactive, SkipLastModified] public string? TrackDescriptionText { get; set; } + + [Reactive, SkipLastModified, ReactiveLinkedProperties(nameof(ShowNonSplitButton), nameof(ShowSplitButton))] public bool IsScratchPad { get; set; } + + [Reactive, SkipLastModified] public bool DisplayAddSong { get; set; } + [Reactive, SkipLastModified] public string AddSongButtonHeaderText { get; set; } = string.Empty; + + public MsuProject? Project { get; set; } + public MsuTrackInfo? TrackInfo { get; set; } + public MsuSongInfo? SongInfo { get; set; } + + [Reactive, SkipLastModified] public bool HasTrackDescription { get; set; } + + [Reactive, SkipLastModified] public bool IsEnabled { get; set; } + + [Reactive] public bool IsComplete { get; set; } + [Reactive, SkipLastModified] public bool DisplayCompleteCheckbox { get; set; } + [Reactive, SkipLastModified] public string AverageAudioLevel { get; set; } = ""; + [Reactive, SkipLastModified] public string PeakAudioLevel { get; set; } = ""; + [Reactive, SkipLastModified] public bool DisplaySecondAudioLine { get; set; } + [Reactive, SkipLastModified, ReactiveLinkedProperties(nameof(ShowNonSplitButton), nameof(ShowSplitButton))] public bool CanGeneratePcmFiles { get; set; } = true; + [Reactive, SkipLastModified] public bool IsGeneratingPcmFiles { get; set; } + [SkipLastModified] public bool ShowSplitButton => CanGeneratePcmFiles && !IsScratchPad; + [SkipLastModified] public bool ShowNonSplitButton => CanGeneratePcmFiles && IsScratchPad; + + public MsuSongBasicPanelViewModel BasicPanelViewModel { get; set; } = new(); + + public MsuSongAdvancedPanelViewModel AdvancedPanelViewModel { get; set; } = new(); + + public MsuProjectWindowViewModelTreeData? TreeData { get; set; } + + public MsuSongOuterPanelViewModel() + { + PropertyChanged += OnPropertyChanged; + return; + + void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(IsComplete) && SongInfo != null) + { + SongInfo.IsComplete = IsComplete; + TreeData?.UpdateCompletedFlag(); + TreeData?.ParentTreeData?.UpdateCompletedFlag(); + } + } + } + + public void UpdateViewModel(MsuProject project, MsuTrackInfo trackInfo, MsuSongInfo? songInfo, MsuProjectWindowViewModelTreeData treeData) + { + var altText = trackInfo.TrackNumber == 9999 + ? "" + : trackInfo.Songs.Count <= 1 || songInfo == null + ? "" + : songInfo.IsAlt + ? " - Alt Song #" + trackInfo.Songs.IndexOf(songInfo) + : " - Primary Song"; + var trackInfoNumber = trackInfo.TrackNumber != 9999 ? $"#{trackInfo.TrackNumber} " : string.Empty; + Project = project; + TrackInfo = trackInfo; + SongInfo = songInfo; + TrackTitleText = $"{trackInfoNumber}{trackInfo.TrackName}{altText}"; + TrackDescriptionText = trackInfo.TrackNumber != 9999 + ? project.MsuType.Tracks.FirstOrDefault(x => x.Number == trackInfo.TrackNumber)?.Description + : "A temporary location for creating songs before moving them to a specific track."; + IsScratchPad = trackInfo.IsScratchPad; + HasTrackDescription = !string.IsNullOrEmpty(TrackDescriptionText); + AverageAudioLevel = ""; + PeakAudioLevel = ""; + CanGeneratePcmFiles = project.BasicInfo.IsMsuPcmProject; + + if (songInfo != null && treeData.ChildTreeData.Count == 0) + { + IsComplete = songInfo.IsComplete; + + if (songInfo.DisplayAdvancedMode == true && project.BasicInfo.IsMsuPcmProject) + { + AdvancedPanelViewModel.UpdateViewModel(project, trackInfo, songInfo, treeData); + BasicPanelViewModel.IsEnabled = false; + AdvancedPanelViewModel.IsEnabled = true; + } + else + { + songInfo.DisplayAdvancedMode = false; + BasicPanelViewModel.UpdateViewModel(project, trackInfo, songInfo, treeData); + BasicPanelViewModel.IsEnabled = true; + AdvancedPanelViewModel.IsEnabled = false; + } + + DisplayCompleteCheckbox = true; + DisplayAddSong = false; + } + else + { + AddSongButtonHeaderText = treeData.ChildTreeData.Count == 0 + ? "No song has currently been added. Click below to add a song." + : IsScratchPad + ? "You have added multiple songs to the scratch pad. Click to add another." + : "You have added multiple songs. Click to add a new song as the primary song for this track."; + DisplayAddSong = true; + BasicPanelViewModel.IsEnabled = false; + AdvancedPanelViewModel.IsEnabled = false; + DisplayCompleteCheckbox = false; + } + + IsEnabled = true; + TreeData = treeData; + HasBeenModified = false; + + if (songInfo != null) + { + LastModifiedDate = songInfo.LastModifiedDate; + } + } + + public override ViewModelBase DesignerExample() + { + return new MsuSongOuterPanelViewModel + { + TrackTitleText = "#1 Opening Theme - Primary Song", + TrackDescriptionText = "Song played when first booting up the game after the three Triforce pieces start flying in and goes into the title screen. Lasts for about 16.5 seconds and does not loop. Not used in SMZ3.", + HasTrackDescription = true, + DisplayAddSong = false, + DisplayCompleteCheckbox = true, + BasicPanelViewModel = new MsuSongBasicPanelViewModel() + { + IsEnabled = false + }, + AdvancedPanelViewModel = new MsuSongAdvancedPanelViewModel() + { + IsEnabled = false + } + }; + } + + public override void SaveChanges() + { + if (BasicPanelViewModel.IsEnabled) + { + BasicPanelViewModel.SaveChanges(); + if (BasicPanelViewModel.LastModifiedDate > LastModifiedDate) + { + LastModifiedDate = BasicPanelViewModel.LastModifiedDate; + } + } + else if (AdvancedPanelViewModel.IsEnabled) + { + AdvancedPanelViewModel.SaveChanges(); + if (AdvancedPanelViewModel.LastModifiedDate > LastModifiedDate) + { + LastModifiedDate = AdvancedPanelViewModel.LastModifiedDate; + } + } + + HasBeenModified = false; + } +} \ No newline at end of file diff --git a/MSUScripter/ViewModels/MsuTrackInfoViewModel.cs b/MSUScripter/ViewModels/MsuTrackInfoViewModel.cs deleted file mode 100644 index e45d630..0000000 --- a/MSUScripter/ViewModels/MsuTrackInfoViewModel.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System; -using System.Collections.ObjectModel; -using System.IO; -using System.Linq; -using MSUScripter.Models; -using ReactiveUI; -using ReactiveUI.Fody.Helpers; - -namespace MSUScripter.ViewModels; - -public class MsuTrackInfoViewModel : ViewModelBase -{ - public MsuTrackInfoViewModel() - { - Songs.CollectionChanged += (_, _) => - { - this.RaisePropertyChanged(nameof(Songs)); - LastModifiedDate = DateTime.Now; - }; - } - - public int TrackNumber { get; init; } - - public string TrackName { get; init; } = ""; - - public DateTime LastModifiedDate { get; set; } - - [SkipConvert, Reactive] public string? Description { get; set; } - - [SkipConvert] public bool HasDescription => !string.IsNullOrEmpty(Description); - - [SkipConvert] public MsuProjectViewModel Project { get; set; } = new(); - - [SkipConvert] public ObservableCollection Songs { get; init; } = []; - - [SkipConvert] public string Display => ToString(); - - public bool IsScratchPad { get; set; } - - public bool HasChangesSince(DateTime time) - { - return Songs.Any(x => x.HasChangesSince(time)) || LastModifiedDate > time; - } - - public void FixTrackSuffixes(bool? canPlaySongs = null) - { - var msu = new FileInfo(Project.MsuPath); - - canPlaySongs ??= Songs.Any(x => x.CanPlaySongs); - - for (var i = 0; i < Songs.Count; i++) - { - var songInfo = Songs[i]; - - if (i == 0) - { - songInfo.OutputPath = msu.FullName.Replace(msu.Extension, $"-{TrackNumber}.pcm"); - } - else - { - var altSuffix = i == 1 ? "alt" : $"alt{i}"; - songInfo.OutputPath = - msu.FullName.Replace(msu.Extension, $"-{TrackNumber}_{altSuffix}.pcm"); - } - - songInfo.ApplyCascadingSettings(Project, this, i > 0, canPlaySongs == true, true, true); - } - } - - public override string ToString() - { - return IsScratchPad ? "Scratch Pad" : $"Track #{TrackNumber} - {TrackName}"; - } - - public override ViewModelBase DesignerExample() - { - return this; - } -} \ No newline at end of file diff --git a/MSUScripter/ViewModels/NewProjectPanelViewModel.cs b/MSUScripter/ViewModels/NewProjectPanelViewModel.cs deleted file mode 100644 index 5eaea6d..0000000 --- a/MSUScripter/ViewModels/NewProjectPanelViewModel.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Collections.Generic; -using AvaloniaControls.Models; -using MSURandomizerLibrary.Configs; -using MSUScripter.Configs; -using ReactiveUI.Fody.Helpers; - -namespace MSUScripter.ViewModels; - -public class NewProjectPanelViewModel : ViewModelBase -{ - public List MsuTypes { get; set; } = []; - - [Reactive, ReactiveLinkedProperties(nameof(CanCreateNewProject))] public MsuType? SelectedMsuType { get; set; } - [Reactive, ReactiveLinkedProperties(nameof(CanCreateNewProject))] public string? MsuPath { get; set; } - [Reactive] public string? MsuPcmTracksJsonPath { get; set; } - [Reactive] public string? MsuPcmWorkingDirectoryPath { get; set; } - [Reactive, ReactiveLinkedProperties(nameof(AnyRecentProjects))] public List RecentProjects { get; set; } = []; - public bool CanCreateNewProject => SelectedMsuType != null && !string.IsNullOrEmpty(MsuPath); - public bool AnyRecentProjects => RecentProjects.Count > 0; - public override ViewModelBase DesignerExample() - { - return this; - } -} \ No newline at end of file diff --git a/MSUScripter/ViewModels/PackageMsuWindowViewModel.cs b/MSUScripter/ViewModels/PackageMsuWindowViewModel.cs deleted file mode 100644 index 625ef2a..0000000 --- a/MSUScripter/ViewModels/PackageMsuWindowViewModel.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Collections.Generic; -using ReactiveUI.Fody.Helpers; - -namespace MSUScripter.ViewModels; - -public class PackageMsuWindowViewModel : ViewModelBase -{ - [Reactive] public string ButtonText { get; set; } = "Cancel"; - - [Reactive] public MsuProjectViewModel Project { get; set; } = new(); - - [Reactive] public string Response { get; set; } = ""; - - [Reactive] public bool IsRunning { get; set; } - - public List ValidPcmPaths = []; - - public override ViewModelBase DesignerExample() - { - return this; - } -} \ No newline at end of file diff --git a/MSUScripter/ViewModels/PyMusicLooperPanelViewModel.cs b/MSUScripter/ViewModels/PyMusicLooperPanelViewModel.cs index 1237c72..94d4873 100644 --- a/MSUScripter/ViewModels/PyMusicLooperPanelViewModel.cs +++ b/MSUScripter/ViewModels/PyMusicLooperPanelViewModel.cs @@ -3,12 +3,14 @@ using System.Linq; using AvaloniaControls.Models; using MSUScripter.Configs; +using MSUScripter.Models; using ReactiveUI.Fody.Helpers; #pragma warning disable CS0067 // Event is never used namespace MSUScripter.ViewModels; -public class PyMusicLooperPanelViewModel : ViewModelBase +[SkipLastModified] +public class PyMusicLooperPanelViewModel : TranslatedViewModelBase { [Reactive] public double MinDurationMultiplier { get; set; } = 0.25; [Reactive] public int? MinLoopDuration { get; set; } @@ -17,10 +19,7 @@ public class PyMusicLooperPanelViewModel : ViewModelBase [Reactive] public int? ApproximateEnd { get; set; } [Reactive] public List PyMusicLooperResults { get; set; } = []; [Reactive] public PyMusicLooperResultViewModel? SelectedResult { get; set; } - [Reactive] public MsuSongInfoViewModel MsuSongInfoViewModel { get; set; } = new(); - [Reactive] public MsuProjectViewModel MsuProjectViewModel { get; set; } = new(); [Reactive] public MsuProject MsuProject { get; set; } = new(); - [Reactive] public MsuSongMsuPcmInfoViewModel MsuSongMsuPcmInfoViewModel { get; set; } = new(); [Reactive] public bool DisplayGitHubLink { get; set; } [Reactive] public bool DisplayOldVersionWarning { get; set; } @@ -44,6 +43,10 @@ public class PyMusicLooperPanelViewModel : ViewModelBase [Reactive, ReactiveLinkedProperties(nameof(CurrentPageResults))] public List FilteredResults { get; set; } = []; + + public string? FilePath { get; set; } = string.Empty; + + public double? Normalization { get; set; } public bool HasTestedPyMusicLooper { get; set; } public bool IsRunning { get; set; } @@ -52,6 +55,9 @@ public class PyMusicLooperPanelViewModel : ViewModelBase public bool CanClickOnPrev => Page > 0 && !GeneratingPcms; public bool CanClickOnNext => Page < LastPage && !GeneratingPcms; public int NumPerPage => 8; + [Reactive] public bool CanRun { get; set; } + [Reactive] public bool DisplayAutoRun { get; set; } + [Reactive] public bool AutoRun { get; set; } public event EventHandler? FilteredResultsUpdated; diff --git a/MSUScripter/ViewModels/SavableViewModelBase.cs b/MSUScripter/ViewModels/SavableViewModelBase.cs new file mode 100644 index 0000000..46eef38 --- /dev/null +++ b/MSUScripter/ViewModels/SavableViewModelBase.cs @@ -0,0 +1,6 @@ +namespace MSUScripter.ViewModels; + +public abstract class SavableViewModelBase : TranslatedViewModelBase +{ + public abstract void SaveChanges(); +} diff --git a/MSUScripter/ViewModels/SettingsPanelViewModel.cs b/MSUScripter/ViewModels/SettingsPanelViewModel.cs new file mode 100644 index 0000000..39bd1f3 --- /dev/null +++ b/MSUScripter/ViewModels/SettingsPanelViewModel.cs @@ -0,0 +1,61 @@ +using System; +using MSUScripter.Configs; +using ReactiveUI.Fody.Helpers; + +namespace MSUScripter.ViewModels; + +public enum DefaultSongPanel +{ + Prompt, + Basic, + Advanced, +} + +public class SettingsPanelViewModel : SavableViewModelBase +{ + [Reactive] public bool CheckForUpdates { get; set; } + + [Reactive] public DefaultSongPanel DefaultSongPanel { get; set; } + + [Reactive] public int LoopDuration { get; set; } = 5; + [Reactive] public decimal UiScaling { get; set; } = 1; + [Reactive] public bool HideSubTracksSubChannelsWarning { get; set; } + + [Reactive] public bool AutomaticallyRunPyMusicLooper { get; set; } + + [Reactive] public bool RunMsuPcmWithKeepTemps { get; set; } + public bool ShowDesktopFileButton => OperatingSystem.IsLinux(); + + public Settings Settings { get; set; } = new(); + + public override ViewModelBase DesignerExample() + { + return new SettingsPanelViewModel(); + } + + public override void SaveChanges() + { + Settings.CheckForUpdates = CheckForUpdates; + Settings.LoopDuration = LoopDuration; + Settings.DefaultSongPanel = DefaultSongPanel; + Settings.UiScaling = UiScaling; + Settings.HideSubTracksSubChannelsWarning = HideSubTracksSubChannelsWarning; + Settings.AutomaticallyRunPyMusicLooper = AutomaticallyRunPyMusicLooper; + Settings.RunMsuPcmWithKeepTemps = RunMsuPcmWithKeepTemps; + } + + public void LoadSettings(Settings? settings = null) + { + if (settings != null) + { + Settings = settings; + } + CheckForUpdates = Settings.CheckForUpdates; + LoopDuration = Settings.LoopDuration; + DefaultSongPanel = Settings.DefaultSongPanel; + UiScaling = Settings.UiScaling; + HideSubTracksSubChannelsWarning = Settings.HideSubTracksSubChannelsWarning; + AutomaticallyRunPyMusicLooper = Settings.AutomaticallyRunPyMusicLooper; + RunMsuPcmWithKeepTemps = Settings.RunMsuPcmWithKeepTemps; + } +} \ No newline at end of file diff --git a/MSUScripter/ViewModels/SettingsWindowViewModel.cs b/MSUScripter/ViewModels/SettingsWindowViewModel.cs index 3d0e21e..0e5b783 100644 --- a/MSUScripter/ViewModels/SettingsWindowViewModel.cs +++ b/MSUScripter/ViewModels/SettingsWindowViewModel.cs @@ -1,27 +1,8 @@ -using System.Collections.Generic; -using MSUScripter.Configs; -using MSUScripter.Models; -using ReactiveUI.Fody.Helpers; - -namespace MSUScripter.ViewModels; +namespace MSUScripter.ViewModels; public class SettingsWindowViewModel : ViewModelBase { - [Reactive] public string? MsuPcmPath { get; set; } - [Reactive] public bool PromptOnUpdate { get; set; } - [Reactive] public bool PromptOnPreRelease { get; set; } - [Reactive] public bool DarkTheme { get; set; } - [Reactive] public int LoopDuration { get; set; } = 5; - [Reactive] public decimal UiScaling { get; set; } - [Reactive] public ICollection RecentProjects { get; set; } = []; - [Reactive] public double Volume { get; set; } - [Reactive] public string? PreviousPath { get; set; } - [Reactive] public bool RunMsuPcmWithKeepTemps { get; set; } - [Reactive] public bool AutomaticallyRunPyMusicLooper { get; set; } - [Reactive] public bool HideSubTracksSubChannelsWarning { get; set; } - [Reactive] public string? PyMusicLooperPath { get; set; } - public bool HasDoneFirstTimeSetup { get; set; } - [SkipConvert] public bool CanSetPyMusicLooperPath { get; set; } + public SettingsPanelViewModel SettingsPanelViewModel { get; } = new(); public override ViewModelBase DesignerExample() { diff --git a/MSUScripter/ViewModels/SongPanelPromptWindowViewModel.cs b/MSUScripter/ViewModels/SongPanelPromptWindowViewModel.cs new file mode 100644 index 0000000..bee67ba --- /dev/null +++ b/MSUScripter/ViewModels/SongPanelPromptWindowViewModel.cs @@ -0,0 +1,15 @@ +using ReactiveUI.Fody.Helpers; + +namespace MSUScripter.ViewModels; + +public class SongPanelPromptWindowViewModel : TranslatedViewModelBase +{ + [Reactive] public bool Basic { get; set; } = true; + [Reactive] public bool Advanced { get; set; } + [Reactive] public bool DontAskAgain { get; set; } = true; + + public override ViewModelBase DesignerExample() + { + return new SongPanelPromptWindowViewModel(); + } +} \ No newline at end of file diff --git a/MSUScripter/ViewModels/TrackOverviewPanelViewModel.cs b/MSUScripter/ViewModels/TrackOverviewPanelViewModel.cs deleted file mode 100644 index c91d709..0000000 --- a/MSUScripter/ViewModels/TrackOverviewPanelViewModel.cs +++ /dev/null @@ -1,72 +0,0 @@ -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.ComponentModel; -using System.Linq; -using AvaloniaControls.Models; -using DynamicData; -using ReactiveUI; -using ReactiveUI.Fody.Helpers; - -namespace MSUScripter.ViewModels; - -public class TrackOverviewPanelViewModel : ViewModelBase -{ - public MsuProjectViewModel MsuProjectViewModel { get; set; } = new(); - - [Reactive] public ObservableCollection Rows { get; set; } = new(); - - [Reactive] public string CompletedSongDetails { get; set; } = ""; - - [Reactive] public string CompletedTrackDetails { get; set; } = ""; - - [Reactive] public int SelectedIndex { get; set; } = 0; - - public int TotalTrackCount => MsuProjectViewModel.Tracks.Count(x => !x.IsScratchPad); - - public int CompletedTrackCount => MsuProjectViewModel.Tracks.Count(x => !x.IsScratchPad && x.Songs.Any(y => y.HasFiles())); - - public int TotalSongCount => Rows.Count(x => x.HasSong); - - public int CompletedSongCount => Rows.Count(x => x is { HasSong: true, SongInfo.IsComplete: true }); - - public void UpdateCompletedTrackDetails() - { - CompletedSongDetails = $"{CompletedSongCount} out of {TotalSongCount} songs are marked as finished"; - CompletedTrackDetails = $"{CompletedTrackCount} out of {TotalTrackCount} tracks have songs with audio files"; - } - - public class TrackOverviewRow(int trackNumber, string trackName, MsuSongInfoViewModel? song = null) : ViewModelBase - { - public int TrackNumber => trackNumber; - public string TrackName => trackName; - - [Reactive, ReactiveLinkedProperties(nameof(HasSong), nameof(Name), nameof(Artist), nameof(Album), nameof(File))] - public MsuSongInfoViewModel? SongInfo { get; set; } = song; - - public bool HasSong => SongInfo != null; - public string Name => SongInfo?.SongName ?? ""; - public string Artist => SongInfo?.Artist ?? ""; - public string Album => SongInfo?.Album ?? ""; - - public string File => - SongInfo == null - ? "" - : !SongInfo.MsuPcmInfo.HasFiles() - ? "" - : SongInfo.MsuPcmInfo.GetFileCount() == 1 - ? SongInfo.MsuPcmInfo.File! - : $"{SongInfo.MsuPcmInfo.GetFileCount()} files"; - - public override ViewModelBase DesignerExample() - { - return this; - } - } - - public override ViewModelBase DesignerExample() - { - CompletedSongDetails = $"1 out of 4 songs are marked as finished"; - CompletedTrackDetails = $"1 out of 6 tracks have songs with audio files"; - return this; - } -} \ No newline at end of file diff --git a/MSUScripter/ViewModels/TranslatedViewModelBase.cs b/MSUScripter/ViewModels/TranslatedViewModelBase.cs new file mode 100644 index 0000000..9e0fa1f --- /dev/null +++ b/MSUScripter/ViewModels/TranslatedViewModelBase.cs @@ -0,0 +1,17 @@ +using MSUScripter.Text; +using ReactiveUI.Fody.Helpers; + +namespace MSUScripter.ViewModels; + +public abstract class TranslatedViewModelBase : ViewModelBase +{ + public TranslatedViewModelBase() + { + ApplicationText.LanguageChanged += (_, text) => + { + Text = text; + }; + } + + [Reactive] public ApplicationText Text { get; set; } = ApplicationText.CurrentLanguageText; +} \ No newline at end of file diff --git a/MSUScripter/ViewModels/VideoCreatorWindowViewModel.cs b/MSUScripter/ViewModels/VideoCreatorWindowViewModel.cs deleted file mode 100644 index e841bc8..0000000 --- a/MSUScripter/ViewModels/VideoCreatorWindowViewModel.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Collections.Generic; -using ReactiveUI.Fody.Helpers; - -namespace MSUScripter.ViewModels; - -public class VideoCreatorWindowViewModel : ViewModelBase -{ - [Reactive] public bool DisplayGitHubLink { get; set; } - [Reactive] public string DisplayText { get; set; } = "Select video file to create"; - [Reactive] public bool CanRunVideoCreator { get; set; } - [Reactive] public List PcmPaths { get; set; } = []; - [Reactive] public string CloseButtonText { get; set; } = "Cancel"; - public string? PreviousPath { get; set; } - - public override ViewModelBase DesignerExample() - { - return this; - } -} \ No newline at end of file diff --git a/MSUScripter/ViewModels/ViewModelBase.cs b/MSUScripter/ViewModels/ViewModelBase.cs index 6f290c0..4885bd8 100644 --- a/MSUScripter/ViewModels/ViewModelBase.cs +++ b/MSUScripter/ViewModels/ViewModelBase.cs @@ -1,5 +1,9 @@ +using System; +using System.Collections.Generic; +using System.Reflection; using AvaloniaControls.Extensions; using MSUScripter.Models; +using MSUScripter.Tools; using ReactiveUI; using ReactiveUI.Fody.Helpers; @@ -10,19 +14,34 @@ public abstract class ViewModelBase : ReactiveObject protected ViewModelBase() { this.LinkProperties(); + SkipLastModifiedProperties = this.GetSkipLastModifiedPropertyNames(); + if (GetType().GetCustomAttribute() != null) + { + return; + } - PropertyChanged += (sender, args) => + PropertyChanged += (_, args) => { - if (args.PropertyName != nameof(HasBeenModified)) + try + { + if (args.PropertyName != null && !SkipLastModifiedProperties.Contains(args.PropertyName)) + { + LastModifiedDate = DateTime.Now; + HasBeenModified = true; + } + } + catch (Exception) { - HasBeenModified = true; + // TODO: Log } }; } public abstract ViewModelBase DesignerExample(); - [Reactive, SkipConvert] public bool HasBeenModified { get; set; } - - + [Reactive, SkipConvert, SkipLastModified] public DateTime LastModifiedDate { get; set; } + [Reactive, SkipConvert, SkipLastModified] public bool HasBeenModified { get; set; } + private HashSet SkipLastModifiedProperties { get; } + + } diff --git a/MSUScripter/Views/AboutPanel.axaml b/MSUScripter/Views/AboutPanel.axaml new file mode 100644 index 0000000..da4a932 --- /dev/null +++ b/MSUScripter/Views/AboutPanel.axaml @@ -0,0 +1,166 @@ + + + + Created by MattEqualsCoder + + + + Need Assistance? + + + + Creating an + + + issue on GitHub + + + is the preferred way of reporting an issue, but you can also reach out on Discord on the ALttPR and SMZ3 randomizer Discord servers. + If you had a crash, please provide the + + + log file + + + for troubleshooting. + + + + Special Thanks: + + + + + + + + + qwertymodo + + + for creating + + + msupcm++ + + , + the original tool that this application is built around utilizing for creating PCM files. + + + + + + + + + arkrow + + + for creating + + + PyMusicLooper + + , + which is used for automatically detecting loop points for songs. + + + + + + + + + StructuralMike + + + for creating the original Python script for creating videos to test for Copyright strikes on YouTube. + + + + + + + + + Vivelin + + + for contributions and for creating the + + + SMZ3 Cas' Randomizer + + , that started me working on this. + + + + + + + + + Minnie Trethewey + + + for the files for the MSU types of different randomizers and rom hacks. + + + + + + + + + Astral + + + for the standalone Python package to make it easier to provide PyMusicLooper and other Python apps. + + + + + + + + + Vo1dTear + + + for the AppImage version of msupcm++ to make it easier to install for Linux users. + + + + + + + + + PinkKittyRose + + , + + Phiggle + , and + + + codemann8 + + + for testing and providing suggestions. + + + + + + + All of the MSU creators for making it worth creating this! + + + diff --git a/MSUScripter/Views/AboutPanel.axaml.cs b/MSUScripter/Views/AboutPanel.axaml.cs new file mode 100644 index 0000000..8526f13 --- /dev/null +++ b/MSUScripter/Views/AboutPanel.axaml.cs @@ -0,0 +1,36 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Markup.Xaml; +using AvaloniaControls; +using AvaloniaControls.Controls; +using MSUScripter.Models; + +namespace MSUScripter.Views; + +public partial class AboutPanel : UserControl +{ + public AboutPanel() + { + InitializeComponent(); + } + + private void LinkControlButton_OnClick(object? sender, RoutedEventArgs e) + { + if (sender is not LinkControl control) + { + return; + } + + var url = ToolTip.GetTip(control) as string; + + if (!string.IsNullOrEmpty(url)) + { + CrossPlatformTools.OpenUrl(url); + } + else + { + CrossPlatformTools.OpenDirectory(Directories.LogFolder); + } + } +} \ No newline at end of file diff --git a/MSUScripter/Views/AddSongWindow.axaml b/MSUScripter/Views/AddSongWindow.axaml deleted file mode 100644 index b38322f..0000000 --- a/MSUScripter/Views/AddSongWindow.axaml +++ /dev/null @@ -1,243 +0,0 @@ - - - - - - - - - - - - - - - - - - - - Non-44100Hz File - - - - - Track - File - - - - - - Song Name - Artist - Album - - - - - - - - Trim Start - Trim End - Loop Point - Normalization - Copyright - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - | - - - - - - - - - - - - - - - - - - - - - - - - - Select an audio file - - - - - - - - diff --git a/MSUScripter/Views/AddSongWindow.axaml.cs b/MSUScripter/Views/AddSongWindow.axaml.cs deleted file mode 100644 index 2ad8873..0000000 --- a/MSUScripter/Views/AddSongWindow.axaml.cs +++ /dev/null @@ -1,164 +0,0 @@ -using System; -using System.Linq; -using Avalonia.Controls; -using Avalonia.Input; -using Avalonia.Interactivity; -using AvaloniaControls; -using AvaloniaControls.Controls; -using AvaloniaControls.Extensions; -using MSUScripter.Events; -using MSUScripter.Services.ControlServices; -using MSUScripter.ViewModels; - -namespace MSUScripter.Views; - -public partial class AddSongWindow : ScalableWindow -{ - private bool _forceClosing; - private readonly AddSongWindowService? _service; - private readonly AddSongWindowViewModel _model; - - public AddSongWindow() - { - InitializeComponent(); - DataContext = _model = (AddSongWindowViewModel)new AddSongWindowViewModel().DesignerExample(); - } - - public AddSongWindow(MsuProjectViewModel msuProjectViewModel, int? trackNumber, string? filePath, bool singleMode = false) - { - _service = this.GetControlService(); - var model = _service?.InitializeModel(msuProjectViewModel, trackNumber, filePath, singleMode) ?? new AddSongWindowViewModel(); - DataContext = _model = model; - - model.TrimStartUpdated += (sender, args) => - { - this.FindControl(nameof(PyMusicLooperPanel))?.UpdateFilterStart(model.TrimStart); - }; - - InitializeComponent(); - - WindowStartupLocation = WindowStartupLocation.CenterOwner; - - AddHandler(DragDrop.DropEvent, DropFile); - } - - private void DropFile(object? sender, DragEventArgs e) - { - if (_service?.IsPyMusicLooperRunning() == true) - { - return; - } - - var file = e.Data.GetFiles()?.FirstOrDefault(); - if (file == null) - { - return; - } - - FilePathUpdated(file.Path.LocalPath); - } - - private void FilePathUpdated(string? path) - { - _service?.UpdateFilePath(path); - _service?.UpdatePyMusicLooperPanel(this.FindControl(nameof(PyMusicLooperPanel))); - } - - private void TestAudioLevelButton_OnClick(object? sender, RoutedEventArgs e) - { - _service?.AnalyzeAudio(); - } - - private void PlaySongButton_OnClick(object? sender, RoutedEventArgs e) - { - _ = _service?.PlaySong(false); - } - - private void TestLoopButton_OnClick(object? sender, RoutedEventArgs e) - { - _ = _service?.PlaySong(true); - } - - private void StopSongButton_OnClick(object? sender, RoutedEventArgs e) - { - _ = _service?.StopSong(); - } - - private void Control_OnLoaded(object? sender, RoutedEventArgs e) - { - _ = _service?.StopSong(); - - if (!string.IsNullOrEmpty(_model.FilePath)) - { - _service?.UpdatePyMusicLooperPanel(this.FindControl(nameof(PyMusicLooperPanel))); - } - } - - private void CloseButton_OnClick(object? sender, RoutedEventArgs e) - { - Close(); - } - - private async void AddSongButton_OnClick(object? sender, RoutedEventArgs e) - { - if (_service == null) return; - await _service.AddSongToProject(this); - _service.ClearModel(); - } - - private async void Window_OnClosing(object? sender, WindowClosingEventArgs e) - { - _ = _service?.StopSong(); - - if (_forceClosing || _service?.HasChanges != true) - { - return; - } - - e.Cancel = true; - - if (!await MessageWindow.ShowYesNoDialog( - "You currently have unsaved changes. Are you sure you want to close this window?", parentWindow: this)) return; - _forceClosing = true; - Close(); - } - - private async void AddSongAndCloseButton_OnClick(object? sender, RoutedEventArgs e) - { - if (_service == null) return; - var song = await _service.AddSongToProject(this); - _forceClosing = true; - Close(song); - } - - private void FileControl_OnOnUpdated(object? sender, FileControlUpdatedEventArgs e) - { - FilePathUpdated(e.Path); - } - - private void PyMusicLooperPanel_OnOnUpdated(object? sender, PyMusicLooperPanelUpdatedArgs e) - { - _service?.UpdateFromPyMusicLooper(e.Result); - } - - private void CheckCopyrightButton_OnClick(object? sender, RoutedEventArgs e) - { - _model.CheckCopyright = !_model.CheckCopyright; - } - - private void IsCopyrightSafeButton_OnClick(object? sender, RoutedEventArgs e) - { - if (_model.IsCopyrightSafe == null) - { - _model.IsCopyrightSafe = true; - } - else if (_model.IsCopyrightSafe == true) - { - _model.IsCopyrightSafe = false; - } - else - { - _model.IsCopyrightSafe = null; - } - } -} \ No newline at end of file diff --git a/MSUScripter/Views/AudioAnalysisWindow.axaml.cs b/MSUScripter/Views/AudioAnalysisWindow.axaml.cs index 90d0e80..58de6cb 100644 --- a/MSUScripter/Views/AudioAnalysisWindow.axaml.cs +++ b/MSUScripter/Views/AudioAnalysisWindow.axaml.cs @@ -1,4 +1,5 @@ -using Avalonia.Controls; +using System; +using Avalonia.Controls; using Avalonia.Interactivity; using Avalonia.Threading; using AvaloniaControls; @@ -8,6 +9,7 @@ using MSUScripter.Tools; using MSUScripter.ViewModels; using System.IO; +using MSUScripter.Configs; namespace MSUScripter.Views; @@ -17,13 +19,14 @@ public partial class AudioAnalysisWindow : ScalableWindow private readonly AudioAnalysisViewModel _model; private AudioAnalysisWindow? _compareWindow; + // ReSharper disable once UnusedMember.Global public AudioAnalysisWindow() { InitializeComponent(); DataContext = _model = (AudioAnalysisViewModel)new AudioAnalysisViewModel().DesignerExample(); } - public AudioAnalysisWindow(MsuProjectViewModel project) + public AudioAnalysisWindow(MsuProject project) { InitializeComponent(); _service = this.GetControlService(); @@ -31,7 +34,7 @@ public AudioAnalysisWindow(MsuProjectViewModel project) if (_service == null) return; - _service.Completed += (sender, args) => + _service.Completed += (_, _) => { Dispatcher.UIThread.Invoke(() => { @@ -40,7 +43,7 @@ public AudioAnalysisWindow(MsuProjectViewModel project) }; } - public AudioAnalysisWindow(string msuPath) + private AudioAnalysisWindow(string msuPath) { InitializeComponent(); _service = this.GetControlService(); @@ -48,7 +51,7 @@ public AudioAnalysisWindow(string msuPath) if (_service == null) return; - _service.Completed += (sender, args) => + _service.Completed += (_, _) => { Dispatcher.UIThread.Invoke(() => { @@ -59,14 +62,22 @@ public AudioAnalysisWindow(string msuPath) private async void Control_OnLoaded(object? sender, RoutedEventArgs e) { - if (!string.IsNullOrEmpty(_model.LoadError)) + try { - await MessageWindow.ShowErrorDialog(_model.LoadError, "Error", this); - Close(); - return; - } + if (!string.IsNullOrEmpty(_model.LoadError)) + { + await MessageWindow.ShowErrorDialog(_model.LoadError, "Error", this); + Close(); + return; + } - _service?.Run(); + _service?.Run(); + } + catch (Exception ex) + { + _service?.LogError(ex, "Error while running audio analysis"); + await MessageWindow.ShowErrorDialog(_model.Text.GenericError, _model.Text.GenericErrorTitle, this); + } } private void RefreshSongButton_OnClick(object? sender, RoutedEventArgs e) @@ -79,25 +90,33 @@ private void RefreshSongButton_OnClick(object? sender, RoutedEventArgs e) private async void CompareButton_OnClick(object? sender, RoutedEventArgs e) { - if (_service == null) return; + try + { + if (_service == null) return; - var documentsPath = await this.GetDocumentsFolderPath(); + var documentsPath = await this.GetDocumentsFolderPath(); - if (string.IsNullOrEmpty(documentsPath)) return; + if (string.IsNullOrEmpty(documentsPath)) return; - var file = await CrossPlatformTools.OpenFileDialogAsync( - parentWindow: this, - type: FileInputControlType.OpenFile, - filter: $"MSU File:*.msu", - path: documentsPath, - title: $"Select MSU To Compare"); + var file = await CrossPlatformTools.OpenFileDialogAsync( + parentWindow: this, + type: FileInputControlType.OpenFile, + filter: $"MSU File:*.msu", + path: documentsPath, + title: $"Select MSU To Compare"); - if (file == null || !File.Exists(file.Path.LocalPath)) return; + if (file == null || !File.Exists(file.Path.LocalPath)) return; - _model.CompareEnabled = false; - _compareWindow = new AudioAnalysisWindow(file.Path.LocalPath); - _compareWindow.Closing += CompareWindow_Closing; - _compareWindow.Show(this); + _model.CompareEnabled = false; + _compareWindow = new AudioAnalysisWindow(file.Path.LocalPath); + _compareWindow.Closing += CompareWindow_Closing; + _compareWindow.Show(this); + } + catch (Exception ex) + { + _service?.LogError(ex, "Error opening second audio analysis window"); + await MessageWindow.ShowErrorDialog(_model.Text.GenericError, _model.Text.GenericErrorTitle, this); + } } private void CompareWindow_Closing(object? sender, WindowClosingEventArgs e) diff --git a/MSUScripter/Views/AudioControl.axaml b/MSUScripter/Views/AudioControl.axaml index c395e19..cc09ca6 100644 --- a/MSUScripter/Views/AudioControl.axaml +++ b/MSUScripter/Views/AudioControl.axaml @@ -97,20 +97,6 @@ /> - - diff --git a/MSUScripter/Views/AudioControl.axaml.cs b/MSUScripter/Views/AudioControl.axaml.cs index b83b1fb..a89b3bb 100644 --- a/MSUScripter/Views/AudioControl.axaml.cs +++ b/MSUScripter/Views/AudioControl.axaml.cs @@ -4,6 +4,7 @@ using Avalonia.Controls; using Avalonia.Controls.Primitives; using Avalonia.Interactivity; +using Avalonia.Threading; using AvaloniaControls.Extensions; using MSUScripter.Services.ControlServices; using MSUScripter.ViewModels; @@ -24,15 +25,28 @@ public AudioControl() else { _service = this.GetControlService(); + + if (_service != null) + { + _service.OnPlayStarted += async void (_, _) => + { + try + { + await Task.Delay(TimeSpan.FromSeconds(0.1)); + Dispatcher.UIThread.Post(() => + { + this.GetControl + diff --git a/MSUScripter/Views/CopyProjectWindow.axaml.cs b/MSUScripter/Views/CopyProjectWindow.axaml.cs index 89a6897..ca78bde 100644 --- a/MSUScripter/Views/CopyProjectWindow.axaml.cs +++ b/MSUScripter/Views/CopyProjectWindow.axaml.cs @@ -1,3 +1,4 @@ +using System; using System.Threading.Tasks; using Avalonia.Controls; using Avalonia.Interactivity; @@ -30,37 +31,46 @@ public CopyProjectWindow() } } - public async Task ShowDialog(Window parentWindow, MsuProject project) + public async Task ShowDialog(Window parentWindow, MsuProject project, bool isCopy) { - _service?.SetProject(project); + _service?.SetProject(project, isCopy); await ShowDialog(parentWindow); - return _model.NewProject; + return _model.SavedProject; } private async void UpdatePathButton_OnClick(object? sender, RoutedEventArgs e) { - if (sender is not Button { Tag: CopyProjectViewModel viewModel }) + try { - return; - } + if (sender is not Button { Tag: CopyProjectViewModel viewModel }) + { + return; + } - var file = await CrossPlatformTools.OpenFileDialogAsync( - parentWindow: this, - type: viewModel.IsSongFile ? FileInputControlType.OpenFile : FileInputControlType.SaveFile, - filter: $"{viewModel.Extension} File:*{viewModel.Extension}", - path: viewModel.PreviousPath, - title: $"Select Replacement File for {viewModel.BaseFileName}"); + var file = await CrossPlatformTools.OpenFileDialogAsync( + parentWindow: this, + type: viewModel.IsSongFile ? FileInputControlType.OpenFile : FileInputControlType.SaveFile, + filter: $"{viewModel.Extension} File:*{viewModel.Extension}", + path: viewModel.PreviousPath, + title: $"Select Replacement File for {viewModel.BaseFileName}"); - if (string.IsNullOrEmpty(file?.Path.LocalPath)) + if (string.IsNullOrEmpty(file?.Path.LocalPath)) + { + return; + } + + _service?.UpdatePath(viewModel, file); + } + catch (Exception ex) { - return; + _service?.LogError(ex, "Error updating path"); + await MessageWindow.ShowErrorDialog(_model.Text.GenericError, _model.Text.GenericErrorTitle, this); } - - _service?.UpdatePath(viewModel, file); } private void CloseButton_OnClick(object? sender, RoutedEventArgs e) { + _model.NewProject = null; Close(); } diff --git a/MSUScripter/Views/DuplicateMoveTrackWindow.axaml b/MSUScripter/Views/DuplicateMoveTrackWindow.axaml deleted file mode 100644 index e749a4b..0000000 --- a/MSUScripter/Views/DuplicateMoveTrackWindow.axaml +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/MSUScripter/Views/DuplicateMoveTrackWindow.axaml.cs b/MSUScripter/Views/DuplicateMoveTrackWindow.axaml.cs deleted file mode 100644 index 4c1cc5d..0000000 --- a/MSUScripter/Views/DuplicateMoveTrackWindow.axaml.cs +++ /dev/null @@ -1,42 +0,0 @@ -using Avalonia.Controls; -using Avalonia.Interactivity; -using AvaloniaControls.Extensions; -using MSUScripter.Services.ControlServices; -using MSUScripter.ViewModels; - -namespace MSUScripter.Views; - -public partial class DuplicateMoveTrackWindow : Window -{ - private CopyMoveTrackWindowService? _service; - - public DuplicateMoveTrackWindow() - { - InitializeComponent(); - DataContext = new CopyMoveTrackWindowViewModel().DesignerExample(); - } - - public DuplicateMoveTrackWindow(MsuProjectViewModel msuProjectViewModel, MsuTrackInfoViewModel trackViewModel, - MsuSongInfoViewModel msuSongInfoViewModel, CopyMoveType type) - { - InitializeComponent(); - _service = this.GetControlService(); - DataContext = _service?.InitializeModel(msuProjectViewModel, trackViewModel, msuSongInfoViewModel, type); - } - - private void OkButton_OnClick(object? sender, RoutedEventArgs e) - { - _service?.RunCopyMove(); - Close(); - } - - private void CloseButton_OnClick(object? sender, RoutedEventArgs e) - { - Close(); - } - - private void TrackComboBox_OnSelectionChanged(object? sender, SelectionChangedEventArgs e) - { - _service?.UpdateTrackLocations(); - } -} \ No newline at end of file diff --git a/MSUScripter/Views/EditProjectPanel.axaml b/MSUScripter/Views/EditProjectPanel.axaml deleted file mode 100644 index c1523d0..0000000 --- a/MSUScripter/Views/EditProjectPanel.axaml +++ /dev/null @@ -1,169 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/MSUScripter/Views/EditProjectPanel.axaml.cs b/MSUScripter/Views/EditProjectPanel.axaml.cs deleted file mode 100644 index e114b82..0000000 --- a/MSUScripter/Views/EditProjectPanel.axaml.cs +++ /dev/null @@ -1,286 +0,0 @@ -using System; -using System.Threading.Tasks; -using Avalonia; -using Avalonia.Controls; -using Avalonia.Input; -using Avalonia.Interactivity; -using AvaloniaControls.Controls; -using AvaloniaControls.Extensions; -using MSUScripter.Models; -using MSUScripter.Services.ControlServices; -using MSUScripter.ViewModels; - -namespace MSUScripter.Views; - -public partial class EditProjectPanel : UserControl -{ - private readonly EditProjectPanelService? _service; - - public static readonly StyledProperty ParentDataContextProperty = AvaloniaProperty.Register( - nameof(ParentDataContext)); - - public MainWindowViewModel? ParentDataContext - { - get => GetValue(ParentDataContextProperty); - set => SetValue(ParentDataContextProperty, value); - } - - public EditProjectPanelViewModel Model { get; private set; } = new(); - - public EditProjectPanel() - { - InitializeComponent(); - - if (Design.IsDesignMode) - { - DataContext = Model = (EditProjectPanelViewModel)new EditProjectPanelViewModel().DesignerExample(); - } - else - { - _service = this.GetControlService()!; - } - - ParentDataContextProperty.Changed.Subscribe(x => - { - if (x.Sender != this || x.NewValue.Value == null || _service == null) - { - return; - } - - x.NewValue.Value.CurrentMsuProjectChanged += (sender, args) => - { - if (x.NewValue.Value.CurrentMsuProject != null) - { - DataContext = Model = _service.InitializeModel(x.NewValue.Value.CurrentMsuProject); - - Model.PropertyChanged += (o, eventArgs) => - { - if (eventArgs.PropertyName != nameof(Model.PageNumber) || !Model.DisplayTrackOverviewPanel) return; - this.FindControl(nameof(TrackOverviewPanel))!.Refresh(); - }; - } - }; - - }); - - try - { - HotKeyManager.SetHotKey(this.Find(nameof(SaveMenuItem))!, new KeyGesture(Key.S, KeyModifiers.Control)); - } - catch - { - // Do nothing - } - } - - public event EventHandler? OnCloseProject; - - public bool HasPendingChanges => _service?.HasPendingChanges() == true; - - private void PrevButton_OnClick(object? sender, RoutedEventArgs e) - { - _service?.IncrementPage(-1); - } - - private void NextButton_OnClick(object? sender, RoutedEventArgs e) - { - _service?.IncrementPage(1); - } - - private void TrackOverviewMenuItem_OnClick(object? sender, RoutedEventArgs e) - { - _service?.SetPage(1); - } - - private void MsuDetailsMenuItem_OnClick(object? sender, RoutedEventArgs e) - { - _service?.SetPage(0); - } - - private async void SettingsMenuItem_OnClick(object? sender, RoutedEventArgs e) - { - var settingsWindow = new SettingsWindow(); - await settingsWindow.ShowDialog(ParentWindow); - } - - private void SaveMenuItem_OnClick(object? sender, RoutedEventArgs e) - { - _service?.SaveProject(); - } - - private async void NewMenuItem_OnClick(object? sender, RoutedEventArgs e) - { - if (_service?.HasPendingChanges() == true) - { - await DisplayPendingChangesWindow(); - } - - _service?.Disable(); - OnCloseProject?.Invoke(this, EventArgs.Empty); - } - - private async void AddSongButton_OnClick(object? sender, RoutedEventArgs e) - { - if (_service?.MsuProjectViewModel == null) return; - var window = new AddSongWindow(_service.MsuProjectViewModel, null, null); - await window.ShowDialog(ParentWindow); - } - - private async void AnalysisButton_OnClick(object? sender, RoutedEventArgs e) - { - if (_service?.MsuProjectViewModel == null) return; - var window = new AudioAnalysisWindow(_service.MsuProjectViewModel); - await window.ShowDialog(ParentWindow); - } - - private async void ExportButton_OnClick(object? sender, RoutedEventArgs e) - { - if (_service?.MsuProjectViewModel == null) return; - - DisableExport(); - - var initError = _service.SetupForMsuGenerationWindow(); - - if (!string.IsNullOrEmpty(initError)) - { - await MessageWindow.ShowErrorDialog(initError, "MSU Generation Error", ParentWindow); - EnableExport(); - return; - } - - var project = _service.MsuProjectViewModel; - var window = new MsuPcmGenerationWindow(project, project.BasicInfo.WriteYamlFile); - await window.ShowDialog(ParentWindow); - EnableExport(); - } - - private async void ExportButtonYaml_OnClick(object? sender, RoutedEventArgs e) - { - DisableExport(); - var result = _service?.ExportYaml(); - if (!string.IsNullOrEmpty(result)) - { - await MessageWindow.ShowErrorDialog(result, "YAML Generation Error", ParentWindow); - } - EnableExport(); - } - - private async void ExportButtonValidateYaml_OnClick(object? sender, RoutedEventArgs e) - { - DisableExport(); - var result = _service?.ValidateProject(); - if (!string.IsNullOrEmpty(result)) - { - await MessageWindow.ShowErrorDialog(result, "Validation Failed", ParentWindow); - } - else - { - await MessageWindow.ShowInfoDialog("Generated MSU and YAML file matches the project", "Validation Successful"); - } - EnableExport(); - } - - private void ExportButtonTrackList_OnClick(object? sender, RoutedEventArgs e) - { - _service?.WriteTrackList(); - } - - private void ExportButtonJson_OnClick(object? sender, RoutedEventArgs e) - { - _service?.WriteTrackJson(); - } - - private async void ExportButtonSwapper_OnClick(object? sender, RoutedEventArgs e) - { - DisableExport(); - var result = _service?.WriteSwapperBatchFiles(); - if (!string.IsNullOrEmpty(result)) - { - await MessageWindow.ShowErrorDialog(result, "Script Generation Failed", ParentWindow); - } - EnableExport(); - } - - private async void ExportButtonSmz3_OnClick(object? sender, RoutedEventArgs e) - { - DisableExport(); - var result = _service?.CreateSmz3SplitBatchFile(); - if (!string.IsNullOrEmpty(result)) - { - await MessageWindow.ShowErrorDialog(result, "Script Generation Failed", ParentWindow); - } - EnableExport(); - } - - private async void ExportButtonMsu_OnClick(object? sender, RoutedEventArgs e) - { - if (_service?.MsuProjectViewModel == null) return; - DisableExport(); - _service.WriteTrackJson(); - var window = new MsuPcmGenerationWindow(_service.MsuProjectViewModel, false); - await window.ShowDialog(ParentWindow); - EnableExport(); - } - - private async void OpenFolderMenuItem_OnClick(object? sender, RoutedEventArgs e) - { - if (_service?.OpenFolder() != true) - { - await MessageWindow.ShowErrorDialog("Could not open MSU folder. Make sure the directory exists.", "Could Not Open", ParentWindow); - } - } - - private async void ExportButtonVideo_OnClick(object? sender, RoutedEventArgs e) - { - if (_service?.MsuProjectViewModel == null) return; - DisableExport(); - var window = new VideoCreatorWindow(_service.MsuProjectViewModel); - await window.ShowDialog(ParentWindow); - EnableExport(); - } - - private async void ExportButtonPackage_OnClick(object? sender, RoutedEventArgs e) - { - if (_service?.MsuProjectViewModel == null || _service?.ArePcmFilesUpToDate() != true) return; - DisableExport(); - var packageWindow = new PackageMsuWindow(_service.MsuProjectViewModel); - await packageWindow.ShowDialog(ParentWindow); - EnableExport(); - } - - public async Task DisplayPendingChangesWindow() - { - if (await MessageWindow.ShowYesNoDialog("You currently have unsaved changes. Do you want to save your changes?", - "Save Changes?", ParentWindow)) - { - _service?.SaveProject(); - } - } - - private Window ParentWindow => TopLevel.GetTopLevel(this) as Window ?? App.MainWindow!; - - private void PopupFlyoutBase_OnOpening(object? sender, EventArgs e) - { - _service?.UpdateExportMenuOptions(); - } - - private void TrackOverviewPanel_OnOnSelectedTrack(object? sender, TrackEventArgs e) - { - _service?.SetToTrackPage(e.TrackNumber); - } - - private void DisableExport() - { - this.Find(nameof(ExportMenuButton))!.IsEnabled = false; - } - - private void EnableExport() - { - this.Find(nameof(ExportMenuButton))!.IsEnabled = true; - } - - private void ScratchPadMenuItem_OnClick(object? sender, RoutedEventArgs e) - { - _service?.SetPage(9999); - } -} \ No newline at end of file diff --git a/MSUScripter/Views/FormLabel.axaml b/MSUScripter/Views/FormLabel.axaml new file mode 100644 index 0000000..483125c --- /dev/null +++ b/MSUScripter/Views/FormLabel.axaml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + diff --git a/MSUScripter/Views/FormLabel.axaml.cs b/MSUScripter/Views/FormLabel.axaml.cs new file mode 100644 index 0000000..8e37221 --- /dev/null +++ b/MSUScripter/Views/FormLabel.axaml.cs @@ -0,0 +1,113 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Layout; + +namespace MSUScripter.Views; + +public partial class FormLabel : UserControl +{ + public FormLabel() + { + InitializeComponent(); + } + + protected override void OnLoaded(RoutedEventArgs e) + { + base.OnLoaded(e); + AbbreviatedToolTip = ToolTipText.Length > ToolTipCharacterLimit + ? string.Concat(ToolTipText.AsSpan(0, ToolTipCharacterLimit - 3), "...") + : ToolTipText; + UpdateStretch(); + } + + public static readonly StyledProperty LabelTextProperty = AvaloniaProperty.Register( + nameof(LabelText), defaultValue: "Label"); + + public string LabelText + { + get => GetValue(LabelTextProperty); + set => SetValue(LabelTextProperty, value); + } + + public static readonly StyledProperty ToolTipTextProperty = AvaloniaProperty.Register( + nameof(ToolTipText), defaultValue: "Label ToolTip"); + + public string ToolTipText + { + get => GetValue(ToolTipTextProperty); + set + { + SetValue(ToolTipTextProperty, value); + SetValue(AbbreviatedToolTipProperty, + value.Length > ToolTipCharacterLimit + ? string.Concat(value.AsSpan(0, ToolTipCharacterLimit - 3), "...") + : value); + } + } + + public static readonly StyledProperty DisplayToolTipIconProperty = AvaloniaProperty.Register( + nameof(DisplayToolTipIcon), defaultValue: true); + + public bool DisplayToolTipIcon + { + get => GetValue(DisplayToolTipIconProperty); + set => SetValue(DisplayToolTipIconProperty, value); + } + + public static readonly StyledProperty CanClickToolTipIconProperty = AvaloniaProperty.Register( + nameof(CanClickToolTipIcon), defaultValue: true); + + public bool CanClickToolTipIcon + { + get => GetValue(CanClickToolTipIconProperty); + set => SetValue(CanClickToolTipIconProperty, value); + } + + public static readonly StyledProperty AbbreviatedToolTipProperty = AvaloniaProperty.Register( + nameof(AbbreviatedToolTip), defaultValue: "Label ToolTip"); + + public string AbbreviatedToolTip + { + get => GetValue(AbbreviatedToolTipProperty); + set => SetValue(AbbreviatedToolTipProperty, value); + } + + public static readonly StyledProperty ToolTipCharacterLimitProperty = AvaloniaProperty.Register( + nameof(ToolTipCharacterLimit), defaultValue: 100); + + public int ToolTipCharacterLimit + { + get => GetValue(ToolTipCharacterLimitProperty); + set => SetValue(ToolTipCharacterLimitProperty, value); + } + + public static readonly StyledProperty StretchProperty = AvaloniaProperty.Register( + nameof(Stretch), defaultValue: false); + + public bool Stretch + { + get => GetValue(StretchProperty); + set + { + SetValue(StretchProperty, value); + UpdateStretch(); + } + } + + private void UpdateStretch() + { + var grid = this.Get(nameof(MainGrid)); + if (Stretch) + { + grid.ColumnDefinitions = new ColumnDefinitions("*, Auto"); + grid.HorizontalAlignment = HorizontalAlignment.Stretch; + } + else + { + grid.ColumnDefinitions = new ColumnDefinitions("Auto, *"); + grid.HorizontalAlignment = HorizontalAlignment.Left; + } + } +} \ No newline at end of file diff --git a/MSUScripter/Views/InstallDependenciesWindow.axaml b/MSUScripter/Views/InstallDependenciesWindow.axaml new file mode 100644 index 0000000..75444c0 --- /dev/null +++ b/MSUScripter/Views/InstallDependenciesWindow.axaml @@ -0,0 +1,346 @@ + + + + + + + + + + You are missing one or more dependencies which are required for full functionality. + + + + + MsuPcm++ + + + + + + + + + + Verified + + + + + + + + + + + + + + + + + + + + + FFmpeg + + + + + + + + + + Verified + + + + + + + + + + + + + + + + + + + + + Python Companion App + + + + + + + + + + Verified + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MSUScripter/Views/InstallDependenciesWindow.axaml.cs b/MSUScripter/Views/InstallDependenciesWindow.axaml.cs new file mode 100644 index 0000000..331e92b --- /dev/null +++ b/MSUScripter/Views/InstallDependenciesWindow.axaml.cs @@ -0,0 +1,96 @@ +using Avalonia.Controls; +using Avalonia.Interactivity; +using AvaloniaControls; +using AvaloniaControls.Controls; +using AvaloniaControls.Extensions; +using MSUScripter.Services.ControlServices; +using MSUScripter.ViewModels; + +namespace MSUScripter.Views; + +public partial class InstallDependenciesWindow : ScalableWindow +{ + private readonly InstallDependenciesWindowService? _service; + private readonly InstallDependenciesWindowViewModel _viewModel; + + public InstallDependenciesWindow() + { + InitializeComponent(); + + if (Design.IsDesignMode) + { + DataContext = _viewModel = (InstallDependenciesWindowViewModel)new InstallDependenciesWindowViewModel().DesignerExample(); + } + else + { + _service = this.GetControlService(); + DataContext = _viewModel = _service?.InitializeModel() ?? new InstallDependenciesWindowViewModel(); + } + } + + private void Control_OnLoaded(object? sender, RoutedEventArgs e) + { + _viewModel.DontRemindMeAgain = _viewModel.InitialDontRemindMeAgain; + } + + private void InstallMsuPcmButton_OnClick(object? sender, RoutedEventArgs e) + { + _ = _service?.InstallMsuPcm(); + } + + private void LinkControlOpenTagButton_OnClick(object? sender, RoutedEventArgs e) + { + if (sender is not LinkControl { Tag: string url }) + { + return; + } + + CrossPlatformTools.OpenUrl(url); + } + + private void RetryMsuPcmButton_OnClick(object? sender, RoutedEventArgs e) + { + _ = _service?.RetryMsuPcm(); + } + + private void RevalidateMsuPcmButton_OnClick(object? sender, RoutedEventArgs e) + { + _service?.RevalidateMsuPcm(); + } + + private void InstallFfmpegButton_OnClick(object? sender, RoutedEventArgs e) + { + _ = _service?.InstallFfmpeg(); + } + + private void RetryFfmpegButton_OnClick(object? sender, RoutedEventArgs e) + { + _ = _service?.RetryFfmpeg(); + } + + private void RevalidateFfmpegButton_OnClick(object? sender, RoutedEventArgs e) + { + _ = _service?.RevalidateFfmpeg(); + } + + private void InstallPyAppButton_OnClick(object? sender, RoutedEventArgs e) + { + _ = _service?.InstallPyApp(); + } + + private void RetryPyAppButton_OnClick(object? sender, RoutedEventArgs e) + { + _ = _service?.RetryPyApp(); + } + + private void RevalidatePyAppButton_OnClick(object? sender, RoutedEventArgs e) + { + _ = _service?.RevalidatePyApp(); + } + + private void CloseButton_OnClick(object? sender, RoutedEventArgs e) + { + _service?.SaveSettings(); + Close(); + } +} \ No newline at end of file diff --git a/MSUScripter/Views/MainWindow.axaml b/MSUScripter/Views/MainWindow.axaml index d755843..19b537b 100644 --- a/MSUScripter/Views/MainWindow.axaml +++ b/MSUScripter/Views/MainWindow.axaml @@ -6,66 +6,328 @@ xmlns:viewModels="clr-namespace:MSUScripter.ViewModels" xmlns:controls="clr-namespace:AvaloniaControls.Controls;assembly=AvaloniaControls" xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" - mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" + mc:Ignorable="d" d:DesignWidth="600" d:DesignHeight="500" x:Class="MSUScripter.Views.MainWindow" Icon="/Assets/MSUScripterIcon.ico" - MinWidth="1024" - MinHeight="768" - Width="1024" - Height="768" + MinWidth="600" + MinHeight="500" + Width="600" + Height="500" Loaded="Control_OnLoaded" Closing="Window_OnClosing" x:DataType="viewModels:MainWindowViewModel" Title="{Binding Title}"> - - - + + + Checking Dependencies... + + + + + + + + + + + + + + + + + + + + + - + + + - + - - A new version of the MSU Scripter is now available! - Click here to go to the download page. - - - - - + + + + + + + + + + + + + + + + + - - Don't Check for Updates + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MSUScripter/Views/MsuProjectWindow.axaml.cs b/MSUScripter/Views/MsuProjectWindow.axaml.cs new file mode 100644 index 0000000..f5f1603 --- /dev/null +++ b/MSUScripter/Views/MsuProjectWindow.axaml.cs @@ -0,0 +1,805 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Timers; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.Platform.Storage; +using Avalonia.Threading; +using Avalonia.VisualTree; +using AvaloniaControls; +using AvaloniaControls.Controls; +using AvaloniaControls.Extensions; +using AvaloniaControls.Models; +using MSUScripter.Configs; +using MSUScripter.Models; +using MSUScripter.Services.ControlServices; +using MSUScripter.Tools; +using MSUScripter.ViewModels; +using FileInputControlType = AvaloniaControls.FileInputControlType; +using Timer = System.Timers.Timer; + +namespace MSUScripter.Views; + +public partial class MsuProjectWindow : RestorableWindow +{ + private readonly MsuProjectWindowViewModel? _viewModel; + private readonly MsuProjectWindowService? _service; + private readonly Action _performTextFilter; + private readonly MainWindow? _parentWindow; + private readonly Timer _backupTimer = new(TimeSpan.FromSeconds(60)); + private ContextMenu? _currentContextMenu; + private MsuProjectWindowViewModelTreeData? _draggedTreeItem; + private MsuProjectWindowViewModelTreeData? _hoverValue; + private MsuProjectWindowViewModelTreeData? _previousHoverTreeItem; + private bool _forceClose; + + // ReSharper disable once UnusedMember.Global + public MsuProjectWindow() + { + InitializeComponent(); + DataContext = new MsuProjectWindowViewModel().DesignerExample(); + var performSearch = () => _service?.FilterTree(); + _performTextFilter = performSearch.Debounce(); + } + + public MsuProjectWindow(MsuProject project, MainWindow parentWindow) + { + _service = this.GetControlService(); + InitializeComponent(); + DataContext = _viewModel = _service!.InitViewModel(project); + var performSearch = () => _service?.FilterTree(); + _performTextFilter = performSearch.Debounce(200); + _parentWindow = parentWindow; + _backupTimer.Elapsed += BackupTimerOnElapsed; + _backupTimer.Start(); + AddHandler(DragDrop.DropEvent, DropFile); + } + + protected override string RestoreFilePath => Path.Combine(Directories.BaseFolder, "Windows", "project-window.json"); + protected override int DefaultWidth => 1280; + protected override int DefaultHeight => 800; + + private void DropFile(object? sender, DragEventArgs e) + { + var file = e.Data.GetFiles()?.FirstOrDefault(); + if (file == null || _viewModel?.CurrentTreeItem?.TrackInfo == null || _service == null) + { + return; + } + + if (_viewModel.CurrentTreeItem.SongInfo == null) + { + AddNewSong(_viewModel.CurrentTreeItem, false, file.Path.LocalPath); + return; + } + + _service?.DragDropFile(file.Path.LocalPath); + } + + public MsuProjectWindowCloseReason CloseReason { get; private set; } + + public string OpenProjectPath { get; private set; } = string.Empty; + + protected override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(e); + + if (_viewModel?.IsDraggingItem != true) + { + return; + } + + var listBox = this.GetControl(nameof(TreeListBox)); + var point = e.GetPosition(listBox); + var hit = listBox.InputHitTest(point); + + if (hit is Visual visual) + { + var control = visual.FindAncestorOfType(); + if (control is { Tag: MsuProjectWindowViewModelTreeData treeData } && treeData != _hoverValue) + { + _hoverValue = treeData; + _service?.UpdateHover(treeData); + } + } + else + { + _service?.UpdateHover(null); + } + } + + private void MenuButton_OnClick(object? sender, RoutedEventArgs e) + { + if (sender is not Control control) + { + return; + } + + StyledElement? currentControl = control; + while (currentControl is not null) + { + if (currentControl is Control { ContextMenu: { } contextMenu }) + { + contextMenu.Open(); + } + currentControl = currentControl.Parent; + } + } + + private void InputElement_OnDoubleTapped(object? sender, TappedEventArgs e) + { + if ((sender as Control)?.Tag is not MsuProjectWindowViewModelTreeData treeData || _service == null || treeData.ChildTreeData.Count == 0) + { + return; + } + + var songOuterPanel = this.Get(nameof(MsuSongPanel)); + var songBasicPanel = songOuterPanel.Get(nameof(songOuterPanel.MsuSongBasicPanel)); + var pyMusicLooperPanel = songBasicPanel.Get(nameof(songBasicPanel.PyMusicLooperPanel)); + pyMusicLooperPanel.Stop(); + + _service.SelectedTreeItem(treeData, false); + } + + private void TreeLeftIconButton_OnClick(object? sender, RoutedEventArgs e) + { + if ((sender as Control)?.Tag is not MsuProjectWindowViewModelTreeData treeData) + { + return; + } + + var songOuterPanel = this.Get(nameof(MsuSongPanel)); + var songBasicPanel = songOuterPanel.Get(nameof(songOuterPanel.MsuSongBasicPanel)); + var pyMusicLooperPanel = songBasicPanel.Get(nameof(songBasicPanel.PyMusicLooperPanel)); + pyMusicLooperPanel.Stop(); + + _service?.SelectedTreeItem(treeData, true); + } + + private void TreeItemInputElement_OnPointerPressed(object? sender, PointerPressedEventArgs e) + { + if (sender is not Control { Tag: MsuProjectWindowViewModelTreeData treeData } control) + { + return; + } + + var point = e.GetCurrentPoint(control); + if (point.Properties.IsLeftButtonPressed) + { + var summaryBorder = this.GetControl(nameof(SummaryBorder)); + MsuProjectWindowViewModelTreeData.HighlightColor = summaryBorder.Background ?? Brushes.LightSlateGray; + + _service?.UpdateDrag(treeData); + _draggedTreeItem = treeData; + control.Cursor = new Cursor(StandardCursorType.DragMove); + } + else if (point.Properties.IsRightButtonPressed) + { + var contextMenu = control.ContextMenu; + if (contextMenu == null) + { + return; + } + + _currentContextMenu?.Close(); + _currentContextMenu = contextMenu; + contextMenu.PlacementTarget = control; + contextMenu.Open(); + e.Handled = true; + } + } + + private void TreeItemInputElement_OnPointerReleased(object? sender, PointerReleasedEventArgs e) + { + if (_draggedTreeItem == null) + { + return; + } + + if (sender is not Control { Tag: MsuProjectWindowViewModelTreeData } control) + { + return; + } + + _service?.UpdateDrag(null); + control.Cursor = Cursor.Default; + } + + private void TreeItemInputElement_OnPointerEntered(object? sender, PointerEventArgs e) + { + if (_viewModel?.IsDraggingItem == true) return; + if ((sender as Control)?.Tag is not MsuProjectWindowViewModelTreeData treeData) + { + return; + } + + if (_previousHoverTreeItem != null) + { + _previousHoverTreeItem.ShowAddButton = false; + _previousHoverTreeItem.ShowMenuButton = false; + } + + if (treeData.TrackInfo != null || treeData.SongInfo != null) + { + treeData.ShowAddButton = true; + } + + if (treeData.SongInfo != null) + { + treeData.ShowMenuButton = true; + } + + _previousHoverTreeItem = treeData; + } + + private void TreeItemInputElement_OnPointerExited(object? sender, PointerEventArgs e) + { + if (_viewModel?.IsDraggingItem == true) return; + if ((sender as Control)?.Tag is not MsuProjectWindowViewModelTreeData treeData) + { + return; + } + + treeData.ShowAddButton = false; + treeData.ShowMenuButton = false; + _previousHoverTreeItem = null; + } + + private void DisplayIsCompleteMenuItem_OnClick(object? sender, RoutedEventArgs e) + { + _service?.ToggleCompletedIcons(); + } + + private void DisplayHasAudioMenuItem_OnClick(object? sender, RoutedEventArgs e) + { + _service?.ToggleHasAudioIcons(); + } + + private void DisplayCopyrightTestMenuItem_OnClick(object? sender, RoutedEventArgs e) + { + _service?.ToggleCheckCopyrightIcons(); + } + + private void DisplayCopyrightStatusMenuItem_OnClick(object? sender, RoutedEventArgs e) + { + _service?.ToggleCopyrightStatusIcons(); + } + + private void TreeListBox_OnSelectionChanged(object? sender, SelectionChangedEventArgs e) + { + if (e.AddedItems is [MsuProjectWindowViewModelTreeData treeData]) + { + var songOuterPanel = this.Get(nameof(MsuSongPanel)); + var songBasicPanel = songOuterPanel.Get(nameof(songOuterPanel.MsuSongBasicPanel)); + var pyMusicLooperPanel = songBasicPanel.Get(nameof(songBasicPanel.PyMusicLooperPanel)); + pyMusicLooperPanel.Stop(); + + _service?.SelectedTreeItem(treeData, false); + } + } + + private void MsuSongPanel_OnNewSongClicked(object? sender, EventArgs e) + { + AddNewSong(); + } + + private void MsuSongPanel_OnInputFileUpdated(object? sender, EventArgs e) + { + _service?.InputFileUpdated(); + } + + private void InsertSongButton_OnClick(object? sender, RoutedEventArgs e) + { + if (sender is not Button { Tag: MsuProjectWindowViewModelTreeData treeData }) + { + return; + } + + AddNewSong(treeData); + } + + private void TreeContextMenuBase_OnOpened(object? sender, RoutedEventArgs e) + { + if (sender is not ContextMenu { Tag: MsuProjectWindowViewModelTreeData treeData } || _viewModel == null) + { + return; + } + + _viewModel.SelectedTreeItem = treeData; + treeData.CanDelete = treeData.SongInfo != null; + } + + private void DeleteSongMenuItem_OnClick(object? sender, RoutedEventArgs e) + { + if (sender is not MenuItem { Tag: MsuProjectWindowViewModelTreeData treeData } || _viewModel == null) + { + return; + } + + _service?.RemoveSong(treeData); + } + + private async void CopySongMenuItem_OnClick(object? sender, RoutedEventArgs e) + { + try + { + if (sender is not MenuItem { Tag: MsuProjectWindowViewModelTreeData treeData } || _viewModel == null) + { + return; + } + + var songYaml = _service?.GetSongCopyDetails(treeData); + await this.SetClipboardAsync(songYaml); + } + catch (Exception ex) + { + _service?.LogError(ex, "Error copying song"); + await MessageWindow.ShowErrorDialog(_viewModel!.Text.GenericError, _viewModel.Text.GenericErrorTitle, this); + } + } + + private async void PasteMenuItem_OnClick(object? sender, RoutedEventArgs e) + { + try + { + if (sender is not MenuItem { Tag: MsuProjectWindowViewModelTreeData treeData } || _viewModel == null) + { + return; + } + + var songYaml = await this.GetClipboardAsync(); + if (string.IsNullOrEmpty(songYaml)) + { + return; + } + + _service?.PasteSongDetails(treeData, songYaml); + _viewModel.MsuSongViewModel.UpdateViewModel(_viewModel.MsuProject!, treeData.TrackInfo!, treeData.SongInfo, treeData); + } + catch (Exception ex) + { + _service?.LogError(ex, "Error pasting song"); + await MessageWindow.ShowErrorDialog(_viewModel!.Text.GenericError, _viewModel.Text.GenericErrorTitle, this); + } + } + + private void DuplicateSongMenuItem_OnClick(object? sender, RoutedEventArgs e) + { + if (sender is not MenuItem { Tag: MsuProjectWindowViewModelTreeData treeData }) + { + return; + } + + AddNewSong(treeData, true); + } + + private void Window_OnPointerPressed(object? sender, PointerPressedEventArgs e) + { + if (!e.Handled && _currentContextMenu != null) + { + _currentContextMenu.Close(); + _currentContextMenu = null; + } + } + + private void TreeItemMenuBase_OnClosed(object? sender, RoutedEventArgs e) + { + if (Equals(sender, _currentContextMenu) && _currentContextMenu != null) + { + _currentContextMenu = null; + } + } + + private void ToggleCompleteButton_OnClick(object? sender, RoutedEventArgs e) + { + if (sender is not Button { Tag: MsuProjectWindowViewModelTreeData treeData }) + { + return; + } + + _service?.UpdateCompletedFlag(treeData); + + e.Handled = true; + } + + private void ToggleCheckCopyrightButton_OnClick(object? sender, RoutedEventArgs e) + { + if (sender is not Button { Tag: MsuProjectWindowViewModelTreeData treeData }) + { + return; + } + + _service?.UpdateCheckCopyright(treeData); + + e.Handled = true; + } + + private void ToggleCopyrightSafeButton_OnClick(object? sender, RoutedEventArgs e) + { + if (sender is not Button { Tag: MsuProjectWindowViewModelTreeData treeData }) + { + return; + } + + _service?.UpdateCopyrightSafe(treeData); + + e.Handled = true; + } + + private void MsuSongPanel_OnIsCompleteUpdated(object? sender, EventArgs e) + { + _service?.UpdateCompletedSummary(); + } + + private void FilterTextBox_OnTextChanged(object? sender, TextChangedEventArgs e) + { + _performTextFilter(); + } + + private void ToggleFilterTracksMissingSongsMenuItem_OnClick(object? sender, RoutedEventArgs e) + { + _service?.ToggleFilterTracksMissingSongs(); + } + + private void ToggleFilterIncompleteMenuItem_OnClick(object? sender, RoutedEventArgs e) + { + _service?.ToggleFilterIncomplete(); + } + + private void ToggleFilterMissingAudioMenuItem_OnClick(object? sender, RoutedEventArgs e) + { + _service?.ToggleFilterMissingAudio(); + } + + private void ToggleCopyrightUntestedMenuItem_OnClick(object? sender, RoutedEventArgs e) + { + _service?.ToggleFilterCopyrightUntested(); + } + + private void WindowControl_OnLoaded(object? sender, RoutedEventArgs e) + { + var summaryBorder = this.GetControl(nameof(SummaryBorder)); + MsuProjectWindowViewModelTreeData.HighlightColor = summaryBorder.Background ?? Brushes.LightSlateGray; + MsuSongAdvancedPanelViewModelModelTreeData.HighlightColor = summaryBorder.Background ?? Brushes.LightSlateGray; + _parentWindow?.Hide(); + + foreach (var recentProject in _viewModel?.RecentProjects ?? []) + { + var menuItem = new MenuItem { Header = recentProject.ProjectName, Tag = recentProject.ProjectPath }; + menuItem.SetValue(ToolTip.TipProperty, recentProject.ProjectPath); + menuItem.Click += (_, _) => + { + CloseReason = MsuProjectWindowCloseReason.OpenProject; + OpenProjectPath = recentProject.ProjectPath; + Close(); + }; + BrowseMenu.Items.Add(menuItem); + } + + try + { + var menuItem = this.Find - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - | - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/MSUScripter/Views/MsuSongInfoPanel.axaml.cs b/MSUScripter/Views/MsuSongInfoPanel.axaml.cs deleted file mode 100644 index 24cb101..0000000 --- a/MSUScripter/Views/MsuSongInfoPanel.axaml.cs +++ /dev/null @@ -1,264 +0,0 @@ -using System; -using System.ComponentModel; -using System.Linq; -using System.Threading.Tasks; -using Avalonia; -using Avalonia.Controls; -using Avalonia.Interactivity; -using Avalonia.Markup.Xaml; -using AvaloniaControls; -using AvaloniaControls.Controls; -using AvaloniaControls.Extensions; -using AvaloniaControls.Models; -using MSUScripter.Services.ControlServices; -using MSUScripter.Tools; -using MSUScripter.ViewModels; -using FileInputControlType = AvaloniaControls.FileInputControlType; - -namespace MSUScripter.Views; - -public partial class MsuSongInfoPanel : UserControl -{ - public static readonly StyledProperty SongProperty = AvaloniaProperty.Register( - nameof(Song)); - - private MsuSongInfoPanelService? _service; - - public MsuSongInfoViewModel Song - { - get => GetValue(SongProperty); - set => SetValue(SongProperty, value); - } - - public MsuSongInfoPanel() - { - InitializeComponent(); - - if (Design.IsDesignMode) - { - DataContext = new MsuSongMsuPcmInfoViewModel().DesignerExample(); - } - else - { - _service = this.GetControlService(); - } - - SongProperty.Changed.Subscribe(x => - { - if (x.Sender != this || (MsuSongInfoViewModel?)x.NewValue.Value == null) - { - return; - } - _service?.InitializeModel(x.NewValue.Value); - }); - } - - private void InitializeComponent() - { - AvaloniaXamlLoader.Load(this); - } - - private async void RemoveButton_OnClick(object? sender, RoutedEventArgs e) - { - var response = await MessageWindow.ShowYesNoDialog("Are you sure you want to delete this song?", "Delete song?", - TopLevel.GetTopLevel(this) as Window); - if (response) - { - _service?.DeleteSong(); - } - } - - private async void PlaySongButton_OnClick(object? sender, RoutedEventArgs e) - { - if (_service == null) return; - var errorMessage = await _service.PlaySong(false); - if (!string.IsNullOrEmpty(errorMessage)) - { - await MessageWindow.ShowErrorDialog(errorMessage, "Error", TopLevel.GetTopLevel(this) as Window); - } - } - - private async void TestLoopButton_OnClick(object? sender, RoutedEventArgs e) - { - if (_service == null) return; - var errorMessage = await _service.PlaySong(true); - if (!string.IsNullOrEmpty(errorMessage)) - { - await MessageWindow.ShowErrorDialog(errorMessage, "Error", TopLevel.GetTopLevel(this) as Window); - } - } - - private async void ImportSongMetadataButton_OnClick(object? sender, RoutedEventArgs e) - { - var directory = _service?.GetOpenMusicFilePath() ?? await this.GetDocumentsFolderPath(); - if (string.IsNullOrEmpty(directory)) - { - return; - } - - var file = await CrossPlatformTools.OpenFileDialogAsync(TopLevel.GetTopLevel(this) as Window ?? App.MainWindow!, - FileInputControlType.OpenFile, filter: "All Files:*.*", path: directory, title: "Select Audio File"); - var topLevel = TopLevel.GetTopLevel(this); - if (topLevel == null) return; - - if (!string.IsNullOrEmpty(file?.Path.LocalPath)) - { - _service?.ImportAudioMetadata(file.Path.LocalPath); - } - } - - private async void StopMusicButton_OnClick(object? sender, RoutedEventArgs e) - { - if (_service == null) return; - await _service.PauseSong(); - } - - private async void MenuButton_OnClick(object? sender, RoutedEventArgs e) - { - if (sender is not Button button) - { - return; - } - - var contextMenu = button.ContextMenu; - if (contextMenu == null) - { - return; - } - - if (contextMenu.Items.FirstOrDefault(x => x is MenuItem { Name: "PasteMenuItem" }) is MenuItem pasteMenuItem) - { - pasteMenuItem.IsEnabled = !string.IsNullOrEmpty((await this.GetClipboardAsync())?.Trim()); - } - - contextMenu.PlacementTarget = button; - contextMenu.Open(); - e.Handled = true; - } - - private void DuplicateSongMenuItem_OnClick(object? sender, RoutedEventArgs e) - { - var window = new DuplicateMoveTrackWindow(Song.Project, - Song.Project.Tracks.First(x => x.TrackNumber == Song.TrackNumber), Song, CopyMoveType.Copy); - window.ShowDialog(App.MainWindow!); - } - - private void MoveSongMenuItem_OnClick(object? sender, RoutedEventArgs e) - { - var window = new DuplicateMoveTrackWindow(Song.Project, - Song.Project.Tracks.First(x => x.TrackNumber == Song.TrackNumber), Song, CopyMoveType.Move); - window.ShowDialog(App.MainWindow!); - } - - private void SwapSongMenuItem_OnClick(object? sender, RoutedEventArgs e) - { - var window = new DuplicateMoveTrackWindow(Song.Project, - Song.Project.Tracks.First(x => x.TrackNumber == Song.TrackNumber), Song, CopyMoveType.Swap); - window.ShowDialog(App.MainWindow!); - } - - private async void CopySongToClipboardMenuItem_OnClick(object? sender, RoutedEventArgs e) - { - var yamlText = _service?.GetCopyDetailsString(); - if (string.IsNullOrEmpty(yamlText)) return; - await this.SetClipboardAsync(yamlText); - } - - private async void PasteSongFromClipboardMenuItem_OnClick(object? sender, RoutedEventArgs e) - { - var yamlText = await this.GetClipboardAsync(); - if (string.IsNullOrEmpty(yamlText)) return; - var error = _service?.CopyDetailsFromString(yamlText); - if (!string.IsNullOrEmpty(error)) - { - await MessageWindow.ShowErrorDialog(error, "Error", TopLevel.GetTopLevel(this) as Window); - } - } - - private async void GenerateAsMainPcmFileButton_OnClick(object? sender, RoutedEventArgs e) - { - await GeneratePcm(true, false); - } - - private async void GeneratePcmFileButton_OnClick(object? sender, RoutedEventArgs e) - { - await GeneratePcm(false, false); - } - - private async void CreateEmptyPcmFileButton_OnClick(object? sender, RoutedEventArgs e) - { - await GeneratePcm(false, true); - } - - private async Task GeneratePcm(bool asPrimary, bool asEmpty) - { - if (_service == null) return; - - var response = await _service.GeneratePcmFile(asPrimary, asEmpty); - if (!response.Successful) - { - await MessageWindow.ShowErrorDialog(response.Message ?? "Unknown error generating the PCM file via msupcm++", "Error", TopLevel.GetTopLevel(this) as Window); - } - else if (response is { Successful: false, GeneratedPcmFile: true }) - { - var window = new MessageWindow(new MessageWindowRequest - { - Message = response.Message ?? "Unknown error generating the PCM file via msupcm++", - Buttons = MessageWindowButtons.OK, - Icon = MessageWindowIcon.Warning, - CheckBoxText = "Ignore future warnings for this song" - }); - - await window.ShowDialog(TopLevel.GetTopLevel(this) as Control ?? this); - - if (window.DialogResult is { PressedAcceptButton: true, CheckedBox: true }) - { - _service.IgnoreMsuPcmError(); - } - } - } - - private void TestAudioLevelButton_OnClick(object? sender, RoutedEventArgs e) - { - _service?.AnalyzeAudio(); - } - - private async void ContextMenu_OnOpening(object? sender, CancelEventArgs e) - { - if (sender is not ContextMenu contextMenu) - { - return; - } - - if (contextMenu.Items.FirstOrDefault(x => x is MenuItem { Name: "PasteMenuItem" }) is MenuItem pasteMenuItem) - { - pasteMenuItem.IsEnabled = !string.IsNullOrEmpty((await this.GetClipboardAsync())?.Trim()); - } - } - - private void IsCopyrightSafeButton_OnClick(object? sender, RoutedEventArgs e) - { - if (Song.IsCopyrightSafe == null) - { - Song.IsCopyrightSafe = true; - } - else if (Song.IsCopyrightSafe == true) - { - Song.IsCopyrightSafe = false; - } - else - { - Song.IsCopyrightSafe = null; - } - } - - private void CheckCopyrightButton_OnClick(object? sender, RoutedEventArgs e) - { - Song.CheckCopyright = !Song.CheckCopyright; - } - - private void IsCompleteButton_OnClick(object? sender, RoutedEventArgs e) - { - Song.IsComplete = !Song.IsComplete; - } -} \ No newline at end of file diff --git a/MSUScripter/Views/MsuSongMsuPcmInfoPanel.axaml b/MSUScripter/Views/MsuSongMsuPcmInfoPanel.axaml deleted file mode 100644 index 8eaf2d8..0000000 --- a/MSUScripter/Views/MsuSongMsuPcmInfoPanel.axaml +++ /dev/null @@ -1,242 +0,0 @@ - - - - - - - - - Simultaneous Sub Channel and Sub Track - - - - - - Multiple Input Files - - - - - - Non-44100Hz File - - - - - - - - - - - - - - - - - - - - - - - - - - Start: - - - End: - - - - - - - - - - - - - - - - - - - - - - - In: - - Out: - - Cross: - - - - - - - - - - - - - Start: - - End: - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/MSUScripter/Views/MsuSongMsuPcmInfoPanel.axaml.cs b/MSUScripter/Views/MsuSongMsuPcmInfoPanel.axaml.cs deleted file mode 100644 index 5218107..0000000 --- a/MSUScripter/Views/MsuSongMsuPcmInfoPanel.axaml.cs +++ /dev/null @@ -1,245 +0,0 @@ -using System; -using System.ComponentModel; -using System.Linq; -using System.Threading.Tasks; -using Avalonia; -using Avalonia.Controls; -using Avalonia.Interactivity; -using Avalonia.Markup.Xaml; -using AvaloniaControls; -using AvaloniaControls.Controls; -using AvaloniaControls.Extensions; -using AvaloniaControls.Models; -using MSUScripter.Services.ControlServices; -using MSUScripter.ViewModels; - -namespace MSUScripter.Views; - -public partial class MsuSongMsuPcmInfoPanel : UserControl -{ - private readonly MsuSongMsuPcmInfoPanelService? _service; - - public static readonly StyledProperty MsuPcmDataProperty = AvaloniaProperty.Register( - "MsuPcmData"); - - public MsuSongMsuPcmInfoViewModel MsuPcmData - { - get => GetValue(MsuPcmDataProperty); - set => SetValue(MsuPcmDataProperty, value); - } - - public MsuSongMsuPcmInfoPanel() - { - InitializeComponent(); - - if (Design.IsDesignMode) - { - DataContext = new MsuSongMsuPcmInfoViewModel().DesignerExample(); - } - else - { - _service = this.GetControlService(); - } - - MsuPcmDataProperty.Changed.Subscribe(x => - { - if (x.Sender != this || (MsuSongMsuPcmInfoViewModel?)x.NewValue.Value == null) - { - return; - } - _service?.InitializeModel(x.NewValue.Value); - }); - } - - private void InitializeComponent() - { - AvaloniaXamlLoader.Load(this); - } - - private async void RemoveButton_OnClick(object? sender, RoutedEventArgs e) - { - if (!await MessageWindow.ShowYesNoDialog("Are you sure you want to delete these msupcm++ details?", - "Delete details")) - { - return; - } - - _service?.Delete(); - } - - private async void AddSubTrackButton_OnClick(object? sender, RoutedEventArgs e) - { - await ShowSubTracksSubChannelsWarningPopup(true, false); - _service?.AddSubTrack(); - } - - private async void AddSubChannelButton_OnClick(object? sender, RoutedEventArgs e) - { - await ShowSubTracksSubChannelsWarningPopup(false, true); - _service?.AddSubChannel(); - } - - private async Task ShowSubTracksSubChannelsWarningPopup(bool newSubTrack, bool newSubChannel) - { - if (_service?.ShouldShowSubTracksSubChannelsWarningPopup(newSubTrack, newSubChannel) == true) - { - var window = new MessageWindow(new MessageWindowRequest - { - Message = "PCM files can't be generated with both a sub track and a sub channel at the same level. Before generating the PCM, you'll need to make sure it has one or the other.", - Title = "Warning", - Icon = MessageWindowIcon.Warning, - Buttons = MessageWindowButtons.OK, - CheckBoxText = "Don't show this again" - }); - - await window.ShowDialog(this); - - if (window.DialogResult?.CheckedBox == true) - { - _service?.HideSubTracksSubChannelsWarning(); - } - } - } - - private async void LoopWindowButton_OnClick(object? sender, RoutedEventArgs e) - { - if (_service?.HasLoopDetails() == true) - { - var result = await MessageWindow.ShowYesNoDialog( - "Either the trim end or loop point have a value. Are you sure you want to overwrite them?", - "Override Loop Data?", TopLevel.GetTopLevel(this) as Window); - if (!result) - return; - } - - var window = new PyMusicLooperWindow(); - window.SetDetails(MsuPcmData.Project, MsuPcmData.Song, MsuPcmData); - var loopResult = await window.ShowDialog(); - if (loopResult != null) - { - _service?.UpdateLoopSettings(loopResult); - } - } - - private async void GetTrimStartButton_OnClick(object? sender, RoutedEventArgs e) - { - var response = _service?.GetStartingSamples(); - if (!string.IsNullOrEmpty(response)) - { - await MessageWindow.ShowErrorDialog(response, "Error", TopLevel.GetTopLevel(this) as Window); - } - } - - private async void GetTrimEndButton_OnClick(object? sender, RoutedEventArgs e) - { - var response = _service?.GetEndingSamples(); - if (!string.IsNullOrEmpty(response)) - { - await MessageWindow.ShowErrorDialog(response, "Error", TopLevel.GetTopLevel(this) as Window); - } - } - - private async void MenuButton_OnClick(object? sender, RoutedEventArgs e) - { - if (sender is not Button button) - { - return; - } - - var contextMenu = button.ContextMenu; - if (contextMenu == null) - { - return; - } - - await UpdateContextMenu(contextMenu); - - contextMenu.PlacementTarget = button; - contextMenu.Open(); - e.Handled = true; - } - - private async void Copy_OnClick(object? sender, RoutedEventArgs e) - { - var yaml = _service?.GetCopyDetailsString(); - - if (string.IsNullOrEmpty(yaml)) - { - return; - } - - await this.SetClipboardAsync(yaml); - } - - private async void PasteMenuItem_OnClick(object? sender, RoutedEventArgs e) - { - var yamlText = await this.GetClipboardAsync(); - - if (yamlText == null) - { - return; - } - - var error = _service?.CopyDetailsFromString(yamlText); - - if (!string.IsNullOrEmpty(error)) - { - await MessageWindow.ShowErrorDialog(error, "Error", TopLevel.GetTopLevel(this) as Window); - } - } - - private async void ContextMenu_OnOpening(object? sender, CancelEventArgs e) - { - if (sender is not ContextMenu contextMenu) - { - return; - } - - await UpdateContextMenu(contextMenu); - } - - private void Insert_OnClick(object? sender, RoutedEventArgs e) - { - if (MsuPcmData.ParentMsuPcmInfo == null) - { - return; - } - - if (MsuPcmData.IsSubChannel) - { - - var index = MsuPcmData.ParentMsuPcmInfo.SubChannels.IndexOf(MsuPcmData); - _service?.AddSubChannel(index, true); - } - else if (MsuPcmData.IsSubTrack) - { - var index = MsuPcmData.ParentMsuPcmInfo.SubTracks.IndexOf(MsuPcmData); - _service?.AddSubTrack(index, true); - } - } - - private void FileControl_OnOnUpdated(object? sender, FileControlUpdatedEventArgs e) - { - _service?.ImportAudioMetadata(); - } - - private async Task UpdateContextMenu(ContextMenu contextMenu) - { - if (contextMenu.Items.FirstOrDefault(x => x is MenuItem { Name: "PasteMenuItem" }) is MenuItem pasteMenuItem) - { - pasteMenuItem.IsEnabled = !string.IsNullOrEmpty((await this.GetClipboardAsync())?.Trim()); - } - - _service?.UpdateContextMenuOptions(); - } - - private void MoveUpMenuItem_OnClick(object? sender, RoutedEventArgs e) - { - _service?.MoveUp(); - } - - private void MoveDownMenuItem_OnClick(object? sender, RoutedEventArgs e) - { - _service?.MoveDown(); - } -} \ No newline at end of file diff --git a/MSUScripter/Views/MsuSongOuterPanel.axaml b/MSUScripter/Views/MsuSongOuterPanel.axaml new file mode 100644 index 0000000..952307f --- /dev/null +++ b/MSUScripter/Views/MsuSongOuterPanel.axaml @@ -0,0 +1,146 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MSUScripter/Views/MsuSongOuterPanel.axaml.cs b/MSUScripter/Views/MsuSongOuterPanel.axaml.cs new file mode 100644 index 0000000..b037306 --- /dev/null +++ b/MSUScripter/Views/MsuSongOuterPanel.axaml.cs @@ -0,0 +1,291 @@ +using System; +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Interactivity; +using AvaloniaControls; +using AvaloniaControls.Controls; +using AvaloniaControls.Extensions; +using AvaloniaControls.Models; +using MSUScripter.Models; +using MSUScripter.Services.ControlServices; +using MSUScripter.Tools; +using MSUScripter.ViewModels; + +namespace MSUScripter.Views; + +public partial class MsuSongOuterPanel : UserControl +{ + private MsuSongOuterPanelViewModel? _viewModel; + private readonly MsuSongPanelService? _service; + private string? _previousTracksJsonPath; + + public MsuSongOuterPanel() + { + InitializeComponent(); + if (Design.IsDesignMode) + { + DataContext = _viewModel = (MsuSongOuterPanelViewModel)new MsuSongOuterPanelViewModel().DesignerExample(); + } + else + { + _service = this.GetControlService(); + } + } + + public event EventHandler? NewSongClicked; + + public event EventHandler? IsCompleteUpdated; + + public event EventHandler? InputFileUpdated; + + protected override void OnLoaded(RoutedEventArgs e) + { + base.OnLoaded(e); + _viewModel = DataContext as MsuSongOuterPanelViewModel ?? new MsuSongOuterPanelViewModel(); + this.GetControl(nameof(MsuSongBasicPanel)).Service = _service; + this.GetControl(nameof(MsuSongAdvancedPanel)).Service = _service; + } + + private void MsuSongBasicPanel_OnAdvancedModeToggled(object? sender, EventArgs e) + { + if (_viewModel?.SongInfo is null) return; + _viewModel.SongInfo.DisplayAdvancedMode = true; + _viewModel.BasicPanelViewModel.SaveChanges(); + _viewModel.BasicPanelViewModel.IsEnabled = false; + _viewModel.AdvancedPanelViewModel.UpdateViewModel(_viewModel.Project!, _viewModel.TrackInfo!, _viewModel.SongInfo!, _viewModel.TreeData!); + } + + private void MsuSongAdvancedPanel_OnAdvancedModeToggled(object? sender, EventArgs e) + { + if (_viewModel?.SongInfo is null) return; + _viewModel.SongInfo.DisplayAdvancedMode = false; + _viewModel.AdvancedPanelViewModel.SaveChanges(); + _viewModel.AdvancedPanelViewModel.IsEnabled = false; + _viewModel.BasicPanelViewModel.UpdateViewModel(_viewModel.Project!, _viewModel.TrackInfo!, _viewModel.SongInfo!, _viewModel.TreeData!); + } + + private void AddSongButton_OnClick(object? sender, RoutedEventArgs e) + { + NewSongClicked?.Invoke(this, EventArgs.Empty); + } + + private void IconCheckbox_OnOnChecked(object? sender, OnIconCheckboxCheckedEventArgs e) + { + IsCompleteUpdated?.Invoke(this, EventArgs.Empty); + } + + private void MsuSongAdvancedPanel_OnInputFileChanged(object? sender, EventArgs e) + { + InputFileUpdated?.Invoke(this, EventArgs.Empty); + } + + private void MsuSongBasicPanel_OnInputFileUpdated(object? sender, EventArgs e) + { + InputFileUpdated?.Invoke(this, EventArgs.Empty); + } + + private async void PlaySongButton_OnClick(object? sender, RoutedEventArgs e) + { + try + { + if (_viewModel?.Project == null || _viewModel?.SongInfo == null || _service == null) + { + return; + } + + _viewModel.SaveChanges(); + var response = await _service.PlaySong(_viewModel.Project, _viewModel.SongInfo, false); + await HandleMsuPcmResponse(response); + } + catch (Exception ex) + { + _service?.LogError(ex, "Error playing song"); + await MessageWindow.ShowErrorDialog(_viewModel!.Text.GenericError, _viewModel.Text.GenericErrorTitle, this.GetTopLevelWindow()); + } + } + + private async Task HandleMsuPcmResponse(GeneratePcmFileResponse response) + { + if (response.Successful) + { + return; + } + + if (response.GeneratedPcmFile && !string.IsNullOrEmpty(response.Message)) + { + var messageWindow = new MessageWindow(new MessageWindowRequest + { + Message = $"MsuPcm++ generated the file, but with the following error: {response.Message}", + Title = "PCM Generation Error", + CheckBoxText = "Ignore this error for future songs" + }); + await messageWindow.ShowDialog(this.GetTopLevelWindow()); + if (messageWindow.DialogResult?.CheckedBox == true) + { + _viewModel?.Project?.IgnoreWarnings.Add(response.Message); + } + } + else + { + await MessageWindow.ShowErrorDialog(response.Message ?? "Error generating PCM file", "PCM Generation Error", + TopLevel.GetTopLevel(this) as Window); + } + } + + private async void TestLoopButton_OnClick(object? sender, RoutedEventArgs e) + { + try + { + if (_viewModel?.Project == null || _viewModel?.SongInfo == null || _service == null) + { + return; + } + _viewModel.SaveChanges(); + var response = await _service.PlaySong(_viewModel.Project, _viewModel.SongInfo, true); + await HandleMsuPcmResponse(response); + } + catch (Exception ex) + { + _service?.LogError(ex, "Error playing song"); + await MessageWindow.ShowErrorDialog(_viewModel!.Text.GenericError, _viewModel.Text.GenericErrorTitle, this.GetTopLevelWindow()); + } + } + + private async void GeneratePcmSplitButton_OnClick(object? sender, RoutedEventArgs e) + { + try + { + if (_viewModel?.Project == null || _viewModel?.SongInfo == null || _service == null) + { + return; + } + + _viewModel.SaveChanges(); + var response = await _service.GeneratePcm(_viewModel.Project, _viewModel.SongInfo, false, false); + await HandleMsuPcmResponse(response); + } + catch (Exception ex) + { + _service?.LogError(ex, "Error generating PCM file"); + await MessageWindow.ShowErrorDialog(_viewModel!.Text.GenericError, _viewModel.Text.GenericErrorTitle, this.GetTopLevelWindow()); + } + } + + private async void GenerateBlankPcmMenuItem_OnClick(object? sender, RoutedEventArgs e) + { + try + { + if (_viewModel?.Project == null || _viewModel?.SongInfo == null || _service == null) + { + return; + } + + _viewModel.SaveChanges(); + var response = await _service.GeneratePcm(_viewModel.Project, _viewModel.SongInfo, false, true); + await HandleMsuPcmResponse(response); + } + catch (Exception ex) + { + _service?.LogError(ex, "Error generating PCM file"); + await MessageWindow.ShowErrorDialog(_viewModel!.Text.GenericError, _viewModel.Text.GenericErrorTitle, this.GetTopLevelWindow()); + } + } + + private async void GeneratePrimaryPcmMenuItem_OnClick(object? sender, RoutedEventArgs e) + { + try + { + if (_viewModel?.Project == null || _viewModel?.SongInfo == null || _service == null) + { + return; + } + + _viewModel.SaveChanges(); + var response = await _service.GeneratePcm(_viewModel.Project, _viewModel.SongInfo, true, false); + if (!response.Successful) + { + _ = MessageWindow.ShowErrorDialog(response.Message ?? "Could not generate PCM file", "Error", + TopLevel.GetTopLevel(this) as Window); + } + } + catch (Exception ex) + { + _service?.LogError(ex, "Error generating PCM file"); + await MessageWindow.ShowErrorDialog(_viewModel!.Text.GenericError, _viewModel.Text.GenericErrorTitle, this.GetTopLevelWindow()); + } + } + + private async void TestAudioLevelButton_OnClick(object? sender, RoutedEventArgs e) + { + try + { + if (_viewModel?.Project == null || _viewModel?.SongInfo == null || _service == null) + { + return; + } + + _viewModel.SaveChanges(); + + _viewModel.AverageAudioLevel = "Running audio analysis"; + _viewModel.PeakAudioLevel = ""; + _viewModel.DisplaySecondAudioLine = false; + + var output = await _service.AnalyzeAudio(_viewModel.Project, _viewModel.SongInfo); + + if (output is { AvgDecibels: not null, MaxDecibels: not null }) + { + _viewModel.AverageAudioLevel = $"Average: {Math.Round(output.AvgDecibels.Value, 2)}db"; + _viewModel.PeakAudioLevel = $"Peak: {Math.Round(output.MaxDecibels.Value, 2)}db"; + _viewModel.DisplaySecondAudioLine = true; + } + else if (output is null) + { + _viewModel.AverageAudioLevel = "Could not generate audio file"; + _viewModel.PeakAudioLevel = ""; + _viewModel.DisplaySecondAudioLine = false; + } + else + { + _viewModel.AverageAudioLevel = "Error analyzing audio"; + _viewModel.PeakAudioLevel = ""; + _viewModel.DisplaySecondAudioLine = false; + } + } + catch (Exception ex) + { + _service?.LogError(ex, "Error testing audio levels"); + await MessageWindow.ShowErrorDialog(_viewModel!.Text.GenericError, _viewModel.Text.GenericErrorTitle, this.GetTopLevelWindow()); + } + } + + private async void GenerateTracksJsonMenuItem_OnClick(object? sender, RoutedEventArgs e) + { + try + { + if (_viewModel?.Project == null || _viewModel?.SongInfo == null || _service == null) + { + return; + } + + _viewModel.SaveChanges(); + var path = await CrossPlatformTools.OpenFileDialogAsync(this.GetTopLevelWindow(), FileInputControlType.SaveFile, + "JSON File:*.json", _previousTracksJsonPath ?? _viewModel.Project.GetTracksJsonPath()); + + if (path?.Path.LocalPath is { } stringPath) + { + _previousTracksJsonPath = stringPath; + var response = _service.GenerateSongTracksFile(_viewModel.Project, _viewModel.SongInfo, stringPath); + if (!string.IsNullOrEmpty(response.ErrorMessage)) + { + await MessageWindow.ShowErrorDialog(response.ErrorMessage, _viewModel.Text.GenericErrorTitle, + this.GetTopLevelWindow()); + } + } + } + catch (Exception ex) + { + _service?.LogError(ex, "Error generating PCM file"); + await MessageWindow.ShowErrorDialog(_viewModel!.Text.GenericError, _viewModel.Text.GenericErrorTitle, this.GetTopLevelWindow()); + } + } +} \ No newline at end of file diff --git a/MSUScripter/Views/MsuTrackInfoPanel.axaml b/MSUScripter/Views/MsuTrackInfoPanel.axaml deleted file mode 100644 index 7b9b242..0000000 --- a/MSUScripter/Views/MsuTrackInfoPanel.axaml +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/MSUScripter/Views/MsuTrackInfoPanel.axaml.cs b/MSUScripter/Views/MsuTrackInfoPanel.axaml.cs deleted file mode 100644 index 3879e5a..0000000 --- a/MSUScripter/Views/MsuTrackInfoPanel.axaml.cs +++ /dev/null @@ -1,71 +0,0 @@ -using Avalonia.Controls; -using Avalonia.Interactivity; -using Avalonia.Markup.Xaml; -using AvaloniaControls.Extensions; -using MSUScripter.Services.ControlServices; -using MSUScripter.ViewModels; -using ReactiveUI; - -namespace MSUScripter.Views; - -public partial class MsuTrackInfoPanel : UserControl -{ - public void SetTrackInfo(MsuProjectViewModel project, MsuTrackInfoViewModel trackInfo) - { - trackInfo.Project = project; - DataContext = trackInfo; - } - - private readonly MsuTrackInfoPanelService? _service; - - private MsuTrackInfoViewModel? TrackData => DataContext as MsuTrackInfoViewModel; - - public MsuTrackInfoPanel() - { - InitializeComponent(); - - if (Design.IsDesignMode) - { - DataContext = new MsuSongMsuPcmInfoViewModel().DesignerExample(); - } - else - { - _service = this.GetControlService(); - } - - DataContextChanged += (_, _) => - { - if (DataContext is MsuTrackInfoViewModel trackInfoViewModel) - { - _service?.InitializeModel(trackInfoViewModel); - } - }; - } - - protected override void OnLoaded(RoutedEventArgs e) - { - base.OnLoaded(e); - if (DataContext is MsuTrackInfoViewModel trackInfoViewModel && !string.IsNullOrEmpty(trackInfoViewModel.Description)) - { - trackInfoViewModel.RaisePropertyChanged(nameof(trackInfoViewModel.Description)); - } - } - - private void InitializeComponent() - { - AvaloniaXamlLoader.Load(this); - } - - private void AddSongButton_OnClick(object? sender, RoutedEventArgs e) - { - _service?.AddSong(); - } - - private void AddSongWindowButton_OnClick(object? sender, RoutedEventArgs e) - { - if (TrackData == null) return; - var window = new AddSongWindow(TrackData.Project, TrackData.TrackNumber, null); - window.ShowDialog(TopLevel.GetTopLevel(this) as Window ?? App.MainWindow!); - } - -} \ No newline at end of file diff --git a/MSUScripter/Views/NewProjectPanel.axaml b/MSUScripter/Views/NewProjectPanel.axaml deleted file mode 100644 index 30185b3..0000000 --- a/MSUScripter/Views/NewProjectPanel.axaml +++ /dev/null @@ -1,109 +0,0 @@ - - - - - - Welcome to the MSU Scripter! You can use this for creating YAML files for the MSU Randomizer and for creating JSON files for msupcm++. - - - - - - - - If you already have created the MSU, select the pre-existing MSU and msupcm++ tracks json file (if applicable) below. If you're starting a brand new MSU, select where you'd like the MSU file to go. - - - - If you're importing a previously created msupcm++ tracks json file, you will also need to select the folder in which you previously ran msupcm++ from so that the MSU Scripter will know where to find the files. It not provided, it will use the tracks.json file directory. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Recent Projects - - - - - - - - () - - - - - - - - - - - - - diff --git a/MSUScripter/Views/NewProjectPanel.axaml.cs b/MSUScripter/Views/NewProjectPanel.axaml.cs deleted file mode 100644 index e602330..0000000 --- a/MSUScripter/Views/NewProjectPanel.axaml.cs +++ /dev/null @@ -1,181 +0,0 @@ -using System; -using System.IO; -using System.Threading.Tasks; -using Avalonia.Controls; -using Avalonia.Interactivity; -using Avalonia.Markup.Xaml; -using AvaloniaControls; -using AvaloniaControls.Controls; -using AvaloniaControls.Extensions; -using MSUScripter.Configs; -using MSUScripter.Events; -using MSUScripter.Services.ControlServices; -using MSUScripter.Tools; -using MSUScripter.ViewModels; - -namespace MSUScripter.Views; - -public partial class NewProjectPanel : UserControl -{ - private readonly NewProjectPanelService? _service; - private readonly NewProjectPanelViewModel _model; - - public NewProjectPanel() - { - InitializeComponent(); - - if (Design.IsDesignMode) - { - DataContext = _model = (NewProjectPanelViewModel)new NewProjectPanelViewModel().DesignerExample(); - } - else - { - _service = this.GetControlService(); - DataContext = _model = _service?.InitializeModel() ?? new NewProjectPanelViewModel(); - _service?.ResetModel(); - } - } - - public MsuProject? Project { get; set; } - - - public event EventHandler>? OnProjectSelected; - - private void InitializeComponent() - { - AvaloniaXamlLoader.Load(this); - } - - public void ResetModel() - { - _service?.ResetModel(); - } - - public async Task LoadProject(MsuProject project, MsuProject? backupProject) - { - if (backupProject != null && await MessageWindow.ShowYesNoDialog( - "A backup with unsaved changes was detected. Would you like to load from the backup instead?", - "Load Backup?", ParentWindow)) - { - project = backupProject; - } - - OnProjectSelected?.Invoke(this, new ValueEventArgs(project)); - } - - private async void NewProjectButton_OnClick(object? sender, RoutedEventArgs e) - { - if (_service == null) return; - - var path = await OpenMsuProjectFilePicker(true); - - if (string.IsNullOrEmpty(path)) - { - return; - } - - if (!_service.CreateNewProject(path, out var newProject, out var isLegacySmz3, out var error) || newProject == null) - { - await MessageWindow.ShowErrorDialog(error ?? "Error creating new project", "Error", ParentWindow); - return; - } - - if (isLegacySmz3) - { - var result = await MessageWindow.ShowYesNoDialog( - "You have selected the legacy SMZ3 MSU type. While this will be compatible with SMZ3 Cas' and the old ips patch, it will not be compatible with the beta of Total's original SMZ3 version. Would you like to swap the tracks to the new SMZ3 MSU order?", - "Update MSU Type?", ParentWindow); - - if (result && _service.UpdateLegacySmz3Msu(newProject) == false) - { - await MessageWindow.ShowErrorDialog("There was an error updating the classic SMZ3 MSU.", "Error", ParentWindow); - return; - } - } - - OnProjectSelected?.Invoke(this, new ValueEventArgs(newProject)); - } - - private async void RecentProject_OnClick(object? sender, RoutedEventArgs e) - { - if (_service == null) return; - if (sender is not LinkControl { Tag: string projectPath }) return; - await LoadProject(projectPath); - } - - private async void SelectProjectButton_OnClick(object? sender, RoutedEventArgs e) - { - if (_service == null) return; - - var path = await OpenMsuProjectFilePicker(false); - - if (string.IsNullOrEmpty(path)) - { - return; - } - - await LoadProject(path); - } - - private async Task LoadProject(string path) - { - if (_service == null) return; - - if (_service.LoadProject(path, out var project, out var backupProject, out var error) && project != null) - { - await LoadProject(project, backupProject); - } - else - { - await MessageWindow.ShowErrorDialog( - error ?? "Could not open MSU Project. Please contact MattEqualsCoder on GitHub", - "Error Loading Project"); - } - } - - private async void ImportProjectButton_OnClick(object? sender, RoutedEventArgs e) - { - if (_service == null) return; - - var path = await OpenMsuProjectFilePicker(false); - - if (string.IsNullOrEmpty(path)) - { - return; - } - - if (!_service.LoadProject(path, out var oldProject, out _, out var error) || oldProject == null) - { - await MessageWindow.ShowErrorDialog(error ?? "Could not load previous project file", "Error", ParentWindow); - return; - } - - var window = new CopyProjectWindow(); - - var project = await window.ShowDialog(ParentWindow, oldProject); - - if (project != null) - { - _service.SaveProject(project); - OnProjectSelected?.Invoke(this, new ValueEventArgs(project)); - } - } - - private async Task OpenMsuProjectFilePicker(bool isSave) - { - var folder = string.IsNullOrEmpty(_model.MsuPath) - ? await this.GetDocumentsFolderPath() - : Path.GetDirectoryName(_model.MsuPath); - var path = await CrossPlatformTools.OpenFileDialogAsync(ParentWindow, isSave ? FileInputControlType.SaveFile : FileInputControlType.OpenFile, - "MSU Scripter Project File:*.msup", folder); - return path?.Path.LocalPath; - } - - private Window ParentWindow => TopLevel.GetTopLevel(this) as Window ?? App.MainWindow; - - private async void MenuButton_OnClick(object? sender, RoutedEventArgs e) - { - var window = new SettingsWindow(); - await window.ShowDialog(ParentWindow); - } -} \ No newline at end of file diff --git a/MSUScripter/Views/PackageMsuWindow.axaml b/MSUScripter/Views/PackageMsuWindow.axaml deleted file mode 100644 index cd29078..0000000 --- a/MSUScripter/Views/PackageMsuWindow.axaml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - diff --git a/MSUScripter/Views/PackageMsuWindow.axaml.cs b/MSUScripter/Views/PackageMsuWindow.axaml.cs deleted file mode 100644 index fa48008..0000000 --- a/MSUScripter/Views/PackageMsuWindow.axaml.cs +++ /dev/null @@ -1,52 +0,0 @@ -using Avalonia.Controls; -using Avalonia.Interactivity; -using AvaloniaControls; -using AvaloniaControls.Extensions; -using MSUScripter.Services.ControlServices; -using MSUScripter.ViewModels; - -namespace MSUScripter.Views; - -public partial class PackageMsuWindow : Window -{ - private readonly PackageMsuWindowService? _service; - - public PackageMsuWindow() - { - InitializeComponent(); - DataContext = new PackageMsuWindowViewModel().DesignerExample(); - } - - public PackageMsuWindow(MsuProjectViewModel project) - { - InitializeComponent(); - _service = this.GetControlService(); - DataContext = _service?.InitializeModel(project); - } - - private void CloseButton_OnClick(object? sender, RoutedEventArgs e) - { - Close(); - } - - private async void Control_OnLoaded(object? sender, RoutedEventArgs e) - { - if (_service == null) return; - - var zipPath = await CrossPlatformTools.OpenFileDialogAsync(this, FileInputControlType.SaveFile, "Zip File:*.zip", - _service.MsuDirectory, "Select Desired MSU Zip File"); - - if (zipPath == null || string.IsNullOrEmpty(zipPath.Path.LocalPath)) - { - Close(); - return; - } - - _service.PackageProject(zipPath.Path.LocalPath); - } - - private void Window_OnClosing(object? sender, WindowClosingEventArgs e) - { - _service?.Cancel(); - } -} \ No newline at end of file diff --git a/MSUScripter/Views/PyMusicLooperPanel.axaml b/MSUScripter/Views/PyMusicLooperPanel.axaml index 40617c9..a6dd34d 100644 --- a/MSUScripter/Views/PyMusicLooperPanel.axaml +++ b/MSUScripter/Views/PyMusicLooperPanel.axaml @@ -5,6 +5,7 @@ xmlns:viewModels="clr-namespace:MSUScripter.ViewModels" xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" xmlns:controls1="clr-namespace:AvaloniaControls.Controls;assembly=AvaloniaControls" + xmlns:views="clr-namespace:MSUScripter.Views" mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="450" x:Class="MSUScripter.Views.PyMusicLooperPanel" x:DataType="viewModels:PyMusicLooperPanelViewModel"> @@ -19,52 +20,160 @@ - - Min Duration Multiplier - Duration Limit in Seconds - Approximate Loop Time in Seconds - Filter Samples - - - - to - - - - - to - - - - - to - - - + + + + + + + + + + + to + + + + + + + + + to + + + + + + + + to + + + + + - + + + + + + diff --git a/MSUScripter/Views/SettingsPanel.axaml.cs b/MSUScripter/Views/SettingsPanel.axaml.cs new file mode 100644 index 0000000..97efa15 --- /dev/null +++ b/MSUScripter/Views/SettingsPanel.axaml.cs @@ -0,0 +1,37 @@ +using System; +using Avalonia.Controls; +using Avalonia.Interactivity; +using MSUScripter.ViewModels; + +namespace MSUScripter.Views; + +public partial class SettingsPanel : UserControl +{ + public SettingsPanel() + { + InitializeComponent(); + if (Design.IsDesignMode) + { + DataContext = new SettingsPanelViewModel(); + } + } + + private void CheckDependenciesButton_OnClick(object? sender, RoutedEventArgs e) + { + if (TopLevel.GetTopLevel(this) is not Window parent) + { + return; + } + var newWindow = new InstallDependenciesWindow(); + newWindow.ShowDialog(parent); + } + + private void CreateDesktopFileButton_OnClick(object? sender, RoutedEventArgs e) + { + if (!OperatingSystem.IsLinux()) + { + return; + } + App.BuildLinuxDesktopFile(); + } +} \ No newline at end of file diff --git a/MSUScripter/Views/SettingsWindow.axaml b/MSUScripter/Views/SettingsWindow.axaml index 1332e1c..cf8e6b4 100644 --- a/MSUScripter/Views/SettingsWindow.axaml +++ b/MSUScripter/Views/SettingsWindow.axaml @@ -3,9 +3,10 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:viewModels="clr-namespace:MSUScripter.ViewModels" - xmlns:controls1="clr-namespace:AvaloniaControls.Controls;assembly=AvaloniaControls" - mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="300" - Width="650" Height="200" + xmlns:views="clr-namespace:MSUScripter.Views" + xmlns:controls="clr-namespace:AvaloniaControls.Controls;assembly=AvaloniaControls" + mc:Ignorable="d" d:DesignWidth="650" d:DesignHeight="500" + Width="650" Height="500" x:Class="MSUScripter.Views.SettingsWindow" Icon="/Assets/MSUScripterIcon.ico" SizeToContent="Height" @@ -15,121 +16,30 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + diff --git a/MSUScripter/Views/SettingsWindow.axaml.cs b/MSUScripter/Views/SettingsWindow.axaml.cs index 3b8b7a1..5a482f1 100644 --- a/MSUScripter/Views/SettingsWindow.axaml.cs +++ b/MSUScripter/Views/SettingsWindow.axaml.cs @@ -36,34 +36,4 @@ private void CancelButton_OnClick(object? sender, RoutedEventArgs e) { Close(); } - - private async void ValidateMsuPcmButton_OnClick(object? sender, RoutedEventArgs e) - { - var isSuccessful = _service?.ValidateMsuPcm(); - if (isSuccessful != true) - { - await MessageWindow.ShowErrorDialog( - "There was an error verifying msupcm++. Please verify that the application runs independently.", - "msupcm++ Error", this); - } - else - { - await MessageWindow.ShowInfoDialog("msupcm++ verification successful!", "Success", this); - } - } - - private async void ValidatePyMusicLooper_OnClick(object? sender, RoutedEventArgs e) - { - var isSuccessful = _service?.ValidatePyMusicLooper(); - if (isSuccessful != true) - { - await MessageWindow.ShowErrorDialog( - "There was an error verifying PyMusicLooper. Please verify that the application runs independently.", - "PyMusicLooper Error", this); - } - else - { - await MessageWindow.ShowInfoDialog("PyMusicLooper verification successful!", "Success", this); - } - } } \ No newline at end of file diff --git a/MSUScripter/Views/SongPanelPromptWindow.axaml b/MSUScripter/Views/SongPanelPromptWindow.axaml new file mode 100644 index 0000000..5f34267 --- /dev/null +++ b/MSUScripter/Views/SongPanelPromptWindow.axaml @@ -0,0 +1,72 @@ + + + + + + + + + + What view would you like to use for this song? + + + + + Basic + Simple view with just the most basic common settings with a built in PyMusicLooper panel for automatically getting loop points. + + + + + Advanced + Detailed view with all MsuPcm++ settings, including sub-tracks and sub-channels. PyMusicLooper can be ran via a popup window. + + + + + + diff --git a/MSUScripter/Views/SongPanelPromptWindow.axaml.cs b/MSUScripter/Views/SongPanelPromptWindow.axaml.cs new file mode 100644 index 0000000..3c8c5b0 --- /dev/null +++ b/MSUScripter/Views/SongPanelPromptWindow.axaml.cs @@ -0,0 +1,32 @@ +using Avalonia.Controls; +using Avalonia.Interactivity; +using AvaloniaControls.Controls; +using MSUScripter.ViewModels; + +namespace MSUScripter.Views; + +public partial class SongPanelPromptWindow : ScalableWindow +{ + private SongPanelPromptWindowViewModel _model; + + public SongPanelPromptWindow() + { + InitializeComponent(); + DataContext = _model = new SongPanelPromptWindowViewModel(); + } + + private void AddSongButton_OnClick(object? sender, RoutedEventArgs e) + { + Close(DataContext); + } + + private void CancelButton_OnClick(object? sender, RoutedEventArgs e) + { + Close(); + } + + private void Control_OnLoaded(object? sender, RoutedEventArgs e) + { + _model.DontAskAgain = false; + } +} \ No newline at end of file diff --git a/MSUScripter/Views/ToolTipMenuItem.axaml b/MSUScripter/Views/ToolTipMenuItem.axaml new file mode 100644 index 0000000..df85aec --- /dev/null +++ b/MSUScripter/Views/ToolTipMenuItem.axaml @@ -0,0 +1,11 @@ + + + + + diff --git a/MSUScripter/Views/ToolTipMenuItem.axaml.cs b/MSUScripter/Views/ToolTipMenuItem.axaml.cs new file mode 100644 index 0000000..7ca0007 --- /dev/null +++ b/MSUScripter/Views/ToolTipMenuItem.axaml.cs @@ -0,0 +1,58 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Layout; +using AvaloniaControls.Controls; + +namespace MSUScripter.Views; + +public partial class ToolTipMenuItem : IconMenuItem +{ + protected override Type StyleKeyOverride => typeof(MenuItem); + + public ToolTipMenuItem() + { + InitializeComponent(); + } + + protected override void OnLoaded(RoutedEventArgs e) + { + base.OnLoaded(e); + HorizontalAlignment = HorizontalAlignment.Stretch; + Update(); + } + + public static readonly StyledProperty LabelTextProperty = AvaloniaProperty.Register( + nameof(LabelText), defaultValue: "Label"); + + public string LabelText + { + get => GetValue(LabelTextProperty); + set + { + SetValue(LabelTextProperty, value); + Update(); + } + } + + public static readonly StyledProperty ToolTipTextProperty = AvaloniaProperty.Register( + nameof(ToolTipText), defaultValue: "Label ToolTip"); + + public string ToolTipText + { + get => GetValue(ToolTipTextProperty); + set + { + SetValue(ToolTipTextProperty, value); + Update(); + } + } + + private void Update() + { + var label = this.Get(nameof(LabelControl)); + label.LabelText = LabelText; + label.ToolTipText = ToolTipText; + } +} \ No newline at end of file diff --git a/MSUScripter/Views/TrackOverviewPanel.axaml b/MSUScripter/Views/TrackOverviewPanel.axaml deleted file mode 100644 index 8793eed..0000000 --- a/MSUScripter/Views/TrackOverviewPanel.axaml +++ /dev/null @@ -1,131 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/MSUScripter/Views/TrackOverviewPanel.axaml.cs b/MSUScripter/Views/TrackOverviewPanel.axaml.cs deleted file mode 100644 index c7203f8..0000000 --- a/MSUScripter/Views/TrackOverviewPanel.axaml.cs +++ /dev/null @@ -1,167 +0,0 @@ -using System; -using System.Linq; -using System.Threading.Tasks; -using Avalonia; -using Avalonia.Controls; -using Avalonia.Input; -using Avalonia.Interactivity; -using AvaloniaControls.Controls; -using AvaloniaControls.Extensions; -using MSUScripter.Models; -using MSUScripter.Services.ControlServices; -using MSUScripter.ViewModels; - -namespace MSUScripter.Views; - -public partial class TrackOverviewPanel : UserControl -{ - private TrackOverviewPanelService? _service; - - public static readonly StyledProperty ProjectProperty = AvaloniaProperty.Register( - nameof(Project)); - - public EditProjectPanelViewModel Project - { - get => GetValue(ProjectProperty); - set => SetValue(ProjectProperty, value); - } - - public TrackOverviewPanel() - { - if (Design.IsDesignMode) - { - DataContext = new TrackOverviewPanelViewModel().DesignerExample(); - return; - } - - ProjectProperty.Changed.Subscribe(x => - { - if (x.Sender != this || (EditProjectPanelViewModel?)x.NewValue.Value == null) - { - return; - } - _service = this.GetControlService(); - DataContext = _service?.InitializeModel(x.NewValue.Value); - }); - - IsVisibleProperty.Changed.Subscribe(x => - { - if (x.Sender != this || x.NewValue.Value != true) return; - _service?.RefreshTracks(); - }); - - InitializeComponent(); - - AddHandler(DragDrop.DropEvent, DropFile); - } - - private async void DropFile(object? sender, DragEventArgs e) - { - try - { - if (_service == null) return; - - var obj = e.Source; - while (obj is not DataGridRow) - { - if (obj is not Control control) return; - obj = control.Parent; - } - - if (obj is not DataGridRow { DataContext: TrackOverviewPanelViewModel.TrackOverviewRow row }) return; - - var file = e.Data.GetFiles()?.FirstOrDefault(); - - if (file == null || string.IsNullOrEmpty(file.Path.LocalPath)) return; - - await OpenAddSongWindow(row, file.Path.LocalPath); - } - catch - { - await MessageWindow.ShowErrorDialog("Unable to open song window from dropped file"); - } - } - - public event EventHandler? OnSelectedTrack; - - private void AudioDataGrid_OnDoubleTapped(object? sender, TappedEventArgs e) - { - if (e.Source is not TextBlock && e.Source is not Border { Name: "CellBorder"}) - { - return; - } - - var selectedItems = this.Find(nameof(TrackDataGrid))!.SelectedItems; - if (selectedItems.Count <= 0) - { - return; - } - - if (selectedItems[0] is not TrackOverviewPanelViewModel.TrackOverviewRow row) - { - return; - } - - OnSelectedTrack?.Invoke(this, new TrackEventArgs(row.TrackNumber)); - } - - private async void OpenAddSongWindowButton_OnClick(object? sender, RoutedEventArgs e) - { - try - { - if (sender is not Button { Tag: TrackOverviewPanelViewModel.TrackOverviewRow row }) return; - await OpenAddSongWindow(row, null); - } - catch - { - await MessageWindow.ShowErrorDialog("Unable to open add song window"); - } - } - - private async Task OpenAddSongWindow(TrackOverviewPanelViewModel.TrackOverviewRow row, string? filePath) - { - if (_service == null) return; - var window = new AddSongWindow(_service.GetProject(), row.TrackNumber, filePath, true); - var newSongInfo = await window.ShowDialog(TopLevel.GetTopLevel(this) as Window ?? App.MainWindow); - if (newSongInfo == null) return; - _service.AddSong(row, newSongInfo); - } - - public void Refresh() - { - _service?.RefreshTracks(); - } - - private void ToggleCopyrightSafeButton_OnClick(object? sender, RoutedEventArgs e) - { - if (sender is not Button { Tag: TrackOverviewPanelViewModel.TrackOverviewRow row } || row.SongInfo == null) return; - - if (row.SongInfo.IsCopyrightSafe == null) - { - row.SongInfo.IsCopyrightSafe = true; - } - else if (row.SongInfo.IsCopyrightSafe == true) - { - row.SongInfo.IsCopyrightSafe = false; - } - else - { - row.SongInfo.IsCopyrightSafe = null; - } - } - - private void ToggleCheckCopyrightButton_OnClick(object? sender, RoutedEventArgs e) - { - if (sender is not Button { Tag: TrackOverviewPanelViewModel.TrackOverviewRow row } || row.SongInfo == null) return; - - row.SongInfo.CheckCopyright = !row.SongInfo.CheckCopyright; - } - - private void ToggleCompleteButton_OnClick(object? sender, RoutedEventArgs e) - { - if (sender is not Button { Tag: TrackOverviewPanelViewModel.TrackOverviewRow row } || row.SongInfo == null) return; - - row.SongInfo.IsComplete = !row.SongInfo.IsComplete; - _service?.UpdateCompletedTrackDetails(); - } -} \ No newline at end of file diff --git a/MSUScripter/Views/VideoCreatorWindow.axaml b/MSUScripter/Views/VideoCreatorWindow.axaml deleted file mode 100644 index 007d57f..0000000 --- a/MSUScripter/Views/VideoCreatorWindow.axaml +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - -