CLPYmusic 0.1.1__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.
- clpymusic-0.1.1/CLPYmusic.egg-info/PKG-INFO +14 -0
- clpymusic-0.1.1/CLPYmusic.egg-info/SOURCES.txt +25 -0
- clpymusic-0.1.1/CLPYmusic.egg-info/dependency_links.txt +1 -0
- clpymusic-0.1.1/CLPYmusic.egg-info/entry_points.txt +2 -0
- clpymusic-0.1.1/CLPYmusic.egg-info/requires.txt +9 -0
- clpymusic-0.1.1/CLPYmusic.egg-info/top_level.txt +1 -0
- clpymusic-0.1.1/PKG-INFO +14 -0
- clpymusic-0.1.1/README.md +0 -0
- clpymusic-0.1.1/climusic/Interface.py +159 -0
- clpymusic-0.1.1/climusic/assets/defaultAlbumCover.jpeg +0 -0
- clpymusic-0.1.1/climusic/audioEngine.py +41 -0
- clpymusic-0.1.1/climusic/components/audioVisualizer.py +34 -0
- clpymusic-0.1.1/climusic/components/miniTerminal.py +75 -0
- clpymusic-0.1.1/climusic/components/musicPlayerActions.py +619 -0
- clpymusic-0.1.1/climusic/components/nowPlaying.py +37 -0
- clpymusic-0.1.1/climusic/components/songProgress.py +33 -0
- clpymusic-0.1.1/climusic/components/songTable.py +25 -0
- clpymusic-0.1.1/climusic/functions/coverToAscii.py +39 -0
- clpymusic-0.1.1/climusic/functions/extractAudioWave.py +52 -0
- clpymusic-0.1.1/climusic/functions/filterFormats.py +29 -0
- clpymusic-0.1.1/climusic/functions/timeConvert.py +5 -0
- clpymusic-0.1.1/climusic/functions/turnicateText.py +4 -0
- clpymusic-0.1.1/climusic/library.py +44 -0
- clpymusic-0.1.1/climusic/musicController.py +196 -0
- clpymusic-0.1.1/climusic/themes.py +144 -0
- clpymusic-0.1.1/pyproject.toml +26 -0
- clpymusic-0.1.1/setup.cfg +4 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: CLPYmusic
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: A CLI music player
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: textual
|
|
7
|
+
Requires-Dist: pynput
|
|
8
|
+
Requires-Dist: mutagen
|
|
9
|
+
Requires-Dist: python-vlc
|
|
10
|
+
Requires-Dist: librosa
|
|
11
|
+
Requires-Dist: numpy
|
|
12
|
+
Requires-Dist: Pillow
|
|
13
|
+
Requires-Dist: Pillow
|
|
14
|
+
Requires-Dist: pyperclip
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
CLPYmusic.egg-info/PKG-INFO
|
|
4
|
+
CLPYmusic.egg-info/SOURCES.txt
|
|
5
|
+
CLPYmusic.egg-info/dependency_links.txt
|
|
6
|
+
CLPYmusic.egg-info/entry_points.txt
|
|
7
|
+
CLPYmusic.egg-info/requires.txt
|
|
8
|
+
CLPYmusic.egg-info/top_level.txt
|
|
9
|
+
climusic/Interface.py
|
|
10
|
+
climusic/audioEngine.py
|
|
11
|
+
climusic/library.py
|
|
12
|
+
climusic/musicController.py
|
|
13
|
+
climusic/themes.py
|
|
14
|
+
climusic/assets/defaultAlbumCover.jpeg
|
|
15
|
+
climusic/components/audioVisualizer.py
|
|
16
|
+
climusic/components/miniTerminal.py
|
|
17
|
+
climusic/components/musicPlayerActions.py
|
|
18
|
+
climusic/components/nowPlaying.py
|
|
19
|
+
climusic/components/songProgress.py
|
|
20
|
+
climusic/components/songTable.py
|
|
21
|
+
climusic/functions/coverToAscii.py
|
|
22
|
+
climusic/functions/extractAudioWave.py
|
|
23
|
+
climusic/functions/filterFormats.py
|
|
24
|
+
climusic/functions/timeConvert.py
|
|
25
|
+
climusic/functions/turnicateText.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
climusic
|
clpymusic-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: CLPYmusic
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: A CLI music player
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: textual
|
|
7
|
+
Requires-Dist: pynput
|
|
8
|
+
Requires-Dist: mutagen
|
|
9
|
+
Requires-Dist: python-vlc
|
|
10
|
+
Requires-Dist: librosa
|
|
11
|
+
Requires-Dist: numpy
|
|
12
|
+
Requires-Dist: Pillow
|
|
13
|
+
Requires-Dist: Pillow
|
|
14
|
+
Requires-Dist: pyperclip
|
|
File without changes
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
from climusic import musicController
|
|
2
|
+
from textual.app import App, ComposeResult, Binding
|
|
3
|
+
from textual.widgets import Header, DataTable
|
|
4
|
+
from textual.containers import Horizontal, Vertical
|
|
5
|
+
import json
|
|
6
|
+
from climusic.components.audioVisualizer import AudioVisualizer
|
|
7
|
+
from climusic.components.songTable import SongTable
|
|
8
|
+
from climusic.components.nowPlaying import NowPlaying
|
|
9
|
+
from climusic.components.songProgress import SongProgress
|
|
10
|
+
from climusic.components.miniTerminal import MiniTerminal
|
|
11
|
+
from climusic.components.musicPlayerActions import MusicPlayerActions
|
|
12
|
+
from climusic.themes import build_css
|
|
13
|
+
from pynput import keyboard as pynput_keyboard
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
import vlc
|
|
17
|
+
except Exception:
|
|
18
|
+
print("❌ VLC not found!")
|
|
19
|
+
print("Install VLC from https://www.videolan.org then try again.")
|
|
20
|
+
print("Mac: brew install vlc")
|
|
21
|
+
print("Linux: sudo apt install vlc")
|
|
22
|
+
exit(1)
|
|
23
|
+
|
|
24
|
+
class Main(MusicPlayerActions, App):
|
|
25
|
+
allSongs = []
|
|
26
|
+
songsList = []
|
|
27
|
+
|
|
28
|
+
CSS = build_css()
|
|
29
|
+
|
|
30
|
+
BINDINGS = [
|
|
31
|
+
Binding("space", "toggle_pause", "Pause/Resume", priority=True),
|
|
32
|
+
Binding("d", "next_song", "Next", priority=True),
|
|
33
|
+
Binding("a", "prev_song", "Previous", priority=True),
|
|
34
|
+
Binding("w", "vol_up", "Vol Up", priority=True),
|
|
35
|
+
Binding("s", "vol_down", "Vol Down", priority=True),
|
|
36
|
+
Binding("q", "back_song", "Back", priority=True),
|
|
37
|
+
Binding("e", "forward_song", "Forward", priority=True),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
def compose(self) -> ComposeResult:
|
|
41
|
+
yield Header()
|
|
42
|
+
with open(musicController.CONFIG_PATH, "r") as f:
|
|
43
|
+
config = json.load(f)
|
|
44
|
+
if config["visualizer"]:
|
|
45
|
+
right_panel = Vertical(
|
|
46
|
+
NowPlaying(),
|
|
47
|
+
AudioVisualizer(),
|
|
48
|
+
|
|
49
|
+
id="right_panel"
|
|
50
|
+
)
|
|
51
|
+
else:
|
|
52
|
+
right_panel = Horizontal(
|
|
53
|
+
NowPlaying(),
|
|
54
|
+
id="right_panel"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
yield Horizontal(
|
|
58
|
+
Vertical(
|
|
59
|
+
SongTable(),
|
|
60
|
+
MiniTerminal(id="terminal"),
|
|
61
|
+
id="left_panel"
|
|
62
|
+
),
|
|
63
|
+
right_panel
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
|
67
|
+
self.index = event.cursor_row
|
|
68
|
+
if self.index is None or self.index < 0:
|
|
69
|
+
return
|
|
70
|
+
self.load_and_play(self.index)
|
|
71
|
+
|
|
72
|
+
def _setup_global_hotkeys(self):
|
|
73
|
+
def on_press(key):
|
|
74
|
+
if key == pynput_keyboard.Key.media_next:
|
|
75
|
+
self.call_from_thread(self.play_next_song)
|
|
76
|
+
elif key == pynput_keyboard.Key.media_previous:
|
|
77
|
+
self.call_from_thread(self.play_previous_song)
|
|
78
|
+
elif key == pynput_keyboard.Key.media_play_pause:
|
|
79
|
+
self.call_from_thread(self.action_toggle_pause)
|
|
80
|
+
|
|
81
|
+
listener = pynput_keyboard.Listener(on_press=on_press)
|
|
82
|
+
listener.start()
|
|
83
|
+
|
|
84
|
+
def on_mount(self) -> None:
|
|
85
|
+
self.index = 0 # Initialize FIRST
|
|
86
|
+
self.is_paused = False
|
|
87
|
+
|
|
88
|
+
self.allSongs = musicController.return_library()
|
|
89
|
+
with open(musicController.CONFIG_PATH, "r") as f:
|
|
90
|
+
config = json.load(f)
|
|
91
|
+
if config["shuffle"] == True:
|
|
92
|
+
self.songsList = musicController.shuffle_library(self.allSongs)
|
|
93
|
+
else:
|
|
94
|
+
self.songsList = musicController.filter_songs_alphabetically(self.allSongs, sort_by="title")
|
|
95
|
+
|
|
96
|
+
self.query_one(SongTable).load_songs(self.songsList)
|
|
97
|
+
self.progress_bar = self.query_one(SongProgress)
|
|
98
|
+
|
|
99
|
+
if config["visualizer"]:
|
|
100
|
+
self.visualizer = self.query_one(AudioVisualizer)
|
|
101
|
+
|
|
102
|
+
current_theme = config.get("theme", "purple")
|
|
103
|
+
self.add_class(current_theme)
|
|
104
|
+
|
|
105
|
+
self.set_interval(1 / 20, self.update_progress)
|
|
106
|
+
self._setup_global_hotkeys()
|
|
107
|
+
|
|
108
|
+
# ═══════════════════════════════════════════════════════════════
|
|
109
|
+
# Key Binding Actions
|
|
110
|
+
# ═══════════════════════════════════════════════════════════════
|
|
111
|
+
|
|
112
|
+
def action_toggle_pause(self) -> None:
|
|
113
|
+
if self.is_paused:
|
|
114
|
+
musicController.unpause_song()
|
|
115
|
+
self.print_to_terminal("[dim]resumed.[/dim]")
|
|
116
|
+
else:
|
|
117
|
+
musicController.pause_song()
|
|
118
|
+
self.print_to_terminal("[dim]paused.[/dim]")
|
|
119
|
+
self.is_paused = not self.is_paused
|
|
120
|
+
|
|
121
|
+
def action_next_song(self) -> None:
|
|
122
|
+
|
|
123
|
+
self.is_paused = False
|
|
124
|
+
self.play_next_song()
|
|
125
|
+
|
|
126
|
+
def action_prev_song(self) -> None:
|
|
127
|
+
|
|
128
|
+
self.is_paused = False
|
|
129
|
+
self.play_previous_song()
|
|
130
|
+
|
|
131
|
+
def action_forward_song(self) -> None:
|
|
132
|
+
|
|
133
|
+
self.is_paused = False
|
|
134
|
+
self.forward_song()
|
|
135
|
+
|
|
136
|
+
def action_back_song(self) -> None:
|
|
137
|
+
|
|
138
|
+
self.is_paused = False
|
|
139
|
+
self.back_song()
|
|
140
|
+
|
|
141
|
+
def action_vol_up(self) -> None:
|
|
142
|
+
new_vol = min(100, musicController.get_volume() + 10)
|
|
143
|
+
musicController.set_volume(new_vol)
|
|
144
|
+
self.print_to_terminal(f"[dim]vol: {new_vol}%[/dim]")
|
|
145
|
+
|
|
146
|
+
def action_vol_down(self) -> None:
|
|
147
|
+
new_vol = max(0, musicController.get_volume() - 10)
|
|
148
|
+
musicController.set_volume(new_vol)
|
|
149
|
+
self.print_to_terminal(f"[dim]vol: {new_vol}%[/dim]")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def main():
|
|
153
|
+
musicController.init_config()
|
|
154
|
+
app = Main()
|
|
155
|
+
app.run()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
if __name__ == "__main__":
|
|
159
|
+
main()
|
|
Binary file
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import vlc
|
|
2
|
+
|
|
3
|
+
instance = vlc.Instance("--quiet")
|
|
4
|
+
player = instance.media_player_new() # use instance, not vlc.MediaPlayer()
|
|
5
|
+
|
|
6
|
+
def play_song(path):
|
|
7
|
+
media = instance.media_new(path) # use instance here too
|
|
8
|
+
player.set_media(media)
|
|
9
|
+
player.play()
|
|
10
|
+
|
|
11
|
+
def pause_song():
|
|
12
|
+
player.pause()
|
|
13
|
+
|
|
14
|
+
def unpause_song():
|
|
15
|
+
player.set_pause(0)
|
|
16
|
+
|
|
17
|
+
def stop_song():
|
|
18
|
+
player.stop()
|
|
19
|
+
|
|
20
|
+
def get_position():
|
|
21
|
+
ms = player.get_time()
|
|
22
|
+
return ms / 1000 if ms >= 0 else 0.0
|
|
23
|
+
|
|
24
|
+
def get_length():
|
|
25
|
+
ms = player.get_length()
|
|
26
|
+
return ms / 1000 if ms > 0 else 0.0
|
|
27
|
+
|
|
28
|
+
def song_finished():
|
|
29
|
+
return player.get_state() == vlc.State.Ended
|
|
30
|
+
|
|
31
|
+
def set_volume(level):
|
|
32
|
+
level = max(0, min(100, level)) # clamp to 0-100
|
|
33
|
+
player.audio_set_volume(level)
|
|
34
|
+
|
|
35
|
+
def set_position(position):
|
|
36
|
+
length = get_length()
|
|
37
|
+
if length > 0:
|
|
38
|
+
position = max(0, min(position, length)) # clamp to valid range
|
|
39
|
+
player.set_time(int(position * 1000)) # convert to ms
|
|
40
|
+
def get_volume():
|
|
41
|
+
return player.audio_get_volume()
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from textual.widgets import Static
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AudioVisualizer(Static):
|
|
5
|
+
def __init__(self):
|
|
6
|
+
super().__init__(id="audio-visualizer")
|
|
7
|
+
def get_char(self,h, row):
|
|
8
|
+
ratio = row / h if h > 0 else 0
|
|
9
|
+
if ratio > 0.8:
|
|
10
|
+
return "░"
|
|
11
|
+
elif ratio > 0.6:
|
|
12
|
+
return "▒"
|
|
13
|
+
elif ratio > 0.4:
|
|
14
|
+
return "▓"
|
|
15
|
+
else:
|
|
16
|
+
return "█"
|
|
17
|
+
def update_wave(self, frame):
|
|
18
|
+
if not frame:
|
|
19
|
+
return
|
|
20
|
+
|
|
21
|
+
visible = frame
|
|
22
|
+
|
|
23
|
+
height = 8
|
|
24
|
+
rows = []
|
|
25
|
+
|
|
26
|
+
bar_heights = [int(v * height) for v in visible]
|
|
27
|
+
|
|
28
|
+
for row in range(height, 0, -1):
|
|
29
|
+
line = "".join(
|
|
30
|
+
self.get_char(h, row) if h >= row else " "
|
|
31
|
+
for h in bar_heights
|
|
32
|
+
)
|
|
33
|
+
rows.append(line)
|
|
34
|
+
self.update("\n".join(rows))
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from textual.widgets import Input, Static, RichLog
|
|
2
|
+
from textual.containers import Vertical
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
import pyperclip
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class MiniTerminal(Vertical):
|
|
8
|
+
|
|
9
|
+
def compose(self) -> ComposeResult:
|
|
10
|
+
yield Static("terminal", id="terminal-header")
|
|
11
|
+
yield RichLog(id="terminal-log", markup=True, highlight=True)
|
|
12
|
+
yield Input(placeholder="enter command...", id="terminal-input")
|
|
13
|
+
|
|
14
|
+
def on_mount(self) -> None:
|
|
15
|
+
self.history = []
|
|
16
|
+
self.history_index = -1
|
|
17
|
+
self.write("[dim]ready. type a command below.[/dim]")
|
|
18
|
+
self.write("[dim][/dim]")
|
|
19
|
+
|
|
20
|
+
input_widget = self.query_one("#terminal-input", Input)
|
|
21
|
+
input_widget.focus()
|
|
22
|
+
|
|
23
|
+
def write(self, msg: str) -> None:
|
|
24
|
+
log = self.query_one("#terminal-log", RichLog)
|
|
25
|
+
log.write(msg)
|
|
26
|
+
log.scroll_end(animate=True)
|
|
27
|
+
|
|
28
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
29
|
+
cmd = event.value.strip()
|
|
30
|
+
if not cmd:
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
if not self.history or self.history[0] != cmd:
|
|
34
|
+
self.history.insert(0, cmd)
|
|
35
|
+
self.history_index = -1
|
|
36
|
+
|
|
37
|
+
event.input.value = ""
|
|
38
|
+
self.write(f"[dim]>[/dim] {cmd}")
|
|
39
|
+
self.app.handle_command(cmd)
|
|
40
|
+
|
|
41
|
+
# Refocus after command
|
|
42
|
+
self.query_one("#terminal-input", Input).focus()
|
|
43
|
+
|
|
44
|
+
def _on_key(self, event) -> None:
|
|
45
|
+
"""Handle Ctrl+V for paste with length limit"""
|
|
46
|
+
input_widget = self.query_one("#terminal-input", Input)
|
|
47
|
+
|
|
48
|
+
if event.key == "ctrl+v":
|
|
49
|
+
try:
|
|
50
|
+
clipboard = pyperclip.paste()
|
|
51
|
+
max_length = 100
|
|
52
|
+
|
|
53
|
+
if len(clipboard) > max_length:
|
|
54
|
+
self.write(f"[red]text too long ({len(clipboard)} > {max_length})[/red]")
|
|
55
|
+
else:
|
|
56
|
+
input_widget.value = clipboard
|
|
57
|
+
input_widget.cursor_position = len(clipboard)
|
|
58
|
+
except Exception as e:
|
|
59
|
+
self.write(f"[red]paste error: {e}[/red]")
|
|
60
|
+
event.prevent_default()
|
|
61
|
+
elif event.key == "up":
|
|
62
|
+
if self.history and self.history_index < len(self.history) - 1:
|
|
63
|
+
self.history_index += 1
|
|
64
|
+
input_widget.value = self.history[self.history_index]
|
|
65
|
+
input_widget.cursor_position = len(input_widget.value)
|
|
66
|
+
event.prevent_default()
|
|
67
|
+
elif event.key == "down":
|
|
68
|
+
if self.history and self.history_index > 0:
|
|
69
|
+
self.history_index -= 1
|
|
70
|
+
input_widget.value = self.history[self.history_index]
|
|
71
|
+
input_widget.cursor_position = len(input_widget.value)
|
|
72
|
+
elif self.history_index == 0:
|
|
73
|
+
self.history_index = -1
|
|
74
|
+
input_widget.value = ""
|
|
75
|
+
event.prevent_default()
|