volume.ts 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. import { addParent, type Stringifiable } from "@sv443-network/userutils";
  2. import { getFeature } from "../config.js";
  3. import { addStyleFromResource, error, log, resourceAsString, setGlobalCssVar, t, waitVideoElementReady } 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. // 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(getFeature("volumeSliderScrollStep") !== featInfo.volumeSliderScrollStep.default)
  17. initScrollStep(volSliderCont, sliderElem);
  18. addParent(sliderElem, volSliderCont);
  19. if(typeof getFeature("volumeSliderSize") === "number")
  20. setVolSliderSize();
  21. if(getFeature("volumeSliderLabel"))
  22. await addVolumeSliderLabel(sliderElem, volSliderCont);
  23. setVolSliderStep(sliderElem);
  24. if(getFeature("volumeSharedBetweenTabs")) {
  25. sliderElem.addEventListener("change", () => sharedVolumeChanged(Number(sliderElem.value)));
  26. checkSharedVolume();
  27. }
  28. if(getFeature("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) + (getFeature("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 = getFeature("volumeSharedBetweenTabs");
  60. if(volShared) {
  61. const linkIconHtml = await resourceAsString("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.ariaLive = "polite";
  68. linkIconElem.title = linkIconElem.ariaLabel = t("volume_shared_tooltip");
  69. labelContElem.classList.add("has-icon");
  70. labelContElem.appendChild(linkIconElem);
  71. }
  72. }
  73. const getLabel = (value: Stringifiable) => `${value}%`;
  74. const labelElem = document.createElement("div");
  75. labelElem.classList.add("label");
  76. labelElem.textContent = getLabel(sliderElem.value);
  77. labelContElem.appendChild(labelElem);
  78. // prevent video from minimizing
  79. labelContElem.addEventListener("click", (e) => e.stopPropagation());
  80. labelContElem.addEventListener("keydown", (e) => ["Enter", "Space", " "].includes(e.key) && e.stopPropagation());
  81. const getLabelText = (slider: HTMLInputElement) =>
  82. t("volume_tooltip", slider.value, getFeature("volumeSliderStep") ?? slider.step);
  83. const labelFull = getLabelText(sliderElem);
  84. sliderContainer.setAttribute("title", labelFull);
  85. sliderElem.setAttribute("title", labelFull);
  86. sliderElem.setAttribute("aria-valuetext", labelFull);
  87. const updateLabel = () => {
  88. const labelFull = getLabelText(sliderElem);
  89. sliderContainer.setAttribute("title", labelFull);
  90. sliderElem.setAttribute("title", labelFull);
  91. sliderElem.setAttribute("aria-valuetext", labelFull);
  92. const labelElem2 = document.querySelector<HTMLDivElement>("#bytm-vol-slider-label div.label");
  93. if(labelElem2)
  94. labelElem2.textContent = getLabel(sliderElem.value);
  95. };
  96. sliderElem.addEventListener("change", () => updateLabel());
  97. siteEvents.on("configChanged", () => {
  98. updateLabel();
  99. });
  100. addSelectorListener("playerBarRightControls", "#bytm-vol-slider-cont", {
  101. listener: (volumeCont) => volumeCont.appendChild(labelContElem),
  102. });
  103. let lastSliderVal = Number(sliderElem.value);
  104. // show label if hovering over slider or slider is focused
  105. const sliderHoverObserver = new MutationObserver(() => {
  106. if(sliderElem.classList.contains("on-hover") || document.activeElement === sliderElem)
  107. labelContElem.classList.add("bytm-visible");
  108. else if(labelContElem.classList.contains("bytm-visible") || document.activeElement !== sliderElem)
  109. labelContElem.classList.remove("bytm-visible");
  110. if(Number(sliderElem.value) !== lastSliderVal) {
  111. lastSliderVal = Number(sliderElem.value);
  112. updateLabel();
  113. }
  114. });
  115. sliderHoverObserver.observe(sliderElem, {
  116. attributes: true,
  117. });
  118. }
  119. //#region volume slider size
  120. /** Sets the volume slider to a set size */
  121. function setVolSliderSize() {
  122. const size = getFeature("volumeSliderSize");
  123. if(typeof size !== "number" || isNaN(Number(size)))
  124. return error("Invalid volume slider size:", size);
  125. setGlobalCssVar("--bytm-vol-slider-size", `${size}px`);
  126. addStyleFromResource("css-vol_slider_size");
  127. }
  128. //#region volume slider step
  129. /** Sets the `step` attribute of the volume slider */
  130. function setVolSliderStep(sliderElem: HTMLInputElement) {
  131. sliderElem.setAttribute("step", String(getFeature("volumeSliderStep")));
  132. }
  133. //#region shared volume
  134. /** Saves the shared volume level to persistent storage */
  135. async function sharedVolumeChanged(vol: number) {
  136. try {
  137. await GM.setValue("bytm-shared-volume", String(lastCheckedSharedVolume = ignoreVal = vol));
  138. }
  139. catch(err) {
  140. error("Couldn't save shared volume level due to an error:", err);
  141. }
  142. }
  143. let ignoreVal = -1;
  144. let lastCheckedSharedVolume = -1;
  145. /** Only call once as this calls itself after a timeout! - Checks if the shared volume has changed and updates the volume slider accordingly */
  146. async function checkSharedVolume() {
  147. try {
  148. const vol = await GM.getValue("bytm-shared-volume");
  149. if(vol && lastCheckedSharedVolume !== Number(vol)) {
  150. if(ignoreVal === Number(vol))
  151. return;
  152. lastCheckedSharedVolume = Number(vol);
  153. const sliderElem = document.querySelector<HTMLInputElement>("tp-yt-paper-slider#volume-slider");
  154. if(sliderElem) {
  155. sliderElem.value = String(vol);
  156. sliderElem.dispatchEvent(new Event("change", { bubbles: true }));
  157. }
  158. }
  159. setTimeout(checkSharedVolume, 333);
  160. }
  161. catch(err) {
  162. error("Couldn't check for shared volume level due to an error:", err);
  163. }
  164. }
  165. export async function volumeSharedBetweenTabsDisabled() {
  166. await GM.deleteValue("bytm-shared-volume");
  167. document.querySelector<HTMLElement>("#bytm-vol-slider-shared")?.remove();
  168. }
  169. //#region initial volume
  170. /** Sets the volume slider to a set volume level when the session starts */
  171. async function setInitialTabVolume(sliderElem: HTMLInputElement) {
  172. await waitVideoElementReady();
  173. const initialVol = getFeature("initialTabVolumeLevel");
  174. if(getFeature("volumeSharedBetweenTabs")) {
  175. lastCheckedSharedVolume = ignoreVal = initialVol;
  176. if(getFeature("volumeSharedBetweenTabs"))
  177. GM.setValue("bytm-shared-volume", String(initialVol));
  178. }
  179. sliderElem.value = String(initialVol);
  180. sliderElem.dispatchEvent(new Event("change", { bubbles: true }));
  181. log(`Set initial tab volume to ${initialVol}%`);
  182. }