DiscordIntegrationModule.cs 7.1 KB

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