parole-tui 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.3
2
+ Name: parole-tui
3
+ Version: 1.0.0
4
+ Summary: Parole is a TUI tool to display lyrics from the song you are currently playing !
5
+ Author: skueee
6
+ Author-email: skueee <skueee@outlook.com>
7
+ Requires-Dist: dbus-next>=0.2.3 ; sys_platform == 'linux'
8
+ Requires-Dist: httpx>=0.28.1
9
+ Requires-Dist: textual>=8.2.8
10
+ Requires-Dist: winsdk>=1.0.0b10 ; sys_platform == 'win32'
11
+ Requires-Python: >=3.14
12
+ Description-Content-Type: text/markdown
13
+
14
+ # parole
15
+ Parole is a TUI tool to display lyrics from the song you are currently playing !
16
+
17
+ ![GitHub commit activity](https://img.shields.io/github/commit-activity/t/skueee/parole?label=total%20commits)
18
+ ![GitHub Issues](https://img.shields.io/github/issues/skueee/parole)
19
+ ![GitHub Repo stars](https://img.shields.io/github/stars/skueee/parole?style=flat&color=yellow)
20
+
21
+ > [!NOTE]
22
+ > While this tool is meant to be compatible with Windows, it could be more unstable due to less testing.
23
+
24
+ [![asciicast](https://asciinema.org/a/1267969.png)](https://asciinema.org/a/1267969)
25
+
26
+ ## Quick start
27
+ **1. Dependencies**
28
+
29
+ You will need pipx or pip to run this program
30
+
31
+ **2. Download the program**
32
+ ```
33
+ pipx install parole-lyrics
34
+ ```
35
+ or if you don't have pipx
36
+ ```
37
+ pip install parole-lyrics
38
+ ```
39
+ **3. Launch the program**
40
+ ```
41
+ parole
42
+ ```
43
+
44
+ ## Usage
45
+ Here is a list of the arguments that it can take :
46
+ ```
47
+ -h, --help Show the available arguments
48
+ --delay DELAY Delay at which the program will update lyrics
49
+ --no-song-infos Don't show song infos at the start of the song
50
+ --no-ansi Don't use terminal colors
51
+ --line-count LINE_COUNT Number of lyrics shown before and after the current one
52
+ ```
53
+
54
+ ## Features
55
+ - Show lyrics in terminal
56
+ - Multiplatform - Uses MPRIS or winsdk depending on your OS
57
+ - Some customization options
58
+ - Sync lyrics with music
59
+
60
+ ## Development
61
+ You can see infos on how to contribute and develop on the CONTRIBUTING.md file
62
+
63
+ ## How does that works ?
64
+ The program takes the current song using two ways, depending on the OS. If it runs on Linux, it will create a LinuxMediaProvider which uses MPRIS (through dbus-next) to get infos, while on Windows it uses a WindowsMediaProvider that uses winsdk to get infos. If the song changed, it sends a request to LRCLib to get the lyrics and display them !
65
+
66
+ ## Stardance
67
+ This tool was made for Stardance, a program ran by [Hack Club](https://hackclub.com/)
68
+
69
+ [My project page](https://stardance.hackclub.com/projects/63249) | [Hack Club](https://hackclub.com/) | [Stardance Program](https://stardance.hackclub.com/)
70
+
71
+ ## AI Notice
72
+
73
+ AI was used to do research in this project, but the code was always rewritten and edited by an human.
74
+
75
+ ## Credit
76
+
77
+ ### Libraries
78
+ - **dbus-next, to interact with MPRIS on Linux :** [pypi.org/project/dbus-next](https://pypi.org/project/dbus-next/)
79
+ - **Winsdk, to interact with Windows :** [pypi.org/project/winsdk](https://pypi.org/project/winsdk/)
80
+ - **Textual, to display the TUI :** [textual.textualize.io](https://textual.textualize.io/)
81
+
82
+ ### API
83
+ - **LRCLib, to get the lyrics :** [lrclib.net](https://lrclib.net/)
84
+
85
+ ### Project management
86
+ - **UV, to manage the project :** [docs.astral.sh/uv](https://docs.astral.sh/uv/)
87
+ - **uv-script, to create custom scripts (lint, format...) :** [pypi.org/project/uv-script](https://pypi.org/project/uv-script/)
88
+ - **Ruff, to lint and format :** [docs.astral.sh/ruff](https://docs.astral.sh/ruff/)
89
+ - **Deptry, to check dependencies problems :** [deptry.com](https://deptry.com/)
@@ -0,0 +1,76 @@
1
+ # parole
2
+ Parole is a TUI tool to display lyrics from the song you are currently playing !
3
+
4
+ ![GitHub commit activity](https://img.shields.io/github/commit-activity/t/skueee/parole?label=total%20commits)
5
+ ![GitHub Issues](https://img.shields.io/github/issues/skueee/parole)
6
+ ![GitHub Repo stars](https://img.shields.io/github/stars/skueee/parole?style=flat&color=yellow)
7
+
8
+ > [!NOTE]
9
+ > While this tool is meant to be compatible with Windows, it could be more unstable due to less testing.
10
+
11
+ [![asciicast](https://asciinema.org/a/1267969.png)](https://asciinema.org/a/1267969)
12
+
13
+ ## Quick start
14
+ **1. Dependencies**
15
+
16
+ You will need pipx or pip to run this program
17
+
18
+ **2. Download the program**
19
+ ```
20
+ pipx install parole-lyrics
21
+ ```
22
+ or if you don't have pipx
23
+ ```
24
+ pip install parole-lyrics
25
+ ```
26
+ **3. Launch the program**
27
+ ```
28
+ parole
29
+ ```
30
+
31
+ ## Usage
32
+ Here is a list of the arguments that it can take :
33
+ ```
34
+ -h, --help Show the available arguments
35
+ --delay DELAY Delay at which the program will update lyrics
36
+ --no-song-infos Don't show song infos at the start of the song
37
+ --no-ansi Don't use terminal colors
38
+ --line-count LINE_COUNT Number of lyrics shown before and after the current one
39
+ ```
40
+
41
+ ## Features
42
+ - Show lyrics in terminal
43
+ - Multiplatform - Uses MPRIS or winsdk depending on your OS
44
+ - Some customization options
45
+ - Sync lyrics with music
46
+
47
+ ## Development
48
+ You can see infos on how to contribute and develop on the CONTRIBUTING.md file
49
+
50
+ ## How does that works ?
51
+ The program takes the current song using two ways, depending on the OS. If it runs on Linux, it will create a LinuxMediaProvider which uses MPRIS (through dbus-next) to get infos, while on Windows it uses a WindowsMediaProvider that uses winsdk to get infos. If the song changed, it sends a request to LRCLib to get the lyrics and display them !
52
+
53
+ ## Stardance
54
+ This tool was made for Stardance, a program ran by [Hack Club](https://hackclub.com/)
55
+
56
+ [My project page](https://stardance.hackclub.com/projects/63249) | [Hack Club](https://hackclub.com/) | [Stardance Program](https://stardance.hackclub.com/)
57
+
58
+ ## AI Notice
59
+
60
+ AI was used to do research in this project, but the code was always rewritten and edited by an human.
61
+
62
+ ## Credit
63
+
64
+ ### Libraries
65
+ - **dbus-next, to interact with MPRIS on Linux :** [pypi.org/project/dbus-next](https://pypi.org/project/dbus-next/)
66
+ - **Winsdk, to interact with Windows :** [pypi.org/project/winsdk](https://pypi.org/project/winsdk/)
67
+ - **Textual, to display the TUI :** [textual.textualize.io](https://textual.textualize.io/)
68
+
69
+ ### API
70
+ - **LRCLib, to get the lyrics :** [lrclib.net](https://lrclib.net/)
71
+
72
+ ### Project management
73
+ - **UV, to manage the project :** [docs.astral.sh/uv](https://docs.astral.sh/uv/)
74
+ - **uv-script, to create custom scripts (lint, format...) :** [pypi.org/project/uv-script](https://pypi.org/project/uv-script/)
75
+ - **Ruff, to lint and format :** [docs.astral.sh/ruff](https://docs.astral.sh/ruff/)
76
+ - **Deptry, to check dependencies problems :** [deptry.com](https://deptry.com/)
@@ -0,0 +1,43 @@
1
+ [project]
2
+ name = "parole-tui"
3
+ version = "1.0.0"
4
+ description = "Parole is a TUI tool to display lyrics from the song you are currently playing !"
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ dependencies = [
8
+ "dbus-next>=0.2.3 ; sys_platform == 'linux'",
9
+ "httpx>=0.28.1",
10
+ "textual>=8.2.8",
11
+ "winsdk>=1.0.0b10 ; sys_platform == 'win32'",
12
+ ]
13
+
14
+ [[project.authors]]
15
+ name = "skueee"
16
+ email = "skueee@outlook.com"
17
+
18
+ [project.scripts]
19
+ parole = "parole:main"
20
+
21
+ [tool.uvs.scripts]
22
+ format = "uv run ruff format"
23
+ lint = "uv run ruff check"
24
+ fix = "uv run ruff check --fix"
25
+ deps_check = "deptry ."
26
+ checks = [
27
+ "fix",
28
+ "format",
29
+ "lint",
30
+ "deps_check",
31
+ ]
32
+
33
+ [build-system]
34
+ requires = ["uv_build>=0.12.15,<0.13.0"]
35
+ build-backend = "uv_build"
36
+
37
+ [dependency-groups]
38
+ dev = [
39
+ "deptry>=0.25.1",
40
+ "ruff>=0.16.8",
41
+ "textual-dev>=1.8.0",
42
+ "uv-script>=0.1.9",
43
+ ]
@@ -0,0 +1,37 @@
1
+ [project]
2
+ name = "parole-tui"
3
+ version = "1.0.0"
4
+ description = "Parole is a TUI tool to display lyrics from the song you are currently playing !"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "skueee", email = "skueee@outlook.com" }
8
+ ]
9
+ requires-python = ">=3.14"
10
+ dependencies = [
11
+ "dbus-next>=0.2.3 ; sys_platform == 'linux'",
12
+ "httpx>=0.28.1",
13
+ "textual>=8.2.8",
14
+ "winsdk>=1.0.0b10 ; sys_platform == 'win32'",
15
+ ]
16
+
17
+ [project.scripts]
18
+ parole = "parole:main"
19
+
20
+ [tool.uvs.scripts]
21
+ format = "uv run ruff format"
22
+ lint = "uv run ruff check"
23
+ fix = "uv run ruff check --fix"
24
+ deps_check = "deptry ."
25
+ checks = ["fix", "format", "lint", "deps_check"]
26
+
27
+ [build-system]
28
+ requires = ["uv_build>=0.12.15,<0.13.0"]
29
+ build-backend = "uv_build"
30
+
31
+ [dependency-groups]
32
+ dev = [
33
+ "deptry>=0.25.1",
34
+ "ruff>=0.16.8",
35
+ "textual-dev>=1.8.0",
36
+ "uv-script>=0.1.9",
37
+ ]
@@ -0,0 +1,4 @@
1
+ from . import lrclib, monitor, parole
2
+ from .parole import main
3
+
4
+ __all__ = ["lrclib", "main", "monitor", "parole"]
@@ -0,0 +1,34 @@
1
+ import asyncio
2
+ import time
3
+
4
+ import httpx
5
+
6
+ rate_limit_timeout: int = 0
7
+ endpoint = "https://lrclib.net"
8
+
9
+
10
+ async def get(url: str, params: httpx.ParamsType = None) -> httpx.Response:
11
+ global rate_limit_timeout
12
+
13
+ if int(time.time()) < rate_limit_timeout:
14
+ await asyncio.sleep(int(time.time()) - rate_limit_timeout)
15
+
16
+ async with httpx.AsyncClient() as client:
17
+ r = await client.get(url, params=params)
18
+
19
+ # Handle rate limit
20
+ if r.status_code == 429:
21
+ rate_limit_timeout = int(time.time()) + int(r.headers["Retry-After"])
22
+ r = await get(url, params)
23
+
24
+ return r
25
+
26
+
27
+ async def get_lyrics(artist: str, title: str, album: str | None = None):
28
+ if album != None:
29
+ parameters = {"track_name": title, "artist_name": artist, "album_name": album}
30
+ else:
31
+ parameters = {"track_name": title, "artist_name": artist}
32
+
33
+ response = await get(endpoint + "/api/get", params=parameters)
34
+ return response.json()
@@ -0,0 +1,221 @@
1
+ import abc
2
+ import sys
3
+ from dataclasses import dataclass
4
+ from datetime import UTC, datetime
5
+
6
+ if sys.platform == "linux":
7
+ from dbus_next.aio import MessageBus
8
+ elif sys.platform == "win32":
9
+ from winsdk.windows.media.control import (
10
+ GlobalSystemMediaTransportControlsSessionManager as MediaManager,
11
+ )
12
+ from winsdk.windows.media.control import (
13
+ GlobalSystemMediaTransportControlsSessionPlaybackStatus as PlaybackStatus,
14
+ )
15
+ else:
16
+ MessageBus = None
17
+
18
+
19
+ class NoMPRISException(Exception):
20
+ def __init__(self):
21
+ super().__init__(
22
+ "MPRIS was not found on your system\nIf you are not on Linux, this error is intended"
23
+ )
24
+
25
+
26
+ class MPRISNotConnectedException(Exception):
27
+ def __init__(self):
28
+ super().__init__("The MPRIS client is not connected")
29
+
30
+
31
+ @dataclass
32
+ class TrackMetadata:
33
+ active: bool
34
+ title: str | None = None
35
+ album: str | None = None
36
+ artist: str | None = None
37
+
38
+
39
+ class BaseMediaProvider(abc.ABC):
40
+ @abc.abstractmethod
41
+ async def init(self):
42
+ """Initialize the provider"""
43
+
44
+ async def get_metadata(self) -> TrackMetadata | None:
45
+ """Fetch current song details."""
46
+
47
+ async def get_position(self) -> float | None:
48
+ """Get current position"""
49
+
50
+
51
+ class LinuxMediaProvider(BaseMediaProvider):
52
+ def __init__(self):
53
+ self.bus = None
54
+
55
+ async def init(self):
56
+ if not self.bus:
57
+ return await self.connect()
58
+
59
+ async def connect(self):
60
+ if not MessageBus:
61
+ raise NoMPRISException
62
+ try:
63
+ # Connect to the Linux Session D-Bus
64
+ self.bus = await MessageBus().connect()
65
+ return True
66
+ except Exception: # noqa: BLE001
67
+ raise NoMPRISException
68
+
69
+ async def get_active_player_name(self) -> str | None:
70
+ if not self.bus:
71
+ raise MPRISNotConnectedException
72
+
73
+ introspection = await self.bus.introspect(
74
+ "org.freedesktop.DBus", "/org/freedesktop/DBus"
75
+ )
76
+
77
+ dbus_obj = self.bus.get_proxy_object(
78
+ "org.freedesktop.DBus", "/org/freedesktop/DBus", introspection
79
+ )
80
+ dbus_iface = dbus_obj.get_interface("org.freedesktop.DBus")
81
+ names = await dbus_iface.call_list_names()
82
+
83
+ for name in names:
84
+ if name.startswith("org.mpris.MediaPlayer2."):
85
+ return name
86
+ return None
87
+
88
+ async def get_metadata(self) -> TrackMetadata:
89
+ player_name = await self.get_active_player_name()
90
+ if not player_name:
91
+ return TrackMetadata(active=False)
92
+
93
+ introspection = await self.bus.introspect(
94
+ player_name, "/org/mpris/MediaPlayer2"
95
+ )
96
+ obj = self.bus.get_proxy_object(
97
+ player_name, "/org/mpris/MediaPlayer2", introspection
98
+ )
99
+
100
+ props = obj.get_interface("org.freedesktop.DBus.Properties")
101
+
102
+ metadata_variant = await props.call_get(
103
+ "org.mpris.MediaPlayer2.Player", "Metadata"
104
+ )
105
+
106
+ metadata = metadata_variant.value
107
+
108
+ artist_var = metadata.get("xesam:artist", [None])
109
+ title_var = metadata.get("xesam:title", None)
110
+ album_var = metadata.get("xesam:album", None)
111
+
112
+ title = title_var.value if title_var else None
113
+ album = album_var.value if album_var else None
114
+ artist = None
115
+ try:
116
+ artist = artist_var.value[0]
117
+ except AttributeError:
118
+ artist = artist_var[0]
119
+
120
+ return TrackMetadata(
121
+ active=True,
122
+ title=title,
123
+ album=album,
124
+ artist=artist,
125
+ )
126
+
127
+ async def get_position(self) -> float | None:
128
+ player_name = await self.get_active_player_name()
129
+ if not player_name:
130
+ return None
131
+
132
+ introspection = await self.bus.introspect(
133
+ player_name, "/org/mpris/MediaPlayer2"
134
+ )
135
+ obj = self.bus.get_proxy_object(
136
+ player_name, "/org/mpris/MediaPlayer2", introspection
137
+ )
138
+ props = obj.get_interface("org.freedesktop.DBus.Properties")
139
+ position_variant = await props.call_get(
140
+ "org.mpris.MediaPlayer2.Player", "Position"
141
+ )
142
+
143
+ position = getattr(position_variant, "value", position_variant)
144
+ return position / 1_000_000.0
145
+
146
+
147
+ class WindowsMediaProvider(BaseMediaProvider):
148
+ def __init__(self):
149
+ self.manager = None
150
+
151
+ async def init(self):
152
+ if not self.manager:
153
+ await self.connect()
154
+
155
+ async def connect(self):
156
+ try:
157
+ self.manager = await MediaManager.request_async()
158
+ except OSError:
159
+ self.manager = None
160
+
161
+ async def get_metadata(self) -> TrackMetadata | None:
162
+ session = self.manager.get_current_session()
163
+ if not session:
164
+ return TrackMetadata(active=False)
165
+
166
+ try:
167
+ info = await session.try_get_media_properties_async()
168
+ except PermissionError:
169
+ return TrackMetadata(active=False)
170
+
171
+ return TrackMetadata(
172
+ active=True,
173
+ title=info.title,
174
+ album=info.album_title,
175
+ artist=info.artist,
176
+ )
177
+
178
+ async def get_position(self) -> float | None:
179
+ session = self.manager.get_current_session()
180
+ if not session:
181
+ return None
182
+
183
+ timeline = session.get_timeline_properties()
184
+ playback_info = session.get_playback_info()
185
+
186
+ base_position = timeline.position.total_seconds()
187
+
188
+ if playback_info.playback_status != PlaybackStatus.PLAYING:
189
+ return base_position
190
+
191
+ last_updated = timeline.last_updated_time
192
+ now = datetime.now(UTC)
193
+ elapsed = (now - last_updated).total_seconds()
194
+
195
+ rate = getattr(playback_info, "playback_rate", None)
196
+ if rate is None:
197
+ rate = 1.0
198
+ current_position = base_position + (elapsed * rate)
199
+
200
+ end_time = timeline.end_time.total_seconds()
201
+ return min(current_position, end_time)
202
+
203
+
204
+ class PlatformNotSupportedError(Exception):
205
+ def __init__(self, platform: str) -> None:
206
+ super().__init__(f"Platform not supported: {platform}")
207
+
208
+
209
+ _cached_provider: BaseMediaProvider | None = None
210
+
211
+
212
+ def get_provider() -> BaseMediaProvider:
213
+ global _cached_provider
214
+ if _cached_provider is None:
215
+ if sys.platform == "linux":
216
+ _cached_provider = LinuxMediaProvider()
217
+ elif sys.platform == "win32":
218
+ _cached_provider = WindowsMediaProvider()
219
+ else:
220
+ raise PlatformNotSupportedError(sys.platform)
221
+ return _cached_provider
@@ -0,0 +1,240 @@
1
+ import argparse
2
+ import bisect
3
+ import random
4
+ import re
5
+ from array import array
6
+ from typing import ClassVar
7
+
8
+ from textual.app import App, Binding, ComposeResult
9
+ from textual.widgets import Label
10
+
11
+ from . import lrclib, monitor
12
+
13
+ current_lyrics: array[str] | None
14
+ current_timestamps: array[float] | None
15
+ current_infos: array | None
16
+ trackid: str | None = None
17
+ active: bool
18
+
19
+
20
+ async def get_lyrics(title, artist, album: str | None = None):
21
+ lyrics = await lrclib.get_lyrics(title=title, artist=artist, album=album)
22
+ if "syncedLyrics" in lyrics:
23
+ return {"lyrics": lyrics["syncedLyrics"]}
24
+ else:
25
+ return None
26
+
27
+
28
+ async def get_metadata():
29
+ provider = monitor.get_provider()
30
+ await provider.init()
31
+ metadata = await provider.get_metadata()
32
+ if metadata.artist == None and metadata.title == None and metadata.album == None:
33
+ run = False
34
+ else:
35
+ run = True
36
+ return [run, metadata.artist, metadata.title, metadata.album]
37
+
38
+
39
+ async def get_position():
40
+ provider = monitor.get_provider()
41
+ await provider.init()
42
+ position = await provider.get_position()
43
+ return position
44
+
45
+
46
+ async def check_if_new_song():
47
+ global trackid, active, current_lyrics, current_timestamps, current_infos
48
+ trackid = None if not isinstance(trackid, str) else trackid
49
+ metadata = await get_metadata()
50
+ if trackid == None or trackid != f"{metadata[1]}__{metadata[2]}":
51
+ if metadata[0] == True:
52
+ trackid = f"{metadata[1]}__{metadata[2]}"
53
+ lyrics = await get_lyrics(metadata[2], metadata[1])
54
+ current_lyrics = []
55
+ current_timestamps = []
56
+
57
+ TIMESTAMP_REGEX = re.compile(r"\[(\d{1,2}):(\d{2})(?:\.(\d{2,3}))?\]")
58
+ try:
59
+ for i in lyrics["lyrics"].splitlines():
60
+ match = TIMESTAMP_REGEX.search(i)
61
+
62
+ if not match:
63
+ current_lyrics = None
64
+ break
65
+
66
+ # Transform the timestamp into a position in seconds (because playerctl only outputs the position in seconds)
67
+ seconds = (
68
+ int(match.group(1)) * 60
69
+ + int(match.group(2))
70
+ + int(match.group(3) or 0) / 100
71
+ )
72
+ current_timestamps.append(seconds)
73
+
74
+ # Check if the lyric is not empty and then append it
75
+ lyric = TIMESTAMP_REGEX.sub("", i).strip()
76
+ if not lyric:
77
+ lyric = random.choice(["♫", "♪"])
78
+
79
+ current_lyrics.append(lyric)
80
+ except AttributeError, TypeError:
81
+ current_lyrics = None
82
+
83
+ active = True
84
+ current_infos = [metadata[1], metadata[2]]
85
+
86
+ else:
87
+ current_timestamps = None
88
+ current_infos = None
89
+ trackid = None
90
+ current_lyrics = None
91
+ active = False
92
+
93
+
94
+ async def get_current_lyrics(show_infos: bool, line_count: int):
95
+ if active:
96
+ if current_lyrics == None:
97
+ return ["Can't find lyrics for this song"]
98
+ pos = float(await get_position())
99
+
100
+ index = bisect.bisect_right(current_timestamps, pos) - 1
101
+
102
+ prev_lines = []
103
+ for offset in range(line_count, 0, -1):
104
+ target_idx = index - offset
105
+ prev_lines.append(
106
+ current_lyrics[target_idx]
107
+ if 0 <= target_idx < len(current_lyrics)
108
+ else ""
109
+ )
110
+
111
+ curr_line = (
112
+ current_lyrics[index]
113
+ if 0 <= index < len(current_lyrics)
114
+ else (f"{current_infos[0]} - {current_infos[1]}" if show_infos else "")
115
+ )
116
+
117
+ next_lines = []
118
+ for offset in range(1, line_count + 1):
119
+ target_idx = index + offset
120
+ next_lines.append(
121
+ current_lyrics[target_idx]
122
+ if 0 <= target_idx < len(current_lyrics)
123
+ else ""
124
+ )
125
+
126
+ return [prev_lines, curr_line, next_lines]
127
+ else:
128
+ return ["No song is playing"]
129
+
130
+
131
+ async def get_thing_to_display(show_infos: bool, lines_count: int):
132
+ await check_if_new_song()
133
+ return await get_current_lyrics(show_infos, lines_count)
134
+
135
+
136
+ class ParoleApp(App):
137
+ BINDINGS: ClassVar[list[Binding]] = [
138
+ Binding("ctrl+c", "quit", "Quit", show=True, priority=True),
139
+ ]
140
+ CSS_PATH = "textual.tcss"
141
+
142
+ def __init__(self, delay_ms: float, show_infos: bool, ansi: bool, line_count: int):
143
+ super().__init__()
144
+ self.delay_seconds = delay_ms / 1000.0
145
+ self.show_infos = show_infos
146
+ self.ansi_color = ansi
147
+ self.line_count = line_count
148
+
149
+ def compose(self) -> ComposeResult:
150
+ if self.line_count > 0:
151
+ for i in range(self.line_count):
152
+ yield Label(
153
+ " ", id=f"before-label-{i}", classes="secundary-label before-label"
154
+ )
155
+ yield Label("Loading...", id="main-label")
156
+ if self.line_count > 0:
157
+ for i in range(self.line_count):
158
+ yield Label(
159
+ " ", id=f"after-label-{i}", classes="secundary-label after-label"
160
+ )
161
+
162
+ def on_mount(self) -> ComposeResult:
163
+ self.set_interval(self.delay_seconds, self.update_label)
164
+
165
+ async def update_label(self) -> None:
166
+ main_label = self.query_one("#main-label", Label)
167
+ before_labels = self.query(".before-label")
168
+ after_labels = self.query(".after-label")
169
+
170
+ lyrics = await get_thing_to_display(self.show_infos, self.line_count)
171
+ try:
172
+ before_lines, current_line, after_lines = lyrics
173
+ except ValueError:
174
+ current_line = lyrics[0]
175
+ before_lines = []
176
+ after_lines = []
177
+
178
+ main_label.update(current_line)
179
+
180
+ for label, text in zip(before_labels, before_lines):
181
+ label.display = True
182
+ label.update(text)
183
+
184
+ for label, text in zip(after_labels, after_lines):
185
+ label.display = True
186
+ label.update(text)
187
+
188
+ if len(before_lines) == 0:
189
+ for label in before_labels:
190
+ label.display = False
191
+
192
+ if len(after_lines) == 0:
193
+ for label in after_labels:
194
+ label.display = False
195
+
196
+
197
+ def main():
198
+ parser = argparse.ArgumentParser(
199
+ description="Parole is a TUI tool to display lyrics from the song you are currently playing"
200
+ )
201
+ parser.add_argument(
202
+ "--delay",
203
+ type=float,
204
+ default=100,
205
+ help="Delay at which the program will update lyrics",
206
+ )
207
+ parser.add_argument(
208
+ "--no-song-infos",
209
+ dest="show_infos",
210
+ action="store_false",
211
+ default=True,
212
+ help="Don't show song infos at the start of the song",
213
+ )
214
+ parser.add_argument(
215
+ "--no-ansi",
216
+ dest="ansi",
217
+ action="store_false",
218
+ default=True,
219
+ help="Don't use terminal colors",
220
+ )
221
+ parser.add_argument(
222
+ "--line-count",
223
+ dest="line_count",
224
+ default=1,
225
+ type=int,
226
+ help="Number of lyrics shown before and after the current one",
227
+ )
228
+
229
+ args = parser.parse_args()
230
+ app = ParoleApp(
231
+ delay_ms=args.delay,
232
+ show_infos=args.show_infos,
233
+ ansi=args.ansi,
234
+ line_count=args.line_count,
235
+ )
236
+ app.run()
237
+
238
+
239
+ if __name__ == "__main__":
240
+ main()
@@ -0,0 +1,17 @@
1
+ Screen {
2
+ align: center middle;
3
+ }
4
+
5
+ #main-label {
6
+ text-style: bold;
7
+ text-align: center;
8
+ content-align: center middle;
9
+ width: 100%;
10
+ }
11
+
12
+ .secundary-label {
13
+ opacity: 50%;
14
+ text-align: center;
15
+ content-align: center middle;
16
+ width: 100%;
17
+ }