MainWindowViewModel.cs 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653
  1. using System;
  2. using System.Collections.Generic;
  3. using System.Collections.ObjectModel;
  4. using System.IO;
  5. using System.Linq;
  6. using System.Runtime.InteropServices;
  7. using System.Text.Json;
  8. using System.Diagnostics;
  9. using System.Text.RegularExpressions;
  10. using System.Threading;
  11. using System.Threading.Tasks;
  12. using CommunityToolkit.Mvvm.ComponentModel;
  13. using CommunityToolkit.Mvvm.Input;
  14. using SlamCross.Models;
  15. using SlamCross.Services;
  16. namespace SlamCross.ViewModels;
  17. public partial class TrackItem : ObservableObject
  18. {
  19. [ObservableProperty] private bool _isPlaying;
  20. public string Name { get; set; } = "";
  21. public string Hotkey { get; set; } = "";
  22. public string Volume { get; set; } = "";
  23. public string Trimmed { get; set; } = "";
  24. public string Tags { get; set; } = "";
  25. public int Index { get; set; }
  26. }
  27. public partial class MainWindowViewModel : ViewModelBase
  28. {
  29. private readonly SettingsService _settings = new();
  30. private readonly IAudioService _audio = AudioServiceFactory.Create();
  31. private SourceGame _game = null!;
  32. private string? _steamAppsPath;
  33. private CancellationTokenSource? _pollCts;
  34. [ObservableProperty] private ObservableCollection<TrackItem> _tracks = new();
  35. [ObservableProperty] private ObservableCollection<AudioDevice> _outputDevices = new();
  36. [ObservableProperty] private ObservableCollection<AudioDevice> _inputDevices = new();
  37. [ObservableProperty] private AudioDevice? _selectedVirtualInCable;
  38. [ObservableProperty] private AudioDevice? _selectedVirtualOutCable;
  39. [ObservableProperty] private AudioDevice? _selectedRegularMic;
  40. [ObservableProperty] private string _statusText = "Status: Idle.";
  41. [ObservableProperty] private bool _isRunning;
  42. [ObservableProperty] private bool _interfaceEnabled = true;
  43. [ObservableProperty] private double _progress;
  44. [ObservableProperty] private double _progressMax = 1;
  45. [ObservableProperty] private string _startButtonText = "Start";
  46. [ObservableProperty] private string _playKeyButtonText = "Play key: (change)";
  47. [ObservableProperty] private bool _isStreaming;
  48. [ObservableProperty] private string _streamButtonText = "Stream to Mic";
  49. [ObservableProperty] private string _streamStatusText = "";
  50. [ObservableProperty] private int _globalVolume = 50;
  51. public AppSettings Settings => _settings.Settings;
  52. public MainWindowViewModel()
  53. {
  54. _settings.Load();
  55. _globalVolume = Settings.GlobalVolume;
  56. InitGame();
  57. LoadAudioDevices();
  58. RefreshTracks();
  59. RefreshPlayKey();
  60. if (Settings.StartEnabled)
  61. _ = StartPollAsync();
  62. }
  63. partial void OnGlobalVolumeChanged(int value)
  64. {
  65. Settings.GlobalVolume = value;
  66. _settings.Save();
  67. }
  68. private void InitGame()
  69. {
  70. var sep = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? '\\' : '/';
  71. _game = new SourceGame
  72. {
  73. Name = "Counter-Strike 2",
  74. Id = 730,
  75. Directory = $"common{sep}Counter-Strike Global Offensive{sep}",
  76. ToCfg = $"game{sep}csgo{sep}cfg{sep}",
  77. LibraryName = $"game{sep}csgo{sep}",
  78. ExeName = "cs2",
  79. SampleRate = 22050,
  80. VoiceFadeOut = false
  81. };
  82. _game.Blacklist.AddRange(new[]
  83. {
  84. "attack","attack2","autobuy","back","buy","buyammo1","buyammo2","buymenu","callvote",
  85. "cancelselect","cheer","compliment","coverme","drop","duck","enemydown","enemyspot",
  86. "fallback","followme","forward","getout","go","holdpos","inposition","invnext","invprev",
  87. "jump","lastinv","messagemode","messagemode2","moveleft","moveright","mute","negative",
  88. "quit","radio1","radio2","radio3","rebuy","regroup","reload","reporting_in",
  89. "reporting_in_team_only","roger","sectorclear","showscores","slot1","slot10","slot2",
  90. "slot3","slot4","slot5","slot6","slot7","slot8","slot9","speed","sticktog","takepoint",
  91. "takingfire","teammenu","thanks","toggleconsole","use","voicerecord",
  92. "say","say_team","exec","echo","alias","bind","unbind","unbindall","kill","explode",
  93. "disconnect","retry","status","map","volume","stop","play","record","screenshot",
  94. "host_writeconfig","voice_loopback","con_enable","clear","help","find"
  95. });
  96. }
  97. private void LoadAudioDevices()
  98. {
  99. OutputDevices.Clear();
  100. InputDevices.Clear();
  101. foreach (var d in _audio.GetOutputDevices())
  102. OutputDevices.Add(d);
  103. foreach (var d in _audio.GetInputDevices())
  104. InputDevices.Add(d);
  105. if (Settings.VirtualMicInDeviceIndex >= 0 && Settings.VirtualMicInDeviceIndex < OutputDevices.Count)
  106. SelectedVirtualInCable = OutputDevices[Settings.VirtualMicInDeviceIndex];
  107. if (Settings.VirtualMicOutDeviceIndex >= 0 && Settings.VirtualMicOutDeviceIndex < InputDevices.Count)
  108. SelectedVirtualOutCable = InputDevices[Settings.VirtualMicOutDeviceIndex];
  109. if (Settings.RegularMicDeviceIndex >= 0 && Settings.RegularMicDeviceIndex < InputDevices.Count)
  110. SelectedRegularMic = InputDevices[Settings.RegularMicDeviceIndex];
  111. }
  112. partial void OnSelectedVirtualInCableChanged(AudioDevice? value)
  113. {
  114. if (value == null) return;
  115. Settings.VirtualMicInDeviceIndex = value.Index;
  116. Settings.VirtualMicInDeviceName = value.Name;
  117. Settings.VirtualMicInPactlId = value.PactlId;
  118. _settings.Save();
  119. }
  120. partial void OnSelectedVirtualOutCableChanged(AudioDevice? value)
  121. {
  122. if (value == null) return;
  123. Settings.VirtualMicOutDeviceIndex = value.Index;
  124. Settings.VirtualMicOutDeviceName = value.Name;
  125. Settings.VirtualMicOutPactlId = value.PactlId;
  126. _settings.Save();
  127. }
  128. partial void OnSelectedRegularMicChanged(AudioDevice? value)
  129. {
  130. if (value == null) return;
  131. Settings.RegularMicDeviceIndex = value.Index;
  132. Settings.RegularMicDeviceName = value.Name;
  133. Settings.RegularMicPactlId = value.PactlId;
  134. _settings.Save();
  135. }
  136. public void RefreshTracks()
  137. {
  138. if (!Directory.Exists(_game.LibraryName))
  139. Directory.CreateDirectory(_game.LibraryName);
  140. _game.Tracks.Clear();
  141. foreach (var f in Directory.GetFiles(_game.LibraryName))
  142. {
  143. if (Path.GetExtension(f) == _game.FileExtension)
  144. _game.Tracks.Add(new Track { Name = Path.GetFileNameWithoutExtension(f) });
  145. }
  146. CreateTags(_game);
  147. TrackSettingsService.Load(_game);
  148. TrackSettingsService.Save(_game);
  149. RebuildTrackList();
  150. }
  151. private void RebuildTrackList()
  152. {
  153. Tracks.Clear();
  154. for (int i = 0; i < _game.Tracks.Count; i++)
  155. {
  156. var t = _game.Tracks[i];
  157. Tracks.Add(new TrackItem
  158. {
  159. Index = i,
  160. Name = t.Name,
  161. Hotkey = t.Hotkey,
  162. Volume = t.Volume + "%",
  163. Trimmed = t.EndPos > 0 ? "Yes" : "",
  164. Tags = t.Tags.Count > 0 ? "\"" + string.Join("\", \"", t.Tags) + "\"" : ""
  165. });
  166. }
  167. }
  168. public async Task StartPollAsync()
  169. {
  170. IsRunning = true;
  171. StartButtonText = "Stop";
  172. InterfaceEnabled = false;
  173. _pollCts = new CancellationTokenSource();
  174. try { await Task.Run(() => PollLoop(_pollCts.Token)); }
  175. catch (OperationCanceledException) { }
  176. catch (Exception ex) { ShowError(ex.Message); }
  177. finally
  178. {
  179. IsRunning = false;
  180. StartButtonText = "Start";
  181. InterfaceEnabled = true;
  182. StatusText = "Status: Idle.";
  183. RebuildTrackList();
  184. }
  185. }
  186. public void StopPoll()
  187. {
  188. _pollCts?.Cancel();
  189. _audio.Stop();
  190. }
  191. private void PollLoop(CancellationToken ct)
  192. {
  193. StatusText = "Status: Searching...";
  194. if (!Settings.OverrideFolders)
  195. {
  196. while (!ct.IsCancellationRequested)
  197. {
  198. _steamAppsPath = GameLocatorService.FindSteamAppsPath(_game.ExeName);
  199. if (!string.IsNullOrEmpty(_steamAppsPath) && Directory.Exists(_steamAppsPath))
  200. break;
  201. Thread.Sleep(_game.PollInterval);
  202. }
  203. }
  204. else
  205. {
  206. _steamAppsPath = Settings.SteamAppsOverride;
  207. if (!Directory.Exists(_steamAppsPath))
  208. throw new Exception("Steam apps folder does not exist. Check Settings.");
  209. }
  210. ct.ThrowIfCancellationRequested();
  211. CfgService.WriteCfgFiles(_game, _steamAppsPath!, _settings.Settings);
  212. StatusText = "Status: Working.";
  213. var virtualMicOutPactl = string.IsNullOrEmpty(Settings.VirtualMicOutPactlId) ? "VirtualMic.monitor" : Settings.VirtualMicOutPactlId;
  214. var cs2SrcOutputIds = FindCs2SourceOutputIds();
  215. DebugLog($"CS2 pactl source-outputs: [{string.Join(",", cs2SrcOutputIds)}] moving to {virtualMicOutPactl}");
  216. MoveCs2SourceOutputs(cs2SrcOutputIds, virtualMicOutPactl);
  217. var userDataDir = Settings.OverrideFolders && !string.IsNullOrEmpty(Settings.UserDataOverride)
  218. ? Settings.UserDataOverride
  219. : FindUserDataDir(_steamAppsPath!);
  220. var startTime = DateTime.UtcNow;
  221. var seenMtimes = new Dictionary<string, DateTime>();
  222. // seed from the newest existing vcfg so a stale bind from a previous session doesn't fire
  223. string? lastRelayValue = null;
  224. if (userDataDir != null && Directory.Exists(userDataDir))
  225. {
  226. var newest = FindKeyVcfgs(userDataDir).OrderByDescending(File.GetLastWriteTimeUtc).FirstOrDefault();
  227. if (newest != null)
  228. try { lastRelayValue = ParseRelayCommand(File.ReadAllText(newest), Settings.RelayKey); } catch { }
  229. }
  230. DebugLog($"SLAM started. userDataDir={userDataDir ?? "null"} startTime={startTime:O} lastRelayValue={lastRelayValue}");
  231. while (!ct.IsCancellationRequested)
  232. {
  233. try
  234. {
  235. if (userDataDir != null && Directory.Exists(userDataDir))
  236. {
  237. foreach (var vcfg in FindKeyVcfgs(userDataDir))
  238. {
  239. var mtime = File.GetLastWriteTimeUtc(vcfg);
  240. if (mtime <= startTime) continue;
  241. if (seenMtimes.TryGetValue(vcfg, out var lastSeen) && mtime <= lastSeen) continue;
  242. seenMtimes[vcfg] = mtime;
  243. DebugLog($"New vcfg: {vcfg} mtime={mtime:O}");
  244. var content = File.ReadAllText(vcfg);
  245. var command = ParseRelayCommand(content, Settings.RelayKey);
  246. DebugLog($" relayKey={Settings.RelayKey} command={command}");
  247. // unchanged bind value means CS2 rewrote the file on its own (round start, buy menu)
  248. if (command == lastRelayValue) continue;
  249. lastRelayValue = command;
  250. int trackNum = ParseTrackNumber(command);
  251. if (trackNum > 0)
  252. {
  253. var idx = trackNum - 1;
  254. if (idx >= 0 && idx < _game.Tracks.Count)
  255. {
  256. // CS2 voice stream (SDL Application, media.role=game) only exists while +voicerecord is active.
  257. // host_writeconfig runs before +voicerecord, so the stream may not exist yet - poll briefly.
  258. var freshSrcIds = FindCs2SourceOutputIds();
  259. if (freshSrcIds.Count == 0) { Thread.Sleep(150); freshSrcIds = FindCs2SourceOutputIds(); }
  260. if (freshSrcIds.Count == 0) { Thread.Sleep(150); freshSrcIds = FindCs2SourceOutputIds(); }
  261. DebugLog($" relay: game-capture source-outputs [{string.Join(",", freshSrcIds)}] -> {virtualMicOutPactl}");
  262. MoveCs2SourceOutputs(freshSrcIds, virtualMicOutPactl);
  263. PlayTrack(idx);
  264. Avalonia.Threading.Dispatcher.UIThread.Post(() =>
  265. {
  266. foreach (var ti in Tracks) ti.IsPlaying = false;
  267. if (idx < Tracks.Count) Tracks[idx].IsPlaying = true;
  268. });
  269. }
  270. // Don't delete remote/ files - they are Steam Cloud data CS2 relies on.
  271. // seenMtimes prevents re-processing until the next host_writeconfig.
  272. if (!vcfg.Contains("/remote/"))
  273. {
  274. try { File.Delete(vcfg); seenMtimes.Remove(vcfg); } catch { }
  275. }
  276. }
  277. }
  278. }
  279. }
  280. catch (Exception ex) when (ex.HResult != -2147024864)
  281. {
  282. LogError(ex);
  283. }
  284. Thread.Sleep(_game.PollInterval);
  285. }
  286. _audio.Stop();
  287. var regularMicPactl = string.IsNullOrEmpty(Settings.RegularMicPactlId) ? "@DEFAULT_SOURCE@" : Settings.RegularMicPactlId;
  288. MoveCs2SourceOutputs(FindCs2SourceOutputIds(), regularMicPactl);
  289. if (!string.IsNullOrEmpty(_steamAppsPath))
  290. CfgService.DeleteCfgFiles(_game, _steamAppsPath);
  291. }
  292. private static List<int> FindCs2SourceOutputIds()
  293. {
  294. var ids = new List<int>();
  295. if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) return ids;
  296. try
  297. {
  298. var psi = new ProcessStartInfo("pactl") { Arguments = "--format=json list source-outputs", UseShellExecute = false, RedirectStandardOutput = true, CreateNoWindow = true };
  299. using var proc = Process.Start(psi)!;
  300. var json = proc.StandardOutput.ReadToEnd();
  301. proc.WaitForExit();
  302. using var doc = JsonDocument.Parse(json);
  303. foreach (var so in doc.RootElement.EnumerateArray())
  304. {
  305. if (!so.TryGetProperty("properties", out var props)) continue;
  306. var mediaRole = props.TryGetProperty("media.role", out var r) ? r.GetString() : null;
  307. var mediaCategory = props.TryGetProperty("media.category", out var c) ? c.GetString() : null;
  308. var binary = props.TryGetProperty("application.process.binary", out var b) ? b.GetString() : null;
  309. // CS2 voice capture appears as "SDL Application" with media.role=game, media.category=Capture
  310. if ((mediaRole == "game" && mediaCategory == "Capture") || binary == "cs2")
  311. if (so.TryGetProperty("index", out var idx)) ids.Add(idx.GetInt32());
  312. }
  313. }
  314. catch (Exception ex) { DebugLog($"FindCs2SourceOutputIds error: {ex.Message}"); }
  315. return ids;
  316. }
  317. private static void MoveCs2SourceOutputs(IEnumerable<int> sourceOutputIds, string pactlSourceName)
  318. {
  319. if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) return;
  320. foreach (var id in sourceOutputIds)
  321. {
  322. try
  323. {
  324. var args = $"move-source-output {id} {pactlSourceName}";
  325. var psi = new ProcessStartInfo("pactl", args) { UseShellExecute = false, CreateNoWindow = true };
  326. Process.Start(psi)?.WaitForExit();
  327. DebugLog($"pactl {args}");
  328. }
  329. catch { }
  330. }
  331. }
  332. private static string? FindUserDataDir(string steamAppsPath)
  333. {
  334. var steamDir = Path.GetDirectoryName(steamAppsPath);
  335. if (steamDir == null) return null;
  336. var candidate = Path.Combine(steamDir, "userdata");
  337. if (Directory.Exists(candidate)) return candidate;
  338. if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
  339. {
  340. var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
  341. candidate = Path.Combine(home, ".local/share/Steam/userdata");
  342. if (Directory.Exists(candidate)) return candidate;
  343. }
  344. return null;
  345. }
  346. private List<string> FindKeyVcfgs(string userDataDir)
  347. {
  348. // CS2 writes key bindings to remote/ (Steam Cloud) and optionally local/cfg/
  349. var candidates = new List<string>();
  350. foreach (var userDir in Directory.GetDirectories(userDataDir))
  351. {
  352. var gameUserDir = Path.Combine(userDir, _game.Id.ToString());
  353. if (!Directory.Exists(gameUserDir)) continue;
  354. var remoteKeys = Path.Combine(gameUserDir, "remote", "cs2_user_keys.vcfg");
  355. if (File.Exists(remoteKeys)) candidates.Add(remoteKeys);
  356. var localCfg = Path.Combine(gameUserDir, "local", "cfg");
  357. if (Directory.Exists(localCfg))
  358. candidates.AddRange(Directory.GetFiles(localCfg, "*keys*.vcfg"));
  359. }
  360. return candidates;
  361. }
  362. private static string ParseRelayCommand(string cfgContent, string relayKey)
  363. {
  364. var pattern = $"\"{Regex.Escape(relayKey)}\"\\s+\"(.*?)\"";
  365. var m = Regex.Match(cfgContent, pattern, RegexOptions.IgnoreCase);
  366. return m.Success ? m.Groups[1].Value : "";
  367. }
  368. private static int ParseTrackNumber(string relayValue)
  369. {
  370. var m = Regex.Match(relayValue, @"^slam_r_(\d+)_[ab]$");
  371. if (m.Success) return int.Parse(m.Groups[1].Value);
  372. return int.TryParse(relayValue, out var n) ? n : 0; // plain number from an old slam.cfg
  373. }
  374. public void PlayTrack(int index)
  375. {
  376. DebugLog($"PlayTrack({index}) trackCount={_game.Tracks.Count}");
  377. if (index < 0 || index >= _game.Tracks.Count) { DebugLog(" -> index out of range"); return; }
  378. var track = _game.Tracks[index];
  379. var filePath = Path.Combine(_game.LibraryName, track.Name + _game.FileExtension);
  380. DebugLog($" filePath={filePath} exists={File.Exists(filePath)} globalVol={Settings.GlobalVolume} trackVol={track.Volume} pactlId={Settings.VirtualMicInPactlId}");
  381. if (!File.Exists(filePath)) { DebugLog(" -> file not found"); return; }
  382. try
  383. {
  384. float volume = (Settings.GlobalVolume / 100f) * (track.Volume / 100f);
  385. _audio.Play(filePath, Settings.VirtualMicInDeviceIndex, volume, track.StartPos, track.EndPos, Settings.VirtualMicInPactlId);
  386. DebugLog(" -> audio.Play called");
  387. if (!string.IsNullOrEmpty(_steamAppsPath))
  388. CfgService.WriteTrackCfgs(_game, _steamAppsPath, track);
  389. }
  390. catch (Exception ex)
  391. {
  392. LogError(ex);
  393. ShowError("Playback failed: " + ex.Message);
  394. }
  395. }
  396. public void StreamToMic(int index)
  397. {
  398. if (index < 0 || index >= _game.Tracks.Count) return;
  399. var track = _game.Tracks[index];
  400. var filePath = Path.Combine(_game.LibraryName, track.Name + _game.FileExtension);
  401. if (!File.Exists(filePath)) return;
  402. try
  403. {
  404. _audio.Play(filePath, Settings.VirtualMicInDeviceIndex, (Settings.GlobalVolume / 100f) * (track.Volume / 100f), track.StartPos, track.EndPos, Settings.VirtualMicInPactlId);
  405. }
  406. catch (Exception ex)
  407. {
  408. LogError(ex);
  409. ShowError("Playback failed: " + ex.Message);
  410. return;
  411. }
  412. IsStreaming = true;
  413. StreamButtonText = "Stop Stream";
  414. StreamStatusText = $"Streaming: {track.Name}";
  415. foreach (var ti in Tracks) ti.IsPlaying = false;
  416. if (index < Tracks.Count) Tracks[index].IsPlaying = true;
  417. }
  418. public void StopStream()
  419. {
  420. _audio.Stop();
  421. IsStreaming = false;
  422. StreamButtonText = "Stream to Mic";
  423. StreamStatusText = "";
  424. foreach (var ti in Tracks) ti.IsPlaying = false;
  425. }
  426. public async Task ImportFilesAsync(IEnumerable<string> files, bool deleteSource = false)
  427. {
  428. var fileList = files.ToList();
  429. InterfaceEnabled = false;
  430. ProgressMax = fileList.Count;
  431. Progress = 0;
  432. var failed = new List<string>();
  433. await Task.Run(() =>
  434. {
  435. foreach (var file in fileList)
  436. {
  437. try
  438. {
  439. var outFile = Path.Combine(_game.LibraryName,
  440. Path.GetFileNameWithoutExtension(file) + ".wav");
  441. ConversionService.ConvertToWav(file, outFile, _game.SampleRate, _game.Channels);
  442. if (deleteSource) File.Delete(file);
  443. }
  444. catch (Exception ex)
  445. {
  446. LogError(ex);
  447. failed.Add(Path.GetFileName(file));
  448. }
  449. Avalonia.Threading.Dispatcher.UIThread.Post(() => Progress++);
  450. }
  451. });
  452. Progress = 0;
  453. RefreshTracks();
  454. InterfaceEnabled = true;
  455. if (failed.Count > 0)
  456. ShowError("Conversion complete! Failed: " + string.Join(", ", failed));
  457. }
  458. public void DeleteTracks(IEnumerable<int> indices)
  459. {
  460. foreach (var idx in indices.OrderByDescending(x => x))
  461. {
  462. var t = _game.Tracks[idx];
  463. var path = Path.Combine(_game.LibraryName, t.Name + _game.FileExtension);
  464. if (File.Exists(path)) try { File.Delete(path); } catch { }
  465. }
  466. RefreshTracks();
  467. }
  468. public void SetTrackVolume(IEnumerable<int> indices, int volume)
  469. {
  470. foreach (var idx in indices)
  471. if (idx < _game.Tracks.Count)
  472. _game.Tracks[idx].Volume = volume;
  473. TrackSettingsService.Save(_game);
  474. RefreshTracks();
  475. }
  476. public bool SetTrackHotkey(int index, string key)
  477. {
  478. if (index < 0 || index >= _game.Tracks.Count) return false;
  479. if (key.Equals(Settings.PlayKey, StringComparison.OrdinalIgnoreCase) ||
  480. key.Equals(Settings.RelayKey, StringComparison.OrdinalIgnoreCase))
  481. { ShowError($"\"{key}\" is used as the play/relay key!"); return false; }
  482. bool inUse = _game.Tracks.Where((_, i) => i != index).Any(t => t.Hotkey == key);
  483. if (inUse) { ShowError($"\"{key}\" is already assigned!"); return false; }
  484. _game.Tracks[index].Hotkey = key;
  485. TrackSettingsService.Save(_game);
  486. RefreshTracks();
  487. return true;
  488. }
  489. public void RemoveHotkey(int index)
  490. {
  491. if (index < 0 || index >= _game.Tracks.Count) return;
  492. _game.Tracks[index].Hotkey = "";
  493. TrackSettingsService.Save(_game);
  494. RefreshTracks();
  495. }
  496. public void SetTrackTrim(int index, int startPos, int endPos)
  497. {
  498. if (index < 0 || index >= _game.Tracks.Count) return;
  499. _game.Tracks[index].StartPos = startPos;
  500. _game.Tracks[index].EndPos = endPos;
  501. TrackSettingsService.Save(_game);
  502. RefreshTracks();
  503. }
  504. public void RenameTrack(int index, string newName)
  505. {
  506. if (index < 0 || index >= _game.Tracks.Count) return;
  507. var track = _game.Tracks[index];
  508. var oldPath = Path.Combine(_game.LibraryName, track.Name + _game.FileExtension);
  509. var newPath = Path.Combine(_game.LibraryName, newName + _game.FileExtension);
  510. if (File.Exists(newPath)) { ShowError("A track with that name already exists."); return; }
  511. try { File.Move(oldPath, newPath); }
  512. catch (Exception ex) { ShowError("Rename failed: " + ex.Message); return; }
  513. track.Name = newName;
  514. TrackSettingsService.Save(_game);
  515. RefreshTracks();
  516. }
  517. public string GetTrackFilePath(int index)
  518. {
  519. if (index < 0 || index >= _game.Tracks.Count) return "";
  520. return Path.Combine(_game.LibraryName, _game.Tracks[index].Name + _game.FileExtension);
  521. }
  522. public Track? GetTrack(int index)
  523. => index >= 0 && index < _game.Tracks.Count ? _game.Tracks[index] : null;
  524. public void SetPlayKey(string key)
  525. {
  526. if (key == Settings.RelayKey) { ShowError("Play key and relay key cannot be the same!"); return; }
  527. Settings.PlayKey = key;
  528. _settings.Save();
  529. PlayKeyButtonText = $"Play key: \"{Settings.PlayKey}\" (change)";
  530. }
  531. private void RefreshPlayKey()
  532. {
  533. PlayKeyButtonText = $"Play key: \"{Settings.PlayKey}\" (change)";
  534. }
  535. private static void CreateTags(SourceGame game)
  536. {
  537. var wordMap = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
  538. var ambiguous = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
  539. for (int i = 0; i < game.Tracks.Count; i++)
  540. {
  541. foreach (var w in game.Tracks[i].Name.Split(' ', '.', '-', '_'))
  542. {
  543. // only plain alphanumeric words survive as cfg aliases
  544. if (w.Length == 0 || w.Length >= 32) continue;
  545. if (!w.All(char.IsAsciiLetterOrDigit) || w.All(char.IsDigit)) continue;
  546. if (game.Blacklist.Contains(w.ToLower()) || ambiguous.Contains(w)) continue;
  547. if (wordMap.TryGetValue(w, out var owner))
  548. {
  549. if (owner != i) { wordMap.Remove(w); ambiguous.Add(w); }
  550. }
  551. else wordMap[w] = i;
  552. }
  553. }
  554. foreach (var (word, idx) in wordMap)
  555. game.Tracks[idx].Tags.Add(word);
  556. }
  557. private void LogError(Exception ex)
  558. {
  559. if (!Settings.LogError) return;
  560. File.AppendAllText("errorlog.txt", $"----{DateTime.UtcNow} UTC----\n{ex}\n");
  561. }
  562. private static readonly bool DebugLogging = false; // flip to true to log to slam_debug.log
  563. private static void DebugLog(string msg)
  564. {
  565. if (DebugLogging)
  566. File.AppendAllText("slam_debug.log", $"{DateTime.UtcNow:O} {msg}\n");
  567. }
  568. public event Action<string>? ErrorOccurred;
  569. public void ShowError(string msg) => ErrorOccurred?.Invoke(msg);
  570. public void SaveSettings() => _settings.Save();
  571. }