Skip to content

Commit 81a92d8

Browse files
committed
feat: add synchronized lyrics to lyrics-gtk
- Parse LRC time tags and global offsets - Handle multi-timestamp lines and chronological sorting - Add a timer to update and highlight lyrics during playback - Add configuration option for synchronization
1 parent 1976b6c commit 81a92d8

File tree

2 files changed

+210
-4
lines changed

2 files changed

+210
-4
lines changed

src/lyrics-common/preferences.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ static const PreferencesWidget widgets[] = {
7171
{{remote_sources}}),
7272
WidgetCheck (N_("Store fetched lyrics in local cache"),
7373
WidgetBool (CFG_SECTION, "enable-cache")),
74+
WidgetCheck(N_("Enable lyric synchronization"),
75+
WidgetBool("lyricwiki", "sync_lyrics")),
7476
WidgetLabel (N_("<b>Local Storage</b>")),
7577
WidgetCheck (N_("Load lyric files (.lrc) from local storage"),
7678
WidgetBool (CFG_SECTION, "enable-file-provider"))

src/lyrics-gtk/lyrics-gtk.cc

Lines changed: 208 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,21 @@
2929
#include "../lyrics-common/lyrics.h"
3030
#include "../lyrics-common/preferences.h"
3131

32+
#include <algorithm> // For std::sort
33+
#include <clocale>
34+
#include <regex> // For std::regex and std::smatch
35+
#include <sstream> // For std::istringstream
36+
#include <vector>
37+
38+
struct TimedLyricLine
39+
{
40+
int timestamp_ms; // Timestamp in milliseconds
41+
String text; // Lyric text at this timestamp
42+
};
43+
44+
std::vector<TimedLyricLine>
45+
timed_lyrics; // Stores parsed lyrics with timestamps
46+
3247
class LyricsGtk : public GeneralPlugin
3348
{
3449
public:
@@ -88,8 +103,100 @@ void update_lyrics_window (const char * title, const char * artist, const char *
88103
gtk_text_buffer_insert (textbuffer, & iter, "\n\n", -1);
89104
gtk_text_buffer_insert (textbuffer, & iter, lyrics, -1);
90105

91-
gtk_text_buffer_get_start_iter (textbuffer, & iter);
92-
gtk_text_view_scroll_to_iter (textview, & iter, 0, true, 0, 0);
106+
// Parse the lyrics and populate timed_lyrics
107+
timed_lyrics.clear ();
108+
109+
// Add a dummy timestamp for the first line (at -1ms to ensure it's always
110+
// before the actual lyrics)
111+
TimedLyricLine title_line;
112+
title_line.timestamp_ms = -1;
113+
title_line.text = String (title);
114+
timed_lyrics.push_back (title_line);
115+
116+
std::istringstream iss (lyrics);
117+
std::string line;
118+
119+
int global_offset = 0;
120+
std::regex time_re (R"(\[\s*(\d+)\s*:\s*(\d+(?:\.\d+)?)\s*\])");
121+
std::regex offset_re (R"(\[\s*offset\s*:\s*([+-]?\d+)\s*\])",
122+
std::regex_constants::icase);
123+
124+
while (std::getline (iss, line))
125+
{
126+
// Sanitize the line: remove leading/trailing spaces and carriage return
127+
line.erase (0, line.find_first_not_of (" \t\r")); // Remove leading whitespace and \r
128+
line.erase (line.find_last_not_of (" \t\r") + 1); // Remove trailing whitespace and \r
129+
130+
if (line.empty ())
131+
continue;
132+
133+
// 1. Check for the global offset tag
134+
std::smatch offset_match;
135+
if (std::regex_search (line, offset_match, offset_re))
136+
{
137+
global_offset = std::stoi (offset_match[1].str());
138+
continue;
139+
}
140+
141+
// 2. Extract MULTIPLE time tags from a single line
142+
std::sregex_iterator it (line.begin (), line.end (), time_re);
143+
std::sregex_iterator end;
144+
145+
std::vector <int> timestamps;
146+
size_t last_pos = 0;
147+
148+
for (; it != end; ++it)
149+
{
150+
int minutes = std::stoi (it->str (1));
151+
float seconds = std::stof (it->str (2));
152+
int timestamp_ms =
153+
static_cast <int> ((minutes * 60 + seconds) * 1000);
154+
155+
timestamps.push_back (timestamp_ms);
156+
last_pos = it->position () + it->length ();
157+
}
158+
159+
// 3. If timestamps were found, attach the remaining text to all of them
160+
if (!timestamps.empty ())
161+
{
162+
std::string text = line.substr (last_pos);
163+
text.erase (0, text.find_first_not_of (" \t")); // Trim leading spaces
164+
165+
for (int ts : timestamps)
166+
{
167+
TimedLyricLine timed_line;
168+
timed_line.timestamp_ms = ts;
169+
timed_line.text = String (text.c_str ());
170+
171+
timed_lyrics.push_back (timed_line);
172+
}
173+
}
174+
}
175+
176+
// 4. Apply offsets and sort chronologically (skipping the dummy title line
177+
// at index 0)
178+
if (timed_lyrics.size () > 1)
179+
{
180+
for (size_t i = 1; i < timed_lyrics.size (); ++i)
181+
{
182+
timed_lyrics[i].timestamp_ms -= global_offset;
183+
}
184+
185+
std::sort (timed_lyrics.begin () + 1, timed_lyrics.end (),
186+
[](const TimedLyricLine & a, const TimedLyricLine & b) {
187+
return a.timestamp_ms < b.timestamp_ms;
188+
});
189+
190+
// Ensure the dummy title line stays chronologically before the first
191+
// actual lyric
192+
if (timed_lyrics[1].timestamp_ms <= timed_lyrics[0].timestamp_ms)
193+
{
194+
timed_lyrics[0].timestamp_ms = timed_lyrics[1].timestamp_ms - 1000;
195+
}
196+
}
197+
198+
// gtk_text_buffer_get_start_iter (textbuffer, & iter);
199+
// gtk_text_view_scroll_to_iter (textview, & iter, 0, true, 0, 0);
93200
}
94201

95202
bool try_parse_json (const Index<char> & buf, const char * key, String & output)
@@ -224,7 +331,7 @@ static GtkWidget * build_widget ()
224331
gtk_box_pack_start ((GtkBox *) vbox, scrollview, true, true, 0);
225332

226333
gtk_widget_show_all (vbox);
227-
334+
gtk_text_buffer_create_tag (textbuffer, "highlight", "foreground", "yellow", nullptr);
228335
gtk_text_buffer_create_tag (textbuffer, "weight_bold", "weight", PANGO_WEIGHT_BOLD, nullptr);
229336
gtk_text_buffer_create_tag (textbuffer, "scale_large", "scale", PANGO_SCALE_LARGE, nullptr);
230337
gtk_text_buffer_create_tag (textbuffer, "style_italic", "style", PANGO_STYLE_ITALIC, nullptr);
@@ -235,6 +342,103 @@ static GtkWidget * build_widget ()
235342
return vbox;
236343
}
237344

