git-cli-yt 1.2.0 → 2.0.0
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.
- package/PRIVACY_POLICY.md +10 -0
- package/README.md +49 -1
- package/app.py +1322 -0
- package/audio/__init__.py +0 -0
- package/audio/audio_player.py +566 -0
- package/bin/cli.js +104 -66
- package/main.py +8 -1065
- package/mix/__init__.py +0 -0
- package/mix/mix_service.py +227 -0
- package/package.json +32 -29
- package/requirements.txt +10 -8
- package/utils/__init__.py +0 -0
- package/utils/helpers.py +37 -0
- package/youtube/__init__.py +0 -0
- package/youtube/auth.py +196 -0
- package/youtube/liked.py +121 -0
- package/youtube/playlist.py +139 -0
- package/youtube/search.py +362 -0
package/main.py
CHANGED
|
@@ -1,1065 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
import av
|
|
11
|
-
from google.auth.transport.requests import Request
|
|
12
|
-
|
|
13
|
-
from google.oauth2.credentials import Credentials
|
|
14
|
-
from google_auth_oauthlib.flow import InstalledAppFlow
|
|
15
|
-
from googleapiclient.discovery import build
|
|
16
|
-
from googleapiclient.errors import HttpError
|
|
17
|
-
import numpy as np
|
|
18
|
-
import sounddevice as sd
|
|
19
|
-
from textual.app import App, ComposeResult
|
|
20
|
-
from textual.containers import Horizontal, Vertical
|
|
21
|
-
from textual.widgets import (
|
|
22
|
-
Button,
|
|
23
|
-
Footer,
|
|
24
|
-
Header,
|
|
25
|
-
Input,
|
|
26
|
-
Label,
|
|
27
|
-
ListItem,
|
|
28
|
-
ListView,
|
|
29
|
-
ProgressBar,
|
|
30
|
-
Select,
|
|
31
|
-
)
|
|
32
|
-
import yt_dlp
|
|
33
|
-
|
|
34
|
-
# Tenta carregar variáveis do .env se existir
|
|
35
|
-
try:
|
|
36
|
-
from dotenv import load_dotenv
|
|
37
|
-
|
|
38
|
-
load_dotenv(Path.cwd() / ".env")
|
|
39
|
-
load_dotenv(Path(__file__).resolve().parent / ".env")
|
|
40
|
-
except ImportError:
|
|
41
|
-
pass
|
|
42
|
-
|
|
43
|
-
socket.setdefaulttimeout(15)
|
|
44
|
-
|
|
45
|
-
# Diretório para salvar o token do usuário
|
|
46
|
-
CONFIG_DIR = Path.home() / ".config" / "meu-player-tui"
|
|
47
|
-
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
48
|
-
TOKEN_FILE = CONFIG_DIR / "token.json"
|
|
49
|
-
|
|
50
|
-
SCOPES = ["https://www.googleapis.com/auth/youtube.readonly"]
|
|
51
|
-
MUSIC_CATEGORY_ID = "10"
|
|
52
|
-
|
|
53
|
-
DEFAULT_CLIENT_ID = os.environ.get(
|
|
54
|
-
"GOOGLE_CLIENT_ID",
|
|
55
|
-
"50790974670-qshvlqkejhu0ksj76v0t0lpcq3krm593.apps.googleusercontent.com",
|
|
56
|
-
)
|
|
57
|
-
DEFAULT_CLIENT_SECRET = os.environ.get(
|
|
58
|
-
"GOOGLE_CLIENT_SECRET", "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"
|
|
59
|
-
)
|
|
60
|
-
|
|
61
|
-
def get_client_config():
|
|
62
|
-
"""Retorna a configuração OAuth 2.0 padrão para aplicação desktop."""
|
|
63
|
-
return {
|
|
64
|
-
"installed": {
|
|
65
|
-
"client_id": DEFAULT_CLIENT_ID,
|
|
66
|
-
"client_secret": DEFAULT_CLIENT_SECRET,
|
|
67
|
-
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
|
68
|
-
"token_uri": "https://oauth2.googleapis.com/token",
|
|
69
|
-
"redirect_uris": ["http://localhost:8080/"],
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def get_youtube_service(interactive=False):
|
|
75
|
-
"""Obtém a conexão com a API do YouTube."""
|
|
76
|
-
creds = None
|
|
77
|
-
|
|
78
|
-
if TOKEN_FILE.exists():
|
|
79
|
-
try:
|
|
80
|
-
creds = Credentials.from_authorized_user_file(
|
|
81
|
-
str(TOKEN_FILE), SCOPES
|
|
82
|
-
)
|
|
83
|
-
except Exception:
|
|
84
|
-
creds = None
|
|
85
|
-
|
|
86
|
-
if creds and creds.expired and creds.refresh_token:
|
|
87
|
-
try:
|
|
88
|
-
creds.refresh(Request())
|
|
89
|
-
with open(TOKEN_FILE, "w", encoding="utf-8") as token:
|
|
90
|
-
token.write(creds.to_json())
|
|
91
|
-
except Exception:
|
|
92
|
-
creds = None
|
|
93
|
-
|
|
94
|
-
if not creds or not creds.valid:
|
|
95
|
-
if not interactive:
|
|
96
|
-
return None
|
|
97
|
-
|
|
98
|
-
client_config = get_client_config()
|
|
99
|
-
flow = InstalledAppFlow.from_client_config(
|
|
100
|
-
client_config,
|
|
101
|
-
SCOPES,
|
|
102
|
-
redirect_uri="http://localhost:8080/",
|
|
103
|
-
)
|
|
104
|
-
|
|
105
|
-
creds = flow.run_local_server(
|
|
106
|
-
port=8080,
|
|
107
|
-
prompt="consent",
|
|
108
|
-
access_type="offline",
|
|
109
|
-
)
|
|
110
|
-
|
|
111
|
-
with open(TOKEN_FILE, "w", encoding="utf-8") as token:
|
|
112
|
-
token.write(creds.to_json())
|
|
113
|
-
|
|
114
|
-
return build("youtube", "v3", credentials=creds)
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
class MusicPlayerApp(App):
|
|
118
|
-
CSS = """
|
|
119
|
-
$youtube-bg: #0f0f0f;
|
|
120
|
-
$youtube-panel: #212121;
|
|
121
|
-
$youtube-panel-dark: #181818;
|
|
122
|
-
$youtube-hover: #3f3f3f;
|
|
123
|
-
$youtube-border: #303030;
|
|
124
|
-
$youtube-text: #f1f1f1;
|
|
125
|
-
$youtube-muted: #aaaaaa;
|
|
126
|
-
$youtube-red: #ff0000;
|
|
127
|
-
$youtube-red-dark: #cc0000;
|
|
128
|
-
|
|
129
|
-
Screen {
|
|
130
|
-
layout: vertical;
|
|
131
|
-
background: $youtube-bg;
|
|
132
|
-
color: $youtube-text;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
Header {
|
|
136
|
-
height: 3;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
#main {
|
|
140
|
-
height: 1fr;
|
|
141
|
-
width: 100%;
|
|
142
|
-
layout: horizontal;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
#sidebar {
|
|
146
|
-
width: 28;
|
|
147
|
-
height: 100%;
|
|
148
|
-
background: $youtube-panel-dark;
|
|
149
|
-
border-right: solid $youtube-border;
|
|
150
|
-
padding: 1 1;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
#sidebar-title {
|
|
154
|
-
height: 3;
|
|
155
|
-
content-align: center middle;
|
|
156
|
-
text-style: bold;
|
|
157
|
-
color: $youtube-red;
|
|
158
|
-
margin-bottom: 1;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
.nav-button {
|
|
162
|
-
width: 100%;
|
|
163
|
-
margin-bottom: 1;
|
|
164
|
-
background: $youtube-panel-dark;
|
|
165
|
-
color: $youtube-text;
|
|
166
|
-
border: none;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
.nav-button:hover {
|
|
170
|
-
background: $youtube-hover;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
.nav-button.-primary {
|
|
174
|
-
background: $youtube-red;
|
|
175
|
-
color: #ffffff;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
.nav-button.-primary:hover {
|
|
179
|
-
background: $youtube-red-dark;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
#nav-spacer {
|
|
183
|
-
height: 1fr;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
#content {
|
|
187
|
-
width: 1fr;
|
|
188
|
-
height: 100%;
|
|
189
|
-
padding: 2 3;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
.page {
|
|
193
|
-
width: 100%;
|
|
194
|
-
height: 100%;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
.page-title {
|
|
198
|
-
text-style: bold;
|
|
199
|
-
color: $youtube-text;
|
|
200
|
-
margin-bottom: 1;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
#search_input {
|
|
204
|
-
width: 100%;
|
|
205
|
-
margin-bottom: 1;
|
|
206
|
-
background: $youtube-panel-dark;
|
|
207
|
-
color: $youtube-text;
|
|
208
|
-
border: solid $youtube-border;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
#search_input:focus {
|
|
212
|
-
border: solid $youtube-text;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
#search-toolbar {
|
|
216
|
-
height: auto;
|
|
217
|
-
margin-bottom: 1;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
#filter_select {
|
|
221
|
-
width: 32;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
#results_list {
|
|
225
|
-
width: 100%;
|
|
226
|
-
height: 1fr;
|
|
227
|
-
background: $youtube-bg;
|
|
228
|
-
border: solid $youtube-border;
|
|
229
|
-
margin-top: 1;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
#liked_results_list {
|
|
233
|
-
width: 100%;
|
|
234
|
-
height: 1fr;
|
|
235
|
-
background: $youtube-bg;
|
|
236
|
-
border: solid $youtube-border;
|
|
237
|
-
margin-top: 1;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
#btn_load_more, #btn_liked_load_more {
|
|
241
|
-
width: 100%;
|
|
242
|
-
margin-top: 1;
|
|
243
|
-
background: $youtube-panel;
|
|
244
|
-
color: $youtube-text;
|
|
245
|
-
border: solid $youtube-border;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
#btn_load_more:hover, #btn_liked_load_more:hover {
|
|
249
|
-
background: $youtube-hover;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
#liked_hint, #login_hint, #settings_hint {
|
|
253
|
-
color: $youtube-muted;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
#login-panel {
|
|
257
|
-
width: 100%;
|
|
258
|
-
height: auto;
|
|
259
|
-
border: round $youtube-border;
|
|
260
|
-
background: $youtube-panel-dark;
|
|
261
|
-
padding: 2;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
#login-user {
|
|
265
|
-
margin: 1 0;
|
|
266
|
-
text-style: bold;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
#player {
|
|
270
|
-
height: 11;
|
|
271
|
-
width: 100%;
|
|
272
|
-
background: $youtube-panel;
|
|
273
|
-
border-top: solid $youtube-border;
|
|
274
|
-
padding: 1 2;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
#now-playing {
|
|
278
|
-
width: 100%;
|
|
279
|
-
height: 2;
|
|
280
|
-
text-align: center;
|
|
281
|
-
text-style: bold;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
#visualizer {
|
|
285
|
-
width: 100%;
|
|
286
|
-
height: 1;
|
|
287
|
-
text-align: center;
|
|
288
|
-
color: $youtube-red;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
#progress-container {
|
|
292
|
-
width: 100%;
|
|
293
|
-
height: 2;
|
|
294
|
-
align: center middle;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
#progress-container ProgressBar {
|
|
298
|
-
width: 1fr;
|
|
299
|
-
margin: 0 1;
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
#volume-container {
|
|
303
|
-
width: 100%;
|
|
304
|
-
height: 3;
|
|
305
|
-
align: center middle;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
#volume-container Button {
|
|
309
|
-
margin: 0 1;
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
#controls {
|
|
313
|
-
width: 100%;
|
|
314
|
-
height: 3;
|
|
315
|
-
align: center middle;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
#controls Button {
|
|
319
|
-
margin: 0 1;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
#status {
|
|
323
|
-
width: 100%;
|
|
324
|
-
height: 1;
|
|
325
|
-
text-align: center;
|
|
326
|
-
color: $youtube-muted;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
.danger-nav {
|
|
330
|
-
color: $youtube-red;
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
#controls Button {
|
|
334
|
-
background: $youtube-panel-dark;
|
|
335
|
-
color: $youtube-text;
|
|
336
|
-
border: solid $youtube-border;
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
#controls Button:hover {
|
|
340
|
-
background: $youtube-hover;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
#controls #btn_toggle.-primary {
|
|
344
|
-
background: $youtube-red;
|
|
345
|
-
color: #ffffff;
|
|
346
|
-
border: none;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
#controls #btn_toggle.-primary:hover {
|
|
350
|
-
background: $youtube-red-dark;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
#controls #btn_stop.-error {
|
|
354
|
-
background: $youtube-red;
|
|
355
|
-
color: #ffffff;
|
|
356
|
-
border: none;
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
#controls #btn_stop.-error:hover {
|
|
360
|
-
background: $youtube-red-dark;
|
|
361
|
-
}
|
|
362
|
-
"""
|
|
363
|
-
|
|
364
|
-
BINDINGS = [
|
|
365
|
-
("q", "quit", "Sair"),
|
|
366
|
-
("space", "toggle_play", "Play/Pause"),
|
|
367
|
-
("up", "volume_up", "Aumentar Vol"),
|
|
368
|
-
("down", "volume_down", "Diminuir Vol"),
|
|
369
|
-
]
|
|
370
|
-
|
|
371
|
-
def __init__(self):
|
|
372
|
-
super().__init__()
|
|
373
|
-
self.stream_thread = None
|
|
374
|
-
self.stop_event = threading.Event()
|
|
375
|
-
self.pause_event = threading.Event()
|
|
376
|
-
self.pause_event.set()
|
|
377
|
-
|
|
378
|
-
self.is_playing = False
|
|
379
|
-
self.raw_results = []
|
|
380
|
-
self.filtered_results = []
|
|
381
|
-
self.youtube_api = None
|
|
382
|
-
|
|
383
|
-
# Paginação e Busca
|
|
384
|
-
self.next_page_token = None
|
|
385
|
-
self.last_query = ""
|
|
386
|
-
self.last_source_type = None # 'search' ou 'liked'
|
|
387
|
-
self.is_loading_more = False
|
|
388
|
-
|
|
389
|
-
# Áudio
|
|
390
|
-
self.volume = 0.8
|
|
391
|
-
self.duration_seconds = 0
|
|
392
|
-
self.current_position_seconds = 0
|
|
393
|
-
self.seek_target_seconds = None
|
|
394
|
-
self.current_page = "home"
|
|
395
|
-
|
|
396
|
-
def compose(self) -> ComposeResult:
|
|
397
|
-
yield Header()
|
|
398
|
-
|
|
399
|
-
with Horizontal(id="main"):
|
|
400
|
-
with Vertical(id="sidebar"):
|
|
401
|
-
yield Label("🎵 MEU PLAYER", id="sidebar-title")
|
|
402
|
-
yield Button("🏠 Início", id="btn_home", classes="nav-button", variant="primary")
|
|
403
|
-
yield Button("❤️ Curtidas", id="btn_liked", classes="nav-button")
|
|
404
|
-
yield Button("👤 Login", id="btn_login", classes="nav-button")
|
|
405
|
-
yield Button("⚙️ Configurações", id="btn_settings", classes="nav-button")
|
|
406
|
-
yield Label("", id="nav-spacer")
|
|
407
|
-
yield Button("🚪 Sair", id="btn_exit", classes="nav-button danger-nav")
|
|
408
|
-
|
|
409
|
-
with Vertical(id="content"):
|
|
410
|
-
with Vertical(id="home_page", classes="page"):
|
|
411
|
-
yield Label("Início", classes="page-title")
|
|
412
|
-
yield Input(
|
|
413
|
-
placeholder="Pesquise por música, artista ou álbum...",
|
|
414
|
-
id="search_input",
|
|
415
|
-
)
|
|
416
|
-
with Horizontal(id="search-toolbar"):
|
|
417
|
-
yield Select(
|
|
418
|
-
[
|
|
419
|
-
("🎵 Apenas Músicas", "music"),
|
|
420
|
-
("🎬 Todos os Vídeos", "all"),
|
|
421
|
-
],
|
|
422
|
-
value="music",
|
|
423
|
-
id="filter_select",
|
|
424
|
-
allow_blank=False,
|
|
425
|
-
)
|
|
426
|
-
yield ListView(id="results_list")
|
|
427
|
-
yield Button("➕ Carregar mais", id="btn_load_more")
|
|
428
|
-
|
|
429
|
-
with Vertical(id="liked_page", classes="page"):
|
|
430
|
-
yield Label("Curtidas", classes="page-title")
|
|
431
|
-
yield Label(
|
|
432
|
-
"Suas músicas curtidas no YouTube.",
|
|
433
|
-
id="liked_hint",
|
|
434
|
-
)
|
|
435
|
-
yield ListView(id="liked_results_list")
|
|
436
|
-
yield Button("➕ Carregar mais", id="btn_liked_load_more")
|
|
437
|
-
|
|
438
|
-
with Vertical(id="login_page", classes="page"):
|
|
439
|
-
yield Label("Login", classes="page-title")
|
|
440
|
-
with Vertical(id="login-panel"):
|
|
441
|
-
yield Label("Verificando sessão...", id="login-user")
|
|
442
|
-
yield Label(
|
|
443
|
-
"Conecte sua conta do YouTube para acessar suas curtidas.",
|
|
444
|
-
id="login_hint",
|
|
445
|
-
)
|
|
446
|
-
yield Button("🔑 Entrar com Google", id="btn_login_action", variant="primary")
|
|
447
|
-
yield Button("🔄 Trocar conta", id="btn_logout_account")
|
|
448
|
-
|
|
449
|
-
with Vertical(id="settings_page", classes="page"):
|
|
450
|
-
yield Label("Configurações", classes="page-title")
|
|
451
|
-
yield Label("🚧 Em construção...", id="settings_hint")
|
|
452
|
-
|
|
453
|
-
with Vertical(id="player"):
|
|
454
|
-
yield Label("Nenhuma música selecionada", id="now-playing")
|
|
455
|
-
yield Label("░░░░░░░░░░░░░░░░░░░░", id="visualizer")
|
|
456
|
-
with Horizontal(id="progress-container"):
|
|
457
|
-
yield Label("00:00 ", id="time_current")
|
|
458
|
-
yield ProgressBar(id="song_progress", total=100, show_percentage=False)
|
|
459
|
-
yield Label(" 00:00", id="time_total")
|
|
460
|
-
with Horizontal(id="controls"):
|
|
461
|
-
yield Button("⏪ -10s", id="btn_rewind")
|
|
462
|
-
yield Button("▶ / ⏸", id="btn_toggle", variant="primary")
|
|
463
|
-
yield Button("⏩ +10s", id="btn_forward")
|
|
464
|
-
yield Button("⏹ Parar", id="btn_stop", variant="error")
|
|
465
|
-
yield Button("🔉 -", id="btn_vol_down")
|
|
466
|
-
yield Label("Volume: 80%", id="vol_label")
|
|
467
|
-
yield Button("🔊 +", id="btn_vol_up")
|
|
468
|
-
yield Label("Status: Pronto.", id="status")
|
|
469
|
-
|
|
470
|
-
yield Footer()
|
|
471
|
-
|
|
472
|
-
def on_mount(self) -> None:
|
|
473
|
-
self._show_page("home")
|
|
474
|
-
|
|
475
|
-
def auto_auth():
|
|
476
|
-
self.youtube_api = get_youtube_service(interactive=False)
|
|
477
|
-
status = self.query_one("#status", Label)
|
|
478
|
-
if self.youtube_api:
|
|
479
|
-
self.call_from_thread(
|
|
480
|
-
status.update,
|
|
481
|
-
"[green]Sessão de usuário carregada com sucesso![/green]",
|
|
482
|
-
)
|
|
483
|
-
self.call_from_thread(self._update_login_page)
|
|
484
|
-
else:
|
|
485
|
-
self.call_from_thread(self._update_login_page)
|
|
486
|
-
|
|
487
|
-
threading.Thread(target=auto_auth, daemon=True).start()
|
|
488
|
-
|
|
489
|
-
def _show_page(self, page: str) -> None:
|
|
490
|
-
pages = {
|
|
491
|
-
"home": "#home_page",
|
|
492
|
-
"liked": "#liked_page",
|
|
493
|
-
"login": "#login_page",
|
|
494
|
-
"settings": "#settings_page",
|
|
495
|
-
}
|
|
496
|
-
for name, selector in pages.items():
|
|
497
|
-
self.query_one(selector).display = name == page
|
|
498
|
-
|
|
499
|
-
self.current_page = page
|
|
500
|
-
nav_buttons = {
|
|
501
|
-
"home": "#btn_home",
|
|
502
|
-
"liked": "#btn_liked",
|
|
503
|
-
"login": "#btn_login",
|
|
504
|
-
"settings": "#btn_settings",
|
|
505
|
-
}
|
|
506
|
-
for name, selector in nav_buttons.items():
|
|
507
|
-
button = self.query_one(selector, Button)
|
|
508
|
-
button.variant = "primary" if name == page else "default"
|
|
509
|
-
|
|
510
|
-
if page == "login":
|
|
511
|
-
self._update_login_page()
|
|
512
|
-
|
|
513
|
-
def _update_login_page(self) -> None:
|
|
514
|
-
user_label = self.query_one("#login-user", Label)
|
|
515
|
-
login_button = self.query_one("#btn_login_action", Button)
|
|
516
|
-
logout_button = self.query_one("#btn_logout_account", Button)
|
|
517
|
-
|
|
518
|
-
if not self.youtube_api:
|
|
519
|
-
user_label.update("Nenhuma conta conectada")
|
|
520
|
-
login_button.display = True
|
|
521
|
-
logout_button.display = False
|
|
522
|
-
return
|
|
523
|
-
|
|
524
|
-
user_label.update("Conta conectada. Carregando usuário...")
|
|
525
|
-
login_button.display = False
|
|
526
|
-
logout_button.display = True
|
|
527
|
-
|
|
528
|
-
def fetch_user():
|
|
529
|
-
try:
|
|
530
|
-
response = self.youtube_api.channels().list(
|
|
531
|
-
part="snippet",
|
|
532
|
-
mine=True,
|
|
533
|
-
).execute()
|
|
534
|
-
items = response.get("items", [])
|
|
535
|
-
if items:
|
|
536
|
-
name = items[0].get("snippet", {}).get("title", "Conta do YouTube")
|
|
537
|
-
else:
|
|
538
|
-
name = "Conta do YouTube"
|
|
539
|
-
self.call_from_thread(
|
|
540
|
-
user_label.update,
|
|
541
|
-
f"👤 {name}",
|
|
542
|
-
)
|
|
543
|
-
except Exception:
|
|
544
|
-
self.call_from_thread(user_label.update, "👤 Conta do YouTube conectada")
|
|
545
|
-
|
|
546
|
-
threading.Thread(target=fetch_user, daemon=True).start()
|
|
547
|
-
|
|
548
|
-
def _logout_youtube(self) -> None:
|
|
549
|
-
self._stop_audio()
|
|
550
|
-
try:
|
|
551
|
-
if TOKEN_FILE.exists():
|
|
552
|
-
TOKEN_FILE.unlink()
|
|
553
|
-
except OSError as e:
|
|
554
|
-
self.query_one("#status", Label).update(
|
|
555
|
-
f"[red]Erro ao remover sessão: {e}[/red]"
|
|
556
|
-
)
|
|
557
|
-
return
|
|
558
|
-
|
|
559
|
-
self.youtube_api = None
|
|
560
|
-
self.query_one("#status", Label).update("Sessão encerrada.")
|
|
561
|
-
self._update_login_page()
|
|
562
|
-
|
|
563
|
-
def _handle_navigation(self, button_id: str) -> None:
|
|
564
|
-
if button_id == "btn_home":
|
|
565
|
-
self._show_page("home")
|
|
566
|
-
elif button_id == "btn_liked":
|
|
567
|
-
self._show_page("liked")
|
|
568
|
-
self.last_query = ""
|
|
569
|
-
self.next_page_token = None
|
|
570
|
-
self._fetch_liked_videos()
|
|
571
|
-
elif button_id == "btn_login":
|
|
572
|
-
self._show_page("login")
|
|
573
|
-
elif button_id == "btn_settings":
|
|
574
|
-
self._show_page("settings")
|
|
575
|
-
elif button_id == "btn_exit":
|
|
576
|
-
self.exit()
|
|
577
|
-
|
|
578
|
-
# --- FILTRAGEM E ATUALIZAÇÃO ---
|
|
579
|
-
|
|
580
|
-
def on_select_changed(self, event: Select.Changed) -> None:
|
|
581
|
-
if event.select.id == "filter_select":
|
|
582
|
-
self._apply_filter_and_update_ui()
|
|
583
|
-
|
|
584
|
-
def _apply_filter_and_update_ui(self) -> None:
|
|
585
|
-
filter_type = self.query_one("#filter_select", Select).value
|
|
586
|
-
|
|
587
|
-
if filter_type == "music":
|
|
588
|
-
self.filtered_results = [
|
|
589
|
-
item for item in self.raw_results if item.get("is_music")
|
|
590
|
-
]
|
|
591
|
-
else:
|
|
592
|
-
self.filtered_results = list(self.raw_results)
|
|
593
|
-
|
|
594
|
-
list_id = "#liked_results_list" if self.current_page == "liked" else "#results_list"
|
|
595
|
-
list_view = self.query_one(list_id, ListView)
|
|
596
|
-
list_view.clear()
|
|
597
|
-
|
|
598
|
-
for item in self.filtered_results:
|
|
599
|
-
tag = "🎵" if item.get("is_music") else "🎬"
|
|
600
|
-
list_view.append(ListItem(Label(f"{tag} {item['title']}")))
|
|
601
|
-
|
|
602
|
-
status = self.query_one("#status", Label)
|
|
603
|
-
status.update(
|
|
604
|
-
f"[green]Exibindo {len(self.filtered_results)} item(ns).[/green]"
|
|
605
|
-
)
|
|
606
|
-
|
|
607
|
-
# --- AUTENTICAÇÃO E CURTIDAS COM PAGINAÇÃO ---
|
|
608
|
-
|
|
609
|
-
def _authenticate_youtube(self) -> None:
|
|
610
|
-
status = self.query_one("#status", Label)
|
|
611
|
-
status.update("[yellow]Abra o navegador para autorizar o acesso...[/yellow]")
|
|
612
|
-
|
|
613
|
-
try:
|
|
614
|
-
self.youtube_api = get_youtube_service(interactive=True)
|
|
615
|
-
self.call_from_thread(
|
|
616
|
-
status.update, "[green]Login realizado com sucesso![/green]"
|
|
617
|
-
)
|
|
618
|
-
self.call_from_thread(self._update_login_page)
|
|
619
|
-
except Exception as e:
|
|
620
|
-
self.call_from_thread(
|
|
621
|
-
status.update, f"[red]Erro na autenticação: {e}[/red]"
|
|
622
|
-
)
|
|
623
|
-
|
|
624
|
-
def _fetch_liked_videos(self, load_more=False) -> None:
|
|
625
|
-
status = self.query_one("#status", Label)
|
|
626
|
-
status.update("[yellow]Carregando músicas curtidas...[/yellow]")
|
|
627
|
-
|
|
628
|
-
def fetch_task():
|
|
629
|
-
try:
|
|
630
|
-
if not self.youtube_api:
|
|
631
|
-
self.youtube_api = get_youtube_service(interactive=True)
|
|
632
|
-
|
|
633
|
-
kwargs = {
|
|
634
|
-
"part": "snippet",
|
|
635
|
-
"myRating": "like",
|
|
636
|
-
"maxResults": 20,
|
|
637
|
-
}
|
|
638
|
-
if load_more and self.next_page_token:
|
|
639
|
-
kwargs["pageToken"] = self.next_page_token
|
|
640
|
-
|
|
641
|
-
request = self.youtube_api.videos().list(**kwargs)
|
|
642
|
-
response = request.execute()
|
|
643
|
-
|
|
644
|
-
items = response.get("items", [])
|
|
645
|
-
self.next_page_token = response.get("nextPageToken")
|
|
646
|
-
|
|
647
|
-
new_items = [
|
|
648
|
-
{
|
|
649
|
-
"title": item["snippet"]["title"],
|
|
650
|
-
"url": f"https://www.youtube.com/watch?v={item['id']}",
|
|
651
|
-
"is_music": True,
|
|
652
|
-
}
|
|
653
|
-
for item in items
|
|
654
|
-
if item.get("snippet", {}).get("categoryId") == MUSIC_CATEGORY_ID
|
|
655
|
-
]
|
|
656
|
-
|
|
657
|
-
if load_more:
|
|
658
|
-
self.raw_results.extend(new_items)
|
|
659
|
-
else:
|
|
660
|
-
self.raw_results = new_items
|
|
661
|
-
|
|
662
|
-
self.last_source_type = "liked"
|
|
663
|
-
self.is_loading_more = False
|
|
664
|
-
self.call_from_thread(self._apply_filter_and_update_ui)
|
|
665
|
-
|
|
666
|
-
except Exception as e:
|
|
667
|
-
self.is_loading_more = False
|
|
668
|
-
self.call_from_thread(
|
|
669
|
-
status.update, f"[red]Erro ao carregar curtidas: {e}[/red]"
|
|
670
|
-
)
|
|
671
|
-
|
|
672
|
-
if not self.is_loading_more:
|
|
673
|
-
self.is_loading_more = True
|
|
674
|
-
threading.Thread(target=fetch_task, daemon=True).start()
|
|
675
|
-
|
|
676
|
-
# --- BUSCA COM YT-DLP E PAGINAÇÃO ---
|
|
677
|
-
|
|
678
|
-
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
679
|
-
query = event.value.strip()
|
|
680
|
-
if not query:
|
|
681
|
-
return
|
|
682
|
-
|
|
683
|
-
self.last_query = query
|
|
684
|
-
self.next_page_token = None
|
|
685
|
-
self.raw_results = []
|
|
686
|
-
|
|
687
|
-
status = self.query_one("#status", Label)
|
|
688
|
-
status.update("[yellow]Buscando no YouTube...[/yellow]")
|
|
689
|
-
|
|
690
|
-
threading.Thread(
|
|
691
|
-
target=self._search_youtube, args=(query, False), daemon=True
|
|
692
|
-
).start()
|
|
693
|
-
|
|
694
|
-
def _search_youtube(self, query: str, load_more=False) -> None:
|
|
695
|
-
count = 15
|
|
696
|
-
search_query = f"ytsearch{count}:{query}"
|
|
697
|
-
|
|
698
|
-
ydl_opts = {
|
|
699
|
-
"quiet": True,
|
|
700
|
-
"extract_flat": True,
|
|
701
|
-
"no_warnings": True,
|
|
702
|
-
"nocheckcertificate": True,
|
|
703
|
-
"extractor_args": {
|
|
704
|
-
"youtube": {"player_client": ["mweb", "web", "android"]}
|
|
705
|
-
},
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
try:
|
|
709
|
-
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
|
710
|
-
info = ydl.extract_info(search_query, download=False)
|
|
711
|
-
entries = info.get("entries", []) if info else []
|
|
712
|
-
|
|
713
|
-
new_entries = [
|
|
714
|
-
{
|
|
715
|
-
"title": entry.get("title", "Sem título"),
|
|
716
|
-
"url": entry.get("url")
|
|
717
|
-
or f"https://www.youtube.com/watch?v={entry.get('id')}",
|
|
718
|
-
"duration": entry.get("duration", 0),
|
|
719
|
-
"is_music": True,
|
|
720
|
-
}
|
|
721
|
-
for entry in entries
|
|
722
|
-
if entry
|
|
723
|
-
]
|
|
724
|
-
|
|
725
|
-
if load_more:
|
|
726
|
-
self.raw_results.extend(new_entries)
|
|
727
|
-
else:
|
|
728
|
-
self.raw_results = new_entries
|
|
729
|
-
|
|
730
|
-
self.last_source_type = "search"
|
|
731
|
-
self.is_loading_more = False
|
|
732
|
-
self.call_from_thread(self._apply_filter_and_update_ui)
|
|
733
|
-
except Exception as e:
|
|
734
|
-
self.is_loading_more = False
|
|
735
|
-
self.call_from_thread(
|
|
736
|
-
self.query_one("#status", Label).update,
|
|
737
|
-
f"[red]Erro na busca: {e}[/red]",
|
|
738
|
-
)
|
|
739
|
-
|
|
740
|
-
def _load_more_content(self) -> None:
|
|
741
|
-
if self.is_loading_more:
|
|
742
|
-
return
|
|
743
|
-
|
|
744
|
-
if self.last_source_type == "liked":
|
|
745
|
-
self._fetch_liked_videos(load_more=True)
|
|
746
|
-
elif self.last_source_type == "search" and self.last_query:
|
|
747
|
-
self.is_loading_more = True
|
|
748
|
-
threading.Thread(
|
|
749
|
-
target=self._search_youtube,
|
|
750
|
-
args=(self.last_query, True),
|
|
751
|
-
daemon=True,
|
|
752
|
-
).start()
|
|
753
|
-
|
|
754
|
-
# --- REPRODUÇÃO E ÁUDIO ---
|
|
755
|
-
|
|
756
|
-
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
|
757
|
-
index = event.list_view.index
|
|
758
|
-
if index is not None and index < len(self.filtered_results):
|
|
759
|
-
selected_track = self.filtered_results[index]
|
|
760
|
-
self._play_stream(selected_track)
|
|
761
|
-
|
|
762
|
-
if index >= len(self.filtered_results) - 2:
|
|
763
|
-
self._load_more_content()
|
|
764
|
-
|
|
765
|
-
def _play_stream(self, track: dict) -> None:
|
|
766
|
-
now_playing = self.query_one("#now-playing", Label)
|
|
767
|
-
status = self.query_one("#status", Label)
|
|
768
|
-
|
|
769
|
-
self._stop_audio()
|
|
770
|
-
|
|
771
|
-
self.current_position_seconds = 0
|
|
772
|
-
self.duration_seconds = 0
|
|
773
|
-
self.seek_target_seconds = None
|
|
774
|
-
|
|
775
|
-
now_playing.update(f"[bold cyan]Tocando:[/bold cyan] {track['title']}")
|
|
776
|
-
status.update("[yellow]Obtendo áudio...[/yellow]")
|
|
777
|
-
|
|
778
|
-
def stream_worker():
|
|
779
|
-
max_retries = 3
|
|
780
|
-
retry_count = 0
|
|
781
|
-
|
|
782
|
-
self.stop_event.clear()
|
|
783
|
-
self.pause_event.set()
|
|
784
|
-
|
|
785
|
-
pcm_buffer = bytearray()
|
|
786
|
-
pcm_lock = threading.Lock()
|
|
787
|
-
played_samples = 0
|
|
788
|
-
|
|
789
|
-
def audio_callback(outdata, frames, time_info, status_flags):
|
|
790
|
-
nonlocal pcm_buffer, played_samples
|
|
791
|
-
bytes_needed = frames * 2 * 4 # frames * 2 canais * 4 bytes (float32)
|
|
792
|
-
|
|
793
|
-
with pcm_lock:
|
|
794
|
-
if len(pcm_buffer) >= bytes_needed:
|
|
795
|
-
chunk = pcm_buffer[:bytes_needed]
|
|
796
|
-
pcm_buffer = pcm_buffer[bytes_needed:]
|
|
797
|
-
outdata[:] = np.frombuffer(chunk, dtype=np.float32).reshape(
|
|
798
|
-
frames, 2
|
|
799
|
-
)
|
|
800
|
-
if self.pause_event.is_set():
|
|
801
|
-
played_samples += frames
|
|
802
|
-
else:
|
|
803
|
-
outdata.fill(0)
|
|
804
|
-
|
|
805
|
-
while retry_count <= max_retries and not self.stop_event.is_set():
|
|
806
|
-
try:
|
|
807
|
-
ydl_opts = {
|
|
808
|
-
"format": "bestaudio/best/b",
|
|
809
|
-
"quiet": True,
|
|
810
|
-
"no_warnings": True,
|
|
811
|
-
"nocheckcertificate": True,
|
|
812
|
-
"extractor_args": {
|
|
813
|
-
"youtube": {"player_client": ["mweb", "web", "android"]}
|
|
814
|
-
},
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
|
818
|
-
info = ydl.extract_info(track["url"], download=False)
|
|
819
|
-
direct_url = info["url"]
|
|
820
|
-
self.duration_seconds = info.get("duration", 0)
|
|
821
|
-
headers = info.get("http_headers", {})
|
|
822
|
-
|
|
823
|
-
headers_str = "".join([f"{k}: {v}\r\n" for k, v in headers.items()])
|
|
824
|
-
|
|
825
|
-
container_options = {
|
|
826
|
-
"user_agent": headers.get(
|
|
827
|
-
"User-Agent",
|
|
828
|
-
"Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
|
|
829
|
-
),
|
|
830
|
-
"headers": headers_str,
|
|
831
|
-
"reconnect": "1",
|
|
832
|
-
"reconnect_streamed": "1",
|
|
833
|
-
"reconnect_delay_max": "5",
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
container = av.open(direct_url, options=container_options)
|
|
837
|
-
|
|
838
|
-
audio_stream = next(
|
|
839
|
-
(s for s in container.streams if s.type == "audio"), None
|
|
840
|
-
)
|
|
841
|
-
|
|
842
|
-
if audio_stream is None:
|
|
843
|
-
raise ValueError("Nenhuma faixa de áudio encontrada no fluxo do vídeo.")
|
|
844
|
-
|
|
845
|
-
sample_rate = (
|
|
846
|
-
audio_stream.codec_context.sample_rate or 48000
|
|
847
|
-
)
|
|
848
|
-
time_base = float(audio_stream.time_base)
|
|
849
|
-
|
|
850
|
-
resampler = av.AudioResampler(
|
|
851
|
-
format="fltp", layout="stereo", rate=sample_rate
|
|
852
|
-
)
|
|
853
|
-
|
|
854
|
-
with sd.OutputStream(
|
|
855
|
-
samplerate=sample_rate,
|
|
856
|
-
channels=2,
|
|
857
|
-
dtype="float32",
|
|
858
|
-
callback=audio_callback,
|
|
859
|
-
blocksize=1024,
|
|
860
|
-
):
|
|
861
|
-
self.is_playing = True
|
|
862
|
-
self.call_from_thread(
|
|
863
|
-
status.update, "[green]Reproduzindo ♪[/green]"
|
|
864
|
-
)
|
|
865
|
-
|
|
866
|
-
last_ui_update = 0
|
|
867
|
-
|
|
868
|
-
for frame in container.decode(audio_stream):
|
|
869
|
-
if self.stop_event.is_set():
|
|
870
|
-
break
|
|
871
|
-
|
|
872
|
-
# Controla a velocidade de decodificação para não sobrecarregar o buffer
|
|
873
|
-
max_buffer_bytes = sample_rate * 2 * 4 * 3 # ~3 segundos de áudio
|
|
874
|
-
while len(pcm_buffer) > max_buffer_bytes and not self.stop_event.is_set():
|
|
875
|
-
time.sleep(0.05)
|
|
876
|
-
|
|
877
|
-
if self.seek_target_seconds is not None:
|
|
878
|
-
target_pts = int(
|
|
879
|
-
self.seek_target_seconds / time_base
|
|
880
|
-
)
|
|
881
|
-
container.seek(target_pts, stream=audio_stream)
|
|
882
|
-
played_samples = int(self.seek_target_seconds * sample_rate)
|
|
883
|
-
with pcm_lock:
|
|
884
|
-
pcm_buffer.clear()
|
|
885
|
-
self.seek_target_seconds = None
|
|
886
|
-
|
|
887
|
-
resampler = av.AudioResampler(
|
|
888
|
-
format="fltp",
|
|
889
|
-
layout="stereo",
|
|
890
|
-
rate=sample_rate,
|
|
891
|
-
)
|
|
892
|
-
continue
|
|
893
|
-
|
|
894
|
-
self.pause_event.wait()
|
|
895
|
-
|
|
896
|
-
resampled_frames = resampler.resample(frame)
|
|
897
|
-
if not resampled_frames:
|
|
898
|
-
continue
|
|
899
|
-
|
|
900
|
-
for r_frame in resampled_frames:
|
|
901
|
-
if self.stop_event.is_set():
|
|
902
|
-
break
|
|
903
|
-
|
|
904
|
-
audio_array = r_frame.to_ndarray()
|
|
905
|
-
if audio_array.ndim == 1:
|
|
906
|
-
audio_array = np.vstack((audio_array, audio_array))
|
|
907
|
-
|
|
908
|
-
audio_array = audio_array * self.volume
|
|
909
|
-
audio_data = np.ascontiguousarray(audio_array.T, dtype=np.float32)
|
|
910
|
-
|
|
911
|
-
with pcm_lock:
|
|
912
|
-
pcm_buffer.extend(audio_data.tobytes())
|
|
913
|
-
|
|
914
|
-
# Atualização da interface sincronizada por tempo real de reprodução
|
|
915
|
-
current_time = time.time()
|
|
916
|
-
if current_time - last_ui_update >= 0.2:
|
|
917
|
-
self.current_position_seconds = played_samples / sample_rate
|
|
918
|
-
rms = np.sqrt(np.mean(audio_data**2)) if len(audio_data) > 0 else 0
|
|
919
|
-
self.call_from_thread(self._update_playback_ui, rms)
|
|
920
|
-
last_ui_update = current_time
|
|
921
|
-
|
|
922
|
-
# Aguarda consumir os últimos bytes de áudio no buffer antes de finalizar
|
|
923
|
-
while len(pcm_buffer) > 0 and not self.stop_event.is_set():
|
|
924
|
-
self.current_position_seconds = played_samples / sample_rate
|
|
925
|
-
self.call_from_thread(self._update_playback_ui, 0)
|
|
926
|
-
time.sleep(0.1)
|
|
927
|
-
|
|
928
|
-
container.close()
|
|
929
|
-
break
|
|
930
|
-
|
|
931
|
-
except (av.FFmpegError, OSError, Exception) as e:
|
|
932
|
-
if self.stop_event.is_set():
|
|
933
|
-
break
|
|
934
|
-
|
|
935
|
-
retry_count += 1
|
|
936
|
-
if retry_count <= max_retries:
|
|
937
|
-
self.call_from_thread(
|
|
938
|
-
status.update,
|
|
939
|
-
f"[yellow]Reconectando ({retry_count}/{max_retries})...[/yellow]",
|
|
940
|
-
)
|
|
941
|
-
time.sleep(1)
|
|
942
|
-
else:
|
|
943
|
-
self.is_playing = False
|
|
944
|
-
self.call_from_thread(
|
|
945
|
-
status.update, f"[red]Erro ao tocar: {e}[/red]"
|
|
946
|
-
)
|
|
947
|
-
|
|
948
|
-
self.is_playing = False
|
|
949
|
-
self.call_from_thread(self._reset_playback_ui)
|
|
950
|
-
|
|
951
|
-
self.stream_thread = threading.Thread(
|
|
952
|
-
target=stream_worker, daemon=True
|
|
953
|
-
)
|
|
954
|
-
self.stream_thread.start()
|
|
955
|
-
|
|
956
|
-
def _update_playback_ui(self, rms_volume: float) -> None:
|
|
957
|
-
curr_str = time.strftime(
|
|
958
|
-
"%M:%S", time.gmtime(self.current_position_seconds)
|
|
959
|
-
)
|
|
960
|
-
tot_str = time.strftime("%M:%S", time.gmtime(self.duration_seconds))
|
|
961
|
-
|
|
962
|
-
self.query_one("#time_current", Label).update(f"{curr_str} ")
|
|
963
|
-
self.query_one("#time_total", Label).update(f" {tot_str}")
|
|
964
|
-
|
|
965
|
-
progress_bar = self.query_one("#song_progress", ProgressBar)
|
|
966
|
-
if self.duration_seconds > 0:
|
|
967
|
-
progress_bar.progress = min(
|
|
968
|
-
100.0,
|
|
969
|
-
(self.current_position_seconds / self.duration_seconds) * 100,
|
|
970
|
-
)
|
|
971
|
-
|
|
972
|
-
bars = [" ", " ", "▂", "▃", "▄", "▅", "▆", "▇", "█"]
|
|
973
|
-
level = min(int(rms_volume * 35), len(bars) - 1)
|
|
974
|
-
char = bars[level]
|
|
975
|
-
|
|
976
|
-
meter_str = f"░▒▓█ {char * 12} █▓▒░"
|
|
977
|
-
self.query_one("#visualizer", Label).update(meter_str)
|
|
978
|
-
|
|
979
|
-
def _reset_playback_ui(self) -> None:
|
|
980
|
-
self.query_one("#visualizer", Label).update("░░░░░░░░░░░░░░░░░░░░")
|
|
981
|
-
self.query_one("#song_progress", ProgressBar).progress = 0
|
|
982
|
-
self.query_one("#time_current", Label).update("00:00 ")
|
|
983
|
-
self.query_one("#time_total", Label).update(" 00:00")
|
|
984
|
-
|
|
985
|
-
# --- CONTROLES E ATALHOS ---
|
|
986
|
-
|
|
987
|
-
def _stop_audio(self) -> None:
|
|
988
|
-
"""Interrompe e encerra síncronamente qualquer thread de reprodução em execução."""
|
|
989
|
-
self.stop_event.set()
|
|
990
|
-
self.pause_event.set()
|
|
991
|
-
|
|
992
|
-
if self.stream_thread and self.stream_thread.is_alive():
|
|
993
|
-
if threading.current_thread() != self.stream_thread:
|
|
994
|
-
self.stream_thread.join(timeout=2.0)
|
|
995
|
-
|
|
996
|
-
self.is_playing = False
|
|
997
|
-
|
|
998
|
-
def action_toggle_play(self) -> None:
|
|
999
|
-
self.toggle_audio()
|
|
1000
|
-
|
|
1001
|
-
def action_volume_up(self) -> None:
|
|
1002
|
-
self._adjust_volume(0.05)
|
|
1003
|
-
|
|
1004
|
-
def action_volume_down(self) -> None:
|
|
1005
|
-
self._adjust_volume(-0.05)
|
|
1006
|
-
|
|
1007
|
-
def _adjust_volume(self, delta: float) -> None:
|
|
1008
|
-
self.volume = max(0.0, min(1.0, self.volume + delta))
|
|
1009
|
-
vol_pct = int(self.volume * 100)
|
|
1010
|
-
self.query_one("#vol_label", Label).update(f" Volume: {vol_pct}% ")
|
|
1011
|
-
|
|
1012
|
-
def _seek(self, seconds_delta: float) -> None:
|
|
1013
|
-
new_pos = max(0, self.current_position_seconds + seconds_delta)
|
|
1014
|
-
if self.duration_seconds > 0:
|
|
1015
|
-
new_pos = min(self.duration_seconds - 1, new_pos)
|
|
1016
|
-
self.seek_target_seconds = new_pos
|
|
1017
|
-
|
|
1018
|
-
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
1019
|
-
b_id = event.button.id
|
|
1020
|
-
if b_id in {"btn_home", "btn_liked", "btn_login", "btn_settings", "btn_exit"}:
|
|
1021
|
-
self._handle_navigation(b_id)
|
|
1022
|
-
elif b_id == "btn_login_action":
|
|
1023
|
-
self._show_page("login")
|
|
1024
|
-
threading.Thread(
|
|
1025
|
-
target=self._authenticate_youtube, daemon=True
|
|
1026
|
-
).start()
|
|
1027
|
-
elif b_id == "btn_logout_account":
|
|
1028
|
-
self._logout_youtube()
|
|
1029
|
-
elif b_id == "btn_toggle":
|
|
1030
|
-
self.toggle_audio()
|
|
1031
|
-
elif b_id == "btn_stop":
|
|
1032
|
-
self._stop_audio()
|
|
1033
|
-
self._reset_playback_ui()
|
|
1034
|
-
self.query_one("#status", Label).update("Reprodução parada.")
|
|
1035
|
-
self.query_one("#now-playing", Label).update(
|
|
1036
|
-
"Nenhuma música selecionada"
|
|
1037
|
-
)
|
|
1038
|
-
elif b_id == "btn_vol_up":
|
|
1039
|
-
self._adjust_volume(0.1)
|
|
1040
|
-
elif b_id == "btn_vol_down":
|
|
1041
|
-
self._adjust_volume(-0.1)
|
|
1042
|
-
elif b_id == "btn_rewind":
|
|
1043
|
-
self._seek(-10)
|
|
1044
|
-
elif b_id == "btn_forward":
|
|
1045
|
-
self._seek(10)
|
|
1046
|
-
elif b_id in {"btn_load_more", "btn_liked_load_more"}:
|
|
1047
|
-
self._load_more_content()
|
|
1048
|
-
|
|
1049
|
-
def toggle_audio(self) -> None:
|
|
1050
|
-
if not self.stream_thread or not self.stream_thread.is_alive():
|
|
1051
|
-
return
|
|
1052
|
-
|
|
1053
|
-
if self.is_playing:
|
|
1054
|
-
self.pause_event.clear()
|
|
1055
|
-
self.is_playing = False
|
|
1056
|
-
self.query_one("#status", Label).update("Pausado ⏸")
|
|
1057
|
-
else:
|
|
1058
|
-
self.pause_event.set()
|
|
1059
|
-
self.is_playing = True
|
|
1060
|
-
self.query_one("#status", Label).update("Reproduzindo ♪")
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
if __name__ == "__main__":
|
|
1064
|
-
app = MusicPlayerApp()
|
|
1065
|
-
app.run()
|
|
1
|
+
"""Launcher da V2 - abre a interface Textual."""
|
|
2
|
+
|
|
3
|
+
from app import YouTubePlayer
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
if __name__ == "__main__":
|
|
7
|
+
app = YouTubePlayer()
|
|
8
|
+
app.run()
|