Files
dotfiles/config/quickshell/.config/quickshell/Services/LyricsService.qml
T

267 lines
7.1 KiB
QML

import QtQuick
import Quickshell
import Quickshell.Io
import qs.Constants
import qs.Services
import qs.Utils
pragma Singleton
Singleton {
id: root
property bool isFetchingLyrics: false
// Lyrics state
// { "time": 0, "line": "lyric line" }
// time is in ms
property ListModel lyrics
property int lyricsOffset: 0 // in ms
property int currentIndex: -1
// Player state
// this property will be updated regardless of shouldRun for simplicity
property int internalPosition: 0
// Reference counting
property var _registered: ({
})
readonly property int _registeredCount: Object.keys(_registered).length
readonly property bool shouldRun: MediaService.currentPlayer && _registeredCount > 0
// readonly property bool shouldRun: MediaService.currentPlayer !== null
// Bar state
readonly property bool showLyricsBar: ShellState.lyricsState.showLyricsBar || false
signal lyricsUpdated()
function registerComponent(componentId) {
root._registered[componentId] = true;
root._registered = Object.assign({
}, root._registered);
Logger.d("Lyrics", "Component registered:", componentId, "- total:", root._registeredCount);
}
function unregisterComponent(componentId) {
delete root._registered[componentId];
root._registered = Object.assign({
}, root._registered);
Logger.d("Lyrics", "Component unregistered:", componentId, "- total:", root._registeredCount);
}
function _requestFetchLyrics() {
if (!root.shouldRun)
return ;
fetchLyricsTimer.restart();
}
function fetchLyrics() {
root.lyrics.clear();
root.lyricsUpdated();
root.currentIndex = -1;
if (!root.shouldRun)
return ;
root.isFetchingLyrics = true;
if (!MediaService.currentPlayer?.identity) {
root.isFetchingLyrics = false;
return ;
} else {
lyricsProcess.request(MediaService.currentPlayer.identity.toLowerCase());
}
}
function parseLRC(text) {
if (!root.shouldRun)
return ;
// Logger.d("Lyrics", "Parsing :\n" + text);
const lines = text.split("\n");
let newLyrics = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (line.length === 0)
continue;
const lyricLine = line.replace(/\[.*?]/g, "").trim();
const regex = /\[(\d{2}):(\d{2})(?:\.(\d{2,3}))?]/g;
let match;
while ((match = regex.exec(line)) !== null) {
const minutes = parseInt(match[1], 10);
const seconds = parseInt(match[2], 10);
const milliseconds = match[3] ? parseInt(match[3].padEnd(3, "0"), 10) : 0;
const time = minutes * 60 * 1000 + seconds * 1000 + milliseconds;
newLyrics.push({
"time": time,
"line": lyricLine
});
}
}
newLyrics.sort((a, b) => {
return a.time - b.time;
});
root.lyrics.clear();
root.lyrics.append(newLyrics);
root.isFetchingLyrics = false;
root.lyricsUpdated();
updateIndex();
}
function updateIndex() {
if (!root.shouldRun)
return ;
if (root.lyrics.count === 0) {
if (root.currentIndex !== -1)
root.currentIndex = -1;
return ;
}
const effectiveTime = root.internalPosition + root.lyricsOffset;
let low = 0;
let high = root.lyrics.count - 1;
let bestMatch = -1;
while (low <= high) {
let mid = (low + high) >> 1;
let midTime = root.lyrics.get(mid).time;
if (midTime <= effectiveTime) {
bestMatch = mid;
low = mid + 1;
} else {
high = mid - 1;
}
}
if (root.currentIndex !== bestMatch)
root.currentIndex = bestMatch;
}
function increaseOffset() {
root.lyricsOffset += 500;
}
function decreaseOffset() {
root.lyricsOffset -= 500;
}
function resetOffset() {
if (root.lyricsOffset !== 0)
root.lyricsOffset = 0;
}
function toggleLyricsBar() {
ShellState.lyricsState = {
"showLyricsBar": !ShellState.lyricsState.showLyricsBar
};
}
function seekToLyric(index) {
if (index < 0 || index >= root.lyrics.count)
return ;
const time = root.lyrics.get(index).time - root.lyricsOffset;
MediaService.seek(time / 1000);
}
onShouldRunChanged: {
Logger.d("Lyrics", "Should run changed:", root.shouldRun);
if (!root.shouldRun) {
root.lyrics.clear();
root.lyricsUpdated();
root.currentIndex = -1;
} else {
root._requestFetchLyrics();
}
}
onInternalPositionChanged: updateIndex()
onLyricsOffsetChanged: updateIndex()
Connections {
function onCurrentPlayerChanged() {
_requestFetchLyrics();
}
function onTrackTitleChanged() {
_requestFetchLyrics();
}
function onTrackArtistChanged() {
_requestFetchLyrics();
}
function onTrackAlbumChanged() {
_requestFetchLyrics();
}
function onCurrentPositionChanged() {
root.internalPosition = MediaService.currentPosition * 1000;
if (shouldRun && MediaService.isPlaying)
updateInternalTimer.restart();
}
target: MediaService
}
Timer {
id: fetchLyricsTimer
interval: 100
repeat: false
running: false
onTriggered: fetchLyrics()
}
Process {
id: lyricsProcess
property string queuedPlayer: ""
function request(player) {
this.queuedPlayer = player;
if (this.running)
this.running = false;
else
handleQueue();
}
function handleQueue() {
if (!this.queuedPlayer) {
root.isFetchingLyrics = false;
return ;
}
const player = this.queuedPlayer.toLowerCase();
this.queuedPlayer = "";
this.command = ["lrcfetch", "fetch", "--player", player];
this.running = true;
}
running: false
onExited: (exitCode, exitStatus) => {
if (this.queuedPlayer)
Qt.callLater(handleQueue);
else
root.isFetchingLyrics = false;
}
stdout: StdioCollector {
onStreamFinished: {
root.parseLRC(this.text);
}
}
}
Timer {
id: updateInternalTimer
interval: 50
repeat: true
running: shouldRun && MediaService.isPlaying
onTriggered: {
root.internalPosition += this.interval;
}
}
lyrics: ListModel {
}
}