345+
void highlight_lyrics (int current_time_ms)
346+
{
347+
if (!textbuffer)
348+
return;
349+
350+
// Check if lyrics synchronization is enabled
351+
if (!aud_get_bool ("lyricwiki", "sync_lyrics"))
352+
return;
353+
354+
// Clear the text buffer
355+
gtk_text_buffer_set_text (textbuffer, "", -1);
356+
357+
// Find the 4 lines closest to the current timestamp (current + 3 neighbors)
358+
std::vector <TimedLyricLine> lines_to_display;
359+
360+
// Store up to 4 lines starting from the current one
361+
for (size_t i = 0; i < timed_lyrics.size (); ++i)
362+
{
363+
364+
if (timed_lyrics[i].timestamp_ms >= current_time_ms)
365+
{
366+
// Ensure we have the current line and its neighbors (3 more lines)
367+
size_t start_index = (i > 1) ? i - 2 : 0; // Start 2 lines before the current line
368+
size_t end_index = std::min (i + 2, timed_lyrics.size () - 1); // Limit to 3 more lines after
369+
370+
// Ensure no more than 4 lines are selected
371+
size_t line_count = 0;
372+
373+
for (size_t j = start_index; j <= end_index && line_count < 4; ++j)
374+
{
375+
lines_to_display.push_back (timed_lyrics[j]);
376+
line_count++;
377+
}
378+
break;
379+
}
380+
}
381+
382+
// Retrieve the tag table
383+
GtkTextTagTable * tag_table = gtk_text_buffer_get_tag_table (textbuffer);
384+
385+
// Check if the enlarge tag exists, and create it if not
386+
GtkTextTag * enlarge_tag =
387+
gtk_text_tag_table_lookup (tag_table, "enlarge_tag");
388+
if (!enlarge_tag)
389+
{
390+
enlarge_tag = gtk_text_tag_new ("enlarge_tag");
391+
g_object_set (enlarge_tag, "scale", 1.5, NULL); // Enlarge by 1.5 times (adjust as needed)
392+
gtk_text_tag_table_add (tag_table, enlarge_tag);
393+
}
394+
395+
// Insert the selected lines into the text buffer
396+
GtkTextIter iter;
397+
gtk_text_buffer_get_start_iter (textbuffer, &iter);
398+
399+
for (size_t i = 0; i < lines_to_display.size (); ++i)
400+
{
401+
const TimedLyricLine & line = lines_to_display[i];
402+
std::string text_with_newline = std::string (line.text);
403+
404+
// Special handling for the title line (dummy timestamp)
405+
if (line.timestamp_ms < 0 && i == 0 &&
406+
text_with_newline ==
407+
static_cast <const char *> (timed_lyrics[0].text))
408+
{
409+
gtk_text_buffer_insert_with_tags_by_name (
410+
textbuffer, &iter, text_with_newline.c_str (), -1, "weight_bold",
411+
"scale_large", nullptr);
412+
}
413+
else if (i == 1 && line.timestamp_ms >= 0)
414+
{ // Current line (but not title)
415+
gtk_text_buffer_insert_with_tags_by_name (textbuffer, &iter,
416+
text_with_newline.c_str (),
417+
-1, "highlight", nullptr);
418+
}
419+
else
420+
{
421+
gtk_text_buffer_insert (textbuffer, &iter, text_with_newline.c_str (), -1);
422+
}
423+
424+
gtk_text_buffer_insert (textbuffer, &iter, "\n", -1);
425+
}
426+
427+
// After inserting lines, force scroll to the last line
428+
GtkTextIter end_iter;
429+
gtk_text_buffer_get_end_iter (textbuffer, &end_iter);
430+
gtk_text_view_scroll_to_iter (textview, &end_iter, 0, TRUE, 0, 0);
431+
}
432+
433+
gboolean update_lyrics_display (gpointer data)
434+
{
435+
int current_time_ms =
436+
aud_drct_get_time (); // Get current time from player in ms
437+
highlight_lyrics (current_time_ms);
438+
439+
return G_SOURCE_CONTINUE; // Continue calling this function
440+
}
441+
238442
void * LyricsGtk::get_gtk_widget ()
239443
{
240444
GtkWidget * vbox = build_widget ();
@@ -246,6 +450,6 @@ void * LyricsGtk::get_gtk_widget ()
246450
lyrics_playback_began ();
247451

248452
g_signal_connect (vbox, "destroy", (GCallback) destroy_cb, nullptr);
249-
453+
g_timeout_add (100, update_lyrics_display, nullptr);
250454
return vbox;
251455
}

0 commit comments

Comments
 (0)