YoutubePlayer.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782
  1. <script setup lang="ts">
  2. import { computed, onBeforeUnmount, onMounted, ref } from "vue";
  3. import Toast from "toasters";
  4. import { useYoutubePlayer } from "@/composables/useYoutubePlayer";
  5. import { useStationStore } from "@/stores/station";
  6. import aw from "@/aw";
  7. const props = defineProps<{
  8. song: {
  9. mediaSource: string;
  10. title: string;
  11. artists: string[];
  12. duration: number;
  13. };
  14. }>();
  15. const {
  16. player: youtubePlayer,
  17. updatePlayer: youtubeUpdatePlayer,
  18. loadVideoById: youtubeLoadVideoById,
  19. playVideo: youtubePlayVideo,
  20. pauseVideo: youtubePauseVideo,
  21. stopVideo: youtubeStopVideo,
  22. setPlaybackRate: youtubeSetPlaybackRate
  23. } = useYoutubePlayer();
  24. const { updateMediaModalPlayingAudio } = useStationStore();
  25. const interval = ref(null);
  26. const canvasWidth = ref(760);
  27. const volumeSliderValue = ref(20);
  28. const durationCanvas = ref(null);
  29. const duration = ref(props.song.duration);
  30. const activityWatchMediaDataInterval = ref(null);
  31. const activityWatchMediaLastStatus = ref("");
  32. const activityWatchMediaLastStartDuration = ref(0);
  33. const playerElement = ref(null);
  34. const youtubeId = computed(() => props.song.mediaSource.split(":")[1]);
  35. const seekTo = position => {
  36. youtubePlayVideo();
  37. youtubePlayer.value.player.seekTo(position);
  38. };
  39. const settings = type => {
  40. switch (type) {
  41. case "stop":
  42. youtubeStopVideo();
  43. youtubePauseVideo();
  44. break;
  45. case "pause":
  46. youtubePauseVideo();
  47. break;
  48. case "play":
  49. youtubePlayVideo();
  50. break;
  51. case "skipToLast10Secs":
  52. seekTo(Number(youtubePlayer.value.duration) - 10);
  53. break;
  54. default:
  55. break;
  56. }
  57. };
  58. const play = () => {
  59. if (
  60. youtubePlayer.value.player.getVideoData().video_id !== youtubeId.value
  61. ) {
  62. duration.value = -1;
  63. youtubeLoadVideoById(youtubeId.value);
  64. }
  65. settings("play");
  66. };
  67. const changeVolume = () => {
  68. const { volume } = youtubePlayer.value;
  69. localStorage.setItem("volume", `${volume}`);
  70. youtubePlayer.value.player.setVolume(volume);
  71. if (volume > 0) {
  72. youtubePlayer.value.player.unMute();
  73. youtubePlayer.value.muted = false;
  74. }
  75. };
  76. const toggleMute = () => {
  77. const previousVolume = parseFloat(localStorage.getItem("volume"));
  78. const volume =
  79. youtubePlayer.value.player.getVolume() <= 0 ? previousVolume : 0;
  80. youtubePlayer.value.muted = !youtubePlayer.value.muted;
  81. volumeSliderValue.value = volume;
  82. youtubePlayer.value.player.setVolume(volume);
  83. if (!youtubePlayer.value.muted)
  84. localStorage.setItem("volume", volume.toString());
  85. };
  86. // const increaseVolume = () => {
  87. // const previousVolume = parseFloat(localStorage.getItem("volume"));
  88. // let volume = previousVolume + 5;
  89. // youtubePlayer.value.muted = false;
  90. // if (volume > 100) volume = 100;
  91. // youtubePlayer.value.volume = volume;
  92. // youtubePlayer.value.player.setVolume(volume);
  93. // localStorage.setItem("volume", volume.toString());
  94. // };
  95. const drawCanvas = () => {
  96. const canvasElement = durationCanvas.value;
  97. if (!canvasElement) return;
  98. const ctx = canvasElement.getContext("2d");
  99. const videoDuration = Number(youtubePlayer.value.duration);
  100. const _duration = Number(duration.value);
  101. const afterDuration = videoDuration - _duration;
  102. canvasWidth.value = Math.min(document.body.clientWidth - 40, 760);
  103. const width = canvasWidth.value;
  104. const currentTime =
  105. youtubePlayer.value.player && youtubePlayer.value.player.getCurrentTime
  106. ? youtubePlayer.value.player.getCurrentTime()
  107. : 0;
  108. const widthDuration = (_duration / videoDuration) * width;
  109. const widthAfterDuration = (afterDuration / videoDuration) * width;
  110. const widthCurrentTime = (currentTime / videoDuration) * width;
  111. const durationColor = "#03A9F4";
  112. const afterDurationColor = "#41E841";
  113. const currentDurationColor = "#3b25e8";
  114. ctx.fillStyle = durationColor;
  115. ctx.fillRect(0, 0, widthDuration, 20);
  116. ctx.fillStyle = afterDurationColor;
  117. ctx.fillRect(widthDuration, 0, widthAfterDuration, 20);
  118. ctx.fillStyle = currentDurationColor;
  119. ctx.fillRect(widthCurrentTime, 0, 1, 20);
  120. };
  121. const setTrackPosition = event => {
  122. seekTo(
  123. Number(
  124. Number(youtubePlayer.value.player.getDuration()) *
  125. ((event.pageX - event.target.getBoundingClientRect().left) /
  126. canvasWidth.value)
  127. )
  128. );
  129. };
  130. const sendActivityWatchMediaData = () => {
  131. if (
  132. !youtubePlayer.value.paused &&
  133. youtubePlayer.value.player.getPlayerState() ===
  134. window.YT.PlayerState.PLAYING
  135. ) {
  136. if (activityWatchMediaLastStatus.value !== "playing") {
  137. activityWatchMediaLastStatus.value = "playing";
  138. activityWatchMediaLastStartDuration.value = Math.floor(
  139. Number(youtubePlayer.value.currentTime)
  140. );
  141. }
  142. const videoData = {
  143. title: props.song.title,
  144. artists: props.song.artists?.join(", ") || "",
  145. mediaSource: props.song.mediaSource,
  146. muted: youtubePlayer.value.muted,
  147. volume: youtubePlayer.value.volume,
  148. startedDuration:
  149. activityWatchMediaLastStartDuration.value <= 0
  150. ? 0
  151. : activityWatchMediaLastStartDuration.value,
  152. source: `viewMedia#${props.song.mediaSource}`,
  153. hostname: window.location.hostname,
  154. playerState: Object.keys(window.YT.PlayerState).find(
  155. key =>
  156. window.YT.PlayerState[key] ===
  157. youtubePlayer.value.player.getPlayerState()
  158. ),
  159. playbackRate: youtubePlayer.value.playbackRate
  160. };
  161. aw.sendMediaData(videoData);
  162. } else {
  163. activityWatchMediaLastStatus.value = "not_playing";
  164. }
  165. };
  166. onMounted(() => {
  167. interval.value = setInterval(() => {
  168. if (
  169. duration.value !== -1 &&
  170. youtubePlayer.value.paused === false &&
  171. youtubePlayer.value.playerReady &&
  172. (youtubePlayer.value.player.getCurrentTime() > duration.value ||
  173. (youtubePlayer.value.player.getCurrentTime() > 0 &&
  174. youtubePlayer.value.player.getCurrentTime() >=
  175. youtubePlayer.value.player.getDuration()))
  176. ) {
  177. youtubeStopVideo();
  178. youtubePauseVideo();
  179. drawCanvas();
  180. }
  181. if (
  182. youtubePlayer.value.playerReady &&
  183. youtubePlayer.value.player.getVideoData &&
  184. youtubePlayer.value.player.getVideoData() &&
  185. youtubePlayer.value.player.getVideoData().video_id ===
  186. youtubeId.value
  187. ) {
  188. const currentTime = youtubePlayer.value.player.getCurrentTime();
  189. if (currentTime !== undefined)
  190. youtubePlayer.value.currentTime = currentTime.toFixed(3);
  191. if (youtubePlayer.value.duration.indexOf(".000") !== -1) {
  192. const duration = youtubePlayer.value.player.getDuration();
  193. if (duration !== undefined) {
  194. if (
  195. `${youtubePlayer.value.duration}` ===
  196. `${Number(duration.value).toFixed(3)}`
  197. )
  198. duration.value = duration.toFixed(3);
  199. youtubePlayer.value.duration = duration.toFixed(3);
  200. if (youtubePlayer.value.duration.indexOf(".000") !== -1)
  201. youtubePlayer.value.videoNote = "(~)";
  202. else youtubePlayer.value.videoNote = "";
  203. drawCanvas();
  204. }
  205. }
  206. }
  207. if (youtubePlayer.value.paused === false) drawCanvas();
  208. }, 200);
  209. activityWatchMediaDataInterval.value = setInterval(() => {
  210. sendActivityWatchMediaData();
  211. }, 1000);
  212. if (window.YT && window.YT.Player) {
  213. youtubePlayer.value.player = new window.YT.Player(playerElement.value, {
  214. height: 298,
  215. width: 530,
  216. videoId: youtubeId.value,
  217. host: "https://www.youtube-nocookie.com",
  218. playerVars: {
  219. controls: 0,
  220. iv_load_policy: 3,
  221. rel: 0,
  222. showinfo: 0,
  223. autoplay: 0
  224. },
  225. events: {
  226. onReady: () => {
  227. let volume = parseFloat(localStorage.getItem("volume"));
  228. volume = typeof volume === "number" ? volume : 20;
  229. youtubePlayer.value.player.setVolume(volume);
  230. if (volume > 0) youtubePlayer.value.player.unMute();
  231. youtubePlayer.value.playerReady = true;
  232. youtubePlayer.value.player.cueVideoById(youtubeId.value);
  233. youtubeSetPlaybackRate();
  234. drawCanvas();
  235. },
  236. onStateChange: event => {
  237. drawCanvas();
  238. if (event.data === 1) {
  239. youtubePlayer.value.paused = false;
  240. updateMediaModalPlayingAudio(true);
  241. const youtubeDuration =
  242. youtubePlayer.value.player.getDuration();
  243. const newYoutubeVideoDuration =
  244. youtubeDuration.toFixed(3);
  245. if (
  246. youtubePlayer.value.duration.indexOf(".000") !==
  247. -1 &&
  248. `${youtubePlayer.value.duration}` !==
  249. `${newYoutubeVideoDuration}`
  250. ) {
  251. const songDurationNumber = Number(duration.value);
  252. const songDurationNumber2 =
  253. Number(duration.value) + 1;
  254. const songDurationNumber3 =
  255. Number(duration.value) - 1;
  256. const fixedSongDuration =
  257. songDurationNumber.toFixed(3);
  258. const fixedSongDuration2 =
  259. songDurationNumber2.toFixed(3);
  260. const fixedSongDuration3 =
  261. songDurationNumber3.toFixed(3);
  262. if (
  263. `${youtubePlayer.value.duration}` ===
  264. `${Number(duration.value).toFixed(3)}` &&
  265. (fixedSongDuration ===
  266. youtubePlayer.value.duration ||
  267. fixedSongDuration2 ===
  268. youtubePlayer.value.duration ||
  269. fixedSongDuration3 ===
  270. youtubePlayer.value.duration)
  271. )
  272. duration.value = newYoutubeVideoDuration;
  273. youtubePlayer.value.duration =
  274. newYoutubeVideoDuration;
  275. if (
  276. youtubePlayer.value.duration.indexOf(".000") !==
  277. -1
  278. )
  279. youtubePlayer.value.videoNote = "(~)";
  280. else youtubePlayer.value.videoNote = "";
  281. }
  282. if (duration.value === -1)
  283. duration.value = Number(
  284. youtubePlayer.value.duration
  285. );
  286. if (duration.value > youtubeDuration + 1) {
  287. youtubeStopVideo();
  288. youtubePauseVideo();
  289. return new Toast(
  290. "Video can't play. Specified duration is bigger than the YouTube song duration."
  291. );
  292. }
  293. if (duration.value <= 0) {
  294. youtubeStopVideo();
  295. youtubePauseVideo();
  296. return new Toast(
  297. "Video can't play. Specified duration has to be more than 0 seconds."
  298. );
  299. }
  300. youtubeSetPlaybackRate();
  301. } else if (event.data === 2) {
  302. youtubePlayer.value.paused = true;
  303. updateMediaModalPlayingAudio(false);
  304. }
  305. return false;
  306. }
  307. }
  308. });
  309. } else {
  310. youtubeUpdatePlayer({
  311. error: true,
  312. errorMessage: "Player could not be loaded."
  313. });
  314. }
  315. let volume = parseFloat(localStorage.getItem("volume"));
  316. volume = typeof volume === "number" && !Number.isNaN(volume) ? volume : 20;
  317. localStorage.setItem("volume", volume.toString());
  318. youtubeUpdatePlayer({ volume });
  319. });
  320. onBeforeUnmount(() => {
  321. clearInterval(interval.value);
  322. youtubeStopVideo();
  323. youtubePauseVideo();
  324. updateMediaModalPlayingAudio(false);
  325. youtubePlayer.value.duration = "0.000";
  326. youtubePlayer.value.currentTime = 0;
  327. youtubePlayer.value.playerReady = false;
  328. youtubePlayer.value.videoNote = "";
  329. clearInterval(activityWatchMediaDataInterval.value);
  330. });
  331. </script>
  332. <template>
  333. <div class="player-section">
  334. <div class="player-container">
  335. <div ref="playerElement"></div>
  336. </div>
  337. <div v-show="youtubePlayer.error" class="player-error">
  338. <h2>{{ youtubePlayer.errorMessage }}</h2>
  339. </div>
  340. <canvas
  341. ref="durationCanvas"
  342. class="duration-canvas"
  343. v-show="!youtubePlayer.error"
  344. height="20"
  345. :width="canvasWidth"
  346. @click="setTrackPosition($event)"
  347. />
  348. <div class="player-footer">
  349. <div class="player-footer-left">
  350. <button
  351. class="button is-primary"
  352. @click="play()"
  353. @keyup.enter="play()"
  354. v-if="youtubePlayer.paused"
  355. content="Resume Playback"
  356. v-tippy
  357. >
  358. <i class="material-icons">play_arrow</i>
  359. </button>
  360. <button
  361. class="button is-primary"
  362. @click="settings('pause')"
  363. @keyup.enter="settings('pause')"
  364. v-else
  365. content="Pause Playback"
  366. v-tippy
  367. >
  368. <i class="material-icons">pause</i>
  369. </button>
  370. <button
  371. class="button is-danger"
  372. @click.exact="settings('stop')"
  373. @click.shift="settings('hardStop')"
  374. @keyup.enter.exact="settings('stop')"
  375. @keyup.shift.enter="settings('hardStop')"
  376. content="Stop Playback"
  377. v-tippy
  378. >
  379. <i class="material-icons">stop</i>
  380. </button>
  381. <tippy
  382. class="playerRateDropdown"
  383. :touch="true"
  384. :interactive="true"
  385. placement="bottom"
  386. theme="dropdown"
  387. ref="dropdown"
  388. trigger="click"
  389. append-to="parent"
  390. @show="
  391. () => {
  392. youtubePlayer.showRateDropdown = true;
  393. }
  394. "
  395. @hide="
  396. () => {
  397. youtubePlayer.showRateDropdown = false;
  398. }
  399. "
  400. >
  401. <div
  402. ref="trigger"
  403. class="control has-addons"
  404. content="Set Playback Rate"
  405. v-tippy
  406. >
  407. <button class="button is-primary">
  408. <i class="material-icons">fast_forward</i>
  409. </button>
  410. <button class="button dropdown-toggle">
  411. <i class="material-icons">
  412. {{
  413. youtubePlayer.showRateDropdown
  414. ? "expand_more"
  415. : "expand_less"
  416. }}
  417. </i>
  418. </button>
  419. </div>
  420. <template #content>
  421. <div class="nav-dropdown-items">
  422. <button
  423. class="nav-item button"
  424. :class="{
  425. active: youtubePlayer.playbackRate === 0.5
  426. }"
  427. title="0.5x"
  428. @click="youtubeSetPlaybackRate(0.5)"
  429. >
  430. <p>0.5x</p>
  431. </button>
  432. <button
  433. class="nav-item button"
  434. :class="{
  435. active: youtubePlayer.playbackRate === 1
  436. }"
  437. title="1x"
  438. @click="youtubeSetPlaybackRate(1)"
  439. >
  440. <p>1x</p>
  441. </button>
  442. <button
  443. class="nav-item button"
  444. :class="{
  445. active: youtubePlayer.playbackRate === 2
  446. }"
  447. title="2x"
  448. @click="youtubeSetPlaybackRate(2)"
  449. >
  450. <p>2x</p>
  451. </button>
  452. </div>
  453. </template>
  454. </tippy>
  455. </div>
  456. <div class="player-footer-center">
  457. <span>
  458. <span>
  459. {{ youtubePlayer.currentTime }}
  460. </span>
  461. /
  462. <span>
  463. {{ youtubePlayer.duration }}
  464. {{ youtubePlayer.videoNote }}
  465. </span>
  466. </span>
  467. </div>
  468. <div class="player-footer-right">
  469. <p id="volume-control">
  470. <i
  471. class="material-icons"
  472. @click="toggleMute()"
  473. :content="`${youtubePlayer.muted ? 'Unmute' : 'Mute'}`"
  474. v-tippy
  475. >{{
  476. youtubePlayer.muted
  477. ? "volume_mute"
  478. : youtubePlayer.volume >= 50
  479. ? "volume_up"
  480. : "volume_down"
  481. }}</i
  482. >
  483. <input
  484. v-model="youtubePlayer.volume"
  485. type="range"
  486. min="0"
  487. max="100"
  488. class="volume-slider active"
  489. @change="changeVolume()"
  490. @input="changeVolume()"
  491. />
  492. </p>
  493. </div>
  494. </div>
  495. </div>
  496. </template>
  497. <style lang="less" scoped>
  498. .night-mode {
  499. .player-section {
  500. background-color: var(--dark-grey-3) !important;
  501. border: 0 !important;
  502. .duration-canvas {
  503. background-color: var(--dark-grey-2) !important;
  504. }
  505. }
  506. }
  507. .player-section {
  508. display: flex;
  509. flex-direction: column;
  510. margin: 10px auto 0 auto;
  511. border: 1px solid var(--light-grey-3);
  512. border-radius: @border-radius;
  513. overflow: hidden;
  514. .player-container {
  515. position: relative;
  516. padding-bottom: 56.25%; /* proportion value to aspect ratio 16:9 (9 / 16 = 0.5625 or 56.25%) */
  517. height: 0;
  518. overflow: hidden;
  519. :deep(iframe) {
  520. position: absolute;
  521. top: 0;
  522. left: 0;
  523. width: 100%;
  524. height: 100%;
  525. min-height: 200px;
  526. }
  527. }
  528. .duration-canvas {
  529. background-color: var(--light-grey-2);
  530. }
  531. .player-error {
  532. display: flex;
  533. height: 428px;
  534. align-items: center;
  535. * {
  536. margin: 0;
  537. flex: 1;
  538. font-size: 30px;
  539. text-align: center;
  540. }
  541. }
  542. .player-footer {
  543. display: flex;
  544. justify-content: space-between;
  545. height: 54px;
  546. padding-left: 10px;
  547. padding-right: 10px;
  548. > * {
  549. width: 33.3%;
  550. display: flex;
  551. align-items: center;
  552. }
  553. .player-footer-left {
  554. flex: 1;
  555. & > .button:not(:first-child) {
  556. margin-left: 5px;
  557. }
  558. & > .playerRateDropdown {
  559. margin-left: 5px;
  560. margin-bottom: unset !important;
  561. .control.has-addons {
  562. margin-bottom: unset !important;
  563. & > .button {
  564. font-size: 24px;
  565. }
  566. }
  567. }
  568. :deep(.tippy-box[data-theme~="dropdown"]) {
  569. max-width: 100px !important;
  570. .nav-dropdown-items .nav-item {
  571. justify-content: center !important;
  572. border-radius: @border-radius !important;
  573. &.active {
  574. background-color: var(--primary-color);
  575. color: var(--white);
  576. }
  577. }
  578. }
  579. }
  580. .player-footer-center {
  581. justify-content: center;
  582. align-items: center;
  583. flex: 2;
  584. font-size: 18px;
  585. font-weight: 400;
  586. width: 200px;
  587. margin: 0 5px;
  588. img {
  589. height: 21px;
  590. margin-right: 12px;
  591. filter: invert(26%) sepia(54%) saturate(6317%) hue-rotate(2deg)
  592. brightness(92%) contrast(115%);
  593. }
  594. }
  595. .player-footer-right {
  596. justify-content: right;
  597. flex: 1;
  598. #volume-control {
  599. margin: 3px;
  600. margin-top: 0;
  601. display: flex;
  602. align-items: center;
  603. cursor: pointer;
  604. .volume-slider {
  605. width: 100%;
  606. padding: 0 15px;
  607. background: transparent;
  608. min-width: 100px;
  609. }
  610. input[type="range"] {
  611. -webkit-appearance: none;
  612. margin: 7.3px 0;
  613. }
  614. input[type="range"]:focus {
  615. outline: none;
  616. }
  617. input[type="range"]::-webkit-slider-runnable-track {
  618. width: 100%;
  619. height: 5.2px;
  620. cursor: pointer;
  621. box-shadow: 0;
  622. background: var(--light-grey-3);
  623. border-radius: @border-radius;
  624. border: 0;
  625. }
  626. input[type="range"]::-webkit-slider-thumb {
  627. box-shadow: 0;
  628. border: 0;
  629. height: 19px;
  630. width: 19px;
  631. border-radius: 100%;
  632. background: var(--primary-color);
  633. cursor: pointer;
  634. -webkit-appearance: none;
  635. margin-top: -6.5px;
  636. }
  637. input[type="range"]::-moz-range-track {
  638. width: 100%;
  639. height: 5.2px;
  640. cursor: pointer;
  641. box-shadow: 0;
  642. background: var(--light-grey-3);
  643. border-radius: @border-radius;
  644. border: 0;
  645. }
  646. input[type="range"]::-moz-range-thumb {
  647. box-shadow: 0;
  648. border: 0;
  649. height: 19px;
  650. width: 19px;
  651. border-radius: 100%;
  652. background: var(--primary-color);
  653. cursor: pointer;
  654. -webkit-appearance: none;
  655. margin-top: -6.5px;
  656. }
  657. input[type="range"]::-ms-track {
  658. width: 100%;
  659. height: 5.2px;
  660. cursor: pointer;
  661. box-shadow: 0;
  662. background: var(--light-grey-3);
  663. border-radius: @border-radius;
  664. }
  665. input[type="range"]::-ms-fill-lower {
  666. background: var(--light-grey-3);
  667. border: 0;
  668. border-radius: 0;
  669. box-shadow: 0;
  670. }
  671. input[type="range"]::-ms-fill-upper {
  672. background: var(--light-grey-3);
  673. border: 0;
  674. border-radius: 0;
  675. box-shadow: 0;
  676. }
  677. input[type="range"]::-ms-thumb {
  678. box-shadow: 0;
  679. border: 0;
  680. height: 15px;
  681. width: 15px;
  682. border-radius: 100%;
  683. background: var(--primary-color);
  684. cursor: pointer;
  685. -webkit-appearance: none;
  686. margin-top: 1.5px;
  687. }
  688. }
  689. }
  690. }
  691. }
  692. </style>