feat(music): syncronized lyrics by lrc file format

!!!DRAFT!!!

Co-authored-by: Mikhail Serebryakov <126566943+synzr@users.noreply.github.com>
This commit is contained in:
veselcraft 2024-11-26 03:32:40 +03:00
parent 6007a81546
commit da82c84730
No known key found for this signature in database
GPG key ID: 9CF0B42766CCF7BA
11 changed files with 214 additions and 8 deletions

View file

@ -167,6 +167,11 @@ class Audio extends Media
return !is_null($this->getRecord()->lyrics) ? htmlspecialchars($this->getRecord()->lyrics, ENT_DISALLOWED | ENT_XHTML) : NULL;
}
function getLRC(): ?string
{
return !is_null($this->getRecord()->lrc) ? htmlspecialchars($this->getRecord()->lrc, ENT_DISALLOWED | ENT_XHTML) : NULL;
}
function getLength(): int
{
return $this->getRecord()->length;

View file

@ -802,4 +802,15 @@ final class AudioPresenter extends OpenVKPresenter
$this->returnJson($resultArr);
}
function renderLRC(int $id) {
$audio = (new Audios)->get($id);
header("Content-Type: application/octet-stream");
if ($audio->isAvailable()) {
exit($audio->getLRC());
} else {
exit("");
}
}
}

View file

@ -48,9 +48,11 @@
</div>
<div class="additionalButtons">
<div class="lyricsButton musicIcon" title="{_lyrics_tip}"></div>
<div class="repeatButton musicIcon" title="{_repeat_tip} [R]" ></div>
<div class="shuffleButton musicIcon" title="{_shuffle_tip}"></div>
<div class="deviceButton musicIcon" title="{_mute_tip} [M]"></div>
</div>
</div>
<div class="lyrics"></div>
</div>

View file

@ -2,7 +2,7 @@
{php $isWithdrawn = $audio->isWithdrawn()}
{php $isAvailable = $audio->isAvailable()}
{php $editable = isset($thisUser) && $audio->canBeModifiedBy($thisUser)}
<div id="audioEmbed-{$id}" data-realid="{$audio->getId()}" {if $hideButtons}data-prettyid="{$audio->getPrettyId()}" data-name="{$audio->getName()}"{/if} data-genre="{$audio->getGenre()}" class="audioEmbed {if !$isAvailable}processed{/if} {if $isWithdrawn}withdrawn{/if}" data-length="{$audio->getLength()}" data-keys="{json_encode($audio->getKeys())}" data-url="{$audio->getURL()}">
<div id="audioEmbed-{$id}" data-realid="{$audio->getId()}" {if $hideButtons}data-prettyid="{$audio->getPrettyId()}" data-name="{$audio->getName()}"{/if} data-genre="{$audio->getGenre()}" class="audioEmbed {if !$isAvailable}processed{/if} {if $isWithdrawn}withdrawn{/if}" data-length="{$audio->getLength()}" data-keys="{json_encode($audio->getKeys())}" data-url="{$audio->getURL()}" {if !empty($audio->getLRC()) }data-lrc="/audio{$audio->getId()}/lrc"{/if}>
<audio class="audio" />
<div id="miniplayer" class="audioEntry">

View file

@ -225,6 +225,8 @@ routes:
handler: "Audio->playlists"
- url: "/audio{num}/action"
handler: "Audio->action"
- url: "/audio{num}/lrc"
handler: "Audio->lrc"
- url: "/{?!club}{num}"
handler: "Group->view"
placeholders:

View file

