YoutubePlayer.vue 18 KB

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