Explorer melhorado, drasticamente

This commit is contained in:
2026-01-30 23:33:21 +00:00
parent 36a2b465d3
commit da65b8d99f
2 changed files with 397 additions and 271 deletions

View File

@@ -1,327 +1,453 @@
from nicegui import ui, app from nicegui import ui, run
import os import os
import shutil import shutil
import asyncio
import datetime import datetime
import subprocess import subprocess
import json import json
ROOT_DIR = "/downloads" ROOT_DIR = "/downloads"
# --- UTILITÁRIOS --- # --- UTILITÁRIOS ASSÍNCRONOS ---
def get_human_size(size): async def get_human_size(size):
for unit in ['B', 'KB', 'MB', 'GB']: for unit in ['B', 'KB', 'MB', 'GB']:
if size < 1024: return f"{size:.2f} {unit}" if size < 1024: return f"{size:.2f} {unit}"
size /= 1024 size /= 1024
return f"{size:.2f} TB" return f"{size:.2f} TB"
def get_subfolders(root): async def get_media_info_async(filepath):
folders = [root] def _probe():
try: cmd = ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", "-show_format", filepath]
for r, d, f in os.walk(root): try:
if "finalizados" in r or "temp" in r: continue res = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
for folder in d: return json.loads(res.stdout)
if not folder.startswith('.'): folders.append(os.path.join(r, folder)) except: return None
except: pass
return sorted(folders)
# --- LEITOR DE METADADOS (FFPROBE) --- data = await run.io_bound(_probe)
def get_media_info(filepath): if not data: return None
"""Lê as faixas de áudio e legenda do arquivo"""
cmd = ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", "-show_format", filepath] info = {
try: "filename": os.path.basename(filepath),
res = subprocess.run(cmd, capture_output=True, text=True) "size": int(data['format'].get('size', 0)),
data = json.loads(res.stdout) "duration": float(data['format'].get('duration', 0)),
"bitrate": int(data['format'].get('bit_rate', 0)),
info = { "video": [], "audio": [], "subtitle": []
"duration": float(data['format'].get('duration', 0)), }
"bitrate": int(data['format'].get('bit_rate', 0)),
"video": [],
"audio": [],
"subtitle": []
}
for s in data.get('streams', []): for s in data.get('streams', []):
type = s['codec_type'] stype = s['codec_type']
lang = s.get('tags', {}).get('language', 'und') lang = s.get('tags', {}).get('language', 'und').upper()
title = s.get('tags', {}).get('title', '') codec = s.get('codec_name', 'unknown').upper()
codec = s.get('codec_name', 'unknown') if stype == 'video':
info['video'].append({"codec": codec, "res": f"{s.get('width','?')}x{s.get('height','?')}", "fps": s.get('r_frame_rate', '')})
elif stype == 'audio':
info['audio'].append({"lang": lang, "codec": codec, "ch": s.get('channels', 0), "title": s.get('tags', {}).get('title', '')})
elif stype == 'subtitle':
info['subtitle'].append({"lang": lang, "codec": codec})
desc = f"[{lang.upper()}] {codec}" return info
if title: desc += f" - {title}"
if type == 'video':
w = s.get('width', 0)
h = s.get('height', 0)
info['video'].append(f"{codec.upper()} ({w}x{h})")
elif type == 'audio':
ch = s.get('channels', 0)
info['audio'].append(f"{desc} ({ch}ch)")
elif type == 'subtitle':
info['subtitle'].append(desc)
return info
except:
return None
# --- CLASSE GERENCIADORA --- # --- CLASSE GERENCIADORA ---
class FileManager: class FileManager:
def __init__(self): def __init__(self):
self.path = ROOT_DIR self.path = ROOT_DIR
self.view_mode = 'grid' self.view_mode = 'grid'
self.container = None self.is_selecting = False
self.selected_items = set()
self.search_term = ""
self.refreshing = False
def navigate(self, path): # Elementos UI
self.container_content = None
self.footer = None
self.lbl_selection_count = None
self.btn_select_mode = None
self.header_row = None # Para atualizar breadcrumbs
# --- NAVEGAÇÃO ---
async def navigate(self, path):
if os.path.exists(path) and os.path.isdir(path): if os.path.exists(path) and os.path.isdir(path):
self.path = path self.path = path
self.refresh() self.selected_items.clear()
self.is_selecting = False
self.search_term = ""
self.update_footer_state()
# Atualiza o header completamente para refazer breadcrumbs
if self.header_row:
self.header_row.clear()
with self.header_row:
self.build_header_content()
await self.refresh()
else: else:
ui.notify('Caminho inválido', type='negative') ui.notify('Caminho inválido.', type='negative')
def navigate_up(self): async def navigate_up(self):
parent = os.path.dirname(self.path) parent = os.path.dirname(self.path)
if self.path != ROOT_DIR: await self.navigate(parent)
# --- UPLOAD ---
def open_upload_dialog(self):
with ui.dialog() as dialog, ui.card():
ui.label(f'Upload para: {os.path.basename(self.path)}').classes('font-bold')
async def handle(e):
try:
target = os.path.join(self.path, e.name)
await run.io_bound(self._save_file, target, e.content)
ui.notify(f'Sucesso: {e.name}')
except Exception as ex: ui.notify(f'Erro: {ex}', type='negative')
ui.upload(on_upload=handle, auto_upload=True, multiple=True).props('accept=*').classes('w-full')
async def close_and_refresh():
dialog.close()
await self.refresh()
ui.button('Fechar e Atualizar', on_click=close_and_refresh).props('flat w-full')
dialog.open()
def _save_file(self, target, content):
with open(target, 'wb') as f: f.write(content.read())
# --- LÓGICA DE SELEÇÃO ---
def toggle_select_mode(self):
self.is_selecting = not self.is_selecting
if not self.is_selecting:
self.selected_items.clear()
self.update_footer_state()
ui.timer(0, self.refresh, once=True)
def toggle_selection(self, item_path):
if item_path in self.selected_items:
self.selected_items.remove(item_path)
else:
self.selected_items.add(item_path)
self.update_footer_state()
ui.timer(0, self.refresh, once=True)
def select_all(self, entries):
current = {e.path for e in entries}
if self.selected_items.issuperset(current):
self.selected_items.difference_update(current)
else:
self.selected_items.update(current)
self.update_footer_state()
ui.timer(0, self.refresh, once=True)
def update_footer_state(self):
if self.footer:
self.footer.set_visibility(self.is_selecting)
if self.lbl_selection_count:
self.lbl_selection_count.text = f'{len(self.selected_items)} item(s) selecionados'
# --- AÇÕES EM LOTE ---
async def delete_selected(self):
if not self.selected_items: return
with ui.dialog() as dialog, ui.card():
ui.label(f'Excluir {len(self.selected_items)} itens?').classes('font-bold text-red')
async def confirm():
dialog.close()
count = 0
items_copy = list(self.selected_items)
for item in items_copy:
try:
if os.path.isdir(item): await run.io_bound(shutil.rmtree, item)
else: await run.io_bound(os.remove, item)
count += 1
except: pass
ui.notify(f'{count} excluídos.', type='positive')
self.selected_items.clear()
self.update_footer_state()
await self.refresh()
with ui.row().classes('w-full justify-end'):
ui.button('Cancelar', on_click=dialog.close).props('flat')
ui.button('Confirmar', on_click=confirm).props('color=red')
dialog.open()
async def open_move_dialog(self, target_items=None):
items_to_move = target_items if target_items else list(self.selected_items)
if not items_to_move:
ui.notify('Nada para mover.', type='warning')
return
browser_path = ROOT_DIR
with ui.dialog() as dialog, ui.card().classes('w-96 h-[500px] flex flex-col p-4'):
ui.label(f'Mover {len(items_to_move)} item(s) para...').classes('font-bold')
lbl_path = ui.label(ROOT_DIR).classes('text-xs bg-gray-100 p-2 w-full truncate border rounded')
scroll = ui.scroll_area().classes('flex-grow border rounded p-1 bg-white')
async def load_folders(p):
nonlocal browser_path
browser_path = p
lbl_path.text = p
scroll.clear()
try:
entries = await run.io_bound(os.scandir, p)
sorted_e = sorted([e for e in entries if e.is_dir() and not e.name.startswith('.')], key=lambda e: e.name.lower())
with scroll:
if p != ROOT_DIR:
ui.button('.. (Voltar)', on_click=lambda: load_folders(os.path.dirname(p))).props('flat dense icon=arrow_upward w-full align=left')
for e in sorted_e:
ui.button(e.name, on_click=lambda path=e.path: load_folders(path)).props('flat dense w-full align=left icon=folder color=amber')
except: pass
ui.timer(0, lambda: load_folders(ROOT_DIR), once=True)
async def execute_move():
dialog.close()
ui.notify('Movendo...', type='info')
count = 0
for item in items_to_move:
try:
tgt = os.path.join(browser_path, os.path.basename(item))
if item != tgt:
await run.io_bound(shutil.move, item, tgt)
count += 1
except Exception as e: ui.notify(f"Erro: {e}", type='negative')
ui.notify(f'{count} movidos!', type='positive')
if not target_items:
self.selected_items.clear()
self.update_footer_state()
await self.refresh()
with ui.row().classes('w-full justify-between mt-auto'):
ui.button('Cancelar', on_click=dialog.close).props('flat')
ui.button('Mover Aqui', on_click=execute_move).props('color=green icon=drive_file_move')
dialog.open()
# --- INTERFACE PRINCIPAL ---
def create_layout(self):
# 1. Header (Fixo)
self.header_row = ui.row().classes('w-full items-center bg-gray-100 p-2 rounded-lg gap-2 sticky top-0 z-20 shadow-sm')
with self.header_row:
self.build_header_content()
# 2. Área de Conteúdo
self.container_content = ui.column().classes('w-full gap-4 pb-24')
# 3. Footer (SIMULADO com ROW FIXED)
with ui.row().classes('fixed bottom-0 left-0 w-full z-50 bg-white border-t p-2 justify-center gap-4 shadow-[0_-4px_10px_rgba(0,0,0,0.1)]') as f:
self.footer = f
self.lbl_selection_count = ui.label('0 selecionados').classes('font-bold self-center')
ui.button('Mover Seleção', on_click=lambda: self.open_move_dialog(None)).props('color=amber icon=drive_file_move dense')
ui.button('Excluir Seleção', on_click=self.delete_selected).props('color=red icon=delete dense')
ui.button(icon='close', on_click=self.toggle_select_mode).props('flat round dense').tooltip('Sair da Seleção')
self.footer.visible = False
def build_header_content(self):
# Botão Subir Nível
if self.path != ROOT_DIR: if self.path != ROOT_DIR:
self.navigate(parent) ui.button(icon='arrow_upward', on_click=self.navigate_up).props('flat round dense').tooltip('Subir Nível')
else:
ui.button(icon='home').props('flat round dense disabled text-color=grey')
# BREADCRUMBS (Barra de Endereço Restaurada)
rel = os.path.relpath(self.path, ROOT_DIR)
parts = rel.split(os.sep) if rel != '.' else []
with ui.row().classes('items-center gap-0 overflow-hidden flex-grow'):
ui.button('root', on_click=lambda: self.navigate(ROOT_DIR)).props('flat dense no-caps min-w-0 px-1 text-xs')
acc = ROOT_DIR
for part in parts:
acc = os.path.join(acc, part)
ui.label('/').classes('text-gray-400')
# Precisamos capturar o valor de acc no lambda
ui.button(part, on_click=lambda p=acc: self.navigate(p)).props('flat dense no-caps min-w-0 px-1 text-xs')
# Botões de Ação
self.btn_select_mode = ui.button(icon='check_box', on_click=self.toggle_select_mode).props('flat round dense').tooltip('Modo Seleção')
ui.button(icon='create_new_folder', on_click=self.open_create_folder).props('flat round dense').tooltip('Nova Pasta')
ui.button(icon='cloud_upload', on_click=self.open_upload_dialog).props('flat round dense').tooltip('Upload')
ui.button(icon='view_list', on_click=self.toggle_view).props('flat round dense')
ui.button(icon='refresh', on_click=self.refresh).props('flat round dense')
def toggle_view(self): def toggle_view(self):
self.view_mode = 'list' if self.view_mode == 'grid' else 'grid' self.view_mode = 'list' if self.view_mode == 'grid' else 'grid'
self.refresh() ui.timer(0, self.refresh, once=True)
def refresh(self):
if self.container:
self.container.clear()
with self.container:
self.render_header()
self.render_content()
# --- PLAYER DE VÍDEO ---
def open_player(self, path):
filename = os.path.basename(path)
# Converte caminho local (/downloads/pasta/video.mkv) para URL (/files/pasta/video.mkv)
# O prefixo /files foi configurado no main.py
rel_path = os.path.relpath(path, ROOT_DIR)
video_url = f"/files/{rel_path}"
# Pega dados técnicos
info = get_media_info(path)
with ui.dialog() as dialog, ui.card().classes('w-full max-w-4xl h-[80vh] p-0 gap-0'):
# Header
with ui.row().classes('w-full bg-gray-100 p-2 justify-between items-center'):
ui.label(filename).classes('font-bold text-lg truncate')
ui.button(icon='close', on_click=dialog.close).props('flat round dense')
with ui.row().classes('w-full h-full'):
# Coluna Esquerda: Player
with ui.column().classes('w-2/3 h-full bg-black justify-center'):
# Player HTML5 Nativo
ui.video(video_url).classes('w-full max-h-full')
ui.label('Nota: Áudios AC3/DTS podem ficar mudos no navegador.').classes('text-gray-500 text-xs text-center w-full')
# Coluna Direita: Informações
with ui.column().classes('w-1/3 h-full p-4 overflow-y-auto bg-white border-l'):
ui.label('📋 Detalhes do Arquivo').classes('text-lg font-bold mb-4 text-blue-600')
if info:
# Vídeo
ui.label('Vídeo').classes('font-bold text-xs text-gray-500 uppercase')
for v in info['video']:
ui.label(f"📺 {v}").classes('ml-2 text-sm')
ui.separator().classes('my-2')
# Áudio
ui.label('Áudio').classes('font-bold text-xs text-gray-500 uppercase')
if info['audio']:
for a in info['audio']:
ui.label(f"🔊 {a}").classes('ml-2 text-sm')
else:
ui.label("Sem áudio").classes('ml-2 text-sm text-gray-400')
ui.separator().classes('my-2')
# Legenda
ui.label('Legendas').classes('font-bold text-xs text-gray-500 uppercase')
if info['subtitle']:
for s in info['subtitle']:
ui.label(f"💬 {s}").classes('ml-2 text-sm')
else:
ui.label("Sem legendas").classes('ml-2 text-sm text-gray-400')
else:
ui.label('Não foi possível ler os metadados.').classes('text-red')
dialog.open()
# --- DIÁLOGOS DE AÇÃO ---
def open_delete_dialog(self, path):
with ui.dialog() as dialog, ui.card():
ui.label('Excluir item?').classes('font-bold')
ui.label(os.path.basename(path))
with ui.row().classes('w-full justify-end'):
ui.button('Cancelar', on_click=dialog.close).props('flat')
def confirm():
try:
if os.path.isdir(path): shutil.rmtree(path)
else: os.remove(path)
dialog.close()
self.refresh()
ui.notify('Excluído!')
except Exception as e: ui.notify(str(e), type='negative')
ui.button('Excluir', on_click=confirm).props('color=red')
dialog.open()
def open_rename_dialog(self, path):
with ui.dialog() as dialog, ui.card():
ui.label('Renomear')
name = ui.input('Novo Nome', value=os.path.basename(path)).classes('w-full')
def save():
try:
os.rename(path, os.path.join(os.path.dirname(path), name.value))
dialog.close()
self.refresh()
ui.notify('Renomeado!')
except Exception as e: ui.notify(str(e), type='negative')
ui.button('Salvar', on_click=save)
dialog.open()
def open_move_dialog(self, path):
folders = get_subfolders(ROOT_DIR)
if os.path.isdir(path) and path in folders: folders.remove(path)
opts = {f: f.replace(ROOT_DIR, "Raiz") if f != ROOT_DIR else "Raiz" for f in folders}
with ui.dialog() as dialog, ui.card().classes('w-96'):
ui.label('Mover Para')
target = ui.select(opts, value=ROOT_DIR, with_input=True).classes('w-full')
def confirm():
try:
shutil.move(path, target.value)
dialog.close()
self.refresh()
ui.notify('Movido!')
except Exception as e: ui.notify(str(e), type='negative')
ui.button('Mover', on_click=confirm)
dialog.open()
def open_create_folder(self): def open_create_folder(self):
with ui.dialog() as dialog, ui.card(): with ui.dialog() as dialog, ui.card():
ui.label('Nova Pasta') ui.label('Nova Pasta')
name = ui.input('Nome') name = ui.input('Nome')
def create(): async def create():
try: try:
os.makedirs(os.path.join(self.path, name.value)) await run.io_bound(os.makedirs, os.path.join(self.path, name.value))
dialog.close() dialog.close()
self.refresh() await self.refresh()
except Exception as e: ui.notify(str(e), type='negative') except Exception as e: ui.notify(str(e), type='negative')
ui.button('Criar', on_click=create) ui.button('Criar', on_click=create)
dialog.open() dialog.open()
def open_rename_dialog(self, path):
with ui.dialog() as dialog, ui.card():
ui.label('Renomear')
name = ui.input('Novo Nome', value=os.path.basename(path)).classes('w-full')
async def save():
try:
new_path = os.path.join(os.path.dirname(path), name.value)
await run.io_bound(os.rename, path, new_path)
dialog.close()
await self.refresh()
except Exception as e: ui.notify(str(e), type='negative')
ui.button('Salvar', on_click=save)
dialog.open()
# --- RENDERIZAÇÃO DE CONTEÚDO ---
async def refresh(self):
if self.refreshing: return
self.refreshing = True
color = 'green' if self.is_selecting else 'grey'
if self.btn_select_mode:
self.btn_select_mode.props(f'text-color={color}')
if self.container_content:
self.container_content.clear()
try:
entries = await run.io_bound(os.scandir, self.path)
entries = sorted(entries, key=lambda e: (not e.is_dir(), e.name.lower()))
if self.is_selecting:
with self.container_content:
with ui.row().classes('w-full px-2 py-1 bg-green-50 items-center justify-between text-xs text-green-800 rounded border border-green-200'):
ui.checkbox(f'Selecionar Todos ({len(entries)})', on_change=lambda: self.select_all(entries)).props('dense size=xs')
with self.container_content:
if not entries:
ui.label('Pasta Vazia').classes('w-full text-center text-gray-400 mt-10')
elif self.view_mode == 'grid':
with ui.grid().classes('w-full grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3'):
for entry in entries:
self.render_card(entry)
else:
with ui.column().classes('w-full gap-0'):
for entry in entries:
self.render_list_item(entry)
except Exception as e:
with self.container_content:
ui.label(f'Erro ao ler pasta: {e}').classes('text-red')
self.refreshing = False
def render_card(self, entry):
is_selected = entry.path in self.selected_items
is_dir = entry.is_dir()
icon = 'folder' if is_dir else 'description'
if not is_dir and entry.name.lower().endswith(('.mkv', '.mp4', '.avi')): icon = 'movie'
color = 'amber-8' if is_dir else 'blue-grey'
if icon == 'movie': color = 'purple-6'
bg_cls = 'bg-green-100 ring-2 ring-green-500' if is_selected else 'bg-white hover:shadow-md'
if self.is_selecting and not is_selected: bg_cls += ' border-dashed border-2 border-gray-300'
with ui.card().classes(f'w-full aspect-square p-2 items-center justify-center relative group cursor-pointer select-none {bg_cls}') as card:
self.bind_context_menu(card, entry)
if self.is_selecting:
card.on('click', lambda: self.toggle_selection(entry.path))
with ui.column().classes('absolute top-1 left-1 z-10'):
ui.checkbox(value=is_selected, on_change=lambda: self.toggle_selection(entry.path)).props('dense').on('click', lambda e: e.stop_propagation())
else:
if is_dir: card.on('click', lambda e, p=entry.path: self.navigate(p))
elif icon == 'movie': card.on('click', lambda e, p=entry.path: self.open_inspector(p))
ui.icon(icon, size='3rem', color=color)
ui.label(entry.name).classes('text-xs text-center leading-tight line-clamp-2 w-full break-all mt-2')
def render_list_item(self, entry):
is_selected = entry.path in self.selected_items
bg_cls = 'bg-green-100' if is_selected else 'hover:bg-gray-50'
icon = 'folder' if entry.is_dir() else 'description'
with ui.row().classes(f'w-full items-center px-2 py-2 border-b cursor-pointer group {bg_cls}') as row:
if self.is_selecting:
ui.checkbox(value=is_selected, on_change=lambda: self.toggle_selection(entry.path)).props('dense')
row.on('click', lambda: self.toggle_selection(entry.path))
else:
if entry.is_dir(): row.on('click', lambda p=entry.path: self.navigate(p))
self.bind_context_menu(row, entry)
ui.icon(icon, color='amber' if entry.is_dir() else 'grey')
ui.label(entry.name).classes('text-sm font-medium flex-grow')
# --- MENU DE CONTEXTO ---
def bind_context_menu(self, element, entry): def bind_context_menu(self, element, entry):
with ui.menu() as m: with ui.menu() as m:
if not entry.is_dir and entry.name.lower().endswith(('.mkv', '.mp4', '.avi')): if not entry.is_dir and entry.name.lower().endswith(('.mkv', '.mp4')):
ui.menu_item('▶️ Reproduzir / Detalhes', on_click=lambda: self.open_player(entry.path)) ui.menu_item('Media Info', on_click=lambda: self.open_inspector(entry.path))
ui.separator()
ui.menu_item('Renomear', on_click=lambda: self.open_rename_dialog(entry.path)) ui.menu_item('Renomear', on_click=lambda: self.open_rename_dialog(entry.path))
ui.menu_item('Mover Para...', on_click=lambda: self.open_move_dialog(entry.path)) ui.menu_item('Mover Para...', on_click=lambda: self.open_move_dialog([entry.path]))
ui.separator()
ui.menu_item('Excluir', on_click=lambda: self.open_delete_dialog(entry.path)).props('text-color=red') async def delete_single():
try:
ui.notify(f'Excluindo {entry.name}...')
if entry.is_dir: await run.io_bound(shutil.rmtree, entry.path)
else: await run.io_bound(os.remove, entry.path)
await self.refresh()
except Exception as e: print(f"Erro delete: {e}")
ui.menu_item('Excluir', on_click=delete_single).props('text-color=red')
element.on('contextmenu.prevent', lambda: m.open()) element.on('contextmenu.prevent', lambda: m.open())
# --- RENDERIZADORES --- # --- INSPECTOR (RESTAURADO E RICO) ---
def render_header(self): async def open_inspector(self, path):
with ui.row().classes('w-full items-center bg-gray-100 p-2 rounded-lg gap-2'): dialog = ui.dialog()
if self.path != ROOT_DIR: with dialog, ui.card().classes('w-full max-w-3xl'):
ui.button(icon='arrow_upward', on_click=self.navigate_up).props('flat round dense').tooltip('Subir') with ui.row().classes('w-full justify-between items-start'):
else: ui.label(os.path.basename(path)).classes('text-lg font-bold break-all w-10/12 text-blue-800')
ui.button(icon='home').props('flat round dense disabled text-color=grey') ui.button(icon='close', on_click=dialog.close).props('flat dense round')
rel = os.path.relpath(self.path, ROOT_DIR) content = ui.column().classes('w-full')
parts = rel.split(os.sep) if rel != '.' else [] with content: ui.spinner('dots').classes('self-center')
with ui.row().classes('items-center gap-0'):
ui.button('/', on_click=lambda: self.navigate(ROOT_DIR)).props('flat dense no-caps min-w-0 px-2') dialog.open()
acc = ROOT_DIR info = await get_media_info_async(path)
for part in parts: content.clear()
acc = os.path.join(acc, part)
ui.label('/') if not info:
ui.button(part, on_click=lambda p=acc: self.navigate(p)).props('flat dense no-caps min-w-0 px-2') with content: ui.label('Não foi possível ler os metadados.').classes('text-red font-bold')
return
ui.space() with content:
ui.button(icon='create_new_folder', on_click=self.open_create_folder).props('flat round dense') # 1. Resumo Geral (Stats)
ui.button(icon='view_list' if self.view_mode == 'grid' else 'grid_view', on_click=self.toggle_view).props('flat round dense') with ui.row().classes('w-full bg-blue-50 p-2 rounded mb-4 gap-4'):
ui.button(icon='refresh', on_click=self.refresh).props('flat round dense') ui.label(f"⏱️ {datetime.timedelta(seconds=int(info['duration']))}").tooltip('Duração')
ui.label(f"📦 {await get_human_size(info['size'])}").tooltip('Tamanho')
ui.label(f"🚀 {int(info['bitrate']/1000)} kbps").tooltip('Bitrate Total')
def render_content(self): # 2. Vídeo
try: ui.label('Vídeo').classes('text-xs font-bold text-gray-500 uppercase mt-2')
entries = sorted(os.scandir(self.path), key=lambda e: (not e.is_dir(), e.name.lower())) for v in info['video']:
except: return with ui.card().classes('w-full p-2 bg-gray-50 border-l-4 border-blue-500'):
ui.label(f"{v['codec']}{v['res']}{v['fps']} fps").classes('font-bold')
if not entries: # 3. Áudio
ui.label('Pasta vazia').classes('w-full text-center text-gray-400 mt-10') ui.label('Áudio').classes('text-xs font-bold text-gray-500 uppercase mt-4')
return if info['audio']:
with ui.grid().classes('grid-cols-[auto_1fr_auto] w-full gap-2 text-sm'):
for a in info['audio']:
ui.label(a['lang']).classes('font-bold bg-gray-200 px-2 rounded text-center')
ui.label(f"{a['codec']} - {a['title']}")
ui.label(a['ch']).classes('text-gray-500')
else:
ui.label('Sem faixas de áudio').classes('italic text-gray-400')
if self.view_mode == 'grid': # 4. Legendas
with ui.grid().classes('w-full grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3'): ui.label('Legendas').classes('text-xs font-bold text-gray-500 uppercase mt-4')
for entry in entries: if info['subtitle']:
is_dir = entry.is_dir() with ui.row().classes('w-full gap-2'):
icon = 'folder' if is_dir else 'description' for s in info['subtitle']:
if not is_dir and entry.name.lower().endswith(('.mkv', '.mp4', '.avi')): icon = 'movie' color = 'green' if s['codec'] in ['subrip', 'ass'] else 'grey'
color = 'amber-8' if is_dir else 'blue-grey' ui.chip(f"{s['lang']} ({s['codec']})", color=color).props('dense icon=subtitles')
if icon == 'movie': color = 'purple-6' else:
ui.label('Sem legendas internas').classes('italic text-gray-400')
with ui.card().classes('w-full aspect-square p-2 items-center justify-center relative group hover:shadow-md cursor-pointer select-none') as card:
if is_dir:
card.on('click', lambda p=entry.path: self.navigate(p))
elif icon == 'movie':
# Duplo clique no vídeo abre o player
card.on('dblclick', lambda p=entry.path: self.open_player(p))
self.bind_context_menu(card, entry)
ui.icon(icon, size='3rem', color=color)
ui.label(entry.name).classes('text-xs text-center leading-tight line-clamp-2 w-full break-all')
if not is_dir: ui.label(get_human_size(entry.stat().st_size)).classes('text-[10px] text-gray-400')
with ui.button(icon='more_vert').props('flat round dense size=sm').classes('absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity bg-white/90'):
with ui.menu():
if icon == 'movie':
ui.menu_item('▶️ Play', on_click=lambda p=entry.path: self.open_player(p))
ui.separator()
ui.menu_item('Renomear', on_click=lambda p=entry.path: self.open_rename_dialog(p))
ui.menu_item('Mover', on_click=lambda p=entry.path: self.open_move_dialog(p))
ui.separator()
ui.menu_item('Excluir', on_click=lambda p=entry.path: self.open_delete_dialog(p)).props('text-color=red')
else:
with ui.column().classes('w-full gap-0'):
for entry in entries:
is_dir = entry.is_dir()
icon = 'folder' if is_dir else 'description'
color = 'amber-8' if is_dir else 'blue-grey'
with ui.row().classes('w-full items-center px-2 py-2 border-b hover:bg-blue-50 cursor-pointer group') as row:
if is_dir: row.on('click', lambda p=entry.path: self.navigate(p))
elif entry.name.lower().endswith(('.mkv', '.mp4')):
row.on('dblclick', lambda p=entry.path: self.open_player(p))
self.bind_context_menu(row, entry)
ui.icon(icon, color=color).classes('mr-2')
with ui.column().classes('flex-grow gap-0'):
ui.label(entry.name).classes('text-sm font-medium break-all')
if not is_dir:
ui.label(get_human_size(entry.stat().st_size)).classes('text-xs text-gray-500 mr-4')
with ui.button(icon='more_vert').props('flat round dense size=sm').classes('sm:opacity-0 group-hover:opacity-100'):
with ui.menu():
if not is_dir:
ui.menu_item('▶️ Play', on_click=lambda p=entry.path: self.open_player(p))
ui.menu_item('Renomear', on_click=lambda p=entry.path: self.open_rename_dialog(p))
ui.menu_item('Mover', on_click=lambda p=entry.path: self.open_move_dialog(p))
ui.menu_item('Excluir', on_click=lambda p=entry.path: self.open_delete_dialog(p))
# --- INICIALIZADOR ---
def create_ui(): def create_ui():
fm = FileManager() fm = FileManager()
fm.container = ui.column().classes('w-full h-full p-2 md:p-4 gap-4') fm.create_layout()
fm.refresh() ui.timer(0, fm.refresh, once=True)