@ -80,7 +80,7 @@
.bigPlayer .paddingLayer .additionalButtons {
float: left;
margin-top: -6px;
width: 11%;
width: 14%;
}
.bigPlayer .paddingLayer .additionalButtons .repeatButton {
@ -92,6 +92,14 @@
float: left;
}
.bigPlayer .paddingLayer .additionalButtons .lyricsButton {
width: 12px;
height: 16px;
background-position: -79px -65px;
margin-left: 7px;
float: left;
}
.broadcastButton {
width: 16px;
height: 12px;
@ -138,7 +146,7 @@
float: left;
margin-top: -13px;
margin-left: 13px;
width: 63%;
width: 61%;
position: relative;
}
@ -212,6 +220,47 @@
width:72%
}
.bigPlayer .lyrics {
width: 100%;
height: 400px;
background-color: #ffffff;
margin-top: 23px;
overflow-x: hidden;
overflow-y: auto;
scroll-behavior: smooth;
box-shadow: 0px 2px 3px -2px #000a;
transition: 250ms ease-in;
display: none;
opacity: 0;
}
.bigPlayer .lyrics.shown {
opacity: 1;
}
.bigPlayer .lyrics .lyrics__line {
font-family: Tahoma;
font-size: 15pt;
padding: 7px 20px;
color: gray;
transition: 250ms ease-out;
}
.bigPlayer .lyrics .lyrics__line__active {
color:black;
font-size: 17pt;
}
.bigPlayer .lyrics .lyrics__message {
font-family: Tahoma;
font-size: 9pt;
text-align: center;
padding: 3px;
padding-top: 50px;
color: black;
}
.audioEmbed .track > .selectableTrack, .bigPlayer .selectableTrack {
margin-top: 3px;
width: calc(100% - 8px);

BIN
Web/static/img/audios_controls.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

View file

@ -78,10 +78,117 @@ class bigPlayer {
timeType = 0
lyricIndex = 0
lrcInterval = null;
findTrack(id) {
return this.tracks["tracks"].find(item => item.id == id)
}
// LRC | Synced lyrics functionality
_parseTimestamp = (timestamp) => {
if (timestamp[2] !== ":" || timestamp[5] !== ".") return;
const minutes = +timestamp.substring(0, 2);
const seconds = +timestamp.substring(3, 5);
const hundredths = +timestamp.substring(6, 8);
return minutes * 60 + seconds + hundredths / 100;
};
_parseLrcLine = (line) => {
if (!line.startsWith("[") && line.indexOf("]") !== 9) return;
const timestamp = this._parseTimestamp(line.substring(1, 9));
if (!timestamp) return;
return { timestamp, textContent: line.substring(10).trim() };
};
_parseLrc = (textContent) => {
return textContent.split("\n").map(this._parseLrcLine).filter(Boolean);
};
_importLrc = async (path) => {
return await fetch(path)
.then((response) => response.text())
.then(this._parseLrc);
};
// NOTE: LYRICS DOM FUNCTIONS
_createLineElement = (timestamp, textContent) => {
const element = document.createElement("div");
element.classList.add("lyrics__line");
element.setAttribute("data-timestamp", timestamp);
element.textContent = textContent;
document.querySelectorAll(`.lyrics`)[0].appendChild(element);
};
_loadLyrics = (lyrics) => {
document.querySelectorAll(`.lyrics`)[0].innerHTML = "";
document.querySelectorAll(`.lyrics`)[0].scrollTop = 0;
lyrics.forEach(({ timestamp, textContent }) =>
this._createLineElement(timestamp, textContent)
);
if (lyrics.length == 0)
{
document.querySelectorAll(`.lyrics`)[0].innerHTML = `<div class="lyrics__message">${tr("sync_lyrics_not_available")}</div>`
}
};
handleLyricsSync = () => {
let currentLyric = document.querySelectorAll(`.lyrics`)[0].children[this.lyricIndex];
if (currentLyric == undefined) return;
let timestamp = currentLyric.getAttribute("data-timestamp");
let isActive = currentLyric.classList.contains("lyrics__line__active");
if (!isActive && this.player().currentTime >= timestamp) {
document.querySelectorAll(`.lyrics`)[0].scrollTop = currentLyric.offsetTop - 55
return currentLyric.classList.add("lyrics__line__active");
}
if (document.querySelectorAll(`.lyrics`)[0].children.length === this.lyricIndex + 1) {
return;
}
let nextLyric = document.querySelectorAll(`.lyrics`)[0].children[this.lyricIndex + 1];
let nextTimestamp = +nextLyric.getAttribute("data-timestamp");
if (isActive && this.player().currentTime >= nextTimestamp) {
currentLyric.classList.remove("lyrics__line__active");
return this.lyricIndex++;
}
}
updateLyricIndex = () => {
if (this.lyricIndex < 0) {
this.lyricIndex = 0;
}
document.querySelectorAll(`.lyrics`)[0].children[this.lyricIndex].classList.remove(
"lyrics__line__active"
);
this.lyricIndex = [...document.querySelectorAll(`.lyrics`)[0].children].findIndex(
(lyric) => {
const timestamp = +lyric.getAttribute("data-timestamp");
return timestamp >= this.player().currentTime;
}
);
let currentLyric = document.querySelectorAll(`.lyrics`)[0].children[this.lyricIndex];
document.querySelectorAll(`.lyrics`)[0].scrollTop = currentLyric.offsetTop - 55
currentLyric.classList.add("lyrics__line__active");
}
constructor(context, context_id, page = 1) {
this.context["context_type"] = context
this.context["context_id"] = context_id
@ -151,7 +258,6 @@ class bigPlayer {
if (ps <= 100)
this.nodes["thisPlayer"].querySelector(".selectableTrack .slider").style.left = `${ ps}%`;
})
u(this.player()).on("volumechange", (e) => {
@ -173,6 +279,7 @@ class bigPlayer {
const width = e.clientX - rect.left;
const time = Math.ceil((width * this.tracks["currentTrack"].length) / (rect.right - rect.left));
this.updateLyricIndex();
this.player().currentTime = time;
})
@ -275,6 +382,22 @@ class bigPlayer {
else
this.player().loop = false
})
u(".bigPlayer .additionalButtons .lyricsButton").on("click", (e) => {
if(this.tracks["currentTrack"] == null)
return
e.currentTarget.classList.toggle("pressed")
if (document.querySelectorAll(`.lyrics`)[0].classList.contains("shown")) {
document.querySelectorAll(`.lyrics`)[0].style.display = "block";
document.querySelectorAll(`.lyrics`)[0].classList.remove("shown");
setTimeout(() => {document.querySelectorAll(`.lyrics`)[0].style.display = ""}, 250);
} else {
document.querySelectorAll(`.lyrics`)[0].style.display = "block";
setTimeout(() => {document.querySelectorAll(`.lyrics`)[0].classList.add("shown")}, 50);
}
})
u(".bigPlayer .additionalButtons .shuffleButton").on("click", (e) => {
if(this.tracks["currentTrack"] == null)
@ -327,9 +450,11 @@ class bigPlayer {
break
case "ArrowLeft":
this.player().currentTime = this.player().currentTime - 3
this.updateLyricIndex();
break
case "ArrowRight":
this.player().currentTime = this.player().currentTime + 3
this.updateLyricIndex();
break
// буквально
case " ":
@ -400,7 +525,7 @@ class bigPlayer {
return
}
this.lrcInterval = clearInterval(this.lrcInterval);
this.showNextTrack()
})
@ -429,6 +554,9 @@ class bigPlayer {
}
})
}, 2000)
this.lyricIndex = 0;
this._importLrc(`/audio${this.tracks.currentTrack.id}/lrc`).then(this._loadLyrics)
})
if(localStorage.volume != null && localStorage.volume < 1 && localStorage.volume > 0)
@ -447,6 +575,7 @@ class bigPlayer {
navigator.mediaSession.setActionHandler('nexttrack', () => { this.showNextTrack() });
navigator.mediaSession.setActionHandler("seekto", (details) => {
this.player().currentTime = details.seekTime;
this.updateLyricIndex;
});
}
@ -459,7 +588,8 @@ class bigPlayer {
this.player().play()
this.nodes["playButtons"].querySelector(".playButton").classList.add("pause")
document.querySelector('link[rel="icon"], link[rel="shortcut icon"]').setAttribute("href", "/assets/packages/static/openvk/img/favicons/favicon24_paused.png")
this.lrcInterval = setInterval(this.handleLyricsSync);
navigator.mediaSession.playbackState = "playing"
}
@ -471,7 +601,8 @@ class bigPlayer {
this.player().pause()
this.nodes["playButtons"].querySelector(".playButton").classList.remove("pause")
document.querySelector('link[rel="icon"], link[rel="shortcut icon"]').setAttribute("href", "/assets/packages/static/openvk/img/favicons/favicon24_playing.png")
this.lrcInterval = clearInterval(this.lrcInterval);
navigator.mediaSession.playbackState = "paused"
}
@ -788,7 +919,7 @@ function initPlayer(id, keys, url, length) {
$(`#audioEmbed-${ id} .track`).removeClass('shown')
$(`#audioEmbed-${ id}`).removeClass("havePlayed")
}
u(audio).on("play", playButtonImageUpdate);
u(audio).on(["pause", "suspended"], playButtonImageUpdate);
u(audio).on("ended", (e) => {

View file

@ -912,6 +912,8 @@
"audio_popular" = "Popular";
"audio_search" = "Search";
"sync_lyrics_not_available" = "Syncronized lyrics are not available.";
"my_audios_small" = "My audios";
"my_playlists" = "My playlists";
"playlists" = "Playlists";

View file

@ -867,6 +867,8 @@
"audio_popular" = "Популярное";
"audio_search" = "Поиск";
"sync_lyrics_not_available" = "Синхронизированный текст песни недоступен.";
"my_audios_small" = "Мои аудиозаписи";
"my_playlists" = "Мои плейлисты";
"playlists" = "Плейлисты";

View file

@ -828,6 +828,8 @@
"audio_popular" = "Популярні";
"audio_search" = "Пошук";
"sync_lyrics_not_available" = "Синхронізований текст пісні недоступний.";
"my_audios_small" = "Мої аудіозаписи";
"my_playlists" = "Мій список відтворення";
"playlists" = "Списки відтворення";