UNPKG

sonix-player

Version:

A modern, customizable audio player web component with support for thumbnails, speed control, and volume management

647 lines (564 loc) 21.7 kB
import "./sonix-player.css"; // import ion-icons used icons import { play, pause, volumeMute, volumeMedium, volumeHigh, repeatOutline, playForwardOutline, cloudDownload, } from "ionicons/icons"; /* ion icons have a problem when used this way ${icon} return a type of URL data:image */ function splitIcon(iconName) { return iconName.split(",")[1]; } // Check any audio is Playing or not let isPlaying = false; // Keep track of which audio player has auto-play enabled let currentAutoPlayElement = null; // Keep track of all audio elements let allAudioElements = []; // Keep track when user is dragging in the timeline container let isDraggingTime = false; // Keep track when user is dragging in the volume control let isDraggingVol = false; class SonixPlayer extends HTMLElement { connectedCallback() { // Check if this element wants to auto-play if (this.dataset.auto === "true") { // IF there's already an auto-play element, disable it if (currentAutoPlayElement && currentAutoPlayElement !== this) { currentAutoPlayElement.dataset.auto = "false"; console.warn("only one sonix-player element in current page can have data-auto attribute, please fix it"); } // Set this element as the current auto-play element currentAutoPlayElement = this; } // Set audio src with data-src attribute const audioSrc = this.dataset.src || ""; const audioElement = document.createElement("audio"); audioElement.src = audioSrc; // Add this audio element to our tracking array allAudioElements.push(audioElement); // Function to calculating audio minutes and seconds function formatDuration() { if (audioElement.duration) { const totalSeconds = Math.floor(audioElement.duration); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; // Format time as MM:SS return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; } else { // IF audio duration have a problem return this console.warn("No audio duration found. Please check data-src attribute"); return "00:00"; } } // Set audio metadata when loaded audioElement.addEventListener("loadedmetadata", () => { const durationValue = this.querySelector(".sonix-duration-value"); if (durationValue) { durationValue.innerHTML = formatDuration(); } }); // Update duration if it changes audioElement.addEventListener("durationchange", () => { const durationValue = this.querySelector(".sonix-duration-value"); if (durationValue) { durationValue.innerHTML = formatDuration(); } }); // Set thumbnail and figure data-thumbnail="/images/default.png" & data-figure="/images/default.png" const cover = { thumbnail: this.dataset.thumbnail || "", figure: this.dataset.figure || "", }; // Set artist and track name data-artist="text" & data-name="text" const textDisplays = { artist: this.dataset.artist || "", name: this.dataset.name || "", }; // Set audio autoplay - volume - repeat Controls true or false const controls = { autoPlay: this.dataset.auto || "false", volumeControl: this.dataset.volume || "true", repeat: this.dataset.repeat || "true", }; // Set speed Control data-speed="true" & data-speed="number" const speedControl = { speed: this.dataset.speed || "true", minSpeed: 1, maxSpeed: this.dataset.max || "2", }; // Set showing download button or not true or false const canDownload = this.dataset.download || "true"; // Set audio player theme colors catching like this syntax => data-accent="#f50" const setPlayerTheme = () => { const black = this.dataset.black || document.documentElement.style.getPropertyValue("--color-sonix-black"); const accent = this.dataset.accent || document.documentElement.style.getPropertyValue("--color-sonix-accent"); const primary = this.dataset.primary || document.documentElement.style.getPropertyValue("--color-sonix-primary"); const secondary = this.dataset.secondary || document.documentElement.style.getPropertyValue("--color-sonix-secondary"); // Set CSS custom properties for the component this.style.setProperty("--color-sonix-black", black); this.style.setProperty("--color-sonix-accent", accent); this.style.setProperty("--color-sonix-primary", primary); this.style.setProperty("--color-sonix-secondary", secondary); }; // Call setPlayerTheme before setting innerHTML setPlayerTheme(); this.innerHTML = /*html*/ ` <div class="sonix-player"> <!-- --> ${ cover.thumbnail !== "" ? ` <div class="sonix-thumbnail-container"> <div class="sonix-thumbnail-filter" style="background-image:url(${cover.thumbnail})"></div> <img class="sonix-thumbnail-img" src="${cover.thumbnail}" alt="${textDisplays.name}" loading="lazy"/> <div class="sonix-loader"> <div class="sonix-loader-inner"> <div class="sonix-line h-7"></div> <div class="sonix-line h-4"></div> <div class="sonix-line h-8"></div> <div class="sonix-line h-10"></div> <div class="sonix-line h-8"></div> <div class="sonix-line h-4"></div> <div class="sonix-line h-7"></div> </div> </div> </div> ` : "" } <!-- --> <div class="sonix-inner"> <div class="flex items-center"> <div class="sonix-btn-holder"> <button class="sonix-play-btn" title="Play current" aria-label="Play current" tabindex="0"> <span class="sonix-icon"> ${splitIcon(play)} </span> <span class="sonix-icon"> ${splitIcon(pause)} </span> </button> </div> <!-- --> ${ controls.repeat == "true" ? ` <div class="sonix-btn-holder col-span-1"> <button class="sonix-repeat-btn" title="Repeat current" aria-label="Repeat current" tabindex="0"> <span class="sonix-icon"> ${splitIcon(repeatOutline)} </span> </button> </div> ` : "" } </div> <!-- --> <div class="sonix-timeline-container flex-2"> <div class="sonix-current-time-container"> <span class="sonix-current-time-value">${audioElement.currentTime}:00</span> </div> <div class="sonix-timeline-outer"> <div class="sonix-timeline-inner"></div> </div> <div class="sonix-duration-container"> <span class="sonix-duration-value">00:00</span> </div> </div> <!-- --> <div class="flex items-center sonix-vol-speed"> ${ controls.volumeControl == "true" ? ` <div class="sonix-btn-holder flex"> <button class="sonix-volume-btn" title="Control volume" aria-label="Control volume" tabindex="0"> <span class="sonix-icon"> ${splitIcon(volumeHigh)} </span> </button> <div class="sonix-custom-range" data-value="100"> <div class="sonix-custom-range-outer"> <div class="sonix-custom-range-inner"></div> </div> </div> </div> ` : "" } <!-- --> ${ speedControl.speed == "true" ? ` <div class="sonix-btn-holder"> <button class="sonix-speed-btn" title="Control speed" aria-label="Control speed" tabindex="0" data-value="1"> <span class="sonix-icon"> <span class="sonix-speed-value">1x</span> ${splitIcon(playForwardOutline)} </span> </button> </div> ` : "" } </div> <!-- --> <div class="flex justify-between items-center flex-1"> <div class="sonix-info ${canDownload == "false" ? "lg:justify-center w-full" : ""}"> ${ cover.figure !== "" ? ` <div class="sonix-figure-container"> <img class="sonix-figure-img" src="${cover.figure}" alt="${textDisplays.name || ""}" loading="lazy" /> <div class="sonix-loader"> <div class="sonix-loader-inner"> <div class="sonix-line h-7"></div> <div class="sonix-line h-4"></div> <div class="sonix-line h-8"></div> <div class="sonix-line h-10"></div> <div class="sonix-line h-8"></div> <div class="sonix-line h-4"></div> <div class="sonix-line h-7"></div> </div> </div> </div> ` : "" } <div class="sonix-text-info"> ${textDisplays.artist !== "" ? `<span class="sonix-artist">${textDisplays.artist}</span>` : ""} ${textDisplays.name !== "" ? `<span class="sonix-name">${textDisplays.name}</span>` : ""} </div> </div> <!-- --> ${ canDownload == "true" ? ` <div class="sonix-btn-holder"> <button class="sonix-download-btn" title="Download audio" aria-label="Download audio" tabindex="0"> <span class="sonix-icon"> ${splitIcon(cloudDownload)} </span> </button> </div> ` : "" } </div> </div> </div> `; // when data fully loaded catch audio duration and control autoplay audio if true or false window.addEventListener("load", () => { // find duration value element and place audio duration inner const durationValue = this.querySelector(".sonix-duration-value"); durationValue ? (durationValue.innerHTML = formatDuration()) : ""; const playButton = this.querySelector(".sonix-play-btn"); // Attached controlPlayStatus() function to play button playButton.addEventListener("click", handlePlayPause); // Audio status function updateAudioStatus(status, doing) { isPlaying = status; playButton.title = `${doing} current`; playButton.ariaLabel = `${doing} current`; } // Function to Play & Pause Control function handlePlayPause() { const isCurrentlyPaused = playButton.classList.contains("sonix-is-playing"); // if user clicked on play button and have't data-auto="true" attribute we stopping auto-play another audio if (this.dataset.auto == "false" || !this.dataset.auto) { const autoPlayTarget = document.querySelector("[data-auto='true']"); autoPlayTarget?.setAttribute("data-auto", "false"); autoPlayTarget ? console.warn("auto-play disabled") : ""; } if (!audioElement.duration) return; if (!isCurrentlyPaused) { // Pause all other audio elements allAudioElements.forEach((audio) => { if (audio !== audioElement) { audio.pause(); } }); // Remove sonix-is-playing class from all play buttons document.querySelectorAll(".sonix-player .sonix-play-btn").forEach((btn) => { btn.classList.remove("sonix-is-playing"); }); // Add sonix-is-playing class only to the current button playButton.classList.add("sonix-is-playing"); audioElement.play(); updateAudioStatus(true, "Pause"); } else { playButton.classList.remove("sonix-is-playing"); audioElement.pause(); updateAudioStatus(false, "Play"); } } const repeatButton = this.querySelector(".sonix-repeat-btn"); // Attached repeactController to repeat button repeatButton?.addEventListener("click", handleRepeat); function handleRepeat() { const isRepeat = repeatButton.classList.toggle("sonix-active"); if (isRepeat) { audioElement.loop = true; } else { audioElement.loop = false; } } // add timeupdate event to all audio elements audioElement.addEventListener("timeupdate", updatePlaybackTime); audioElement.addEventListener("timeupdate", handlePlaybackEnd); // Initialize Audio Time const currentTime = this.querySelector(".sonix-current-time-value"); const timelineOuter = this.querySelector(".sonix-timeline-outer"); const timelineInner = this.querySelector(".sonix-timeline-inner"); function updatePlaybackTime() { // calculating audio current time and duration let percent = (audioElement.currentTime / audioElement.duration) * 100; const audioCurrentTime = Math.floor(audioElement.currentTime); const minutes = Math.floor(audioCurrentTime / 60); const seconds = Math.floor(audioCurrentTime % 60); // formating seconds and minutes if (audioCurrentTime) { const formatSeconds = seconds < 10 ? `0${seconds}` : seconds; currentTime.innerHTML = minutes + ":" + formatSeconds; } // add number value to width of the timeline inner timelineInner.style.width = `${percent}%`; } function handlePlaybackEnd() { // if current time and duration are equal sonix-is-playing removed and audio status changed if (audioElement.currentTime !== audioElement.duration) return; // if loop activated keep playing current the sound if (audioElement.loop === true) { updateAudioStatus(true, "Pause"); } else { // if loop not activated stopping the current sound updateAudioStatus(false, "Play"); } playButton.classList.remove("sonix-is-playing"); } // Initialize volume control const volumeButton = this.querySelector(".sonix-volume-btn"); const volumeIcon = volumeButton?.querySelector(".sonix-icon"); const customRange = this.querySelector(".sonix-custom-range"); const customRangeInner = this.querySelector(".sonix-custom-range-inner"); if (volumeButton) { // Set initial volume audioElement.volume = 1; customRangeInner.style.width = "100%"; // Handle volume button click - unmute or muted volumeButton.addEventListener("click", handleVolumeToggle); function handleVolumeToggle() { if (!audioElement.muted) { audioElement.volume = 0; audioElement.muted = true; customRangeInner.style.width = "0%"; volumeIcon.innerHTML = splitIcon(volumeMute); } else { audioElement.volume = 1; audioElement.muted = false; customRangeInner.style.width = "100%"; volumeIcon.innerHTML = splitIcon(volumeHigh); } } let volumeMoveHandler; let volumeUpHandler; let volumeTouchMoveHandler; let volumeTouchEndHandler; // Handle volume range interaction for mouse customRange.addEventListener("mousedown", (e) => handleVolumeMouse(e)); function handleVolumeMouse(e) { isDraggingVol = true; updateVolume(e); // Add event listeners only when dragging starts volumeMoveHandler = (e) => { if (isDraggingVol) { updateVolume(e); } }; volumeUpHandler = () => { isDraggingVol = false; // Remove event listeners when dragging ends document.removeEventListener("mousemove", volumeMoveHandler); document.removeEventListener("mouseup", volumeUpHandler); }; document.addEventListener("mousemove", volumeMoveHandler); document.addEventListener("mouseup", volumeUpHandler); } customRange.addEventListener("touchstart", (e) => handleVolumeTouch(e)); function handleVolumeTouch(e) { e.preventDefault(); isDraggingVol = true; updateVolume(e.touches[0]); volumeTouchMoveHandler = (e) => { if (isDraggingVol) { updateVolume(e.touches[0]); } }; volumeTouchEndHandler = () => { isDraggingVol = false; customRangeInner.removeEventListener("touchmove", volumeTouchMoveHandler); customRangeInner.removeEventListener("touchend", volumeTouchEndHandler); }; customRangeInner.addEventListener("touchmove", volumeTouchMoveHandler); customRangeInner.addEventListener("touchend", volumeTouchEndHandler); } } // control sound volume based on mouse listener function updateVolume(e) { const rect = customRange.getBoundingClientRect(); const width = rect.width; const x = Math.max(0, Math.min(e.clientX - rect.left, width)); const volume = Math.max(0, Math.min(1, x / width)); // Ensure volume is a valid number between 0 and 1 if (!isFinite(volume)) return; audioElement.volume = volume; customRangeInner.style.width = `${volume * 100}%`; // Update volume icon based on level if (volume === 0) { volumeIcon.innerHTML = splitIcon(volumeMute); audioElement.muted = true; } else if (volume < 0.5) { volumeIcon.innerHTML = splitIcon(volumeMedium); } else { audioElement.muted = false; volumeIcon.innerHTML = splitIcon(volumeHigh); } } // Handle Audio Speed Controls const speedButton = this.querySelector(".sonix-speed-btn"); speedButton?.addEventListener("click", updatePlaybackSpeed); /** * This function retrieves the current playback speed from the speed button's dataset, * increments it by 0.5 (or resets to the minimum speed if the maximum is reached), * updates the audio element's playback rate, and updates the displayed speed value. */ function updatePlaybackSpeed() { let currentSpeed = parseFloat(speedButton.dataset.value); currentSpeed = currentSpeed >= parseFloat(speedControl.maxSpeed) ? speedControl.minSpeed : currentSpeed + 0.5; // Update speed value speedButton.dataset.value = currentSpeed; audioElement.playbackRate = currentSpeed; // Update speed display speedButton.querySelector(".sonix-speed-value").textContent = `${currentSpeed}x`; } // Control download button this.querySelector(".sonix-download-btn")?.addEventListener("click", handleDownload); function handleDownload() { if (audioElement.duration) { const a = document.createElement("a"); a.href = audioElement.src; a.download = textDisplays.name; a.click(); } else { console.warn("audio source not found check data-src please"); } } // Update audio time based on click and mouse listener in timeline container let mouseMoveHandler; let mouseUpHandler; let touchMoveHandler; let touchEndHandler; timelineOuter.addEventListener("mousedown", (e) => handleTimelineMouse(e)); function handleTimelineMouse(e) { isDraggingTime = true; updateSeekPosition(e); // Add event listeners only when dragging starts mouseMoveHandler = (e) => { if (isDraggingTime) { updateSeekPosition(e); } }; mouseUpHandler = () => { isDraggingTime = false; // Remove event listeners when dragging ends document.removeEventListener("mousemove", mouseMoveHandler); document.removeEventListener("mouseup", mouseUpHandler); }; document.addEventListener("mousemove", mouseMoveHandler); document.addEventListener("mouseup", mouseUpHandler); } timelineOuter.addEventListener("touchstart", (e) => handleTimelineTouch(e)); function handleTimelineTouch(e) { isDraggingTime = true; updateSeekPosition(e.touches[0]); touchMoveHandler = (e) => { if (isDraggingTime && e.cancelable) { e.preventDefault(); updateSeekPosition(e.touches[0]); } }; touchEndHandler = () => { isDraggingTime = false; timelineOuter.removeEventListener("touchmove", touchMoveHandler); timelineOuter.removeEventListener("touchend", touchEndHandler); }; timelineOuter.addEventListener("touchmove", touchMoveHandler); timelineOuter.removeEventListener("touchend", touchEndHandler); } /* Checks if the audio has a valid duration. Calculates where the user clicked relative to the timeline. Converts that position to a percentage of the timeline's width. Sets the audio's current playback time based on that percentage. Visually updates the timeline to reflect the new position. */ function updateSeekPosition(e) { if (!audioElement.duration) return; const rect = timelineOuter.getBoundingClientRect(); let offsetX = e.clientX - rect.left; offsetX = Math.max(0, Math.min(offsetX, timelineOuter.offsetWidth)); const percent = offsetX / timelineOuter.offsetWidth; audioElement.currentTime = percent * audioElement.duration; timelineInner.style.width = `${percent * 100}%`; } setTimeout(() => { // Set autoplay handler when user clicked anywhere of the document playing audio const handleAutoPlay = () => { if (this.dataset.auto == "true" && audioElement.duration) { audioElement.play(); playButton.classList.add("sonix-is-playing"); updateAudioStatus(true, "Pause"); } window.removeEventListener("click", handleAutoPlay); }; window.addEventListener("click", handleAutoPlay); }, 100); if (document.querySelector(".sonix-loader")) { document.querySelectorAll(".sonix-loader").forEach((loader) => loader.remove()); } }); } } customElements.define("sonix-player", SonixPlayer); export default SonixPlayer;