volume.ts 8.0 KB

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