feat: support lyrics (finnaly)

master
qier222 4 years ago
parent cef149e68c
commit 6366886fe8

@ -1,7 +1,7 @@
<template> <template>
<div id="app"> <div id="app">
<Navbar ref="navbar" /> <Navbar ref="navbar" />
<main> <main v-show="!this.$store.state.showLyrics">
<keep-alive> <keep-alive>
<router-view v-if="$route.meta.keepAlive"></router-view> <router-view v-if="$route.meta.keepAlive"></router-view>
</keep-alive> </keep-alive>
@ -20,6 +20,9 @@
<Toast /> <Toast />
<ModalAddTrackToPlaylist v-if="isAccountLoggedIn" /> <ModalAddTrackToPlaylist v-if="isAccountLoggedIn" />
<ModalNewPlaylist v-if="isAccountLoggedIn" /> <ModalNewPlaylist v-if="isAccountLoggedIn" />
<transition name="slide-up">
<Lyrics v-show="this.$store.state.showLyrics" /> </transition
>">
</div> </div>
</template> </template>
@ -31,6 +34,7 @@ import Player from "./components/Player.vue";
import Toast from "./components/Toast.vue"; import Toast from "./components/Toast.vue";
import { ipcRenderer } from "./electron/ipcRenderer"; import { ipcRenderer } from "./electron/ipcRenderer";
import { isAccountLoggedIn } from "@/utils/auth"; import { isAccountLoggedIn } from "@/utils/auth";
import Lyrics from "./views/lyrics.vue";
export default { export default {
name: "App", name: "App",
@ -40,6 +44,7 @@ export default {
Toast, Toast,
ModalAddTrackToPlaylist, ModalAddTrackToPlaylist,
ModalNewPlaylist, ModalNewPlaylist,
Lyrics,
}, },
data() { data() {
return { return {

@ -99,3 +99,32 @@
box-sizing: border-box; box-sizing: border-box;
visibility: visible; visibility: visible;
} }
/* lyrics */
.lyrics-page .vue-slider-rail {
background-color: rgba(128, 128, 128, 0.18);
border-radius: 2px;
height: 4px;
opacity: 0.88;
}
.lyrics-page .vue-slider-process {
background-color: #060606;
}
.lyrics-page .vue-slider-dot-handle {
background-color: #060606;
box-shadow: unset;
}
.lyrics-page .vue-slider-dot-tooltip {
display: none;
}
body[data-theme="dark"] .lyrics-page .vue-slider-process {
background-color: #fafafa;
}
body[data-theme="dark"] .lyrics-page .vue-slider-dot-handle {
background-color: #fff;
}

@ -119,6 +119,14 @@
</div> </div>
</div> </div>
</div> </div>
<button-icon
class="lyrics-button"
title="Lyrics"
style="margin-left: 12px"
@click.native="toggleLyrics"
><svg-icon icon-class="arrow-up"
/></button-icon>
</div> </div>
</template> </template>
@ -175,9 +183,12 @@ export default {
let max = ~~(this.player.currentTrack.dt / 1000); let max = ~~(this.player.currentTrack.dt / 1000);
return max > 1 ? max - 1 : max; return max > 1 ? max - 1 : max;
}, },
isCurrentTrackLiked() {
return this.liked.songs.includes(this.currentTrack.id);
},
}, },
methods: { methods: {
...mapMutations(["updateLikedSongs"]), ...mapMutations(["updateLikedSongs", "toggleLyrics"]),
...mapActions(["showToast"]), ...mapActions(["showToast"]),
play() { play() {
this.player.playing ? this.player.pause() : this.player.play(); this.player.playing ? this.player.pause() : this.player.play();
@ -206,6 +217,9 @@ export default {
this.progress = this.$refs.progress.getValue(); this.progress = this.$refs.progress.getValue();
this.player.seek(this.$refs.progress.getValue()); this.player.seek(this.$refs.progress.getValue());
}, },
setProgress(value) {
this.progress = value;
},
goToNextTracksPage() { goToNextTracksPage() {
this.$route.name === "next" this.$route.name === "next"
? this.$router.go(-1) ? this.$router.go(-1)
@ -399,4 +413,13 @@ export default {
.like-button { .like-button {
margin-left: 16px; margin-left: 16px;
} }
.lyrics-button {
position: fixed;
right: 18px;
.svg-icon {
height: 20px;
width: 20px;
}
}
</style> </style>

@ -33,4 +33,7 @@ export default {
updateModal(state, { modalName, key, value }) { updateModal(state, { modalName, key, value }) {
state.modals[modalName][key] = value; state.modals[modalName][key] = value;
}, },
toggleLyrics(state) {
state.showLyrics = !state.showLyrics;
},
}; };

@ -8,7 +8,7 @@ if (localStorage.getItem("appVersion") === null) {
} }
export default { export default {
howler: null, showLyrics: false,
liked: { liked: {
songs: [], songs: [],
}, },

@ -208,3 +208,10 @@ export function bytesToSize(bytes) {
return (bytes / megaBytes).toFixed(decimal) + " MB"; return (bytes / megaBytes).toFixed(decimal) + " MB";
else return (bytes / gigaBytes).toFixed(decimal) + " GB"; else return (bytes / gigaBytes).toFixed(decimal) + " GB";
} }
export function formatTrackTime(value) {
if (!value) return "";
let min = ~~((value / 60) % 60);
let sec = (~~(value % 60)).toString().padStart(2, "0");
return `${min}:${sec}`;
}

@ -0,0 +1,32 @@
// copy from https://github.com/sl1673495/vue-netease-music/blob/master/src/utils/lrcparse.js
export function lyricParser(lrc) {
return {
lyric: parseLyric(lrc.lrc.lyric || ""),
tlyric: parseLyric(lrc.tlyric.lyric || ""),
lyricuser: lrc.lyricUser,
transuser: lrc.transUser,
};
}
export function parseLyric(lrc) {
const lyrics = lrc.split("\n");
const lrcObj = [];
for (let i = 0; i < lyrics.length; i++) {
const lyric = decodeURIComponent(lyrics[i]);
const timeReg = /\[\d*:\d*((\.|:)\d*)*\]/g;
const timeRegExpArr = lyric.match(timeReg);
if (!timeRegExpArr) continue;
const content = lyric.replace(timeReg, "");
for (let k = 0, h = timeRegExpArr.length; k < h; k++) {
const t = timeRegExpArr[k];
const min = Number(String(t.match(/\[\d*/i)).slice(1));
const sec = Number(String(t.match(/:\d*/i)).slice(1));
const time = min * 60 + sec;
if (content !== "") {
lrcObj.push({ time: time, content });
}
}
}
return lrcObj;
}

@ -24,7 +24,11 @@
</div> </div>
<div class="index-row"> <div class="index-row">
<div class="title">{{ $t("home.recommendArtist") }}</div> <div class="title">{{ $t("home.recommendArtist") }}</div>
<CoverRow type="artist" :items="recommendArtists.items" /> <CoverRow
type="artist"
:columnNumber="6"
:items="recommendArtists.items"
/>
</div> </div>
<div class="index-row"> <div class="index-row">
<div class="title"> <div class="title">

@ -0,0 +1,486 @@
<template>
<transition name="slide-up">
<div class="lyrics-page">
<div class="left-side">
<div>
<div class="cover">
<div class="cover-container">
<img :src="imageUrl" />
<div
class="shadow"
:style="{ backgroundImage: `url(${imageUrl})` }"
></div>
</div>
</div>
<div class="controls">
<div class="top-part">
<div class="track-info">
<div class="title"
><router-link
:to="`/${player.playlistSource.type}/${player.playlistSource.id}`"
@click.native="toggleLyrics"
>{{ currentTrack.name }}</router-link
></div
>
<div class="subtitle"
><router-link
:to="`/artist/${currentTrack.ar[0].id}`"
@click.native="toggleLyrics"
>{{ currentTrack.ar[0].name }}</router-link
>
-
<router-link
:to="`/album/${currentTrack.al.id}`"
@click.native="toggleLyrics"
>{{ currentTrack.al.name }}</router-link
></div
>
</div>
<div class="buttons">
<button-icon
@click.native="playerRef.likeCurrentSong"
:title="$t('player.like')"
><svg-icon
:icon-class="
playerRef.isCurrentTrackLiked ? 'heart-solid' : 'heart'
"
/></button-icon>
<!-- <button-icon @click.native="openMenu" title="Menu"
><svg-icon icon-class="more"
/></button-icon> -->
</div>
</div>
<div class="progress-bar">
<span>{{ formatTrackTime(progress) || "0:00" }}</span>
<div class="slider">
<vue-slider
v-model="progress"
:min="0"
:max="progressMax"
:interval="1"
:drag-on-click="true"
:duration="0"
:dotSize="12"
:height="2"
:tooltipFormatter="formatTrackTime"
@drag-end="setSeek"
ref="progress"
></vue-slider
></div>
<span>{{ formatTrackTime(progressMax) }}</span>
</div>
<div class="media-controls">
<button-icon
@click.native="playerRef.repeat"
:title="
player.repeatMode === 'one'
? $t('player.repeatTrack')
: $t('player.repeat')
"
:class="{ active: player.repeatMode !== 'off' }"
>
<svg-icon
icon-class="repeat"
v-show="player.repeatMode !== 'one'"
/>
<svg-icon
icon-class="repeat-1"
v-show="player.repeatMode === 'one'"
/>
</button-icon>
<div class="middle">
<button-icon
@click.native="playerRef.previous"
:title="$t('player.previous')"
><svg-icon icon-class="previous"
/></button-icon>
<button-icon
@click.native="playerRef.play"
:title="$t(player.playing ? 'player.pause' : 'player.play')"
><svg-icon :icon-class="playerRef.playing ? 'pause' : 'play'"
/></button-icon>
<button-icon
@click.native="playerRef.next"
:title="$t('player.next')"
><svg-icon icon-class="next"
/></button-icon>
</div>
<button-icon
@click.native="playerRef.shuffle"
:title="$t('player.shuffle')"
:class="{ active: player.shuffle }"
><svg-icon icon-class="shuffle"
/></button-icon>
</div>
</div>
</div>
</div>
<div class="right-side">
<div class="lyrics-container" ref="lyricsContainer">
<div
class="line"
:class="{
highlight: highlightLyricIndex === index,
}"
:style="lineStyles"
v-for="(line, index) in lyricWithTranslation"
:key="index"
:id="`line-${index}`"
v-html="
haveTranslation
? line.contents[0] + '<br/>' + line.contents[1]
: line.contents[0]
"
></div>
</div>
</div>
<div class="close-button" @click="toggleLyrics">
<button><svg-icon icon-class="arrow-down" /></button>
</div>
</div>
</transition>
</template>
<script>
// The lyrics page of Apple Music is so gorgeous, so I copy the design.
// Some of the codes are from https://github.com/sl1673495/vue-netease-music
import { mapState, mapMutations } from "vuex";
import VueSlider from "vue-slider-component";
import { formatTrackTime } from "@/utils/common";
import { getLyric } from "@/api/track";
import { lyricParser } from "@/utils/lyrics";
import ButtonIcon from "@/components/ButtonIcon.vue";
export default {
name: "Lyrics",
components: {
VueSlider,
ButtonIcon,
},
data() {
return {
lyricsInterval: null,
lyric: [],
tlyric: [],
highlightLyricIndex: -1,
minimize: true,
};
},
computed: {
...mapState(["player"]),
currentTrack() {
return this.player.currentTrack;
},
imageUrl() {
return this.player.currentTrack.al.picUrl + "?param=1024x1024";
},
progress: {
get() {
return this.$parent.$refs.player.progress;
},
set(value) {
this.$parent.$refs.player.setProgress(value);
},
},
progressMax() {
return this.$parent.$refs.player.progressMax;
},
lyricWithTranslation() {
let ret = [];
//
const lyricFiltered = this.lyric.filter(({ content }) =>
Boolean(content)
);
// content
if (lyricFiltered.length) {
lyricFiltered.forEach((l) => {
const { time, content } = l;
const lyricItem = { time, content, contents: [content] };
const sameTimeTLyric = this.tlyric.find(
({ time: tLyricTime }) => tLyricTime === time
);
if (sameTimeTLyric) {
const { content: tLyricContent } = sameTimeTLyric;
if (content) {
lyricItem.contents.push(tLyricContent);
}
}
ret.push(lyricItem);
});
} else {
ret = lyricFiltered.map(({ time, content }) => ({
time,
content,
contents: [content],
}));
}
return ret;
},
haveTranslation() {
return this.tlyric.length > 0;
},
lineStyles() {
return {
fontSize: this.haveTranslation ? "28px" : "36px",
};
},
playerRef() {
return this.$parent.$refs.player;
},
showLyrics() {
return this.$store.state.showLyrics;
},
},
created() {
this.getLyric();
},
destroyed() {
clearInterval(this.lyricsInterval);
},
methods: {
...mapMutations(["toggleLyrics"]),
getLyric() {
return getLyric(this.currentTrack.id).then((data) => {
let { lyric, tlyric } = lyricParser(data);
this.lyric = lyric;
this.tlyric = tlyric;
});
},
formatTrackTime(value) {
return formatTrackTime(value);
},
setSeek() {
let value = this.$refs.progress.getValue();
this.$parent.$refs.player.setProgress(value);
this.$parent.$refs.player.player.seek(value);
},
setLyricsInterval() {
this.lyricsInterval = setInterval(() => {
let oldHighlightLyricIndex = this.highlightLyricIndex;
this.highlightLyricIndex = this.lyric.findIndex((l, index) => {
const nextLyric = this.lyric[index + 1];
return (
this.progress >= l.time &&
(nextLyric ? this.progress < nextLyric.time : true)
);
});
if (oldHighlightLyricIndex !== this.highlightLyricIndex) {
const el = document.getElementById(
`line-${this.highlightLyricIndex}`
);
if (el) el.scrollIntoView({ behavior: "smooth", block: "center" });
}
}, 500);
},
},
watch: {
currentTrack() {
this.getLyric().then(() => {
const el = document.getElementById(`line-0`);
el.scrollIntoView({ block: "center" });
});
},
showLyrics(show) {
if (show) {
this.setLyricsInterval();
} else {
clearInterval(this.lyricsInterval);
}
},
},
};
</script>
<style lang="scss" scoped>
.lyrics-page {
position: fixed;
top: 0;
right: 0;
left: 0;
bottom: 0;
z-index: 200;
background: var(--color-body-bg);
display: flex;
}
.left-side {
flex: 4;
display: flex;
justify-content: flex-end;
margin-right: 24px;
align-items: center;
.controls {
margin-top: 24px;
color: var(--color-text);
.title {
margin-top: 8px;
font-size: 1.4rem;
font-weight: 600;
opacity: 0.88;
}
.subtitle {
margin-top: 4px;
font-size: 1rem;
opacity: 0.58;
}
.top-part {
display: flex;
justify-content: space-between;
.buttons {
display: flex;
align-items: center;
button {
margin: 0 0 0 4px;
}
.svg-icon {
opacity: 0.58;
height: 18px;
width: 18px;
}
}
}
.progress-bar {
margin-top: 22px;
display: flex;
align-items: center;
justify-content: space-between;
.slider {
width: 100%;
flex-grow: grow;
padding: 0 10px;
}
span {
font-size: 15px;
opacity: 0.58;
min-width: 28px;
}
}
.media-controls {
display: flex;
justify-content: center;
margin-top: 18px;
align-items: center;
button {
margin: 0;
}
.svg-icon {
opacity: 0.38;
height: 14px;
width: 14px;
}
.active .svg-icon {
opacity: 0.88;
}
.middle {
padding: 0 16px;
display: flex;
align-items: center;
button {
margin: 0 8px;
}
button:nth-child(2) .svg-icon {
height: 28px;
width: 28px;
padding: 2px;
}
.svg-icon {
opacity: 0.88;
height: 22px;
width: 22px;
}
}
}
}
}
.cover {
position: relative;
.cover-container {
position: relative;
}
img {
border-radius: 0.75em;
width: 54vh;
user-select: none;
}
.shadow {
position: absolute;
top: 12px;
height: 54vh;
width: 54vh;
filter: blur(16px) opacity(0.6);
transform: scale(0.92, 0.96);
z-index: -1;
background-size: cover;
border-radius: 0.75em;
}
}
.right-side {
flex: 5;
font-weight: 600;
color: var(--color-text);
.lyrics-container {
height: 100%;
display: flex;
flex-direction: column;
padding-left: 96px;
max-width: 460px;
overflow-y: auto;
transition: 0.5s;
.line {
margin-top: 38px;
opacity: 0.28;
}
.highlight {
opacity: 0.98;
transition: 0.5s;
}
}
::-webkit-scrollbar {
display: none;
}
.lyrics-container .line:first-child {
margin-top: 50vh;
}
.lyrics-container .line:last-child {
margin-bottom: calc(50vh - 128px);
}
}
.close-button {
position: fixed;
top: 24px;
right: 24px;
z-index: 300;
border-radius: 50%;
height: 44px;
width: 44px;
display: flex;
justify-content: center;
align-items: center;
opacity: 0.28;
transition: 0.2s;
.svg-icon {
color: var(--color-text);
padding-top: 5px;
height: 22px;
width: 22px;
}
&:hover {
background: var(--color-secondary-bg);
opacity: 0.88;
}
}
.slide-up-enter-active,
.slide-up-leave-active {
transition: all 0.4s;
}
.slide-up-enter, .slide-up-leave-to /* .fade-leave-active below version 2.1.8 */ {
transform: translateY(100%);
}
</style>
Loading…
Cancel
Save