DiscordIntegrationModule.cs 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. using DiscordRPC;
  2. using Gommon;
  3. using MsgPack;
  4. using Ryujinx.Ava.Utilities;
  5. using Ryujinx.Ava.Utilities.AppLibrary;
  6. using Ryujinx.Ava.Utilities.Configuration;
  7. using Ryujinx.Common;
  8. using Ryujinx.Common.Helper;
  9. using Ryujinx.Common.Logging;
  10. using Ryujinx.HLE;
  11. using Ryujinx.HLE.Loaders.Processes;
  12. using Ryujinx.Horizon;
  13. using System;
  14. using System.Collections.Generic;
  15. using System.Collections.ObjectModel;
  16. using System.Linq;
  17. using System.Text;
  18. namespace Ryujinx.Ava
  19. {
  20. public static class DiscordIntegrationModule
  21. {
  22. public static Timestamps EmulatorStartedAt { get; set; }
  23. public static Timestamps GuestAppStartedAt { get; set; }
  24. private static string VersionString
  25. => (ReleaseInformation.IsCanaryBuild ? "Canary " : string.Empty) + $"v{ReleaseInformation.Version}";
  26. private static readonly string _description =
  27. ReleaseInformation.IsValid
  28. ? $"{VersionString} {ReleaseInformation.ReleaseChannelOwner}/{ReleaseInformation.ReleaseChannelSourceRepo}@{ReleaseInformation.BuildGitHash}"
  29. : "dev build";
  30. private const string ApplicationId = "1293250299716173864";
  31. private const int ApplicationByteLimit = 128;
  32. private const string Ellipsis = "…";
  33. private static DiscordRpcClient _discordClient;
  34. private static RichPresence _discordPresenceMain;
  35. private static RichPresence _discordPresencePlaying;
  36. private static ApplicationMetadata _currentApp;
  37. public static void Initialize()
  38. {
  39. _discordPresenceMain = new RichPresence
  40. {
  41. Assets = new Assets
  42. {
  43. LargeImageKey = "ryujinx", LargeImageText = TruncateToByteLength(_description)
  44. },
  45. Details = "Main Menu",
  46. State = "Idling",
  47. Timestamps = EmulatorStartedAt
  48. };
  49. ConfigurationState.Instance.EnableDiscordIntegration.Event += Update;
  50. TitleIDs.CurrentApplication.Event += (_, e) => Use(e.NewValue);
  51. HorizonStatic.PlayReport += HandlePlayReport;
  52. }
  53. private static void Update(object sender, ReactiveEventArgs<bool> evnt)
  54. {
  55. if (evnt.OldValue != evnt.NewValue)
  56. {
  57. // If the integration was active, disable it and unload everything
  58. if (evnt.OldValue)
  59. {
  60. _discordClient?.Dispose();
  61. _discordClient = null;
  62. }
  63. // If we need to activate it and the client isn't active, initialize it
  64. if (evnt.NewValue && _discordClient == null)
  65. {
  66. _discordClient = new DiscordRpcClient(ApplicationId);
  67. _discordClient.Initialize();
  68. Use(TitleIDs.CurrentApplication);
  69. }
  70. }
  71. }
  72. public static void Use(Optional<string> titleId)
  73. {
  74. if (titleId.TryGet(out string tid))
  75. SwitchToPlayingState(
  76. ApplicationLibrary.LoadAndSaveMetaData(tid),
  77. Switch.Shared.Processes.ActiveApplication
  78. );
  79. else
  80. SwitchToMainState();
  81. }
  82. private static RichPresence CreatePlayingState(ApplicationMetadata appMeta, ProcessResult procRes) =>
  83. new()
  84. {
  85. Assets = new Assets
  86. {
  87. LargeImageKey = TitleIDs.GetDiscordGameAsset(procRes.ProgramIdText),
  88. LargeImageText = TruncateToByteLength($"{appMeta.Title} (v{procRes.DisplayVersion})"),
  89. SmallImageKey = "ryujinx",
  90. SmallImageText = TruncateToByteLength(_description)
  91. },
  92. Details = TruncateToByteLength($"Playing {appMeta.Title}"),
  93. State = appMeta.LastPlayed.HasValue && appMeta.TimePlayed.TotalSeconds > 5
  94. ? $"Total play time: {ValueFormatUtils.FormatTimeSpan(appMeta.TimePlayed)}"
  95. : "Never played",
  96. Timestamps = GuestAppStartedAt ??= Timestamps.Now
  97. };
  98. private static void SwitchToPlayingState(ApplicationMetadata appMeta, ProcessResult procRes)
  99. {
  100. _discordClient?.SetPresence(_discordPresencePlaying ??= CreatePlayingState(appMeta, procRes));
  101. _currentApp = appMeta;
  102. }
  103. private static void UpdatePlayingState()
  104. {
  105. _discordClient?.SetPresence(_discordPresencePlaying);
  106. }
  107. private static void SwitchToMainState()
  108. {
  109. _discordClient?.SetPresence(_discordPresenceMain);
  110. _discordPresencePlaying = null;
  111. _currentApp = null;
  112. }
  113. private static void HandlePlayReport(MessagePackObject playReport)
  114. {
  115. if (_discordClient is null) return;
  116. if (!TitleIDs.CurrentApplication.Value.HasValue) return;
  117. if (_discordPresencePlaying is null) return;
  118. PlayReportAnalyzer.FormattedValue value = PlayReport.Analyzer.FormatPlayReportValue(TitleIDs.CurrentApplication.Value, _currentApp, playReport);
  119. if (!value.Handled) return;
  120. if (value.Reset)
  121. {
  122. _discordPresencePlaying.Details = $"Playing {_currentApp.Title}";
  123. Logger.Info?.Print(LogClass.UI, "Reset Discord RPC based on a supported play report value formatter.");
  124. }
  125. else
  126. {
  127. _discordPresencePlaying.Details = value.FormattedString;
  128. Logger.Info?.Print(LogClass.UI, "Updated Discord RPC based on a supported play report.");
  129. }
  130. UpdatePlayingState();
  131. }
  132. private static string TruncateToByteLength(string input)
  133. {
  134. if (Encoding.UTF8.GetByteCount(input) <= ApplicationByteLimit)
  135. {
  136. return input;
  137. }
  138. // Find the length to trim the string to guarantee we have space for the trailing ellipsis.
  139. int trimLimit = ApplicationByteLimit - Encoding.UTF8.GetByteCount(Ellipsis);
  140. // Make sure the string is long enough to perform the basic trim.
  141. // Amount of bytes != Length of the string
  142. if (input.Length > trimLimit)
  143. {
  144. // Basic trim to best case scenario of 1 byte characters.
  145. input = input[..trimLimit];
  146. }
  147. while (Encoding.UTF8.GetByteCount(input) > trimLimit)
  148. {
  149. // Remove one character from the end of the string at a time.
  150. input = input[..^1];
  151. }
  152. return input.TrimEnd() + Ellipsis;
  153. }
  154. public static void Exit()
  155. {
  156. _discordClient?.Dispose();
  157. }
  158. }
  159. }