mirror of
https://github.com/openvk/openvk
synced 2024-12-22 16:42:32 +03:00
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:
parent
6007a81546
commit
da82c84730
11 changed files with 214 additions and 8 deletions
|
@ -167,6 +167,11 @@ class Audio extends Media
|
||||||
return !is_null($this->getRecord()->lyrics) ? htmlspecialchars($this->getRecord()->lyrics, ENT_DISALLOWED | ENT_XHTML) : NULL;
|
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
|
function getLength(): int
|
||||||
{
|
{
|
||||||
return $this->getRecord()->length;
|
return $this->getRecord()->length;
|
||||||
|
|
|
@ -802,4 +802,15 @@ final class AudioPresenter extends OpenVKPresenter
|
||||||
|
|
||||||
$this->returnJson($resultArr);
|
$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("");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -48,9 +48,11 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="additionalButtons">
|
<div class="additionalButtons">
|
||||||
|
<div class="lyricsButton musicIcon" title="{_lyrics_tip}"></div>
|
||||||
<div class="repeatButton musicIcon" title="{_repeat_tip} [R]" ></div>
|
<div class="repeatButton musicIcon" title="{_repeat_tip} [R]" ></div>
|
||||||
<div class="shuffleButton musicIcon" title="{_shuffle_tip}"></div>
|
<div class="shuffleButton musicIcon" title="{_shuffle_tip}"></div>
|
||||||
<div class="deviceButton musicIcon" title="{_mute_tip} [M]"></div>
|
<div class="deviceButton musicIcon" title="{_mute_tip} [M]"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="lyrics"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
{php $isWithdrawn = $audio->isWithdrawn()}
|
{php $isWithdrawn = $audio->isWithdrawn()}
|
||||||
{php $isAvailable = $audio->isAvailable()}
|
{php $isAvailable = $audio->isAvailable()}
|
||||||
{php $editable = isset($thisUser) && $audio->canBeModifiedBy($thisUser)}
|
{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" />
|
<audio class="audio" />
|
||||||
|
|
||||||
<div id="miniplayer" class="audioEntry">
|
<div id="miniplayer" class="audioEntry">
|
||||||
|
|
|
@ -225,6 +225,8 @@ routes:
|
||||||
handler: "Audio->playlists"
|
handler: "Audio->playlists"
|
||||||
- url: "/audio{num}/action"
|
- url: "/audio{num}/action"
|
||||||
handler: "Audio->action"
|
handler: "Audio->action"
|
||||||
|
- url: "/audio{num}/lrc"
|
||||||
|
handler: "Audio->lrc"
|
||||||
- url: "/{?!club}{num}"
|
- url: "/{?!club}{num}"
|
||||||
handler: "Group->view"
|
handler: "Group->view"
|
||||||
placeholders:
|
placeholders:
|
||||||
|
|
|
@ -80,7 +80,7 @@
|
||||||
.bigPlayer .paddingLayer .additionalButtons {
|
.bigPlayer .paddingLayer .additionalButtons {
|
||||||
float: left;
|
float: left;
|
||||||
margin-top: -6px;
|
margin-top: -6px;
|
||||||
width: 11%;
|
width: 14%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bigPlayer .paddingLayer .additionalButtons .repeatButton {
|
.bigPlayer .paddingLayer .additionalButtons .repeatButton {
|
||||||
|
@ -92,6 +92,14 @@
|
||||||
float: left;
|
float: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bigPlayer .paddingLayer .additionalButtons .lyricsButton {
|
||||||
|
width: 12px;
|
||||||
|
height: 16px;
|
||||||
|
background-position: -79px -65px;
|
||||||
|
margin-left: 7px;
|
||||||
|
float: left;
|
||||||
|
}
|
||||||
|
|
||||||
.broadcastButton {
|
.broadcastButton {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 12px;
|
height: 12px;
|
||||||
|
@ -138,7 +146,7 @@
|
||||||
float: left;
|
float: left;
|
||||||
margin-top: -13px;
|
margin-top: -13px;
|
||||||
margin-left: 13px;
|
margin-left: 13px;
|
||||||
width: 63%;
|
width: 61%;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -212,6 +220,47 @@
|
||||||
width:72%
|
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 {
|
.audioEmbed .track > .selectableTrack, .bigPlayer .selectableTrack {
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
width: calc(100% - 8px);
|
width: calc(100% - 8px);
|
||||||
|
|
BIN
Web/static/img/audios_controls.png
Normal file → Executable file
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 |
|
@ -78,10 +78,117 @@ class bigPlayer {
|
||||||
|
|
||||||
timeType = 0
|
timeType = 0
|
||||||
|
|
||||||
|
lyricIndex = 0
|
||||||
|
lrcInterval = null;
|
||||||
|
|
||||||
findTrack(id) {
|
findTrack(id) {
|
||||||
return this.tracks["tracks"].find(item => item.id == 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) {
|
constructor(context, context_id, page = 1) {
|
||||||
this.context["context_type"] = context
|
this.context["context_type"] = context
|
||||||
this.context["context_id"] = context_id
|
this.context["context_id"] = context_id
|
||||||
|
@ -151,7 +258,6 @@ class bigPlayer {
|
||||||
|
|
||||||
if (ps <= 100)
|
if (ps <= 100)
|
||||||
this.nodes["thisPlayer"].querySelector(".selectableTrack .slider").style.left = `${ ps}%`;
|
this.nodes["thisPlayer"].querySelector(".selectableTrack .slider").style.left = `${ ps}%`;
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
u(this.player()).on("volumechange", (e) => {
|
u(this.player()).on("volumechange", (e) => {
|
||||||
|
@ -173,6 +279,7 @@ class bigPlayer {
|
||||||
const width = e.clientX - rect.left;
|
const width = e.clientX - rect.left;
|
||||||
const time = Math.ceil((width * this.tracks["currentTrack"].length) / (rect.right - rect.left));
|
const time = Math.ceil((width * this.tracks["currentTrack"].length) / (rect.right - rect.left));
|
||||||
|
|
||||||
|
this.updateLyricIndex();
|
||||||
this.player().currentTime = time;
|
this.player().currentTime = time;
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -276,6 +383,22 @@ class bigPlayer {
|
||||||
this.player().loop = false
|
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) => {
|
u(".bigPlayer .additionalButtons .shuffleButton").on("click", (e) => {
|
||||||
if(this.tracks["currentTrack"] == null)
|
if(this.tracks["currentTrack"] == null)
|
||||||
return
|
return
|
||||||
|
@ -327,9 +450,11 @@ class bigPlayer {
|
||||||
break
|
break
|
||||||
case "ArrowLeft":
|
case "ArrowLeft":
|
||||||
this.player().currentTime = this.player().currentTime - 3
|
this.player().currentTime = this.player().currentTime - 3
|
||||||
|
this.updateLyricIndex();
|
||||||
break
|
break
|
||||||
case "ArrowRight":
|
case "ArrowRight":
|
||||||
this.player().currentTime = this.player().currentTime + 3
|
this.player().currentTime = this.player().currentTime + 3
|
||||||
|
this.updateLyricIndex();
|
||||||
break
|
break
|
||||||
// буквально
|
// буквально
|
||||||
case " ":
|
case " ":
|
||||||
|
@ -400,7 +525,7 @@ class bigPlayer {
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
this.lrcInterval = clearInterval(this.lrcInterval);
|
||||||
this.showNextTrack()
|
this.showNextTrack()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -429,6 +554,9 @@ class bigPlayer {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, 2000)
|
}, 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)
|
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('nexttrack', () => { this.showNextTrack() });
|
||||||
navigator.mediaSession.setActionHandler("seekto", (details) => {
|
navigator.mediaSession.setActionHandler("seekto", (details) => {
|
||||||
this.player().currentTime = details.seekTime;
|
this.player().currentTime = details.seekTime;
|
||||||
|
this.updateLyricIndex;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -459,6 +588,7 @@ class bigPlayer {
|
||||||
this.player().play()
|
this.player().play()
|
||||||
this.nodes["playButtons"].querySelector(".playButton").classList.add("pause")
|
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")
|
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"
|
navigator.mediaSession.playbackState = "playing"
|
||||||
}
|
}
|
||||||
|
@ -471,6 +601,7 @@ class bigPlayer {
|
||||||
this.player().pause()
|
this.player().pause()
|
||||||
this.nodes["playButtons"].querySelector(".playButton").classList.remove("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")
|
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"
|
navigator.mediaSession.playbackState = "paused"
|
||||||
}
|
}
|
||||||
|
|
|
@ -912,6 +912,8 @@
|
||||||
"audio_popular" = "Popular";
|
"audio_popular" = "Popular";
|
||||||
"audio_search" = "Search";
|
"audio_search" = "Search";
|
||||||
|
|
||||||
|
"sync_lyrics_not_available" = "Syncronized lyrics are not available.";
|
||||||
|
|
||||||
"my_audios_small" = "My audios";
|
"my_audios_small" = "My audios";
|
||||||
"my_playlists" = "My playlists";
|
"my_playlists" = "My playlists";
|
||||||
"playlists" = "Playlists";
|
"playlists" = "Playlists";
|
||||||
|
|
|
@ -867,6 +867,8 @@
|
||||||
"audio_popular" = "Популярное";
|
"audio_popular" = "Популярное";
|
||||||
"audio_search" = "Поиск";
|
"audio_search" = "Поиск";
|
||||||
|
|
||||||
|
"sync_lyrics_not_available" = "Синхронизированный текст песни недоступен.";
|
||||||
|
|
||||||
"my_audios_small" = "Мои аудиозаписи";
|
"my_audios_small" = "Мои аудиозаписи";
|
||||||
"my_playlists" = "Мои плейлисты";
|
"my_playlists" = "Мои плейлисты";
|
||||||
"playlists" = "Плейлисты";
|
"playlists" = "Плейлисты";
|
||||||
|
|
|
@ -828,6 +828,8 @@
|
||||||
"audio_popular" = "Популярні";
|
"audio_popular" = "Популярні";
|
||||||
"audio_search" = "Пошук";
|
"audio_search" = "Пошук";
|
||||||
|
|
||||||
|
"sync_lyrics_not_available" = "Синхронізований текст пісні недоступний.";
|
||||||
|
|
||||||
"my_audios_small" = "Мої аудіозаписи";
|
"my_audios_small" = "Мої аудіозаписи";
|
||||||
"my_playlists" = "Мій список відтворення";
|
"my_playlists" = "Мій список відтворення";
|
||||||
"playlists" = "Списки відтворення";
|
"playlists" = "Списки відтворення";
|
||||||
|
|
Loading…
Reference in a new issue