// ==UserScript==
// @name Read Aloud Speedster
// @description Set a default playback speed for Read Aloud on ChatGPT.com, browse between messages via navigation buttons, and open a settings menu by clicking the speed display to toggle additional UI tweaks. Features include color-coded icons under ChatGPT's responses; highlighted color for bold text; dark and light mode support; compact sidebar with separators; square design, and much more.
// @author Tim Macy
// @license AGPL-3.0-or-later
// @version 4.0
// @namespace TimMacy.ReadAloudSpeedster
// @icon https://www.google.com/s2/favicons?sz=64&domain=chatgpt.com
// @match https://*.chatgpt.com/*
// @grant GM.setValue
// @grant GM.getValue
// @run-at document-start
// @homepageURL https://github.com/TimMacy/ReadAloudSpeedster
// @supportURL https://github.com/TimMacy/ReadAloudSpeedster/issues
// ==/UserScript==
/************************************************************************
* *
* Copyright © 2025 Tim Macy *
* GNU Affero General Public License v3.0 *
* Version: 4.0 - Read Aloud Speedster *
* *
* Visit: https://github.com/TimMacy *
* *
************************************************************************/
(function() {
'use strict';
const className = "sm:mt-5";
const escapedClassName = CSS.escape(className);
const styleSheet = document.createElement('style');
styleSheet.textContent = `
/**************************************
default root settings
**************************************/
:root {
--user-chat-width: 100%; // original 70%
--sidebar-width: 260px;
--sidebar-section-margin-top: 1.25rem;
--sidebar-section-first-margin-top: .5rem;
--sidebar-rail-width: calc(var(--spacing)*13);
--header-height: calc(var(--spacing)*13);
--white: #fff;
--black: #000;
--gray-50: #f9f9f9;
--gray-100: #ececec;
--gray-200: #e3e3e3;
--gray-300: #cdcdcd;
--gray-400: #b4b4b4;
--gray-500: #9b9b9b;
--gray-600: #676767;
--gray-700: #424242;
--gray-750: #2f2f2f;
--gray-800: #212121;
--gray-900: #171717;
--gray-950: #0d0d0d;
--red-500: #e02e2a;
--red-700: #911e1b;
--brand-purple: #ab68ff;
--yellow-900: #4d3b00;
}
/**************************************
general settings
**************************************/
/* chatbox - reduced vertical margin */
.${escapedClassName} {
margin-top: .5rem !important;
margin-bottom: .25rem !important;
}
/* chatbox - fade effect for content */
main form {
border-top-left-radius: .25em !important;
border-top-right-radius: .25em !important;
}
#thread-bottom-container {
box-shadow: 0 -20px 20px 0px var(--main-surface-primary) !important;
}
/* copy icon */
button[aria-label="Copy"],
div[role="menuitem"]:has(path[d^="M12 7.1a"]),
header button:has(path[d^="M12.668 10.667C12"]),
button[data-testid="copy-turn-action-button"] svg,
button.surface-nav-element:has(svg path[d^="M12 7.1a"]) {
color: darkorange !important;
opacity:.9;
}
/* copied */
button:has(svg path[d^="M15.483"]) {
color: springgreen;
}
.light button:has(svg path[d^="M15.483"]) {
color: limegreen;
}
/* thumbs up icon */
button .icon-md path[d^="M12.1318"],
button svg path[d^="M10.9153"],
button[aria-label="Good response"],
div[role="menuitem"]:has(path[d^="m4.5 4.944"]),
button[data-testid="good-response-turn-action-button"] svg {
color: #00ad00 !important;
opacity:.9;
}
/* thumbs down icon */
button[aria-label="Bad response"],
button .icon-md path[d^="M11.8727"],
button svg path[d^="M12.6687"],
button.surface-nav-element:has(svg path[d^="M11.868 21"]),
button[data-testid="bad-response-turn-action-button"] svg {
color: crimson !important;
opacity:.9;
}
/* edit in canvas icon */
button[aria-label="Edit message"],
button[aria-label="Edit in canvas"],
button:has(svg path[d^="M12.0303 4.11328"]) {
color: yellow !important;
opacity: .8;
}
.light button[aria-label="Edit message"],
.light button[aria-label="Edit in canvas"],
.light button:has(svg path[d^="M12.0303 4.11328"]) {
color: indigo !important;
opacity: .8;
}
/* switch model icon */
main .flex.justify-start button[aria-haspopup="menu"][data-state="closed"] > div {
color: gray !important;
}
.light main .flex.justify-start button[aria-haspopup="menu"][data-state="closed"] > div {
color: dimgray !important;
}
/* read aloud and stop icon */
button[aria-label="Read aloud"],
div[role="menuitem"]:has(path[d^="M9 6.25v5.5"]),
button[data-testid="voice-play-turn-action-button"] svg {
color: deepskyblue !important;
opacity:.9;
}
button[aria-label="Stop"] {color: deepskyblue !important;}
/* share icon */
article button[aria-label="Share"] {
opacity:.8;
}
/* hover opacity icons */
:is(
button[aria-label="Copy"],
div[role="menuitem"]:has(path[d^="M12 7.1a"]),
header button:has(path[d^="M12.668 10.667C12"]),
button[data-testid="copy-turn-action-button"] svg,
button.surface-nav-element:has(svg path[d^="M12 7.1a"]),
button .icon-md path[d^="M12.1318"],
button svg path[d^="M10.9153"],
button[aria-label="Good response"],
div[role="menuitem"]:has(path[d^="m4.5 4.944"]),
button[data-testid="good-response-turn-action-button"] svg,
button[aria-label="Bad response"],
button .icon-md path[d^="M11.8727"],
button svg path[d^="M12.6687"],
button.surface-nav-element:has(svg path[d^="M11.868 21"]),
button[data-testid="bad-response-turn-action-button"] svg,
button[aria-label="Edit message"],
button[aria-label="Edit in canvas"],
button:has(svg path[d^="M12.0303 4.11328"]),
.light button[aria-label="Edit message"],
.light button[aria-label="Edit in canvas"],
.light button:has(svg path[d^="M12.0303 4.11328"]),
button[aria-label="Read aloud"],
div[role="menuitem"]:has(path[d^="M9 6.25v5.5"]),
button[data-testid="voice-play-turn-action-button"] svg,
article button[aria-label="Share"]
):hover {opacity:1;}
/* sora star icon */
a:has(svg path[d^="M9.822 2.077c"]),
div.pointer-events-none path[d^="M10.258"],
button.surface-nav-element path[d^="M10.258"],
div[role="menuitem"]:has(path[d^="M9.822 2.077c"]),
button.surface-nav-element path[d^="M9.822 2.077c"],
div[role="menuitem"]:has(path[d^="M10.258 1.555c"]) {
color: gold;
}
/* highlight color - dark mode */
.markdown strong {
color: springgreen !important;
}
/* highlight color - light mode */
.light .markdown strong {
color: darkviolet !important;
}
/* red delete color */
.text-token-text-destructive,
button:has(path[d^="m10 11.5 4"]),
[data-testid="delete-chat-menu-item"],
div[role="menuitem"]:has(path[d^="M10.556 4a1 1 0"]) {
color: #e02e2a !important;
}
.text-token-text-destructive:hover,
button:has(path[d^="m10 11.5 4"]):hover,
[data-testid="delete-chat-menu-item"]:hover,
div[role="menuitem"]:has(path[d^="M10.556 4a1 1 0"]):hover {
color: white !important;
background: rgba(255, 0, 0, .5) !important;
}
/* sore green restore color */
div[role="menuitem"]:has(path[d^="m4.5 4.944"]):hover {
color: white !important;
background: rgba(0, 255, 0, .5) !important;
}
/* stop icon size inner */
#thread-bottom-container .icon-lg {
height: calc(var(--spacing)*5);;
width: calc(var(--spacing)*5);;
}
/* select color */
::selection {
background-color: var(--text-primary);
color: var(--main-surface-tertiary);
}
/* change width of chat containers */
div.text-base.my-auto:has(.bg-token-main-surface-tertiary),
#thread-bottom-container > div {
margin: 0 6.263%;
padding: 0;
}
#thread-bottom-container.mb-4.flex.flex-col > #thread-bottom {
margin: 0 12.525%;
}
#thread-bottom > div {
padding-inline: 0 !important;
--thread-content-margin: 0 !important;
}
[data-message-author-role="user"] > div > div {
max-width: 100%;
}
.px-\\(--thread-content-margin\\):has([data-message-author-role="user"]) {
margin: 20px 6.263% 20px 37.574%;
padding: 0;
}
.px-\\(--thread-content-margin\\):has([data-message-author-role="assistant"]) {
margin: 20px 6.263%;
padding: 0;
}
.grow.overflow-hidden > div > div {
overflow-x:hidden;
}
[class^="_tableContainer_"] {
padding-right: 12.525%;
}
.border-token-border-sharp [class^="_tableContainer_"] {
padding-right: 0;
}
.\\[--composer-overlap-px\\:24px\\] {
--composer-overlap-px: 0;
}
.flex.max-w-full.flex-col.grow:empty + .flex.min-h-\\[46px\\].justify-start [class*="mask-image"] {
margin-left: calc(6.263% - var(--spacing)*6) !important;
}
main div.text-base.my-auto:has(.loading-shimmer) {
padding-left: 6.263%;
}
main .mx-\\[calc\\(--spacing\\(-2\\)-1px\\)\\]:not(.loading-shimmer) {
margin-left:-6px;
}
div.text-base,div[class*="turn-messages"] {
--thread-content-max-width: unset!important;
max-width: 1129px;
}
#prosemirror-editor-container,
#prosemirror-editor-container > .markdown.prose {
width: 100% !important;
}
main.min-h-0 .h-full.w-full >.justify-center {
margin: 0 5dvw !important;
}
main div.flex.basis-auto.flex-col .pb-25 {
padding:0;
}
:root:has(#stage-slideover-sidebar) main div.flex.basis-auto.flex-col.grow.overflow-hidden > div {
position: fixed;
bottom: 125px;
height: calc(100dvh - 177px);
width: -webkit-fill-available;
width: -moz-available;
width: fill-available;
}
/* menu hover shadow fix */
.shadow-long:is(.dark *) {
--tw-shadow: 0px 8px 16px 0px var(--tw-shadow-color,#00000052),0px 0px 1px 0px var(--tw-shadow-color,#0000009e) !important;
box-shadow: var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow) !important;
border:1px solid #272727 !important;
}
.shadow-long {
--tw-shadow: 0px 8px 12px 0px var(--tw-shadow-color,var(--shadow-color-1,#00000014)),0px 0px 1px 0px var(--tw-shadow-color,var(--shadow-color-2,#0000009e)) !important;
box-shadow: var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow) !important;
border: 1px solid #e6e6e6 !important;
}
/**************************************
Read Aloud Speedster
**************************************/
.speed-control-container {
position: relative;
display: flex;
align-items: center;
margin: 0 8px;
}
.speed-btn {
display: flex;
align-items: center;
justify-content: center;
height: 36px;
min-width: 36px;
font-size: .75rem;
line-height: 1rem;
font-weight: 600;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.speed-btn.minus {
border-radius: 50%;
border-right: none;
}
.speed-btn.plus {
border-radius: 50%;
border-left: none;
}
.speed-btn.plus::before,
.speed-btn.minus::before,
.speed-display::before,
.speed-display::after {
content: '';
position: absolute;
width: 1px;
height: 12px;
background-color: var(--border-default);
display: var(--show-dividers, block);
}
.speed-btn.plus::before {
right: 0;
}
.speed-btn.minus::before {
left: 0;
}
.speed-display::after {
transform: translateX(18px);
}
.speed-display::before {
transform: translateX(-18px);
}
.speed-btn:hover,.speed-control-config-popup button:hover {
background-color: #ffffff1a;
}
.light .speed-btn:hover,.light .speed-control-config-popup button:hover {
background-color: #0d0d0d05;
}
.speed-btn:active,.speed-control-config-popup button:active {
background-color: #ffffff0d
}
.light .speed-btn:active,.light .speed-control-config-popup button:active {
background-color: #0d0d0d0d
}
.speed-display {
display: flex;
align-items: center;
justify-content: center;
height: 36px;
min-width: 36px;
padding: .5rem;
font-size: .75rem;
line-height: 1rem;
font-weight: 600;
background: transparent;
color: var(--text-secondary);
cursor: default;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.speed-control-config-popup {
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background: var(--main-surface-primary);
border: 1px solid var(--border-default);
border-radius: 3px;
padding: 15px 30px;
margin-bottom: 4px;
z-index: 2077;
display: none;
flex-direction: column;
gap: 10px;
max-height: 40dvh;
text-rendering:optimizeLegibility !important;
-webkit-font-smoothing:antialiased !important;
}
.speed-control-config-popup .popup-header {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: baseline;
justify-content: center;
font-family: -apple-system, "Roboto", "Arial", sans-serif;
color: var(--text-secondary);
font-weight: 600;
width: 100%
text-decoration: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.speed-control-config-popup .popup-title {
grid-column: 2;
text-align: center;
text-decoration: none;
text-overflow: ellipsis;
white-space: normal;
cursor: pointer;
display:block;
opacity: .8;
cursor: pointer;
transition: opacity .5s;
}
.speed-control-config-popup .popup-content {
overflow-y: auto;
overflow-x: hidden;
flex: 1;
display: flex;
flex-direction: column;
gap: 10px;
padding-bottom: 30px;
}
.speed-control-config-popup .popup-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
width: 100%;
}
.speed-control-config-popup .popup-footer a {
font-family: -apple-system, "Roboto", "Arial", sans-serif;
font-size: .75rem;
line-height: 1.5em;
font-weight: 500;
color: var(--text-secondary);
text-decoration: none;
transition: color 0.2s ease-in-out;
}
.speed-control-config-popup .popup-footer a:hover { color: #369eff; }
.CentAnni-version-label {
grid-column: 3;
padding: 0;
margin: 0 0 0 5px;
color: ghostwhite;
cursor: default;
opacity: .3;
justify-self: start;
max-width: 10ch;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 9px;
line-height: 1.2;
transition: opacity .5s;
}
.speed-control-config-popup .popup-title:hover,.popup-title:hover + .CentAnni-version-label {
opacity: 1;
}
.speed-control-config-popup .popup-footer::before {
content: "";
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3.2rem;
pointer-events: none;
box-shadow: 0 -30px 20px 0 var(--main-surface-primary);
}
.speed-control-config-popup.show {
display: flex;
}
.speed-control-config-popup input {
transition: border-color 0.2s ease-in-out;
}
.speed-control-config-popup input[type="number"] {
width: 6ch;
border: 1px solid rgba(255, 255, 255, 0.27);
border-radius: 3px;
background: transparent;
color: var(--text-primary);
text-align: center;
margin-right: 10px;
}
.light .speed-control-config-popup input[type="number"] {
border-color: rgba(0, 0, 0, 0.27);
}
.speed-control-config-popup input[type="number"]:hover {
border-color: color(display-p3 0.1216 0.3059 0.5804)
}
.speed-control-config-popup input[type="number"]:focus {
border-color: color(display-p3 0 0.402 1)
}
.speed-control-config-popup .toggle-label {
width: 100%;
padding-left: 10px;
}
.speed-control-config-popup input[type="checkbox"],
.speed-control-config-popup .toggle-label:hover {
text-decoration: underline;
cursor: pointer;
}
.speed-control-config-popup .speed-label {
user-select: none;
pointer-events: none;
}
.speed-control-config-popup button {
padding: 4px 8px;
border: 1px solid rgba(255, 255, 255, 0.27);
border-radius: 3px;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
}
.light .speed-control-config-popup button {
border-color: rgba(0, 0, 0, 0.27);
}
.speed-control-config-popup .toggle-container {
display: flex;
align-items: center;
text-wrap: nowrap;
}
.CentAnni-style-nav-btn.enabled {
opacity: 1;
}
.CentAnni-style-nav-btn.disabled {
opacity: .5;
}
.CentAnni-style-nav-btn:active {
opacity: .8;
}
`;
// append css
(document.head
? Promise.resolve(document.head)
: new Promise(resolve => {
if (document.readyState === 'loading')
document.addEventListener('DOMContentLoaded', () => resolve(document.head),{once:true});
else resolve(document.head);
})
).then(head => {
if (head)
head.appendChild(styleSheet);
else {
document.documentElement.appendChild(styleSheet);
console.error("Read Aloud Speedster: Failed to find head element. Using backup to append stylesheet.");
}
});
const features = {
squareDesign: {
label: 'Square Design',
enabled: false,
sheet: null,
style: `
/* button 'send prompt' radius */
button[aria-label="Send prompt"], button[aria-label="Stop streaming"], button[aria-label="Start voice mode"] {
border-radius: 4px !important;
}
/* button radii */
.btn, .rounded-full {
border-radius: 2px !important;
}
/* button minus radius */
.speed-btn.minus {
border-radius: 2px 0 0 2px;
}
/* button plus radius */
.speed-btn.plus {
border-radius: 0 2px 2px 0;
}
/* chatbox - radius */
.rounded-md,
.__menu-item,
.rounded-xl,
.rounded-3xl,
.rounded-b-3xl,
.rounded-t-3xl,
.rounded-\\[36px\\],
.rounded-\\[28px\\],
.rounded-\\[24px\\],
.composer-btn::before,
.surface-popover:before,
.__menu-item-trailing-btn {
border-radius: 2px !important;
}
/* popup radii and overlay */
.rounded-t-2xl,
.rounded-\\[10px\\],
.rounded-lg, .rounded-2xl {
border-radius: 2px !important;
}
/* reply radii */
.rounded-b-lg,
.rounded-\\[14px\\],
.rounded-t-\\[20px\\] {
border-radius: 0 !important;
}
/* canvas */
main .text-black\\!,
#prosemirror-context-children > div,
main .shadow-xl:not([role="toolbar"]),
main .shadow-lg:not([role="toolbar"]),
main div.border-token-border-default.z-70 {
border-radius: 0 !important;
right: -1px !important;
bottom: -1px !important;
}
.speed-btn,
.speed-display,
.composer-btn:enabled {
border: 1px solid var(--border-default);
}
:root {
--show-dividers: none !important;
}
.bg-token-border-default {
background-color: transparent;
}
button.composer-btn[data-pill="true"][aria-haspopup="menu"] {
margin-left: 8px;
}
main div:has(.loading-shimmer) a>span.rounded-ee-full,
main div:has(.loading-shimmer) a>span.rounded-se-full {
border-start-end-radius: 2px;
border-end-end-radius: 2px;
}
`
},
darkerMode: {
label: "Darker Background for Header and Chatbox",
enabled: false,
sheet: null,
style: `
main form > div:first-child {
background-color: #141414 !important;
border: 1px solid #2d2d2d;
}
.h-header-height {
background: #181818 !important;
}
`
},
keepIconsVisible: {
label: "Keep Icons Visible",
enabled: false,
sheet: null,
style: `
main [class*="[mask-image"] {
mask-image: none !important;
-webkit-mask-image: none !important;
}
.group\\/turn-messages .pointer-events-none.opacity-0 {
opacity: 1 !important;
pointer-events: auto !important;
}
`
},
reduceAnimation: {
label: "No Icon Animation",
enabled: false,
sheet: null,
style: `
.motion-safe\\:transition-opacity {
transition-duration: unset;
transition-property: none;
transition-timing-function: unset;
}
`
},
jumpToChat: {
label: "Navigate to User's Responses Instead of ChatGPT's",
enabled: false,
sheet: null,
style: ``
},
hideShareIcon: {
label: "Hide Share Icon",
enabled: false,
sheet: null,
style: `
article button[aria-label="Share"] {
display: none;
}
`
},
hidePlusAvatar: {
label: "Hide Plus/Pro Icon in Avatar",
enabled: false,
sheet: null,
style: `
header button[aria-label="Open profile menu"] span,
main button[aria-label="Open Profile Menu"] span span,
#page-header #conversation-header-actions button[aria-label="Open profile menu"] span {
display: none;
}
`
},
hideViewPlans: {
label: "Hide 'View plans'",
enabled: true,
sheet: null,
style: `
div.__menu-item:has(svg path[d^="M8.44824"]) {
display: none !important;
}
`
},
hideGetProBtn: {
label: "Hide 'Get Pro' Button",
enabled: true,
sheet: null,
style: `
.flex > button.btn-primary:first-child:last-child {
display: none;
}
`
},
hideDictateBtn: {
label: "Hide 'Dictate' Button",
enabled: false,
sheet: null,
style: `
button[aria-label="Dictate button"] {
display: none;
}
`
},
disableVoiceModeBtn: {
label: "Disable Voice Mode Button",
enabled: false,
sheet: null,
style: `
button[aria-label="Start voice mode"] {
pointer-events: none;
opacity: 0.5;
}
`
},
hideMistakesTxt: {
label: "Hide 'ChatGPT can make mistakes' Text",
enabled: false,
sheet: null,
style: `
div.text-token-text-secondary[class*="md:px-"] {
display: none;
}
.xl\\:px-5, main form {
padding-bottom: 1rem;
}
`
},
codexNxtSora: {
label: "Codex and Sora Buttons Next to Each Other",
enabled: true,
sheet: null,
style: `
nav > aside > a.group.__menu-item#sora,
nav > aside > a.group.__menu-item[href="/codex"] {
width:calc(50% - 6px);
}
nav > aside > a.group.__menu-item#sora {
transform:translate(100%,-100%);
margin-bottom:-36px;
}
`
},
projectNxtMore: {
label: "'New project' and 'See more' Buttons Next to Each Other",
enabled: true,
sheet: null,
style: `
nav #snorlax-heading {
display:flex;
flex-direction:column;
}
nav > #snorlax-heading > div:first-child,
nav > #snorlax-heading > div:last-child {
width:calc(50% - 6px);
}
nav > #snorlax-heading > div:last-child {
position: absolute;
transform:translateX(100%);
flex-direction:row-reverse;
padding:8px 10px 8px 20px;
order:-1;
}
`
},
navIconsUp: {
label: "Compact Search and Library Buttons",
enabled: true,
sheet: null,
style: `
nav > aside > a:has(svg path[d^="M2.6687"]),
nav > aside > div:has(svg path[d^="M14.0857"]) div.text-token-text-tertiary,
nav > aside > a:has(svg path[d^="M9.38759"]) div.text-token-text-tertiary {
display: none;
}
.tall\\:top-header-height,
nav > aside.last\\:mb-5.mt-\\(--sidebar-section-first-margin-top\\) {
height: 0;
padding:0;
margin-bottom: -8px;
}
nav > aside > div:has(svg path[d^="M14.0857"]),nav > aside > a:has(svg path[d^="M9.38759"]) {
margin: 0;
z-index: 31;
color: var(--text-tertiary);
}
nav > aside > div:has(svg path[d^="M14.0857"]) {
transform: translate(52px, -44px);
width: 40px;
}
nav > aside > a:has(svg path[d^="M9.38759"]) {
transform: translate(100px, -80px);
width: 92px;
}
nav > aside > div:has(svg path[d^="M14.0857"]):hover,
nav > aside > a:has(svg path[d^="M9.38759"]):hover,
nav button:has(svg path[d^="M6.83496"]):hover {
color:var(--text-primary);
}
`
},
sidebarSections: {
label: "Compact Sidebar with Separators",
enabled: true,
sheet: null,
style: `
nav .__menu-item:not(:has(svg path[d^="M14.0857"])):not(:has(svg path[d^="M9.38759"])),
nav .__menu-item-trailing-btn {
min-height: calc(var(--spacing)*8);
max-height:32px;
}
.self-stretch {
align-self:center;
}
nav .__menu-item-trailing-btn:hover {
background: rgba(255, 255, 255, .1);
}
nav .light .__menu-item-trailing-btn:hover {
background: rgba(1, 1, 1, .1);
}
nav .mt-\\(--sidebar-section-first-margin-top\\),
nav .pt-\\(--sidebar-section-first-margin-top\\),
nav .mt-\\(--sidebar-section-margin-top\\),
nav .pt-\\(--sidebar-section-margin-top\\) {
margin-top: 10px!important;
padding: 0!important;
}
nav .mt-\\(--sidebar-section-first-margin-top\\)::before,
nav .pt-\\(--sidebar-section-first-margin-top\\)::before,
nav .mt-\\(--sidebar-section-margin-top\\)::before,
nav .pt-\\(--sidebar-section-margin-top\\)::before {
content: '';
position: absolute;
width: 100%;
height: 1px;
background-color: color(srgb 1 1 1 / 0.17);
display: block;
transform: translateY(-5px);
}
nav .light .mt-\\(--sidebar-section-first-margin-top\\)::before,
nav .light .pt-\\(--sidebar-section-first-margin-top\\)::before,
nav .light .mt-\\(--sidebar-section-margin-top\\)::before,
nav .light .pt-\\(--sidebar-section-margin-top\\)::before {
background-color: color(srgb 0 0 0 / 0.17);
}
nav .tall\\:top-header-height {
margin-top:0!important;
}
nav .tall\\:top-header-height::before {
background-color:transparent;
}
.tall\\:top-header-height,
nav > aside.last\\:mb-5.mt-\\(--sidebar-section-first-margin-top\\) {
margin-bottom: -10px!important;
}
nav > #history > aside > h2 {
padding:3px 10px 0 10px;
}
`
},
justifyText: {
label: "Justify Text",
enabled: true,
sheet: null,
style: `
.markdown {
text-align: justify;
}
.markdown h1 {
text-align: left;
}
`
},
removeFocusOutlines: {
label: "Remove Focus Outlines (used for keyboard users and screen readers)",
enabled: false,
sheet: null,
style: `
:focus {
outline: none;
box-shadow: 0 0 0 0 transparent;
}
`
},
};
function applyFeature(key) {
const feature = features[key];
if (!feature) return;
if (feature.enabled) {
if (feature.style && !feature.sheet) {
feature.sheet = document.createElement('style');
feature.sheet.textContent = feature.style;
document.head.appendChild(feature.sheet);
}
} else if (feature.sheet) {
feature.sheet.remove();
feature.sheet = null;
}
}
// load feature settings from config or use defaults
const loadCSSsettings = async () => {
// Apply defaults immediately
for (const key in features) {
applyFeature(key);
}
// Fetch stored values concurrently
const entries = await Promise.all(
Object.keys(features).map(async key => [key, await GM.getValue(key)])
);
for (const [key, value] of entries) {
if (value !== undefined) {
features[key].enabled = value;
applyFeature(key);
}
}
};loadCSSsettings();
let speedDisplayElement = null;
let playingAudio = new Set();
let playListener = null;
let rateListener = null;
let controlsContainer = null;
let configPopup = null;
let observer = null;
let playbackSpeed = 1;
let ignoreRateChange = false;
let lastUserRate = playbackSpeed;
let savedSpeed;
const MIN_SPEED = 1;
const MAX_SPEED = 17;
const DELTA = 0.25;
// load playback speed
async function initializeSpeed() {
savedSpeed = await GM.getValue('defaultSpeed', 1);
playbackSpeed = savedSpeed;
lastUserRate = playbackSpeed;
updateSpeedDisplay();
setPlaybackSpeed();
}
// set playback speed and manage listeners
function setPlaybackSpeed() {
playingAudio.forEach(audio => audio.playbackRate = playbackSpeed);
if (!playListener) {
playListener = e => {
const audio = e.target;
if (!(audio instanceof HTMLAudioElement)) return;
audio.playbackRate = playbackSpeed;
playingAudio.add(audio);
const remove = () => {playingAudio.delete(audio);};
audio.addEventListener('pause',remove,{once:true});
audio.addEventListener('ended',remove,{once:true});
};
document.addEventListener('play',playListener,true);
}
if (!rateListener) {
rateListener = e => {
const audio = e.target;
if (!(audio instanceof HTMLAudioElement)) return;
if (ignoreRateChange) {ignoreRateChange = false;return;}
audio.playbackRate = lastUserRate;
};
document.addEventListener('ratechange',rateListener,true);
}
}
// config popup
function createConfigPopup() {
if (configPopup) {
document.removeEventListener('click', handleDocumentClick);
configPopup.remove();
}
configPopup = document.createElement('div');
configPopup.classList.add('speed-control-config-popup');
const headerWrapper = document.createElement('div');
headerWrapper.classList.add('popup-header');
const title = document.createElement('a');
title.href = 'https://github.com/TimMacy/ReadAloudSpeedster';
title.target = '_blank';
title.rel = 'noopener';
title.textContent = 'Read Aloud Speedster';
title.title = 'GitHub Repository for Read Aloud Speedster';
title.classList.add('popup-title');
const versionSpan = document.createElement('span');
const scriptVersion = GM.info.script.version;
versionSpan.textContent = `v${scriptVersion}`;
versionSpan.classList.add('CentAnni-version-label');
headerWrapper.appendChild(title);
headerWrapper.appendChild(versionSpan);
const content = document.createElement('div');
content.classList.add('popup-content');
// input for speed
const speedContainer = document.createElement('div');
speedContainer.classList.add('toggle-container');
const speedLabel = document.createElement('span');
speedLabel.classList.add('speed-label');
speedLabel.textContent = 'Default Playback Speed';
const input = document.createElement('input');
input.id = 'defaultSpeedInput';
input.type = 'number';
input.min = MIN_SPEED;
input.max = MAX_SPEED;
input.step = DELTA;
input.value = savedSpeed;
speedContainer.appendChild(input);
speedContainer.appendChild(speedLabel);
content.appendChild(speedContainer);
const toggleElements = [];
for (const key in features) {
const container = document.createElement('div');
container.classList.add('toggle-container');
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.id = `${key}Toggle`;
checkbox.checked = features[key].enabled;
const label = document.createElement('label');
label.classList.add('toggle-label');
label.textContent = features[key].label;
label.htmlFor = checkbox.id;
container.appendChild(checkbox);
container.appendChild(label);
toggleElements.push({ key, checkbox });
content.appendChild(container);
}
// save button
const saveButton = document.createElement('button');
saveButton.textContent = 'Save';
async function handleSave() {
const newSpeed = parseFloat(input.value);
if (newSpeed >= MIN_SPEED && newSpeed <= MAX_SPEED) {
await GM.setValue('defaultSpeed', newSpeed);
playbackSpeed = newSpeed;
updateSpeedDisplay();
setPlaybackSpeed();
}
let navChanged = false;
for (const { key,checkbox } of toggleElements) {
if (features[key].enabled !== checkbox.checked) {
features[key].enabled = checkbox.checked;
await GM.setValue(key,features[key].enabled);
applyFeature(key);
if (key === 'jumpToChat') navChanged = true;
}
}
if (navChanged) {
navCleanup?.();
navCleanup = navBtns();
}
configPopup.classList.remove('show');
}
saveButton.classList.add('save-button');
saveButton.addEventListener('click', handleSave);
document.addEventListener('click', handleDocumentClick);
configPopup.appendChild(headerWrapper);
configPopup.appendChild(content);
const footer = document.createElement('div');
footer.classList.add('popup-footer');
const copyrightLink = document.createElement('a');
copyrightLink.href = 'https://github.com/TimMacy';
copyrightLink.target = '_blank';
copyrightLink.rel = 'noopener';
copyrightLink.textContent = 'Copyright © 2025 Tim Macy';
copyrightLink.title = 'Copyright © 2025 Tim Macy';
footer.appendChild(copyrightLink);
footer.appendChild(saveButton);
configPopup.appendChild(footer);
document.body.appendChild(configPopup);
return configPopup;
}
function handleDocumentClick(e) {
if (!configPopup.contains(e.target) && !e.target.classList.contains('speed-display')) {
configPopup.classList.remove('show');
}
}
// speed display
function updateSpeedDisplay() {
if (speedDisplayElement) {
speedDisplayElement.textContent = `${playbackSpeed}x`; // raw speed value without formatting
}
}
// create controls
function createControlButtons() {
if (controlsContainer && document.body.contains(controlsContainer)) return;
controlsContainer = document.createElement('div');
controlsContainer.classList.add('speed-control-container');
const minusButton = document.createElement('button');
minusButton.textContent = '-';
minusButton.classList.add('speed-btn', 'minus');
const speedDisplay = document.createElement('span');
speedDisplay.classList.add('speed-display');
speedDisplay.textContent = `${playbackSpeed}x`; // raw speed value without formatting
speedDisplayElement = speedDisplay;
const plusButton = document.createElement('button');
plusButton.textContent = '+';
plusButton.classList.add('speed-btn', 'plus');
function handleMinus() {
ignoreRateChange = true;
playbackSpeed = Math.max(MIN_SPEED, playbackSpeed - DELTA);
lastUserRate = playbackSpeed;
updateSpeedDisplay();
setPlaybackSpeed();
}
function handlePlus() {
ignoreRateChange = true;
playbackSpeed = Math.min(MAX_SPEED, playbackSpeed + DELTA);
lastUserRate = playbackSpeed;
updateSpeedDisplay();
setPlaybackSpeed();
}
function handleSpeedClick(e) {
e.stopPropagation();
if (!configPopup || !document.body.contains(configPopup)) {
configPopup = createConfigPopup();
}
configPopup.classList.toggle('show');
if (configPopup.classList.contains('show')) {
const rect = e.target.getBoundingClientRect();
configPopup.style.position = 'absolute';
configPopup.style.bottom = `${window.innerHeight - rect.top + 10}px`;
configPopup.style.left = `${rect.left + (rect.width / 2)}px`;
configPopup.style.transform = 'translateX(-50%)';
}
}
minusButton.addEventListener('click', handleMinus);
plusButton.addEventListener('click', handlePlus);
speedDisplay.addEventListener('click', handleSpeedClick);
controlsContainer.appendChild(minusButton);
controlsContainer.appendChild(speedDisplay);
controlsContainer.appendChild(plusButton);
const target = document.querySelector('div[style*="var(--vt-composer-system-hint-action)"]');
if (target) target.insertAdjacentElement('beforebegin', controlsContainer);
else if (document.querySelector('div[style*="var(--vt-composer-attach-file-action)"]')?.insertAdjacentElement('afterend', controlsContainer));
}
// message navigation button section
const HEADER_OFFSET = 52;
const UP_ARROW_PATH = 'M10 3.293l-6.354 6.353a1 1 0 001.414 1.414L9 6.414V17a1 1 0 102 0V6.414l3.939 3.939a1 1 0 001.415-1.414L10 3.293z';
const DOWN_ARROW_PATH = 'M10 16.707l6.354-6.353a1 1 0 00-1.414-1.414L11 13.586V3a1 1 0 10-2 0v10.586L5.061 8.94a1 1 0 10-1.415 1.415L10 16.707z';
const createIcon = (pathData) => {
const svg = document.createElementNS('http://www.w3.org/2000/svg','svg');
svg.setAttribute('width','20');
svg.setAttribute('height','20');
svg.setAttribute('viewBox','0 0 20 20');
svg.setAttribute('fill','currentColor');
const path = document.createElementNS('http://www.w3.org/2000/svg','path');
path.setAttribute('fill-rule','evenodd');
path.setAttribute('clip-rule','evenodd');
path.setAttribute('d',pathData);
svg.appendChild(path);
const wrapper = document.createElement('div');
wrapper.className = 'flex w-full items-center justify-center';
wrapper.appendChild(svg);
return wrapper;
};
const createNavButton = (pathData,label) => {
const btn = document.createElement('button');
btn.className = 'CentAnni-style-nav-btn btn relative btn-ghost text-token-text-primary';
btn.setAttribute('aria-label',label);
btn.appendChild(createIcon(pathData));
return btn;
};
const upBtn = createNavButton(UP_ARROW_PATH, 'Jump to previous message');
const downBtn = createNavButton(DOWN_ARROW_PATH, 'Jump to next message');
let navCleanup = null;
function navBtns() {
const targetChat = document.querySelector('main > #thread div.flex.basis-auto.flex-col.grow');
const actions = document.querySelector('#conversation-header-actions');
const shareBtn = actions.querySelector('button[aria-label="Share"]');
if (!shareBtn || !actions || !targetChat) return;
let chatObserver = null;
let messageCache = [];
const role = features.jumpToChat?.enabled?'user':'assistant';
const queryMessages = () => Array.from(targetChat.querySelectorAll(`[data-message-author-role="${role}"]:not([data-message-id^="placeholder-request"])`));
const populateCache = () => {messageCache = queryMessages();};
const getNextMessage = () => {
const current = window.scrollY + HEADER_OFFSET;
for (const msg of messageCache) {
const top = msg.getBoundingClientRect().top + window.scrollY;
if (top > current + 1) return msg;
}
return null;
};
const getPrevMessage = () => {
const current = window.scrollY + HEADER_OFFSET;
for (let i = messageCache.length - 1; i >= 0; i--) {
const top = messageCache[i].getBoundingClientRect().top + window.scrollY;
if (top < current - 1) return messageCache[i];
}
return null;
};
const checkForNewBelow = () => {
const msgs = queryMessages();
if (msgs.length > messageCache.length) {
const newMsgs = msgs.slice(messageCache.length);
messageCache.push(...newMsgs);
return newMsgs[0];
}
return null;
};
const setState = (btn, enabled) => {
btn.classList.toggle("enabled",enabled);
btn.classList.toggle("disabled",!enabled);
};
const update = () => {
setState(upBtn,!!getPrevMessage());
setState(downBtn,!!getNextMessage());
};
const jump = (prev) => {
let target = prev?getPrevMessage():getNextMessage();
if (!prev && !target) target = checkForNewBelow();
if (target) target.scrollIntoView({behavior:'auto',block:'start'});
update();
};
const createButtons = () => {
if (document.body.contains(upBtn)) return;
upBtn.onclick = () => jump(true);
downBtn.onclick = () => jump(false);
actions.insertBefore(downBtn,shareBtn);
actions.insertBefore(upBtn,downBtn);
populateCache();
startObserver();
};
const startObserver = () => {
if (chatObserver||!targetChat) return;
if (queryMessages().length) {
populateCache();
update();
return;
}
chatObserver = new MutationObserver(() => {
if (queryMessages().length) {
populateCache();
stopObserver();
setTimeout(update,250);
}
});
chatObserver.observe(targetChat,{childList:true});
};
const stopObserver = () => {
if (chatObserver) {
chatObserver.disconnect();
chatObserver = null;
}
};
createButtons();
return () => {
stopObserver();
messageCache = [];
upBtn.remove();
downBtn.remove();
};
}
// handle cleanup
function cleanup() {
if (playListener) {
document.removeEventListener('play',playListener,true);
playListener = null;
}
if (rateListener) {
document.removeEventListener('ratechange',rateListener,true);
rateListener = null;
}
playingAudio.clear();
if (observer) {observer.disconnect();observer=null;}
controlsContainer?.remove();
controlsContainer = null;
speedDisplayElement = null;
configPopup?.remove();
navCleanup?.();
navCleanup = null;
}
// initialization after DOM has loaded
function init() {
observer = new MutationObserver(mutations => {
const hasMainMutations = mutations.some(mutation => mutation.target.closest("#main"));
if (!hasMainMutations)return;
// observer for new audio elements
const audioFound = mutations.some(mutation => Array.from(mutation.addedNodes).some(node => node.nodeName === 'AUDIO' || (node.querySelector && node.querySelector('audio'))));
// handle UI updates and audio playback speed
if (audioFound) setPlaybackSpeed();
if (!document.body.contains(controlsContainer)) createControlButtons();
if (!document.querySelector('#conversation-header-actions button[aria-label="Jump to next message"]')) {
navCleanup?.();
navCleanup = navBtns();
}
});
if (document.body) {
observer.observe(document.body,{childList:true,subtree:true});
initializeSpeed();
createControlButtons();
navCleanup = navBtns();
}
}
// wait for document to be ready and cleanup when page unloads
document.readyState === "loading"?document.addEventListener("DOMContentLoaded", init,{once:true}):init();
window.addEventListener('unload', cleanup);
})();