diff --git a/lyrics/player.py b/lyrics/player.py index 99fba96..3c474fb 100644 --- a/lyrics/player.py +++ b/lyrics/player.py @@ -185,6 +185,22 @@ def update(self): return False + def get_time_pos(self): + """Get current playback position in seconds.""" + try: + if not self.running or not self.player_interface: + return 0 + + # Get position in microseconds + position = self.player_interface.Get( + 'org.mpris.MediaPlayer2.Player', 'Position') + + # Convert to seconds + return float(position) / 1000000 + + except Exception as e: + return 0 + def refresh(self, cycle_source=False, source=None, cache=True): ''' Re-fetches lyrics from procided source source -> source name ('google' or 'azlyrics') diff --git a/lyrics/track.py b/lyrics/track.py index 7064200..43c0c82 100644 --- a/lyrics/track.py +++ b/lyrics/track.py @@ -16,6 +16,7 @@ def __init__(self, self.width = width self.length = 0 self.lyrics = [] + self.timestamps = [] # List of timestamps corresponding to lyrics lines self.source = None self.album = None self.trackid = None @@ -73,13 +74,52 @@ def get_lyrics(self, source, cycle_source=False, cache=True): else: source = 'any' - self.lyrics, self.source = util.get_lyrics(self.track_name, source, cache=cache) - self.width = len(max(self.lyrics, key=len)) + result = util.get_lyrics(self.track_name, source, cache=cache) + if len(result) == 3: # New format with timestamps + self.lyrics, self.timestamps, self.source = result + else: # Old format compatibility + self.lyrics, self.source = result + self.timestamps = None + + self.width = len(max(self.lyrics, key=len)) if self.lyrics else 0 + self.length = len(self.lyrics) + + def set_lyrics_with_timestamps(self, lyrics_list, timestamps_list): + """Set lyrics and their corresponding timestamps. + + Args: + lyrics_list: List of lyrics lines + timestamps_list: List of timestamps (in seconds) for each line + """ + self.lyrics = lyrics_list + self.timestamps = timestamps_list + self.length = len(self.lyrics) + if self.lyrics: + self.reset_width() + + def refresh_lyrics(self, source='any', cache=True, cycle_source=False): + ''' refresh lyrics from source + ''' + if cycle_source and self.source in self.sources: + i = self.sources.index(self.source) + 1 + source = self.sources[i % len(self.sources)] + + result = util.get_lyrics(self.track_name, source, cache=cache) + if len(result) == 3: # New format with timestamps + self.lyrics, self.timestamps, self.source = result + else: # Old format compatibility + self.lyrics, self.source = result + self.timestamps = None + + self.width = len(max(self.lyrics, key=len)) if self.lyrics else 0 self.length = len(self.lyrics) def get_text(self, wrap=False, width=0): ''' returns lyrics text seperated by '\\n' ''' + if not self.lyrics: + return '' + if wrap: lyrics=util.wrap_text(self.lyrics, width) else: diff --git a/lyrics/util.py b/lyrics/util.py index ce4f432..a223131 100644 --- a/lyrics/util.py +++ b/lyrics/util.py @@ -11,6 +11,8 @@ import tempfile import re import requests +from pathlib import Path +import sys url = 'https://www.google.com/search?q=' @@ -169,23 +171,123 @@ def parse_google(html: str) -> List[str] | None: return lyrics_lines -def get_filename(track_name): +def parse_lrc_line(line: str) -> List[Tuple[float, str]]: + """Parse a single LRC line and return list of (timestamp, lyrics) pairs. + + Args: + line: A line from LRC file, possibly with multiple timestamps + e.g. "[00:12.34][00:15.67]Lyrics text" + + Returns: + List of tuples (timestamp in seconds, lyrics text) + """ + if not line or not line.startswith('['): + return [] + + try: + # Find all timestamps in the line + timestamps = [] + lyrics_text = line + + while lyrics_text.startswith('['): + bracket_end = lyrics_text.find(']') + if bracket_end == -1: + break + + timestamp_str = lyrics_text[1:bracket_end] # "00:12.34" + + try: + if ':' in timestamp_str: + minutes, seconds = timestamp_str.split(':') + total_seconds = float(minutes) * 60 + float(seconds) + timestamps.append(total_seconds) + except ValueError: + pass # Skip invalid timestamps + + lyrics_text = lyrics_text[bracket_end + 1:] + + lyrics_text = lyrics_text.strip() + if not lyrics_text or not timestamps: + return [] + + # Return a pair for each timestamp with the same lyrics + return [(ts, lyrics_text) for ts in timestamps] + + except Exception as e: + return [] + + +def get_filename(track_name, lrc=False): '''returns name of cache file name from track name with correct format + + Args: + track_name: track name in format "artist - title" + lrc: if True, look for .lrc file instead of plain lyrics ''' + # Clean up leading/trailing spaces and hyphens + filename = track_name.strip(' -') + # removing text in brackets [] () - filename = re.sub(r'(\[.*\].*)|(\(.*\).*)', '', track_name).strip() + filename = re.sub(r'(\[.*\].*)|(\(.*\).*)', '', filename).strip() + + # Remove spaces and special characters filename = re.sub(r'\s|\/|\\|\.', '', filename) - return os.path.join(CACHE_PATH, filename) + # Add .lrc extension if needed + if lrc: + filename = filename + '.lrc' + + # Build full path + full_path = os.path.join(CACHE_PATH, filename) + return full_path -def get_lyrics(track_name: str, source: str = 'any', cache: bool = True) -> Tuple[List[str], str | None]: - ''' returns tuple of list of strings with lines of lyrics and found source +def get_lyrics(track_name: str, source: str = 'any', cache: bool = True) -> Tuple[List[str], List[float] | None, str | None]: + ''' returns tuple of list of strings with lines of lyrics, timestamps and found source also reads/write to cache file | if cache=True track_name -> track name in format "artist - title" source -> source to fetch lyrics from ('google', 'azlyrics', 'genius', 'any') cache -> bool | whether to check lyrics from cache or not. + + Returns: + Tuple of (lyrics_list, timestamps_list, source) + timestamps_list will be None for non-LRC sources ''' + + # Clean up track name for searching + search_name = track_name.strip(' -') + + # First check for .lrc file + lrc_path = get_filename(track_name, lrc=True) + + if os.path.isfile(lrc_path): + with open(lrc_path, 'r', encoding='utf-8') as file: + lrc_lines = file.read().splitlines() + + # Parse LRC lines with timestamps + timed_lyrics = [] + for line in lrc_lines: + results = parse_lrc_line(line) + timed_lyrics.extend(results) # Add all timestamp-lyric pairs + + if timed_lyrics: + # Sort by timestamp and remove duplicates + timed_lyrics.sort(key=lambda x: x[0]) + + # Keep track of unique lyrics while preserving order + seen_lyrics = {} # text -> timestamp + lyrics_list = [] + timestamps_list = [] + + for timestamp, text in timed_lyrics: + if text not in seen_lyrics: + seen_lyrics[text] = timestamp + lyrics_list.append(text) + timestamps_list.append(timestamp) + + return (lyrics_list, timestamps_list, 'lrc') + + # Check regular lyrics cache filepath = get_filename(track_name) if not os.path.isdir(CACHE_PATH): @@ -197,7 +299,7 @@ def get_lyrics(track_name: str, source: str = 'any', cache: bool = True) -> Tupl # cache lyrics exist with open(filepath) as file: lyrics_lines = file.read().splitlines() - return lyrics_lines, 'cache' + return (lyrics_lines, None, 'cache') search_url = url + query(track_name) html = get_html(search_url) diff --git a/lyrics/window.py b/lyrics/window.py index d4d7d18..1c79da6 100644 --- a/lyrics/window.py +++ b/lyrics/window.py @@ -6,7 +6,8 @@ from lyrics import __version__ import curses - +from lyrics import util +import time class Key: def __init__(self): @@ -143,8 +144,10 @@ def __init__(self, stdscr, player, timeout): self.text_padding = 5 self.keys = Key() self.find_position = 0 - self.timeout = timeout - + self.timeout = 100 # Fixed timeout for smoother updates + self.last_highlight = -1 # Track last highlighted line + self.last_update = 0 # Track last display update time + curses.use_default_colors() self.stdscr.timeout(self.timeout) self.set_up() @@ -349,20 +352,66 @@ def update_track(self, show_source=False): else: text = self.player.track.get_text() - pad_height = max(self.height, self.player.track.length) + 2 - pad_width = max(self.width, self.player.track.width) + 2 - - self.scroll_pad.resize(pad_height, pad_width) - self.scroll_pad.addstr(text) - self.set_offset() - + if not text: + return + + lines = text.split('\n') + if not lines: + return + + # Align all lines according to current alignment + aligned_lines = util.align(lines, self.width - 2, self.player.track.alignment) + + # Adjust pad size if needed + pad_height = max(self.height, len(aligned_lines)) + 2 + pad_width = max(self.width, max(len(line) for line in aligned_lines)) + 2 + try: + self.scroll_pad.resize(pad_height, pad_width) + except curses.error: + pass + + # Get current playback position + try: + current_time = float(self.player.get_time_pos()) + except (ValueError, TypeError): + current_time = 0 + + # Update display with highlighting + for i, line in enumerate(aligned_lines): + attrs = curses.A_NORMAL + + # Handle LRC highlighting + if self.player.track.timestamps and self.player.track.source == 'lrc': + current_line = -1 + for j, ts in enumerate(self.player.track.timestamps): + if ts <= current_time: + current_line = j + else: + break + + if i == current_line: + attrs = curses.A_REVERSE + if self.last_highlight != i: + self.current_pos = max(0, i - 2) + self.last_highlight = i + + # Add line to pad + try: + self.scroll_pad.addstr(i, self.pad_offset, line, attrs) + except curses.error: + pass + + # Show source if requested if show_source: self.stdscr.addstr(self.height - 1, 1, f" Source: {self.player.track.source}", curses.A_REVERSE) - + self.set_titlebar() + self.set_statusbar() + self.set_offset() def main(self): key = '' - + last_lrc_update = time.time() + while key != self.keys.binds['quit']: key = self.stdscr.getch() @@ -372,6 +421,13 @@ def main(self): if self.player.update(): self.current_pos = 0 self.update_track() + # Update display every 100ms for LRC files + elif (self.player.running and + self.player.track.timestamps and + self.player.track.source == 'lrc' and + time.time() - last_lrc_update >= 0.1): + self.update_track() + last_lrc_update = time.time() if self.player.running: self.keys.input(self, key)