├── README.md ├── BadBetterMediaPlayer.plugin.js └── BetterMediaPlayer.plugin.js /README.md: -------------------------------------------------------------------------------- 1 | 2 | # BetterMediaPlayer 3 | 4 | Adds more functionality to the media player in discord. 5 | 6 | ## Example 7 | 8 | ![image](https://github.com/user-attachments/assets/a61060c6-4c32-4360-8874-6ba356c62087) 9 | 10 | -------------------------------------------------------------------------------- /BadBetterMediaPlayer.plugin.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @name BadBetterMediaPlayer 3 | * @version 1.2.12 4 | * @author unknown81311_&_Doggybootsy 5 | * @description Deny this plugin please 6 | * @authorLink https://betterdiscord.app/plugin?id=377 7 | * @source https://github.com/unknown81311/BetterMediaPlayer 8 | * @updateUrl https://raw.githubusercontent.com/unknown81311/BetterMediaPlayer/main/BetterMediaPlayer.plugin.js 9 | * @invite yYJA3qQE5F 10 | */ 11 | 12 | module.exports = () => { 13 | return { start(), stop() {} }; 14 | } 15 | -------------------------------------------------------------------------------- /BetterMediaPlayer.plugin.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @name BetterMediaPlayer 3 | * @version 1.2.20 4 | * @author unknown81311_&_Doggybootsy 5 | * @description Adds more features to the MediaPlayer inside of Discord. (**Only adds PIP and Loop!**) 6 | * @authorLink https://betterdiscord.app/plugin?id=377 7 | * @source https://github.com/unknown81311/BetterMediaPlayer 8 | * @updateUrl https://raw.githubusercontent.com/unknown81311/BetterMediaPlayer/main/BetterMediaPlayer.plugin.js 9 | * @invite yYJA3qQE5F 10 | */ 11 | 12 | const { Patcher, Webpack, DOM, React, Data, UI } = new BdApi("BetterMediaPlayer"); 13 | const classes = Object.assign({}, Webpack.getModule(m => m.controlIcon && m.video), Webpack.getModule(m => m.button && m.colorBrand)); 14 | 15 | const [ 16 | _, 17 | PopoutWindowStore, 18 | dispatcher, 19 | useStateFromStores, 20 | PopoutWindow, 21 | errorClasses, 22 | { errorPage, buttons }, 23 | Flex, 24 | i18n, 25 | inviteActions, 26 | InviteModalStore, 27 | native, 28 | GuildStore, 29 | hljs, 30 | Guild, 31 | GuildMemberCountStore, 32 | VolumeSlider, 33 | DurationBar, 34 | scrollerClasses, 35 | MediaControls, 36 | controlClasses, 37 | iconClasses, 38 | titleClasses, 39 | TextBase, 40 | _Button 41 | ] = Webpack.getBulk( 42 | { filter: m => m._ && m.debounce }, 43 | { filter: m => m.getWindow }, 44 | { filter: m => m.subscribe && m.dispatch }, 45 | { filter: Webpack.Filters.byStrings('"useStateFromStores"'), searchExports: true }, 46 | { filter: m => m.render?.toString().includes("Missing guestWindow reference") }, 47 | { filter: m => m.wrapper && m.note }, 48 | { filter: m => m.errorPage && m.buttons }, 49 | { filter: m => m.defaultProps?.basis, searchExports: true }, 50 | { filter: m => m.t && m.intl }, 51 | { filter: m => m.resolveInvite }, 52 | { filter: m => m.getName?.() === "InviteModalStore" }, 53 | { filter: m => m.minimize && m.requireModule }, 54 | { filter: m => m.getName?.() === "GuildStore" }, 55 | { filter: m => typeof m.highlight === "function" && typeof m.highlightAll === "function" }, 56 | { filter: m => m.prototype?.getEveryoneRoleId && m.prototype.getIconURL }, 57 | { filter: m => m.getName?.() === "GuildMemberCountStore" }, 58 | { filter: Webpack.Filters.byStrings("sliderClassName:", "onDragEnd:this.handleDragEnd", "handleValueChange") }, 59 | { filter: m => m.Types?.DURATION }, 60 | { filter: m => m.thin && m.customTheme }, 61 | { filter: Webpack.Filters.byPrototypeKeys("renderControls", "renderVideo") }, 62 | { filter: Webpack.Filters.byKeys("controlIcon", "videoButton") }, 63 | { filter: Webpack.Filters.byKeys("controlIcon", "popoutOpen") }, 64 | { filter: Webpack.Filters.byKeys("guildIcon", "title", "button") }, 65 | { filter: ((m) => (exports, module) => m(Webpack.modules[module.id]))(Webpack.Filters.byStrings("data-text-variant")), searchExports: true }, 66 | { filter: (m) => typeof m === "function" && typeof m.Link === "function", searchExports: true } 67 | ); 68 | 69 | const Button = _Button || BdApi.Components.Button; 70 | 71 | const Text = TextBase?.render ? TextBase : Object.values(TextBase)[0]; 72 | 73 | const [toolbarModule, toolbarKey] = Webpack.getWithKey(Webpack.Filters.byStrings(".PlatformTypes.WINDOWS", "leading:"), { 74 | target: Webpack.getBySource(".shortBar]:", ".WINDOWS&&") 75 | }); 76 | 77 | const { isOpen: originalIsOpen } = InviteModalStore; 78 | const { minimize: originalMinimize, focus: originalFocus } = native; 79 | 80 | function Replay({ width, height }) { 81 | return React.createElement("svg", { 82 | width: width, 83 | height: height, 84 | viewBox: "0 0 24 24", 85 | children: React.createElement("path", { 86 | fill: "currentColor", 87 | d: "M12,5 L12,1 L7,6 L12,11 L12,7 C15.31,7 18,9.69 18,13 C18,16.31 15.31,19 12,19 C8.69,19 6,16.31 6,13 L4,13 C4,17.42 7.58,21 12,21 C16.42,21 20,17.42 20,13 C20,8.58 16.42,5 12,5 L12,5 Z" 88 | }) 89 | }); 90 | }; 91 | function Pause({ width, height }) { 92 | return React.createElement("svg", { 93 | width: width, 94 | height: height, 95 | viewBox: "0 0 18 18", 96 | children: React.createElement("path", { 97 | fill: "currentColor", 98 | d: "M5.25 2.25226H7.5C7.9125 2.25226 8.25 2.58976 8.25 3.00226V15.0023C8.25 15.4148 7.9125 15.7523 7.5 15.7523H5.25C4.8375 15.7523 4.5 15.4148 4.5 15.0023V3.00226C4.5 2.58976 4.8375 2.25226 5.25 2.25226ZM11.25 2.25226H13.5C13.9125 2.25226 14.25 2.58976 14.25 3.00226V15.0023C14.25 15.4148 13.9125 15.7523 13.5 15.7523H11.25C10.8375 15.7523 10.5 15.4148 10.5 15.0023V3.00226C10.5 2.58976 10.8375 2.25226 11.25 2.25226Z" 99 | }) 100 | }); 101 | }; 102 | function Play({ width, height }) { 103 | return React.createElement("svg", { 104 | width: width, 105 | height: height, 106 | viewBox: "0 0 18 18", 107 | children: React.createElement("path", { 108 | fill: "currentColor", 109 | d: "M6.01053 2.82974C5.01058 2.24153 3.75 2.96251 3.75 4.12264V13.8774C3.75 15.0375 5.01058 15.7585 6.01053 15.1703L14.3021 10.2929C15.288 9.71294 15.288 8.28709 14.3021 7.70711L6.01053 2.82974Z" 110 | }) 111 | }); 112 | }; 113 | function Loop({ width, height, className }) { 114 | return React.createElement("svg", { 115 | width: width, 116 | height: height, 117 | viewBox: "-5 0 459 459.648", 118 | className, 119 | children: [ 120 | React.createElement("path", { 121 | fill: "currentColor", 122 | fillRule: "evenodd", 123 | clipRule: "evenodd", 124 | d: "m416.324219 293.824219c0 26.507812-21.492188 48-48 48h-313.375l63.199219-63.199219-22.625-22.625-90.511719 90.511719c-6.246094 6.25-6.246094 16.375 0 22.625l90.511719 90.511719 22.625-22.625-63.199219-63.199219h313.375c44.160156-.054688 79.945312-35.839844 80-80v-64h-32zm0 0" 125 | }), 126 | React.createElement("path", { 127 | fill: "currentColor", 128 | fillRule: "evenodd", 129 | clipRule: "evenodd", 130 | d: "m32.324219 165.824219c0-26.511719 21.488281-48 48-48h313.375l-63.199219 63.199219 22.625 22.625 90.511719-90.511719c6.246093-6.25 6.246093-16.375 0-22.625l-90.511719-90.511719-22.625 22.625 63.199219 63.199219h-313.375c-44.160157.050781-79.949219 35.839843-80 80v64h32zm0 0" 131 | }) 132 | ] 133 | }); 134 | }; 135 | function Download({ width, height }) { 136 | return React.createElement("svg", { 137 | width: width, 138 | height: height, 139 | viewBox: "0 0 24 24", 140 | children: [ 141 | React.createElement("path", { 142 | fill: "currentColor", 143 | fillRule: "evenodd", 144 | clipRule: "evenodd", 145 | d: "M16.293 9.293L17.707 10.707L12 16.414L6.29297 10.707L7.70697 9.293L11 12.586V2H13V12.586L16.293 9.293ZM18 20V18H20V20C20 21.102 19.104 22 18 22H6C4.896 22 4 21.102 4 20V18H6V20H18Z" 146 | }) 147 | ] 148 | }); 149 | }; 150 | 151 | const useVolume = (() => { 152 | const listeners = new Set(); 153 | 154 | return function useVolume() { 155 | const [ volume, setVolume ] = React.useState(() => Data.load("volume") ?? 1); 156 | 157 | React.useLayoutEffect(() => { 158 | function listener(newVolume) { 159 | setVolume(newVolume); 160 | }; 161 | 162 | listeners.add(listener); 163 | return () => void listeners.delete(listener); 164 | }, [ listeners ]); 165 | 166 | function setNewVolume(newVolume) { 167 | Data.save("volume", newVolume); 168 | for (const listener of listeners) listener(newVolume); 169 | }; 170 | 171 | return [ volume, setNewVolume ]; 172 | }; 173 | })(); 174 | const useMuted = (() => { 175 | const listeners = new Set(); 176 | 177 | return function useMuted() { 178 | const [ muted, setMuted ] = React.useState(() => Data.load("muted") ?? false); 179 | 180 | React.useLayoutEffect(() => { 181 | function listener(isMuted) { 182 | setMuted(isMuted); 183 | }; 184 | 185 | listeners.add(listener); 186 | return () => void listeners.delete(listener); 187 | }, [ ]); 188 | 189 | function setIsMuted(isMuted) { 190 | Data.save("muted", isMuted); 191 | for (const listener of listeners) listener(isMuted); 192 | }; 193 | 194 | return [ muted, setIsMuted ]; 195 | }; 196 | })(); 197 | 198 | function calcTime(time) { 199 | const minutes = Math.floor(time / 60); 200 | const seconds = time - (minutes * 60); 201 | return { minutes, seconds }; 202 | }; 203 | 204 | function Duration({ currentTime, duration }) { 205 | const parsedCurrentTime = React.useMemo(() => calcTime(currentTime), [ currentTime ]); 206 | const parsedDuration = React.useMemo(() => calcTime(duration), [ currentTime ]); 207 | 208 | return React.createElement("div", { 209 | id: "duration", 210 | children: [ 211 | `${parsedCurrentTime.minutes}:${10 > parsedCurrentTime.seconds ? `0${parsedCurrentTime.seconds}` : parsedCurrentTime.seconds}`, 212 | "/", 213 | `${parsedDuration.minutes}:${10 > parsedDuration.seconds ? `0${parsedDuration.seconds}` : parsedDuration.seconds}` 214 | ] 215 | }) 216 | }; 217 | 218 | function getBuffers(node) { 219 | const buffers = []; 220 | for (let index = 0; index < node.buffered.length; index++) { 221 | const start = node.buffered.start(index); 222 | const end = node.buffered.end(index); 223 | if (!(end - start < 1)) { 224 | buffers.push([ start / node.duration, (end - start) / node.duration ]); 225 | }; 226 | }; 227 | return buffers; 228 | }; 229 | 230 | class ErrorBoundary extends React.Component { 231 | state = { didError: false, errorInfo: new Error("Minified React error #999; Fake React Error (for debugging)") } 232 | componentDidCatch(error) { 233 | this.setState({ didError: true, errorInfo: error }); 234 | }; 235 | render() { 236 | return React.createElement(React.Fragment, { 237 | children: [ 238 | this.state.didError && React.createElement(ErrorSplash, { ...this.props, errorInfo: this.state.errorInfo }), 239 | !this.state.didError && React.createElement(PictureInPicture, this.props) 240 | ] 241 | }); 242 | }; 243 | }; 244 | 245 | let resolvedInvite; 246 | function JoinGuild() { 247 | const guild = useStateFromStores([ GuildStore ], () => GuildStore.getGuild("864267123694370836")); 248 | 249 | const [ invite, setInvite ] = React.useState(resolvedInvite); 250 | 251 | const guildInfo = React.useMemo(() => { 252 | if (guild ? false : !invite) return; 253 | const guildObject = guild ? guild : new Guild(invite.guild); 254 | 255 | return { 256 | name: guildObject.name, 257 | members: invite ? invite.approximate_member_count : GuildMemberCountStore.getMemberCount("864267123694370836"), 258 | online: invite ? invite.approximate_presence_count : GuildMemberCountStore.getOnlineCount("864267123694370836"), 259 | url: guildObject.getIconURL() 260 | }; 261 | }, [ guild, invite ]); 262 | 263 | React.useLayoutEffect(() => { 264 | if (invite) return; 265 | (async function () { 266 | const { invite } = await inviteActions.resolveInvite("yYJA3qQE5F", "Desktop Modal"); 267 | resolvedInvite = invite; 268 | setInvite(invite); 269 | })(); 270 | }, [ ]); 271 | 272 | const join = React.useCallback(async () => { 273 | if (guild) return; 274 | 275 | // Prevent from focusing to main window 276 | InviteModalStore.isOpen = () => true; 277 | native.minimize = () => {}; 278 | native.focus = () => {}; 279 | 280 | await dispatcher.dispatch({ type: "INVITE_MODAL_OPEN", invite }); 281 | 282 | InviteModalStore.isOpen = originalIsOpen; 283 | native.minimize = originalMinimize; 284 | native.focus = originalFocus; 285 | }, [ guild, invite ]); 286 | 287 | const transitionTo = React.useCallback(() => { 288 | inviteActions.transitionToInviteSync(invite); 289 | }, [ guild, invite ]); 290 | 291 | const onClick = React.useCallback(() => { 292 | if (!invite) return; 293 | if (guild) transitionTo(); 294 | else join(); 295 | }, [ transitionTo, join ]); 296 | 297 | return React.createElement("div", { 298 | style: { 299 | display: "flex", 300 | background: "var(--background-secondary)", 301 | padding: 8, 302 | borderRadius: 8, 303 | border: "1px solid var(--background-secondary-alt)" 304 | }, 305 | children: [ 306 | guildInfo ? React.createElement("img", { 307 | src: guildInfo.url, 308 | height: 38, 309 | width: 38, 310 | style: { 311 | borderRadius: "50%" 312 | } 313 | }) : React.createElement("div", { 314 | style: { 315 | height: 38, 316 | width: 38, 317 | borderRadius: "50%", 318 | background: "var(--background-secondary-alt)" 319 | } 320 | }), 321 | React.createElement("div", { 322 | style: { 323 | color: "var(--text-normal)", 324 | flex: "1 0", 325 | padding: "0 8px", 326 | display: "flex", 327 | flexDirection: "column", 328 | justifyContent: "space-between" 329 | }, 330 | children: [ 331 | React.createElement("div", { 332 | style: { 333 | fontSize: "large", 334 | fontWeight: 600 335 | }, 336 | children: [ 337 | guildInfo && guildInfo.name, 338 | !guildInfo && "Resolving..." 339 | ] 340 | }), 341 | 342 | React.createElement("div", { 343 | style: { 344 | display: "flex" 345 | }, 346 | children: [ 347 | React.createElement("div", { 348 | style: { 349 | display: "flex" 350 | }, 351 | children: [ 352 | React.createElement("div", { 353 | style: { 354 | margin: "auto 8px auto 0", 355 | width: 8, 356 | height: 8, 357 | borderRadius: "50%", 358 | background: "var(--green-360)" 359 | } 360 | }), 361 | guildInfo ? guildInfo.online : "0" 362 | ] 363 | }), 364 | React.createElement("div", { 365 | style: { 366 | display: "flex" 367 | }, 368 | children: [ 369 | React.createElement("div", { 370 | style: { 371 | margin: "auto 8px", 372 | width: 8, 373 | height: 8, 374 | borderRadius: "50%", 375 | background: "var(--primary-400)" 376 | } 377 | }), 378 | guildInfo ? guildInfo.members : "0" 379 | ] 380 | }) 381 | ] 382 | }) 383 | ] 384 | }), 385 | React.createElement(Button, { 386 | color: Button.Colors.GREEN, 387 | disabled: guild ? false : !invite, 388 | children: [ 389 | guild && i18n.intl.string(i18n.t.cEnaW1), 390 | (!guild && invite) && i18n.intl.format(i18n.t.QD7BDA, { guildName: guildInfo.name }), 391 | (!guild && !invite) && i18n.intl.string(i18n.t["N/g9Z2"]) 392 | ], 393 | onClick: onClick 394 | }) 395 | ] 396 | }); 397 | }; 398 | 399 | function ErrorModal({ src, errorInfo }) { 400 | const highlighted = React.useMemo(() => hljs.highlight(errorInfo.stack, { language: "js" }), [ ]); 401 | 402 | const [ copied, setCopied ] = React.useState(false); 403 | 404 | const setNotCopied = React.useMemo(() => _.debounce(() => setCopied(false), 1000), [ ]); 405 | 406 | const copy = React.useCallback(() => { 407 | setCopied(true); 408 | setNotCopied(); 409 | DiscordNative.clipboard.copy(errorInfo.stack); 410 | }, [ ]); 411 | 412 | return React.createElement("div", { 413 | children: [ 414 | React.createElement(JoinGuild, { src }), 415 | React.createElement("div", { 416 | style: { 417 | width: "calc(100% - 16px)", 418 | height: 1, 419 | margin: 8, 420 | background: "var(--background-secondary-alt)" 421 | } 422 | }), 423 | React.createElement("div", { 424 | style: { 425 | position: "relative" 426 | }, 427 | children: [ 428 | React.createElement("h2", { 429 | style: { 430 | color: "var(--header-primary)", 431 | fontWeight: 800, 432 | marginBottom: 8 433 | }, 434 | children: "Error Stack" 435 | }), 436 | React.createElement("pre", { 437 | className: `${scrollerClasses.thin} ${scrollerClasses.fade} ${scrollerClasses.customTheme}`, 438 | style: { 439 | maxHeight: 200, 440 | background: "var(--background-secondary)", 441 | border: "1px solid var(--background-secondary-alt)", 442 | color: "var(--text-normal)", 443 | overflow: "auto", 444 | padding: 8, 445 | userSelect: "text", 446 | borderRadius: 6 447 | }, 448 | children: React.createElement("code", { dangerouslySetInnerHTML: { __html: highlighted.value } }) 449 | }), 450 | React.createElement("div", { 451 | className: `button copy${copied ? " copied" : ""}`, 452 | style: { 453 | position: "absolute", 454 | bottom: 8, 455 | right: 8, 456 | zIndex: 1 457 | }, 458 | onClick: copy, 459 | children: React.createElement("svg", { 460 | viewBox: "0 0 24 24", 461 | height: 24, 462 | width: 24, 463 | children: [ 464 | React.createElement("path", { 465 | fill: "currentColor", 466 | d: "M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1z" 467 | }), 468 | React.createElement("path", { 469 | fill: "currentColor", 470 | d: "M15 5H8c-1.1 0-1.99.9-1.99 2L6 21c0 1.1.89 2 1.99 2H19c1.1 0 2-.9 2-2V11l-6-6zM8 21V7h6v5h5v9H8z" 471 | }) 472 | ] 473 | }) 474 | }) 475 | ] 476 | }) 477 | ] 478 | }); 479 | }; 480 | 481 | function ErrorSplash({ src, errorInfo, windowKey }) { 482 | const Window = useStateFromStores([ PopoutWindowStore ], () => PopoutWindowStore.getWindow(windowKey)) 483 | 484 | React.useLayoutEffect(() => { 485 | const style = document.createElement("style"); 486 | style.innerText = `.button { 487 | display: flex; 488 | color: var(--interactive-normal); 489 | cursor: pointer; 490 | padding: 4px; 491 | border-radius: 4px; 492 | } .button:hover { 493 | color: var(--interactive-hover); 494 | background-color: var(--background-modifier-hover); 495 | } .button:hover:active { 496 | color: var(--interactive-active); 497 | background-color: var(--background-modifier-active); 498 | } .copy { 499 | background: var(--background-modifier-accent); 500 | } .copied { 501 | color: var(--button-positive-background) 502 | } .copied:hover { 503 | color: var(--button-positive-background-hover) 504 | } .copied:hover:active { 505 | color: var(--button-positive-background-active) 506 | }`; 507 | Window.document.head.appendChild(style); 508 | }, [ ]); 509 | 510 | return React.createElement(React.Fragment, { 511 | children: [ 512 | React.createElement("div", { 513 | className: `${errorClasses.wrapper} ${errorPage}`, 514 | style: { 515 | display: "flex", 516 | position: "fixed", 517 | left: 0, 518 | top: 0, 519 | minHeight: "100%" 520 | }, 521 | children: [ 522 | React.createElement(Flex, { 523 | align: Flex.Align.CENTER, 524 | justify: Flex.Justify.CENTER, 525 | direction: Flex.Direction.VERTICAL, 526 | className: classes.flexWrapper, 527 | shrink: 1, 528 | grow: 1, 529 | children: [ 530 | React.createElement(Flex.Child, { 531 | className: errorClasses.image, 532 | grow: 0, 533 | shrink: 1, 534 | wrap: false 535 | }), 536 | React.createElement(Flex.Child, { 537 | className: errorClasses.text, 538 | grow: 0, 539 | shrink: 1, 540 | wrap: false, 541 | children: [ 542 | React.createElement("h2", { 543 | className: errorClasses.title, 544 | children: i18n.intl.string(i18n.t["3h+n+/"]) 545 | }), 546 | React.createElement("div", { 547 | className: errorClasses.note, 548 | children: React.createElement("div", { 549 | children: [ 550 | React.createElement("p", {}, i18n.intl.string(i18n.t["8JQPPj"]).replace("Discord", "Better Media Player")), 551 | React.createElement("p", {}, i18n.intl.string(i18n.t.tx8CkJ).replace("Discord", "Better Media Player")) 552 | ] 553 | }) 554 | }) 555 | ] 556 | }), 557 | React.createElement("div", { 558 | className: buttons, 559 | children: [ 560 | React.createElement(Button, { 561 | size: Button.Sizes.LARGE, 562 | onClick: () => Window.close(), 563 | children: i18n.intl.string(i18n.t.cpT0Cg) 564 | }) 565 | ] 566 | }) 567 | ] 568 | }) 569 | ] 570 | }), 571 | React.createElement("div", { 572 | className: "button", 573 | style: { 574 | right: 8, 575 | bottom: 8, 576 | position: "fixed" 577 | }, 578 | onClick: () => UI.alert(generateTitle(errorInfo), React.createElement(ErrorModal, { src, errorInfo })), 579 | children: React.createElement("svg", { 580 | viewBox: "0 0 24 24", 581 | height: 24, 582 | width: 24, 583 | children: React.createElement("path", { 584 | fill: "currentColor", 585 | d: "M12 2C6.486 2 2 6.487 2 12C2 17.515 6.486 22 12 22C17.514 22 22 17.515 22 12C22 6.487 17.514 2 12 2ZM12 18.25C11.31 18.25 10.75 17.691 10.75 17C10.75 16.31 11.31 15.75 12 15.75C12.69 15.75 13.25 16.31 13.25 17C13.25 17.691 12.69 18.25 12 18.25ZM13 13.875V15H11V12H12C13.104 12 14 11.103 14 10C14 8.896 13.104 8 12 8C10.896 8 10 8.896 10 10H8C8 7.795 9.795 6 12 6C14.205 6 16 7.795 16 10C16 11.861 14.723 13.429 13 13.875Z" 586 | }) 587 | }) 588 | }) 589 | ] 590 | }) 591 | }; 592 | 593 | function generateTitle(error) { 594 | const reactError = error.message.match(/Minified React error #[0-9]+;/); 595 | 596 | if (!reactError) return i18n.intl.string(i18n.t.cqEoj4); 597 | 598 | const [ message ] = reactError; 599 | 600 | return message.slice(0, message.length - 1); 601 | }; 602 | 603 | function PictureInPicture({ src, windowKey }) { 604 | /** @type {React.RefObject} */ 605 | const videoRef = React.useRef(null); 606 | const [ state, setState ] = React.useState(0); 607 | /** @type {Window} */ 608 | const Window = useStateFromStores([ PopoutWindowStore ], () => PopoutWindowStore.getWindow(windowKey)) 609 | 610 | React.useLayoutEffect(() => { 611 | const video = videoRef.current; 612 | if (!video) return; 613 | 614 | const style = document.createElement("style"); 615 | style.innerText = `#wrapper { 616 | width: 100%; 617 | height: 100%; 618 | } 619 | #wrapper:hover #controls, .show-controls #controls { 620 | transform: translateX(-50%); 621 | opacity: 1; 622 | } 623 | #video { 624 | width: 100%; 625 | height: 100%; 626 | background: black; 627 | } 628 | #loop, #download, #video, #action, #pin, #volume { 629 | cursor: pointer; 630 | color: var(--interactive-normal); 631 | } 632 | #loop:hover, #download:hover, #video:hover, #action:hover, #pin:hover, #volume:hover { 633 | cursor: pointer; 634 | color: var(--interactive-hover); 635 | } 636 | #loop:active:hover, #download:active:hover, #video:active:hover, #action:active:hover, #pin:active:hover, #volume:active:hover { 637 | cursor: pointer; 638 | color: var(--interactive-active); 639 | } 640 | #controls { 641 | position: fixed; 642 | left: 50%; 643 | box-sizing: border-box; 644 | width: min(calc(100vw - 32px), 500px); 645 | bottom: 16px; 646 | transform: translateX(-50%) translatey(calc(100% + 16px)); 647 | background: rgba(0, 0, 0, 0.294); 648 | color: white; 649 | padding: 8px; 650 | height: 40px; 651 | border-radius: 24px; 652 | opacity: 0; 653 | transition: transform 150ms ease, opacity 150ms ease; 654 | display: flex; 655 | gap: 8px; 656 | align-items: center; 657 | } 658 | .volumeSlider { 659 | transform: translate(10px, -7px); 660 | background: rgba(0, 0, 0, 0.294); 661 | } 662 | #loop.active { 663 | color: var(--brand-experiment); 664 | } 665 | #slider { 666 | display: flex; 667 | width: 100%; 668 | align-self: stretch; 669 | } 670 | #duration { 671 | display: flex; 672 | align-items: center; 673 | flex: 0 0 auto; 674 | }`; 675 | Window.document.head.appendChild(style); 676 | 677 | function play() { setState(0); }; 678 | function pause() { setState(1); }; 679 | function ended() { 680 | setState(2); 681 | showControls(true); 682 | }; 683 | 684 | video.addEventListener("play", play); 685 | video.addEventListener("pause", pause); 686 | video.addEventListener("ended", ended); 687 | 688 | return () => { 689 | video.removeEventListener("play", play); 690 | video.removeEventListener("pause", pause); 691 | video.removeEventListener("ended", ended); 692 | }; 693 | }, [ ]); 694 | 695 | const [ volume, setVolume ] = useVolume(); 696 | const [ muted, setMuted ] = useMuted(); 697 | React.useLayoutEffect(() => { 698 | const video = videoRef.current; 699 | if (!video) return; 700 | video.volume = muted ? 0 : volume; 701 | }, [ volume, muted ]); 702 | 703 | const [ duration, setDuration ] = React.useState(0); 704 | const [ currentTime, setCurrentTime ] = React.useState(0); 705 | 706 | const durationBar = React.useRef(); 707 | 708 | const [ paused, setPaused ] = React.useState(false); 709 | const [ shouldShowControls, showControls ] = React.useState(false); 710 | 711 | const [ buffers, setBuffers ] = React.useState([ ]); 712 | 713 | return React.createElement("div", { 714 | id: "wrapper", 715 | className: shouldShowControls ? "show-controls" : "", 716 | onContextMenu: () => showControls(!shouldShowControls), 717 | children: [ 718 | React.createElement("video", { 719 | id: "video", 720 | src, 721 | autoPlay: true, 722 | ref: videoRef, 723 | onProgress: () => setBuffers(getBuffers(videoRef.current)), 724 | onClick: () => { 725 | setPaused(!videoRef.current.paused); 726 | showControls(!videoRef.current.paused); 727 | if (videoRef.current.paused) videoRef.current.play(); 728 | else videoRef.current.pause(); 729 | }, 730 | onTimeUpdate: (event) => { 731 | if (!durationBar.current) return; 732 | setCurrentTime(event.currentTarget.currentTime); 733 | durationBar.current.setGrabber( 734 | event.currentTarget.currentTime / event.currentTarget.duration 735 | ); 736 | }, 737 | onLoadedData: (event) => setDuration(event.currentTarget.duration) 738 | }), 739 | React.createElement("div", { 740 | id: "controls", 741 | children: [ 742 | React.createElement("div", { 743 | id: "action", 744 | children: [ 745 | state === 0 && React.createElement(Pause, { width: 24, height: 24 }), 746 | state === 1 && React.createElement(Play, { width: 24, height: 24 }), 747 | state === 2 && React.createElement(Replay, { width: 24, height: 24 }) 748 | ], 749 | onClick: () => { 750 | setPaused(!videoRef.current.paused); 751 | showControls(!videoRef.current.paused); 752 | if (videoRef.current.paused) videoRef.current.play(); 753 | else videoRef.current.pause(); 754 | } 755 | }), 756 | React.createElement("div", { 757 | id: "loop", 758 | children: React.createElement(Loop, { width: 24, height: 24 }), 759 | onClick: (event) => { 760 | const video = videoRef.current; 761 | if (!video) return; 762 | 763 | if (video.loop = !video.loop) event.currentTarget.classList.add("active"); 764 | else event.currentTarget.classList.remove("active"); 765 | } 766 | }), 767 | React.createElement(Duration, { 768 | currentTime: Math.floor(currentTime), 769 | duration: Math.floor(duration) 770 | }), 771 | React.createElement("div", { 772 | id: "slider", 773 | children: React.createElement(DurationBar, { 774 | buffers, 775 | currentWindow: Window, 776 | type: "DURATION", 777 | value: duration, 778 | onDrag: (multiplier) => { 779 | if (multiplier === 1) { 780 | videoRef.current.pause(); 781 | setPaused(true); 782 | } 783 | else videoRef.current.currentTime = multiplier * videoRef.current.duration; 784 | 785 | setCurrentTime(videoRef.current.currentTime); 786 | 787 | if (durationBar.current) durationBar.current.setGrabber(multiplier); 788 | }, 789 | onDragEnd: () => { 790 | if (!paused) videoRef.current.play(); 791 | }, 792 | onDragStart: () => { 793 | videoRef.current.pause(); 794 | }, 795 | ref: durationBar 796 | }) 797 | }), 798 | React.createElement("div", { 799 | id: "volume", 800 | children: React.createElement(VolumeSlider, { 801 | minValue: 0, 802 | maxValue: 1, 803 | value: volume, 804 | currentWindow: Window, 805 | onValueChange: (volume) => { 806 | setVolume(volume); 807 | setMuted(false); 808 | }, 809 | muted, 810 | onToggleMute: () => setMuted(!muted), 811 | sliderClassName: "volumeSlider" 812 | }) 813 | }), 814 | React.createElement("a", { 815 | id: "download", 816 | href: src, 817 | target: "_blank", 818 | rel: "noreferrer noopener", 819 | role: "button", 820 | children: React.createElement(Download, { width: 24, height: 24 }) 821 | }) 822 | ] 823 | }) 824 | ] 825 | }); 826 | }; 827 | 828 | function Popout({ src, windowKey }) { 829 | const fileName = React.useMemo(() => src.split("?")[0].split("/").at(-1), [ src ]); 830 | 831 | React.useInsertionEffect(() => { 832 | /** @type {Window} */ 833 | const window = PopoutWindowStore.getWindow(windowKey); 834 | 835 | const clone = window.document.adoptNode( 836 | document.querySelector("bd-head").cloneNode(true) 837 | ); 838 | 839 | window.document.body.appendChild(clone); 840 | }, []); 841 | 842 | return React.createElement(PopoutWindow, { 843 | windowKey: windowKey, 844 | withTitleBar: true, 845 | macOSFrame: true, 846 | title: `${fileName} - Discord`, 847 | children: React.createElement(ErrorBoundary, { 848 | windowKey, 849 | src: src 850 | }) 851 | }); 852 | }; 853 | 854 | const encodeBase64 = (str) => { 855 | return btoa(new TextEncoder().encode(str).reduce((data, byte) => data + String.fromCharCode(byte), "")); 856 | }; 857 | 858 | const decodeBase64 = (str) => { 859 | return new TextDecoder().decode(Uint8Array.from(atob(str), (m) => m.codePointAt(0))); 860 | }; 861 | 862 | function LoopButton({ mediaRef }) { 863 | const [ active, setActive ] = React.useState(() => mediaRef?.current?.loop || false); 864 | 865 | return React.createElement(Button, { 866 | look: Button.Looks.BLANK, 867 | size: Button.Sizes.NONE, 868 | className: active && "BMP_active", 869 | innerClassName: iconClasses.lineHeightReset, 870 | onClick: () => { 871 | setActive(mediaRef.current.loop = !mediaRef.current.loop); 872 | }, 873 | children: React.createElement(Loop, { 874 | className: `${controlClasses.controlIcon} ${iconClasses.controlIcon}` 875 | }) 876 | }) 877 | } 878 | 879 | function PIPButton({ mediaRef }) { 880 | const [ active, setActive ] = React.useState(() => { 881 | return PopoutWindowStore.getWindowOpen(`DISCORD_PIP_${encodeBase64(mediaRef?.current?.src)}`); 882 | }); 883 | 884 | // button__201d5 lookBlank__201d5 colorBrand__201d5 grow__201d5 885 | // button__201d5 lookFilled__201d5 colorBrand__201d5 sizeMedium__201d5 grow__201d5 886 | return React.createElement(Button, { 887 | look: Button.Looks.BLANK, 888 | size: Button.Sizes.NONE, 889 | className: active && "BMP_active", 890 | innerClassName: iconClasses.lineHeightReset, 891 | onClick: () => { 892 | const windowKey = `DISCORD_PIP_${encodeBase64(mediaRef.current.src)}`; 893 | 894 | if (active) { 895 | setActive(false); 896 | return PopoutWindowStore.unmountWindow(windowKey); 897 | } 898 | 899 | dispatcher.dispatch({ 900 | type: "POPOUT_WINDOW_OPEN", 901 | key: windowKey, 902 | render: () => React.createElement(Popout, { 903 | windowKey, 904 | src: mediaRef.current.src 905 | }), 906 | features: {popout: true} 907 | }); 908 | // Listener to remove the active class 909 | function listener() { 910 | if (PopoutWindowStore.getWindowOpen(windowKey)) return; 911 | 912 | setActive(false); 913 | PopoutWindowStore.removeChangeListener(listener); 914 | }; 915 | PopoutWindowStore.addChangeListener(listener); 916 | 917 | setActive(true); 918 | }, 919 | children: React.createElement("svg", { 920 | role: "img", 921 | xmlns: "http://www.w3.org/2000/svg", 922 | "aria-hidden": true, 923 | viewBox: "0 0 24 24", 924 | fill: "none", 925 | className: `${controlClasses.controlIcon} ${iconClasses.controlIcon}`, 926 | children: [ 927 | React.createElement("path", { 928 | d: "M19 11h-8v6h8v-6zm4 8V4.98C23 3.88 22.1 3 21 3H3c-1.1 0-2 .88-2 1.98V19c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2zm-2 .02H3V4.97h18v14.05z", 929 | fill: "currentColor", 930 | fillRule: "evenodd", 931 | clipRule: "evenodd" 932 | }) 933 | ] 934 | }) 935 | }) 936 | } 937 | 938 | const getMinWidth = () => (!isNaN(MediaControls.minWidth) ? MediaControls.minWidth : 150) + 64; 939 | const [useMinimumWidth, addMinimumWidthListener] = (() => { 940 | const listeners = new Set(); 941 | 942 | return [ 943 | function useMinimumWidth() { 944 | const [ minWidth, setMinWidth ] = React.useState(() => Data.load("minimum-width") ?? getMinWidth()); 945 | 946 | React.useLayoutEffect(() => { 947 | function listener(minWidth) { 948 | setMinWidth(minWidth); 949 | }; 950 | 951 | listeners.add(listener); 952 | return () => void listeners.delete(listener); 953 | }, [ ]); 954 | 955 | function setMinWidthS(minWidth) { 956 | Data.save("minimum-width", minWidth); 957 | for (const listener of listeners) listener(minWidth); 958 | }; 959 | 960 | return [ minWidth, setMinWidthS ]; 961 | }, 962 | (cb) => { 963 | function listener(minWidth) { 964 | cb(minWidth); 965 | }; 966 | 967 | listeners.add(listener); 968 | return () => void listeners.delete(listener); 969 | } 970 | ]; 971 | })(); 972 | 973 | function Settings() { 974 | const [ minWidth, setMinWidth ] = useMinimumWidth(); 975 | 976 | return React.createElement(BdApi.Components.SettingItem, { 977 | name: "Minimum Width", 978 | note: `Default is ${getMinWidth()}px`, 979 | inline: true, 980 | children: React.createElement(BdApi.Components.NumberInput, { 981 | value: minWidth, 982 | onChange: setMinWidth, 983 | min: getMinWidth(), 984 | max: 1e4 985 | }) 986 | }); 987 | } 988 | 989 | module.exports = class BetterMediaPlayer { 990 | start() { 991 | const cache = new WeakMap(); 992 | 993 | Patcher.instead(MediaControls.prototype, "getWidth", (that) => { 994 | if (that.props.width === "100%") { 995 | return that.props.width; 996 | } 997 | 998 | return Math.max(that.props.width, Data.load("minimum-width") ?? getMinWidth()); 999 | }); 1000 | 1001 | Patcher.after(MediaControls.prototype, "componentDidMount", (that) => { 1002 | if (!that.mediaRef.current) return; 1003 | 1004 | that._BMP = addMinimumWidthListener(() => { 1005 | const aspectRatioNode = that.mediaRef.current.parentElement.parentElement; 1006 | 1007 | aspectRatioNode.style.aspectRatio = `${that.getWidth() / that.getHeight()} / 1`; 1008 | aspectRatioNode.parentElement.style.width = `${that.getWidth()}px`; 1009 | }); 1010 | 1011 | const aspectRatioNode = that.mediaRef.current.parentElement.parentElement; 1012 | 1013 | aspectRatioNode.style.aspectRatio = `${that.getWidth() / that.getHeight()} / 1`; 1014 | aspectRatioNode.parentElement.style.width = `${that.getWidth()}px`; 1015 | }); 1016 | 1017 | Patcher.after(MediaControls.prototype, "componentWillUnmount", (that) => { 1018 | that._BMP?.(); 1019 | }); 1020 | 1021 | Patcher.after(MediaControls.prototype, "renderControls", (that, [], ret) => { 1022 | if (that.props.type !== MediaControls.Types.VIDEO) return; 1023 | if (!React.isValidElement(ret)) return; 1024 | 1025 | let type = cache.get(that); 1026 | 1027 | if (!type && ret.type.prototype?.isReactComponent) { 1028 | class Controls extends ret.type { 1029 | render() { 1030 | const render = super.render(); 1031 | 1032 | try { 1033 | render.ref = this._bmp; 1034 | } 1035 | catch {} 1036 | 1037 | try { 1038 | render.props.children.splice(1, 0, React.createElement(LoopButton, { mediaRef: that.mediaRef })); 1039 | render.props.children.splice(-1, 0, React.createElement(PIPButton, { mediaRef: that.mediaRef })); 1040 | } 1041 | finally { 1042 | return render; 1043 | } 1044 | } 1045 | } 1046 | 1047 | cache.set(that, Controls); 1048 | 1049 | type = Controls; 1050 | } 1051 | 1052 | ret.type = type || ret.type; 1053 | }); 1054 | 1055 | Patcher.before(toolbarModule, toolbarKey, (that, [props]) => { 1056 | if (props?.windowKey?.startsWith("DISCORD_PIP_")) { 1057 | // props.short = false; 1058 | props.title = React.createElement("div", { 1059 | title: titleClasses.title, 1060 | children: [ 1061 | React.createElement(Text, { 1062 | children: decodeBase64(props.windowKey.replace("DISCORD_PIP_", "")).split("?")[0].split("/").at(-1) 1063 | }) 1064 | ] 1065 | }); 1066 | } 1067 | }); 1068 | 1069 | DOM.addStyle(".BMP_active svg { color: var(--brand-500) }"); 1070 | }; 1071 | stop() { 1072 | Patcher.unpatchAll(); 1073 | 1074 | DOM.removeStyle(); 1075 | 1076 | for (const key of PopoutWindowStore.getWindowKeys()) { 1077 | if (!key.startsWith("DISCORD_PIP_")) continue; 1078 | try { 1079 | PopoutWindowStore.unmountWindow(key); 1080 | } 1081 | catch (error) { 1082 | console.groupCollapsed( 1083 | `%cBMP%c Error accord when unmounting%c\n${key.replace("DISCORD_PIP_", "")}`, 1084 | "color: #202124; padding: 3px 2px; background: #ed4245; border-radius: 3px;", 1085 | "color: red", 1086 | "color: yellow" 1087 | ); 1088 | console.error(error); 1089 | console.groupEnd(); 1090 | }; 1091 | }; 1092 | }; 1093 | 1094 | getSettingsPanel = () => React.createElement(Settings); 1095 | }; 1096 | --------------------------------------------------------------------------------