volume.ts 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. import { addParent, debounce, type Stringifiable } from "@sv443-network/userutils";
  2. import { getFeature } from "../config.js";
  3. import { addStyleFromResource, error, log, resourceAsString, setGlobalCssVar, setInnerHtml, t, waitVideoElementReady, warn } from "../utils/index.js";
  4. import { siteEvents } from "../siteEvents.js";
  5. import { featInfo } from "./index.js";
  6. import "./volume.css";
  7. import { addSelectorListener } from "../observers.js";
  8. //#region init vol features
  9. /** Initializes all volume-related features */
  10. export async function initVolumeFeatures() {
  11. let listenerOnce = false;
  12. // sliderElem is not technically an input element but behaves pretty much the same
  13. const listener = async (type: "normal" | "expand", sliderElem: HTMLInputElement) => {
  14. const volSliderCont = document.createElement("div");
  15. volSliderCont.classList.add("bytm-vol-slider-cont");
  16. if(getFeature("volumeSliderScrollStep") !== featInfo.volumeSliderScrollStep.default)
  17. initScrollStep(volSliderCont, sliderElem);
  18. addParent(sliderElem, volSliderCont);
  19. if(getFeature("volumeSliderLabel"))
  20. await addVolumeSliderLabel(type, sliderElem, volSliderCont);
  21. setVolSliderStep(sliderElem);
  22. if(getFeature("volumeSharedBetweenTabs"))
  23. sliderElem.addEventListener("change", () => sharedVolumeChanged(Number(sliderElem.value)));
  24. if(listenerOnce)
  25. return;
  26. listenerOnce = true;
  27. // the following are only run once:
  28. if(getFeature("setInitialTabVolume"))
  29. setInitialTabVolume(sliderElem);
  30. if(typeof getFeature("volumeSliderSize") === "number")
  31. setVolSliderSize();
  32. if(getFeature("volumeSharedBetweenTabs"))
  33. checkSharedVolume();
  34. };
  35. addSelectorListener<HTMLInputElement>("playerBarRightControls", "tp-yt-paper-slider#volume-slider", {
  36. listener: (el) => listener("normal", el),
  37. });
  38. let sizeSmOnce = false;
  39. const onResize = () => {
  40. if(sizeSmOnce || window.innerWidth >= 1150)
  41. return;
  42. sizeSmOnce = true;
  43. addSelectorListener<HTMLInputElement>("playerBarRightControls", "ytmusic-player-expanding-menu tp-yt-paper-slider#expand-volume-slider", {
  44. listener: (el) => listener("expand", el),
  45. debounceEdge: "falling",
  46. });
  47. };
  48. window.addEventListener("resize", debounce(onResize, 150, "falling"));
  49. waitVideoElementReady().then(onResize);
  50. onResize();
  51. }
  52. //#region scroll step
  53. /** Initializes the volume slider scroll step feature */
  54. function initScrollStep(volSliderCont: HTMLDivElement, sliderElem: HTMLInputElement) {
  55. for(const evtName of ["wheel", "scroll", "mousewheel", "DOMMouseScroll"]) {
  56. volSliderCont.addEventListener(evtName, (e) => {
  57. e.preventDefault();
  58. // cancels all the other events that would be fired
  59. e.stopImmediatePropagation();
  60. const delta = Number((e as WheelEvent).deltaY ?? (e as CustomEvent<number | undefined>).detail ?? 1);
  61. if(isNaN(delta))
  62. return warn("Invalid scroll delta:", delta);
  63. const volumeDir = -Math.sign(delta);
  64. const newVolume = String(Number(sliderElem.value) + (getFeature("volumeSliderScrollStep") * volumeDir));
  65. sliderElem.value = newVolume;
  66. sliderElem.setAttribute("aria-valuenow", newVolume);
  67. // make the site actually change the volume
  68. sliderElem.dispatchEvent(new Event("change", { bubbles: true }));
  69. }, {
  70. // takes precedence over the slider's own event listener
  71. capture: true,
  72. });
  73. }
  74. }
  75. //#region volume slider label
  76. /** Adds a percentage label to the volume slider and tooltip */
  77. async function addVolumeSliderLabel(type: "normal" | "expand", sliderElem: HTMLInputElement, sliderContainer: HTMLDivElement) {
  78. const labelContElem = document.createElement("div");
  79. labelContElem.classList.add("bytm-vol-slider-label");
  80. const volShared = getFeature("volumeSharedBetweenTabs");
  81. if(volShared) {
  82. const linkIconHtml = await resourceAsString("icon-link");
  83. if(linkIconHtml) {
  84. const linkIconElem = document.createElement("div");
  85. linkIconElem.classList.add("bytm-vol-slider-shared");
  86. setInnerHtml(linkIconElem, linkIconHtml);
  87. linkIconElem.role = "alert";
  88. linkIconElem.ariaLive = "polite";
  89. linkIconElem.title = linkIconElem.ariaLabel = t("volume_shared_tooltip");
  90. labelContElem.classList.add("has-icon");
  91. labelContElem.appendChild(linkIconElem);
  92. }
  93. }
  94. const getLabel = (value: Stringifiable) => `${value}%`;
  95. const labelElem = document.createElement("div");
  96. labelElem.classList.add("label");
  97. labelElem.textContent = getLabel(sliderElem.value);
  98. labelContElem.appendChild(labelElem);
  99. // prevent video from minimizing
  100. labelContElem.addEventListener("click", (e) => e.stopPropagation());
  101. labelContElem.addEventListener("keydown", (e) => ["Enter", "Space", " "].includes(e.key) && e.stopPropagation());
  102. const getLabelText = (slider: HTMLInputElement) =>
  103. t("volume_tooltip", slider.value, getFeature("volumeSliderStep") ?? slider.step);
  104. const labelFull = getLabelText(sliderElem);
  105. sliderContainer.setAttribute("title", labelFull);
  106. sliderElem.setAttribute("title", labelFull);
  107. sliderElem.setAttribute("aria-valuetext", labelFull);
  108. const updateLabel = () => {
  109. const labelFull = getLabelText(sliderElem);
  110. sliderContainer.setAttribute("title", labelFull);
  111. sliderElem.setAttribute("title", labelFull);
  112. sliderElem.setAttribute("aria-valuetext", labelFull);
  113. const labelElem2 = document.querySelectorAll<HTMLDivElement>(".bytm-vol-slider-label div.label");
  114. for(const el of labelElem2)
  115. el.textContent = getLabel(sliderElem.value);
  116. };
  117. sliderElem.addEventListener("change", updateLabel);
  118. siteEvents.on("configChanged", updateLabel);
  119. addSelectorListener(
  120. "playerBarRightControls",
  121. type === "normal" ? ".bytm-vol-slider-cont" : "ytmusic-player-expanding-menu .bytm-vol-slider-cont",
  122. {
  123. listener: (volumeCont) => volumeCont.appendChild(labelContElem),
  124. }
  125. );
  126. let lastSliderVal = Number(sliderElem.value);
  127. // show label if hovering over slider or slider is focused
  128. const sliderHoverObserver = new MutationObserver(() => {
  129. if(sliderElem.classList.contains("on-hover") || document.activeElement === sliderElem)
  130. labelContElem.classList.add("bytm-visible");
  131. else if(labelContElem.classList.contains("bytm-visible") || document.activeElement !== sliderElem)
  132. labelContElem.classList.remove("bytm-visible");
  133. if(Number(sliderElem.value) !== lastSliderVal) {
  134. lastSliderVal = Number(sliderElem.value);
  135. updateLabel();
  136. }
  137. });
  138. sliderHoverObserver.observe(sliderElem, {
  139. attributes: true,
  140. });
  141. }
  142. //#region volume slider size
  143. /** Sets the volume slider to a set size */
  144. function setVolSliderSize() {
  145. const size = getFeature("volumeSliderSize");
  146. if(typeof size !== "number" || isNaN(Number(size)))
  147. return error("Invalid volume slider size:", size);
  148. setGlobalCssVar("vol-slider-size", `${size}px`);
  149. addStyleFromResource("css-vol_slider_size");
  150. }
  151. //#region volume slider step
  152. /** Sets the `step` attribute of the volume slider */
  153. function setVolSliderStep(sliderElem: HTMLInputElement) {
  154. sliderElem.setAttribute("step", String(getFeature("volumeSliderStep")));
  155. }
  156. //#region shared volume
  157. /** Saves the shared volume level to persistent storage */
  158. async function sharedVolumeChanged(vol: number) {
  159. try {
  160. await GM.setValue("bytm-shared-volume", String(lastCheckedSharedVolume = ignoreVal = vol));
  161. }
  162. catch(err) {
  163. error("Couldn't save shared volume level due to an error:", err);
  164. }
  165. }
  166. let ignoreVal = -1;
  167. let lastCheckedSharedVolume = -1;
  168. /** Only call once as this calls itself after a timeout! - Checks if the shared volume has changed and updates the volume slider accordingly */
  169. async function checkSharedVolume() {
  170. try {
  171. const vol = await GM.getValue("bytm-shared-volume");
  172. if(vol && lastCheckedSharedVolume !== Number(vol)) {
  173. if(ignoreVal === Number(vol))
  174. return;
  175. lastCheckedSharedVolume = Number(vol);
  176. const sliderElem = document.querySelector<HTMLInputElement>("tp-yt-paper-slider#volume-slider");
  177. if(sliderElem) {
  178. sliderElem.value = String(vol);
  179. sliderElem.dispatchEvent(new Event("change", { bubbles: true }));
  180. }
  181. }
  182. setTimeout(checkSharedVolume, 333);
  183. }
  184. catch(err) {
  185. error("Couldn't check for shared volume level due to an error:", err);
  186. }
  187. }
  188. export async function volumeSharedBetweenTabsDisabled() {
  189. await GM.deleteValue("bytm-shared-volume");
  190. document.querySelectorAll<HTMLElement>("#bytm-vol-slider-shared").forEach(el => el.remove());
  191. }
  192. //#region initial volume
  193. /** Sets the volume slider to a set volume level when the session starts */
  194. async function setInitialTabVolume(sliderElem: HTMLInputElement) {
  195. await waitVideoElementReady();
  196. const initialVol = getFeature("initialTabVolumeLevel");
  197. if(getFeature("volumeSharedBetweenTabs")) {
  198. lastCheckedSharedVolume = ignoreVal = initialVol;
  199. if(getFeature("volumeSharedBetweenTabs"))
  200. GM.setValue("bytm-shared-volume", String(initialVol));
  201. }
  202. sliderElem.value = String(initialVol);
  203. sliderElem.dispatchEvent(new Event("change", { bubbles: true }));
  204. log(`Set initial tab volume to ${initialVol}%`);
  205. }