diff --git a/README.md b/README.md index e81e98c..3c453d3 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,78 @@ -# Aegisub-DiscordRPC -Outputs Aegisub editing to Discord Rich Presence +# Aegisub DiscordRPC +A Lua plugin (macro) for Aegisub to output currently editing +subtitle information to Discord Rich Presence + +## Installation +- Place the binary file `discord-rpc.dll` in your Aegisub installation folder. + - If you use the discouraged 64-bit Aegisub then please take the + provided `discord-rpc-64.dll` and rename it as `discord-rpc.dll` + before placing it in your Aegisub installation folder. + (I don't guarantee if it would work in 64-bit Aegisub because I + don't use them and I can't test it. Plus, the official website + also discouraged the use of 64-bit release due to lack of support + for most of its video tools and renderer) +- Place the script `discord-rpc.lua` into the `automation\autoload` folder. + +### Example +If you installed Aegisub in `C:\Program Files (x86)\Aegisub` then: +- Place `discord-rpc.dll` in `C:\Program Files (x86)\Aegisub`. +- Place `discord-rpc.lua` in `C:\Program Files (x86)\Aegisub\automation\autoload`. + +## Usage +When installed correctly, it will first start the rich presense +with `Idle` as the detail and `No video file loaded yet` as the state +whenever you launched Aegisub. + +![First launch](./img/first-launch.png "First launch") + +Then, load your subtitle and video, and click on `Update Discord RPC` +from the `Automation` menu to let Aegisub update the Rich Presence +as `Editing subtitle` as the detail and the video file name as +the state. + +![Click on the menu](./img/click-menu.png "Click on the menu") +![Details are updated](./img/detail-updated.png "Details are updated") + +### Warning +Please make sure that you have done either any of below before +clicking `Update Discord RPC` menu: +- You have already loaded the video for the subtitle you're editing. +- You have already loaded subtitle file containing video path. + +This is because of limitation in Aegisub, it doesn't expose the +subtitle file name nor the window title to its Lua environment. +It does, however, expose the subtitle property metadata to the +Lua environment, and video path is one of the metadata in it. + +I'm accessing that video path from the subtitle metadata for +display in Rich Presence, and it would not work if you did not +load any video for the subtitle file ever as the video path will +not be there in the subtitle metadata. + +I initially planned to automatically load the video path when +Aegisub is launched, but it seems that the Lua script is loaded +*before* the subtitle metadata itself, which is why I had to +implement the "click on menu to update Discord RPC" method. + +## References +### Script +This script uses some code from pfirsich's `lua-discordRPC`. +Visit [their repository](https://github.com/pfirsich/lua-discordRPC "pfirsich/lua-discordRPC on GitHub") +to learn more, especially if you want to use Discord Rich Presence in Lua. + +Their script is licensed under MIT. + +### Binary +The binaries included in this repository came from Discord RPC +official releases. You can also download the binaries directly from +their [official repository](https://github.com/discordapp/discord-rpc/releases/latest "Latest Discord RPC release"). + +Make sure to take the dynamic version of binary: +- Take `discord-rpc/win32-dynamic/bin/discord-rpc.dll` for 32-bit Aegisub +- Take `discord-rpc/win64-dynamic/bin/discord-rpc.dll` for 64-bit Aegisub + +Their binaries and source code are licensed under MIT. + +## License +This script and the included binaries are released under +[The MIT License](./LICENSE "Read full license text"). diff --git a/discord-rpc-64.dll b/discord-rpc-64.dll new file mode 100644 index 0000000..8493c54 Binary files /dev/null and b/discord-rpc-64.dll differ diff --git a/discord-rpc.dll b/discord-rpc.dll new file mode 100644 index 0000000..88c7d0c Binary files /dev/null and b/discord-rpc.dll differ diff --git a/discord-rpc.lua b/discord-rpc.lua new file mode 100644 index 0000000..7671b98 --- /dev/null +++ b/discord-rpc.lua @@ -0,0 +1,296 @@ +-- Discord RPC +-- Outputs current editing session to Discord Rich Presence +-- +-- This file is written by muhdnurhidayat +-- Latest release and details at https://github.com/MuhdNurHidayat/Aegisub-DiscordRPC +-- This file is licensed under MIT +-- +-- This file uses some code from https://github.com/pfirsich/lua-discordRPC +-- The codes from pfirsich/lua-discordRPC is licensed under MIT +-- + +local ffi = require "ffi" +local discordRPClib = ffi.load("discord-rpc") +local appId = "592657785368477728" + +script_name = "Discord RPC" +script_description = "Outputs Aegisub editing to Discord Rich Presence" +script_author = "muhdnurhidayat" +script_version = "1" + +ffi.cdef[[ +typedef struct DiscordRichPresence { + const char* state; /* max 128 bytes */ + const char* details; /* max 128 bytes */ + int64_t startTimestamp; + int64_t endTimestamp; + const char* largeImageKey; /* max 32 bytes */ + const char* largeImageText; /* max 128 bytes */ + const char* smallImageKey; /* max 32 bytes */ + const char* smallImageText; /* max 128 bytes */ + const char* partyId; /* max 128 bytes */ + int partySize; + int partyMax; + const char* matchSecret; /* max 128 bytes */ + const char* joinSecret; /* max 128 bytes */ + const char* spectateSecret; /* max 128 bytes */ + int8_t instance; +} DiscordRichPresence; + +typedef struct DiscordUser { + const char* userId; + const char* username; + const char* discriminator; + const char* avatar; +} DiscordUser; + +typedef void (*readyPtr)(const DiscordUser* request); +typedef void (*disconnectedPtr)(int errorCode, const char* message); +typedef void (*erroredPtr)(int errorCode, const char* message); +typedef void (*joinGamePtr)(const char* joinSecret); +typedef void (*spectateGamePtr)(const char* spectateSecret); +typedef void (*joinRequestPtr)(const DiscordUser* request); + +typedef struct DiscordEventHandlers { + readyPtr ready; + disconnectedPtr disconnected; + erroredPtr errored; + joinGamePtr joinGame; + spectateGamePtr spectateGame; + joinRequestPtr joinRequest; +} DiscordEventHandlers; + +void Discord_Initialize(const char* applicationId, + DiscordEventHandlers* handlers, + int autoRegister, + const char* optionalSteamId); + +void Discord_Shutdown(void); + +void Discord_RunCallbacks(void); + +void Discord_UpdatePresence(const DiscordRichPresence* presence); + +void Discord_ClearPresence(void); + +void Discord_Respond(const char* userid, int reply); + +void Discord_UpdateHandlers(DiscordEventHandlers* handlers); +]] + +local discordRPC = {} -- module table + +-- proxy to detect garbage collection of the module +discordRPC.gcDummy = newproxy(true) + +local function unpackDiscordUser(request) + return ffi.string(request.userId), ffi.string(request.username), + ffi.string(request.discriminator), ffi.string(request.avatar) +end + +-- callback proxies +-- note: callbacks are not JIT compiled (= SLOW), try to avoid doing performance critical tasks in them +-- luajit.org/ext_ffi_semantics.html +local ready_proxy = ffi.cast("readyPtr", function(request) + if discordRPC.ready then + discordRPC.ready(unpackDiscordUser(request)) + end +end) + +local disconnected_proxy = ffi.cast("disconnectedPtr", function(errorCode, message) + if discordRPC.disconnected then + discordRPC.disconnected(errorCode, ffi.string(message)) + end +end) + +local errored_proxy = ffi.cast("erroredPtr", function(errorCode, message) + if discordRPC.errored then + discordRPC.errored(errorCode, ffi.string(message)) + end +end) + +-- helpers +function checkArg(arg, argType, argName, func, maybeNil) + assert(type(arg) == argType or (maybeNil and arg == nil), + string.format("Argument \"%s\" to function \"%s\" has to be of type \"%s\"", + argName, func, argType)) +end + +function checkStrArg(arg, maxLen, argName, func, maybeNil) + if maxLen then + assert(type(arg) == "string" and arg:len() <= maxLen or (maybeNil and arg == nil), + string.format("Argument \"%s\" of function \"%s\" has to be of type string with maximum length %d", + argName, func, maxLen)) + else + checkArg(arg, "string", argName, func, true) + end +end + +function checkIntArg(arg, maxBits, argName, func, maybeNil) + maxBits = math.min(maxBits or 32, 52) -- lua number (double) can only store integers < 2^53 + local maxVal = 2^(maxBits-1) -- assuming signed integers, which, for now, are the only ones in use + assert(type(arg) == "number" and math.floor(arg) == arg + and arg < maxVal and arg >= -maxVal + or (maybeNil and arg == nil), + string.format("Argument \"%s\" of function \"%s\" has to be a whole number <= %d", + argName, func, maxVal)) +end + +-- function wrappers +function discordRPC.initialize(applicationId, autoRegister, optionalSteamId) + local func = "discordRPC.Initialize" + checkStrArg(applicationId, nil, "applicationId", func) + checkArg(autoRegister, "boolean", "autoRegister", func) + if optionalSteamId ~= nil then + checkStrArg(optionalSteamId, nil, "optionalSteamId", func) + end + + local eventHandlers = ffi.new("struct DiscordEventHandlers") + eventHandlers.ready = ready_proxy + eventHandlers.disconnected = disconnected_proxy + eventHandlers.errored = errored_proxy + eventHandlers.joinGame = joinGame_proxy + eventHandlers.spectateGame = spectateGame_proxy + eventHandlers.joinRequest = joinRequest_proxy + + discordRPClib.Discord_Initialize(applicationId, eventHandlers, + autoRegister and 1 or 0, optionalSteamId) +end + +function discordRPC.shutdown() + discordRPClib.Discord_Shutdown() +end + +function discordRPC.runCallbacks() + -- http://luajit.org/ext_ffi_semantics.html#callback : + -- One thing that's not allowed, is to let an FFI call into a C function (runCallbacks) + -- get JIT-compiled, which in turn calls a callback, calling into Lua again (i.e. discordRPC.ready). + -- Usually this attempt is caught by the interpreter first and the C function + -- is blacklisted for compilation. + -- solution: + -- Then you'll need to manually turn off JIT-compilation with jit.off() for + -- the surrounding Lua function that invokes such a message polling function. + jit.off() + discordRPClib.Discord_RunCallbacks() + jit.on() +end + +function discordRPC.updatePresence(presence) + local func = "discordRPC.updatePresence" + checkArg(presence, "table", "presence", func) + + -- -1 for string length because of 0-termination + checkStrArg(presence.state, 127, "presence.state", func, true) + checkStrArg(presence.details, 127, "presence.details", func, true) + + checkIntArg(presence.startTimestamp, 64, "presence.startTimestamp", func, true) + checkIntArg(presence.endTimestamp, 64, "presence.endTimestamp", func, true) + + checkStrArg(presence.largeImageKey, 31, "presence.largeImageKey", func, true) + checkStrArg(presence.largeImageText, 127, "presence.largeImageText", func, true) + checkStrArg(presence.smallImageKey, 31, "presence.smallImageKey", func, true) + checkStrArg(presence.smallImageText, 127, "presence.smallImageText", func, true) + checkStrArg(presence.partyId, 127, "presence.partyId", func, true) + + checkIntArg(presence.partySize, 32, "presence.partySize", func, true) + checkIntArg(presence.partyMax, 32, "presence.partyMax", func, true) + + checkStrArg(presence.matchSecret, 127, "presence.matchSecret", func, true) + checkStrArg(presence.joinSecret, 127, "presence.joinSecret", func, true) + checkStrArg(presence.spectateSecret, 127, "presence.spectateSecret", func, true) + + checkIntArg(presence.instance, 8, "presence.instance", func, true) + + local cpresence = ffi.new("struct DiscordRichPresence") + cpresence.state = presence.state + cpresence.details = presence.details + cpresence.startTimestamp = presence.startTimestamp or 0 + cpresence.endTimestamp = presence.endTimestamp or 0 + cpresence.largeImageKey = presence.largeImageKey + cpresence.largeImageText = presence.largeImageText + cpresence.smallImageKey = presence.smallImageKey + cpresence.smallImageText = presence.smallImageText + cpresence.partyId = presence.partyId + cpresence.partySize = presence.partySize or 0 + cpresence.partyMax = presence.partyMax or 0 + cpresence.matchSecret = presence.matchSecret + cpresence.joinSecret = presence.joinSecret + cpresence.spectateSecret = presence.spectateSecret + cpresence.instance = presence.instance or 0 + + discordRPClib.Discord_UpdatePresence(cpresence) +end + +function discordRPC.clearPresence() + discordRPClib.Discord_ClearPresence() +end + +local replyMap = { + no = 0, + yes = 1, + ignore = 2 +} + +-- maybe let reply take ints too (0, 1, 2) and add constants to the module +function discordRPC.respond(userId, reply) + checkStrArg(userId, nil, "userId", "discordRPC.respond") + assert(replyMap[reply], "Argument 'reply' to discordRPC.respond has to be one of \"yes\", \"no\" or \"ignore\"") + discordRPClib.Discord_Respond(userId, replyMap[reply]) +end + +-- garbage collection callback +getmetatable(discordRPC.gcDummy).__gc = function() + discordRPC.shutdown() + ready_proxy:free() + disconnected_proxy:free() + errored_proxy:free() +end + +function discordRPC.ready(userId, username, discriminator, avatar) + print("[discordrpc] Discord: ready (" .. userId .. ", " .. username .. ", " .. discriminator ", " .. avatar .. ")") +end + +function discordRPC.disconnected(errorCode, message) + print("[discordrpc] Discord: disconnected (" .. errorCode .. ": " .. message .. ")") +end + +function discordRPC.errored(errorCode, message) + print("[discordrpc] Discord: error (" .. errorCode .. ": " .. message .. ")") +end + +discordRPC.initialize(appId, true) +local now = os.time(os.date('*t')) +presence = { + state = "No video file loaded yet", + details = "Idle", + startTimestamp = now, + largeImageKey = "aegisub", + smallImageKey = "", +} +discordRPC.updatePresence(presence) + +function update_rpc() + if (aegisub.project_properties() ~= nil) then + local videoname = aegisub.project_properties().video_file; + if (videoname ~= " ") then + videoname = videoname:match("[^\\]*$") + if(string.len(videoname) > 117) then + videoname = string.sub(videoname, 1, 117) .. "…" + end + presence = { + state = "Video: " .. videoname, + details = "Editing subtitle", + startTimestamp = now, + largeImageKey = "aegisub", + smallImageKey = "", + } + discordRPC.updatePresence(presence) + else + aegisub.debug.out("Please ensure your subtitle file has video file path defined before updating the RPC") + end + else + aegisub.debug.out("Please ensure your subtitle file has video file path defined before updating the RPC") + end +end + +aegisub.register_macro("Update Discord RPC", "Update Discord Rich Presence", update_rpc) diff --git a/img/click-menu.png b/img/click-menu.png new file mode 100644 index 0000000..0dee2df Binary files /dev/null and b/img/click-menu.png differ diff --git a/img/detail-updated.png b/img/detail-updated.png new file mode 100644 index 0000000..3609dbd Binary files /dev/null and b/img/detail-updated.png differ diff --git a/img/first-launch.png b/img/first-launch.png new file mode 100644 index 0000000..c77bbc9 Binary files /dev/null and b/img/first-launch.png differ