flow-twinx 0.1.0__py3-none-any.whl

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,5 @@
1
+ # ¯\_(ツ)_/¯ Just some crickets here !
2
+
3
+ # Just joking it is here to make py recognise this is a real module :)
4
+
5
+ # Don't check online's init.py
@@ -0,0 +1,207 @@
1
+ import random
2
+ from .. import config
3
+ from ..config import merge_flags
4
+ from . import file as lib
5
+ from . import player
6
+ from ..tui import print as tprint
7
+
8
+ try:
9
+ import readline
10
+ _HAS_READLINE = True
11
+ except ImportError:
12
+ _HAS_READLINE = False
13
+
14
+ _last_results = []
15
+ _last_played = None
16
+
17
+ COMMANDS = {
18
+ "play": "Play song(s) from local library",
19
+ "search": "Search local music library",
20
+ "list": "List local music library",
21
+ "like": "Like the currently playing song",
22
+ "switch": "Switch to Online mode (checks connection)",
23
+ "help": "Show this help message",
24
+ "exit": "Exit Flow",
25
+ }
26
+
27
+ m = tprint(color="grey", border="none")
28
+ e = tprint(color="red", border="none")
29
+ i = tprint(color="theme", border="none")
30
+
31
+
32
+ class _Completer:
33
+ def __init__(self):
34
+ self.matches = []
35
+
36
+ def complete(self, text, state):
37
+ if state == 0:
38
+ line = readline.get_line_buffer()
39
+ parts = line.split()
40
+ if len(parts) <= 1:
41
+ options = [c for c in COMMANDS if c.startswith(text)]
42
+ elif parts[0] == "play":
43
+ songs = lib.get_song_names()
44
+ albums = lib.get_album_names()
45
+ all_names = songs + albums
46
+ options = sorted(set(
47
+ n for n in all_names
48
+ if n.lower().startswith(text.lower())
49
+ ))
50
+ else:
51
+ options = []
52
+ self.matches = options
53
+ try:
54
+ return self.matches[state]
55
+ except IndexError:
56
+ return None
57
+
58
+
59
+ def _setup_completion():
60
+ if not _HAS_READLINE:
61
+ return
62
+ readline.set_completer(_Completer().complete)
63
+ readline.parse_and_bind("tab: complete")
64
+ readline.set_completer_delims(' \t\n;')
65
+
66
+
67
+ def run(cmd: str, extra: list[str], args):
68
+ _setup_completion()
69
+ extra, args = merge_flags(extra, args)
70
+ if cmd == "play":
71
+ play(extra, args)
72
+ elif cmd == "search":
73
+ search(" ".join(extra) if extra else "")
74
+ elif cmd == "list":
75
+ list_library()
76
+ elif cmd == "like":
77
+ like_track()
78
+ elif cmd == "switch":
79
+ switch_mode()
80
+ elif cmd == "help":
81
+ show_help()
82
+ else:
83
+ e(f"Unknown command: {cmd}")
84
+
85
+
86
+ def play(extra: list[str], args):
87
+ global _last_results, _last_played
88
+ arg = " ".join(extra) if extra else None
89
+ if not arg:
90
+ e("No song specified")
91
+ return
92
+
93
+ if arg == "liked":
94
+ _play_liked(args)
95
+ return
96
+
97
+ if arg in lib.get_album_names():
98
+ _play_album(arg, args)
99
+ return
100
+
101
+ if arg.isdigit():
102
+ idx = int(arg) - 1
103
+ if idx < 0 or idx >= len(_last_results):
104
+ e("Index out of range")
105
+ return
106
+ song_path = _last_results[idx]
107
+ else:
108
+ results = lib.find_songs(arg)
109
+ if not results:
110
+ e(f"No songs found matching '{arg}'")
111
+ return
112
+ if len(results) == 1:
113
+ song_path = results[0]
114
+ else:
115
+ _last_results = results
116
+ m("Multiple matches:")
117
+ for i, p in enumerate(results, 1):
118
+ m(f" {i}. {p.stem}")
119
+ return
120
+
121
+ _last_played = song_path
122
+ player.play_file(song_path, song_path.stem, args)
123
+
124
+
125
+ def _play_liked(args):
126
+ liked = lib.get_liked_songs()
127
+ if not liked:
128
+ e("No liked songs yet")
129
+ return
130
+ if getattr(args, "s", False):
131
+ random.shuffle(liked)
132
+ for song in liked:
133
+ _last_played = song
134
+ player.play_file(song, song.stem, args)
135
+
136
+
137
+ def _play_album(album: str, args):
138
+ songs = lib.get_album_songs(album)
139
+ if not songs:
140
+ e(f"No songs found in album '{album}'")
141
+ return
142
+ if getattr(args, "s", False):
143
+ random.shuffle(songs)
144
+ for song in songs:
145
+ _last_played = song
146
+ player.play_file(song, song.stem, args)
147
+
148
+
149
+ def like_track():
150
+ global _last_played
151
+ if not _last_played:
152
+ e("No song currently playing")
153
+ return
154
+ dest = lib.like_song(_last_played)
155
+ if dest:
156
+ i(f"Liked: {_last_played.stem}")
157
+ else:
158
+ m(f"{_last_played.stem} is already liked")
159
+
160
+
161
+ def search(query: str):
162
+ global _last_results
163
+ if not query:
164
+ e("Search query required")
165
+ return
166
+ results = lib.find_songs(query)
167
+ if not results:
168
+ e("No results found")
169
+ return
170
+ _last_results = results
171
+ for i, p in enumerate(results, 1):
172
+ m(f" {i}. {p.stem}")
173
+
174
+
175
+ def list_library():
176
+ songs = lib.get_songs()
177
+ albums = lib.get_albums()
178
+ liked = lib.get_liked_songs()
179
+
180
+ if songs:
181
+ i("\nSongs:")
182
+ for p in songs:
183
+ m(f" {p.stem}")
184
+ if albums:
185
+ i("\nAlbums:")
186
+ for a in albums:
187
+ m(f" {a}/")
188
+ if liked:
189
+ i("\nLiked Songs:")
190
+ for p in liked:
191
+ m(f" {p.stem}")
192
+ if not songs and not albums and not liked:
193
+ e("No music in library")
194
+
195
+
196
+ def switch_mode():
197
+ from ..ping import is_connected
198
+ if is_connected():
199
+ i("Switched to Online mode")
200
+ else:
201
+ e("No internet connection")
202
+
203
+
204
+ def show_help():
205
+ i("Offline Commands:")
206
+ for cmd, desc in COMMANDS.items():
207
+ m(f" {cmd:12s} {desc}")
@@ -0,0 +1,77 @@
1
+ import pathlib
2
+ import shutil
3
+ from .. import config
4
+
5
+ AUDIO_EXTENSIONS = {".mp3", ".flac", ".wav", ".m4a", ".ogg", ".opus", ".wma", ".aac",".webm"}
6
+ LIKED_DIR_NAME = "liked songs"
7
+
8
+
9
+ def _liked_dir() -> pathlib.Path:
10
+ return config.DOWNLOAD_DIR / LIKED_DIR_NAME
11
+
12
+
13
+ def get_all_songs() -> list[pathlib.Path]:
14
+ if not config.DOWNLOAD_DIR.exists():
15
+ return []
16
+ return sorted([
17
+ p for p in config.DOWNLOAD_DIR.rglob("*")
18
+ if p.suffix.lower() in AUDIO_EXTENSIONS
19
+ ])
20
+
21
+
22
+ def get_songs() -> list[pathlib.Path]:
23
+ liked = _liked_dir()
24
+ return [s for s in get_all_songs() if liked not in s.parents]
25
+
26
+
27
+ def get_albums() -> list[str]:
28
+ if not config.DOWNLOAD_DIR.exists():
29
+ return []
30
+ liked = _liked_dir()
31
+ return sorted([
32
+ d.name for d in config.DOWNLOAD_DIR.iterdir()
33
+ if d.is_dir() and d != liked
34
+ ])
35
+
36
+
37
+ def get_album_songs(album: str) -> list[pathlib.Path]:
38
+ album_dir = config.DOWNLOAD_DIR / album
39
+ if not album_dir.exists() or not album_dir.is_dir():
40
+ return []
41
+ return sorted([
42
+ p for p in album_dir.iterdir()
43
+ if p.suffix.lower() in AUDIO_EXTENSIONS
44
+ ])
45
+
46
+
47
+ def get_liked_songs() -> list[pathlib.Path]:
48
+ liked = _liked_dir()
49
+ if not liked.exists():
50
+ return []
51
+ return sorted([
52
+ p for p in liked.iterdir()
53
+ if p.suffix.lower() in AUDIO_EXTENSIONS
54
+ ])
55
+
56
+
57
+ def like_song(song_path: pathlib.Path) -> pathlib.Path | None:
58
+ liked = _liked_dir()
59
+ liked.mkdir(parents=True, exist_ok=True)
60
+ dest = liked / song_path.name
61
+ if dest.exists():
62
+ return None
63
+ shutil.copy2(song_path, dest)
64
+ return dest
65
+
66
+
67
+ def find_songs(query: str) -> list[pathlib.Path]:
68
+ q = query.lower()
69
+ return [s for s in get_songs() if q in s.stem.lower()]
70
+
71
+
72
+ def get_song_names() -> list[str]:
73
+ return sorted(set(s.stem for s in get_songs()))
74
+
75
+
76
+ def get_album_names() -> list[str]:
77
+ return get_albums()
@@ -0,0 +1,64 @@
1
+ import sys
2
+ import time
3
+ import vlc
4
+ from ..tui import print as tprint
5
+
6
+ m = tprint(color="grey", border="none")
7
+ e = tprint(color="red", border="none")
8
+ i = tprint(color="theme", border="none")
9
+
10
+ BAR_WIDTH = 40
11
+
12
+
13
+ def _progress_bar(elapsed, total):
14
+ if total <= 0:
15
+ return ""
16
+ fraction = min(elapsed / total, 1.0)
17
+ filled = int(fraction * BAR_WIDTH)
18
+ bar = "/" * filled + " " * (BAR_WIDTH - filled)
19
+ pct = int(fraction * 100)
20
+ return f" [{bar}] {pct}%"
21
+
22
+
23
+ def play_file(filepath, title, args=None):
24
+ repeat = args and getattr(args, "r", False)
25
+ shuffle = args and getattr(args, "s", False)
26
+
27
+ while True:
28
+ instance = vlc.Instance("--no-video --quiet")
29
+ player = instance.media_player_new()
30
+ media = instance.media_new(str(filepath))
31
+ player.set_media(media)
32
+ player.play()
33
+
34
+ duration = 0
35
+ for _ in range(50):
36
+ duration = player.get_length() / 1000
37
+ if duration > 0:
38
+ break
39
+ time.sleep(0.1)
40
+ if duration <= 0:
41
+ duration = 0
42
+ dur_min, dur_sec = divmod(int(duration), 60)
43
+ i(f"\nPlaying : {title}")
44
+ m(f" {dur_min}:{dur_sec:02d} | repeat:{repeat} | shuffle:{shuffle}")
45
+
46
+ start = time.time()
47
+ try:
48
+ while player.get_state() not in (vlc.State.Ended, vlc.State.Error):
49
+ elapsed = time.time() - start
50
+ bar = _progress_bar(elapsed, duration)
51
+ sys.stdout.write(f"\r\x1b[35m{bar}\x1b[0m")
52
+ sys.stdout.flush()
53
+ time.sleep(0.5)
54
+ except KeyboardInterrupt:
55
+ player.stop()
56
+ sys.stdout.write("\n")
57
+ sys.stdout.flush()
58
+ break
59
+
60
+ sys.stdout.write("\n")
61
+ sys.stdout.flush()
62
+
63
+ if not repeat:
64
+ break
File without changes
@@ -0,0 +1,5 @@
1
+ # ¯\_(ツ)_/¯ Just some crickets here !
2
+
3
+ # Just joking it is here to make py recognise this is a real module :)
4
+
5
+ # Don't check offline's init.py
@@ -0,0 +1,205 @@
1
+ import random
2
+ import sys
3
+ import threading
4
+ import time
5
+ from .. import config
6
+ from ..config import merge_flags
7
+ from . import youtube
8
+ from . import player
9
+ from ..tui import print as tprint
10
+
11
+ _last_results = []
12
+ _last_played = None
13
+
14
+ COMMANDS = {
15
+ "play": "Play a song from YouTube",
16
+ "search": "Search YouTube for tracks",
17
+ "like": "Like a song",
18
+ "download": "Download audio from YouTube",
19
+ "switch": "Switch to Offline mode",
20
+ "help": "Show this help message",
21
+ "exit": "Exit Flow",
22
+ }
23
+
24
+ m = tprint(color="grey", border="none")
25
+ e = tprint(color="red", border="none")
26
+ i = tprint(color="theme", border="none")
27
+
28
+ def run(cmd: str, extra: list[str], args):
29
+ extra, args = merge_flags(extra, args)
30
+ if cmd == "play":
31
+ play(extra, args)
32
+ elif cmd == "search":
33
+ search(" ".join(extra) if extra else "")
34
+ elif cmd == "like":
35
+ like_track()
36
+ elif cmd == "download":
37
+ download(extra)
38
+ elif cmd == "switch":
39
+ switch_mode()
40
+ elif cmd == "help":
41
+ show_help()
42
+ else:
43
+ print(f"Unknown command: {cmd}")
44
+
45
+
46
+ def _spinner(stop):
47
+ chars = "|/-\\"
48
+ i = 0
49
+ while not stop():
50
+ sys.stdout.write(f"\r\x1b[36mSearching... {chars[i]}\x1b[0m")
51
+ sys.stdout.flush()
52
+ time.sleep(0.1)
53
+ i = (i + 1) % len(chars)
54
+ sys.stdout.write("\r" + " " * 40 + "\r")
55
+ sys.stdout.flush()
56
+
57
+
58
+ def _do_search(query):
59
+ global _last_results
60
+ stop = False
61
+ t = threading.Thread(target=_spinner, args=(lambda: stop,), daemon=True)
62
+ t.start()
63
+ _last_results = youtube.search(query)
64
+ stop = True
65
+ t.join()
66
+
67
+
68
+ def play(extra: list[str], args):
69
+ global _last_results, _last_played
70
+ arg = " ".join(extra) if extra else None
71
+ if not arg:
72
+ print("No song specified")
73
+ return
74
+
75
+ if arg == "liked":
76
+ _play_liked(args)
77
+ return
78
+
79
+ if arg.isdigit():
80
+ idx = int(arg) - 1
81
+ if idx < 0 or idx >= len(_last_results):
82
+ print("Index out of range")
83
+ return
84
+ entry, title, _ = _last_results[idx]
85
+ else:
86
+ _do_search(arg)
87
+ if not _last_results:
88
+ print("No results found")
89
+ return
90
+ entry, title, _ = _last_results[0]
91
+
92
+ _last_played = (entry, title)
93
+ filepath = None
94
+ if getattr(args, "d", False):
95
+ url = entry.get("webpage_url") or entry.get("original_url")
96
+ if not url:
97
+ print("No URL found for this entry")
98
+ return
99
+ m(f" \n Downloading {title}...")
100
+ filepath = youtube.download_url(url, config.DOWNLOAD_DIR)
101
+ m(f" Downloaded to {filepath}")
102
+
103
+ player.play_entry(entry, title, args, filepath)
104
+
105
+
106
+ def _play_liked(args):
107
+ if not config.liked_music.exists():
108
+ e(" No liked songs yet")
109
+ return
110
+ liked = config.liked_music.read_text().strip().splitlines()
111
+ if not liked:
112
+ e(" No liked songs yet")
113
+ return
114
+ if getattr(args, "s", False):
115
+ random.shuffle(liked)
116
+ for line in liked:
117
+ if "|" not in line:
118
+ continue
119
+ title, url = line.split("|", 1)
120
+ _do_search(title)
121
+ if not _last_results:
122
+ m(f" Skipping {title} (not found)")
123
+ continue
124
+ entry, _, _ = _last_results[0]
125
+ _last_played = (entry, title)
126
+ player.play_entry(entry, title, args)
127
+
128
+ def like_track():
129
+ global _last_played
130
+ if not _last_played:
131
+ e(" No song currently playing")
132
+ return
133
+ entry, title = _last_played
134
+ url = entry.get("webpage_url") or entry.get("original_url")
135
+ if not url:
136
+ e(" No URL for current song")
137
+ return
138
+ config.liked_music.parent.mkdir(parents=True, exist_ok=True)
139
+ existing = config.liked_music.read_text().strip().splitlines() if config.liked_music.exists() else []
140
+ if any(title in line for line in existing):
141
+ m(f" {title} already liked")
142
+ return
143
+ with open(config.liked_music, "a") as f:
144
+ f.write(f"{title}|{url}\n")
145
+ i(f" Liked: {title}")
146
+
147
+
148
+ def search(query: str):
149
+ global _last_results
150
+ if not query:
151
+ print("Search query required")
152
+ return
153
+ _do_search(query)
154
+ if not _last_results:
155
+ print("No results found")
156
+ return
157
+ for i, (_, title, dur) in enumerate(_last_results, 1):
158
+ mins, secs = divmod(int(dur), 60)
159
+ m(f" {i}. {title} ({mins}:{secs:02d})")
160
+
161
+ def download(extra: list[str]):
162
+ global _last_results
163
+ arg = " ".join(extra) if extra else None
164
+ if not arg:
165
+ e("No song specified")
166
+ return
167
+
168
+ if arg.isdigit():
169
+ idx = int(arg) - 1
170
+ if idx < 0 or idx >= len(_last_results):
171
+ e(" Index out of range")
172
+ return
173
+ entry, _, _ = _last_results[idx]
174
+ url = entry.get("webpage_url") or entry.get("original_url")
175
+ if not url:
176
+ e(" No URL found for this entry")
177
+ return
178
+ title = entry.get("title", "Unknown")
179
+ filepath = youtube.download_url(url, config.DOWNLOAD_DIR)
180
+ i(f" Downloaded: {title} -> {filepath}")
181
+ else:
182
+ _do_search(arg)
183
+ if not _last_results:
184
+ e(" No results found")
185
+ return
186
+ entry, _, _ = _last_results[0]
187
+ url = entry.get("webpage_url") or entry.get("original_url")
188
+ if not url:
189
+ e(" No URL found for this entry")
190
+ return
191
+ title = entry.get("title", "Unknown")
192
+ filepath = youtube.download_url(url, config.DOWNLOAD_DIR)
193
+ i(f" Downloaded: {title} -> {filepath}")
194
+
195
+
196
+ def switch_mode():
197
+ config.Mode = "Offline"
198
+ config.THEME = config.OFFLINE_THEME
199
+ m("Switched to Offline mode")
200
+
201
+
202
+ def show_help():
203
+ print(f"{config.THEMES[config.THEME]['theme']}Online Commands:{chr(27)}[0m")
204
+ for cmd, desc in COMMANDS.items():
205
+ print(f" {cmd:12s} {desc}")
@@ -0,0 +1,69 @@
1
+ import sys
2
+ import time
3
+ import vlc
4
+ from ..tui import print as tprint
5
+
6
+ m = tprint(color="grey", border="none")
7
+ e = tprint(color="red", border="none")
8
+ i = tprint(color="theme", border="none")
9
+
10
+ BAR_WIDTH = 40
11
+
12
+
13
+ def _progress_bar(elapsed, total):
14
+ if total <= 0:
15
+ return ""
16
+ fraction = min(elapsed / total, 1.0)
17
+ filled = int(fraction * BAR_WIDTH)
18
+ bar = "/" * filled + " " * (BAR_WIDTH - filled)
19
+ pct = int(fraction * 100)
20
+ return f" [{bar}] {pct}%"
21
+
22
+
23
+ def play_entry(entry, title, args=None, filepath=None):
24
+ repeat = args and getattr(args, "r", False)
25
+
26
+ while True:
27
+ instance = vlc.Instance("--no-video --quiet")
28
+ player = instance.media_player_new()
29
+
30
+ if filepath:
31
+ media = instance.media_new(filepath)
32
+ else:
33
+ stream_url = None
34
+ for fmt in reversed(entry.get("formats", [])):
35
+ if fmt.get("acodec") != "none":
36
+ stream_url = fmt["url"]
37
+ break
38
+ if not stream_url:
39
+ e(" No playable stream found")
40
+ return
41
+ media = instance.media_new(stream_url)
42
+
43
+ player.set_media(media)
44
+ player.play()
45
+
46
+ duration = entry.get("duration", 0)
47
+ dur_min, dur_sec = divmod(int(duration), 60)
48
+ i(f"\nPlaying : {title}")
49
+ m(f" {dur_min}:{dur_sec:02d} | repeat:{args.r} | shuffle:{args.s}")
50
+
51
+ start = time.time()
52
+ try:
53
+ while player.get_state() not in (vlc.State.Ended, vlc.State.Error):
54
+ elapsed = time.time() - start
55
+ bar = _progress_bar(elapsed, duration)
56
+ sys.stdout.write(f"\r\x1b[36m{bar}\x1b[0m")
57
+ sys.stdout.flush()
58
+ time.sleep(0.5)
59
+ except KeyboardInterrupt:
60
+ player.stop()
61
+ sys.stdout.write("\n")
62
+ sys.stdout.flush()
63
+ break
64
+
65
+ sys.stdout.write("\n")
66
+ sys.stdout.flush()
67
+
68
+ if not repeat:
69
+ break
@@ -0,0 +1,31 @@
1
+ import yt_dlp
2
+
3
+ ydl_opts = {
4
+ "quiet": True, "no_warnings": True, "noprogress": True,
5
+ "noplaylist": True, "format": "bestaudio/best",
6
+ "default_search": "ytsearch1", "skip_download": True,
7
+ }
8
+
9
+ ydl_opts_dwn = {
10
+ "quiet": True, "no_warnings": True, "noprogress": True,
11
+ "noplaylist": True, "format": "bestaudio/best",
12
+ "outtmpl": "downloads/%(title)s.%(ext)s",
13
+ }
14
+
15
+ def search(query, limit=5):
16
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
17
+ info = ydl.extract_info(f"ytsearch{limit}:{query}", download=False)
18
+ if not info.get("entries"):
19
+ return []
20
+ results = []
21
+ for entry in info["entries"]:
22
+ title = entry.get("title", "Unknown")
23
+ dur = entry.get("duration", 0)
24
+ results.append((entry, title, dur))
25
+ return results
26
+
27
+ def download_url(url, outdir):
28
+ opts = {**ydl_opts_dwn, "outtmpl": f"{outdir}/%(title)s.%(ext)s"}
29
+ with yt_dlp.YoutubeDL(opts) as ydl:
30
+ info = ydl.extract_info(url, download=True)
31
+ return ydl.prepare_filename(info)
flow_twinx/__init__.py ADDED
File without changes
flow_twinx/config.py ADDED
@@ -0,0 +1,36 @@
1
+ import pathlib
2
+
3
+ def merge_flags(extra: list[str], args) -> tuple[list[str], object]:
4
+ mapping = {"-r": "r", "-s": "s", "-d": "d"}
5
+ rest = []
6
+ for item in extra:
7
+ if item in mapping:
8
+ setattr(args, mapping[item], True)
9
+ elif item.startswith("-"):
10
+ print(f"Unknown flag: {item}")
11
+ else:
12
+ rest.append(item)
13
+ return rest, args
14
+
15
+
16
+ ONLINE_THEME = "cyan"
17
+ OFFLINE_THEME = "magenta"
18
+
19
+ THEMES = {
20
+ "blue": {"white": "\033[37m", "grey": "\033[90m", "theme": "\033[34m"},
21
+ "green": {"white": "\033[37m", "grey": "\033[90m", "theme": "\033[32m"},
22
+ "red": {"white": "\033[37m", "grey": "\033[90m", "theme": "\033[31m"},
23
+ "yellow": {"white": "\033[37m", "grey": "\033[90m", "theme": "\033[33m"},
24
+ "magenta": {"white": "\033[37m", "grey": "\033[90m", "theme": "\033[35m"},
25
+ "cyan": {"white": "\033[37m", "grey": "\033[90m", "theme": "\033[36m"},
26
+ }
27
+
28
+ DOWNLOAD_DIR = pathlib.Path.home() / ".flow/downloads"
29
+
30
+ VERSION = 0.1
31
+
32
+ Mode = "Online"
33
+
34
+ THEME = ONLINE_THEME
35
+
36
+ liked_music = pathlib.Path.home() / ".flow/liked.txt"
flow_twinx/main.py ADDED
@@ -0,0 +1,79 @@
1
+ import argparse
2
+ import importlib
3
+ import sys
4
+ import threading
5
+ import time
6
+ from . import config
7
+ from .tui import show_banner, input as tui_input, print as tui_print
8
+ from .ping import is_connected
9
+
10
+
11
+ def _spinner(stop):
12
+ chars = "|/-\\"
13
+ i = 0
14
+ while not stop():
15
+ sys.stdout.write(f"\r\x1b[36mChecking connection... {chars[i]}\x1b[0m")
16
+ sys.stdout.flush()
17
+ time.sleep(0.1)
18
+ i = (i + 1) % len(chars)
19
+ sys.stdout.write("\r" + " " * 40 + "\r")
20
+ sys.stdout.flush()
21
+
22
+
23
+ def _load_commands():
24
+ return importlib.import_module(f".{config.Mode}.commands", package=__package__)
25
+
26
+
27
+ def main():
28
+ stop = False
29
+ t = threading.Thread(target=_spinner, args=(lambda: stop,), daemon=True)
30
+ t.start()
31
+ try:
32
+ is_connected()
33
+ finally:
34
+ stop = True
35
+ t.join()
36
+
37
+ parser = argparse.ArgumentParser(description="Flow Music Player")
38
+ parser.add_argument("-r", action="store_true", help="repeat mode")
39
+ parser.add_argument("-s", action="store_true", help="shuffle")
40
+ parser.add_argument("-d", action="store_true", help="download mode")
41
+ parser.add_argument("command", nargs="?", default=None, help="subcommand (play, search, list, ...)")
42
+
43
+ args, unknown = parser.parse_known_args()
44
+
45
+ commands = _load_commands()
46
+
47
+ show_banner()
48
+
49
+ if args.command:
50
+ commands.run(args.command, unknown, args)
51
+ elif unknown:
52
+ tui_print(color="white", border="double")(f"Unknown argument: {' '.join(unknown)}")
53
+ else:
54
+ while True:
55
+ try:
56
+ parts = tui_input().strip().split()
57
+ if not parts:
58
+ continue
59
+ cmd = parts[0].lower()
60
+ extra = parts[1:]
61
+ if cmd in ("exit", "quit", "q"):
62
+ tui_print(color="grey", border="none")("Goodbye!")
63
+ break
64
+ cmd_args = argparse.Namespace(**vars(args))
65
+ for flag in ("r", "s", "d"):
66
+ setattr(cmd_args, flag, False)
67
+ commands.run(cmd, extra, cmd_args)
68
+ if cmd == "switch":
69
+ mode = config.Mode
70
+ commands = _load_commands()
71
+ show_banner()
72
+ tui_print(color="grey", border="none")(f"Mode changed to {mode}")
73
+ except (EOFError, KeyboardInterrupt):
74
+ tui_print(color="grey", border="none")("\nGoodbye!")
75
+ break
76
+
77
+
78
+ if __name__ == "__main__":
79
+ main()
flow_twinx/ping.py ADDED
@@ -0,0 +1,38 @@
1
+ import socket
2
+ import urllib.request
3
+ from . import config
4
+
5
+
6
+ def is_connected(timeout: int = 2, hosts: list[str] | None = None) -> bool:
7
+ if hosts is None:
8
+ hosts = ["1.1.1.1"]
9
+
10
+ socket.setdefaulttimeout(timeout)
11
+
12
+ for host in hosts:
13
+ try:
14
+ with urllib.request.urlopen(f"https://{host}", timeout=timeout):
15
+ pass
16
+ config.Mode = "Online"
17
+ config.THEME = config.ONLINE_THEME
18
+ return True
19
+ except Exception:
20
+ pass
21
+
22
+ try:
23
+ socket.create_connection((host, 80), timeout=timeout).close()
24
+ config.Mode = "Online"
25
+ config.THEME = config.ONLINE_THEME
26
+ return True
27
+ except Exception:
28
+ continue
29
+
30
+ config.Mode = "Offline"
31
+ config.THEME = config.OFFLINE_THEME
32
+ return False
33
+
34
+
35
+ # if __name__ == "__main__":
36
+ # connected = is_connected()
37
+ # print(f"Status : {config.Mode}")
38
+ # print(f"Returns: {connected}")
flow_twinx/tui.py ADDED
@@ -0,0 +1,69 @@
1
+ import builtins
2
+ from . import config
3
+
4
+ RESET = "\033[0m"
5
+
6
+ BORDER_CHARS = {
7
+ "none": None,
8
+ "single": ("-", "|", "+"),
9
+ "double": ("=", "|", "+"),
10
+ "dashed": ("- ", "!", "+"),
11
+ }
12
+
13
+
14
+ def _color_map():
15
+ colors = config.THEMES.get(config.THEME, config.THEMES["cyan"])
16
+ return {
17
+ "white": colors["white"],
18
+ "grey": colors["grey"],
19
+ "theme": colors["theme"],
20
+ }
21
+
22
+
23
+ def print(color: str = "white", border: str = "none"):
24
+ def styled_print(text: str):
25
+ cm = _color_map()
26
+ color_code = cm.get(color.lower(), cm["white"])
27
+ border_style = BORDER_CHARS.get(border.lower())
28
+
29
+ if border_style is None:
30
+ print_line = f"{color_code}{text}{RESET}"
31
+ builtins.print(print_line)
32
+ else:
33
+ h_char, v_char, corner_char = border_style
34
+ lines = text.split("\n")
35
+ max_w = max(len(l) for l in lines)
36
+ width = max_w + 2
37
+ top_bottom = corner_char + h_char * width + corner_char
38
+ builtins.print(f"{color_code}{top_bottom}{RESET}")
39
+ for line in lines:
40
+ padded = line.ljust(max_w)
41
+ builtins.print(f"{color_code}{v_char} {padded} {v_char}{RESET}")
42
+ builtins.print(f"{color_code}{top_bottom}{RESET}")
43
+
44
+ return styled_print
45
+
46
+
47
+ BANNER = r"""
48
+ ███████╗██╗ ██████╗ ██╗ ██╗
49
+ ██╔════╝██║ ██╔═══██╗██║ ██║
50
+ █████╗ ██║ ██║ ██║██║ █╗ ██║
51
+ ██╔══╝ ██║ ██║ ██║██║███╗██║
52
+ ██║ ███████╗╚██████╔╝╚███╔███╔╝
53
+ ╚═╝ ╚══════╝ ╚═════╝ ╚══╝╚══╝
54
+ """
55
+
56
+
57
+ def show_banner():
58
+ p = print(color="theme", border="none")
59
+ p(BANNER)
60
+ p = print(color="grey", border="none")
61
+ p(f" Flow Music Player v{config.VERSION}")
62
+ p = print(color="theme", border="none")
63
+ p(f" Mode : {config.Mode}")
64
+ builtins.print()
65
+
66
+
67
+ def input(prompt: str = "") -> str:
68
+ cm = _color_map()
69
+ return builtins.input(f"{cm['theme']}{prompt}$ {RESET}")
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: flow-twinx
3
+ Version: 0.1.0
4
+ Summary: A terminal music player with online and offline modes
5
+ Requires-Python: >=3.9
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: python-vlc
8
+ Requires-Dist: yt_dlp
9
+
10
+ # Flow
11
+
12
+ A terminal-based music player with online streaming and offline library modes.
13
+
14
+ ## Features
15
+
16
+ - **Dual-mode operation** — Automatically detects internet and switches between online streaming and offline playback
17
+ - **Online mode** — Search and stream audio from YouTube via `yt-dlp` and `python-vlc`
18
+ - **Offline mode** — Play local audio files with album support, search, and a liked-songs collection
19
+ - **Download** — Save tracks from YouTube to your local library with the `-d` flag
20
+ - **Repeat & shuffle** — Loop tracks or play in random order
21
+ - **Like/unlike** — Save favorites to a dedicated playlist
22
+ - **Tab completion** — Auto-complete commands and song names in offline mode
23
+ - **Colored TUI** — Cyan theme for online, magenta for offline, with borders and banners
24
+
25
+ ## Requirements
26
+
27
+ - Python 3
28
+ - [VLC](https://www.videolan.org/vlc/) media player (for `python-vlc` bindings)
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ git clone https://github.com/yourusername/flow.git
34
+ cd flow
35
+ python -m venv .venv
36
+ .venv\Scripts\pip install -r requirements.txt
37
+ ```
38
+
39
+ Or use the installer script:
40
+
41
+ ```bash
42
+ bash install.sh
43
+ ```
44
+
45
+ ## Usage
46
+
47
+ ```bash
48
+ python main.py [-r] [-s] [-d]
49
+ ```
50
+
51
+ Or direct if you have used install.sh
52
+
53
+ ```bash
54
+ flow <command> <flag>
55
+ ```
56
+
57
+ | Flag | Description |
58
+ | ---- | ---------------------------------------------- |
59
+ | `-r` | Repeat mode (loop current track) |
60
+ | `-s` | Shuffle mode (random order) |
61
+ | `-d` | Download mode (save streamed audio to library) |
62
+
63
+ ### Commands
64
+
65
+ | Command | Description |
66
+ | ---------------------- | -------------------------------------------- |
67
+ | `play <name or #>` | Play a song by name or search result number |
68
+ | `search <query>` | Search YouTube (online) or library (offline) |
69
+ | `list` | Show all songs, albums, or liked tracks |
70
+ | `like <name or #>` | Add a song to your liked collection |
71
+ | `download <name or #>` | Save a streamed song to the local library |
72
+ | `switch` | Toggle between online and offline mode |
73
+ | `help` | Show available commands |
74
+
75
+ ## Configuration
76
+
77
+ - Downloads and library are stored in `~/.flow/downloads/`
78
+ - Liked songs (online) are saved to `~/.flow/liked.txt`
79
+ - Liked songs (offline) are copied to `~/.flow/downloads/liked songs/`
80
+
81
+ ## Project Structure
82
+
83
+ ```
84
+ Flow/
85
+ ├── config.py # Configuration and theme settings
86
+ ├── main.py # Entry point and REPL loop
87
+ ├── ping.py # Internet connectivity check
88
+ ├── tui.py # Terminal UI helpers
89
+ ├── requirements.txt # Python dependencies
90
+ ├── install.sh # Installer script
91
+ ├── Online/
92
+ │ ├── commands.py # Online mode commands
93
+ │ ├── player.py # VLC streaming player
94
+ │ └── youtube.py # YouTube search and download
95
+ └── Offline/
96
+ ├── commands.py # Offline mode commands
97
+ ├── file.py # Library scanner and search
98
+ └── player.py # Local file player
99
+ ```
100
+
101
+ ## License
102
+
103
+ MIT
@@ -0,0 +1,19 @@
1
+ flow_twinx/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ flow_twinx/config.py,sha256=bCdqtwnnhUL0CBuHyq-KlJ5JqvZJA1DQlaGWh3omf_I,1097
3
+ flow_twinx/main.py,sha256=glmm02sT-N_ANkyzJx_5BAn_kKXvxHDwamVh27KD69k,2488
4
+ flow_twinx/ping.py,sha256=HUbgIt74pQa4Rdho4AI9ETl42jEQiwchyStt3FkDJog,974
5
+ flow_twinx/tui.py,sha256=p4CGCxPIRMBQ6LlGi4L3CbpglsyBx2f66dJxaQExqqk,2255
6
+ flow_twinx/Offline/__init__.py,sha256=lbrqtHeIo65UEFVJ9AXqRGey8zqd4i1PtY5EFx_zu3U,151
7
+ flow_twinx/Offline/commands.py,sha256=N9ZSrakpUM8sZ6zyKUuSEKmF9sGcgbz9T2vyfwKOeYI,5191
8
+ flow_twinx/Offline/file.py,sha256=Thna6vgLY0pAtvvOrhoq-vCVYoDSEzaUnCfsAE_TEDQ,1919
9
+ flow_twinx/Offline/player.py,sha256=WTuzdGMgoY1484mCuJ-3ASB41D236mcaJsGgsJQBxwA,1826
10
+ flow_twinx/Offline/youtube.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ flow_twinx/Online/__init__.py,sha256=7VmsUqVyQP7Dhf-tnpi_K0aLs2ZoO0HLWyRvhumF06M,152
12
+ flow_twinx/Online/commands.py,sha256=W7YrbMR5LNNuBWjifQt0FMp-KObZXfkzIUq3GboNJYk,5865
13
+ flow_twinx/Online/player.py,sha256=dJDUD_tDgcFlWM18EdWOYWw8YfXAZ0YSnosBGfL4i0E,2002
14
+ flow_twinx/Online/youtube.py,sha256=wl37V02f6yIkbU4QfhzVY7hbThOXPT8qlYfuOPpb0lo,1069
15
+ flow_twinx-0.1.0.dist-info/METADATA,sha256=3mGOlfS0ZcTLjUbpE8mBAnZXkuvwb0CLxmbF6zVQsTw,3502
16
+ flow_twinx-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
17
+ flow_twinx-0.1.0.dist-info/entry_points.txt,sha256=LtE-zIBzzSCt-MCRHoGtyCZ7bpNRd5F5fx-WNmqEWSk,46
18
+ flow_twinx-0.1.0.dist-info/top_level.txt,sha256=d--FYiR1hjT5uDgDi0yb7SDWAFUhpcNq5ndZ4yPpAXw,11
19
+ flow_twinx-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ flow = flow_twinx.main:main
@@ -0,0 +1 @@
1
+ flow_twinx