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:
for r, d, f in os.walk(root):
if "finalizados" in r or "temp" in r: continue
for folder in d:
if not folder.startswith('.'): folders.append(os.path.join(r, folder))
except: pass
return sorted(folders)
# --- LEITOR DE METADADOS (FFPROBE) ---
def get_media_info(filepath):
"""Lê as faixas de áudio e legenda do arquivo"""
cmd = ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", "-show_format", filepath] cmd = ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", "-show_format", filepath]
try: try:
res = subprocess.run(cmd, capture_output=True, text=True) res = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
data = json.loads(res.stdout) return json.loads(res.stdout)
except: return None
data = await run.io_bound(_probe)
if not data: return None
info = { info = {
"filename": os.path.basename(filepath),
"size": int(data['format'].get('size', 0)),
"duration": float(data['format'].get('duration', 0)), "duration": float(data['format'].get('duration', 0)),
"bitrate": int(data['format'].get('bit_rate', 0)), "bitrate": int(data['format'].get('bit_rate', 0)),
"video": [], "video": [], "audio": [], "subtitle": []
"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', '')})
desc = f"[{lang.upper()}] {codec}" elif stype == 'audio':
if title: desc += f" - {title}" info['audio'].append({"lang": lang, "codec": codec, "ch": s.get('channels', 0), "title": s.get('tags', {}).get('title', '')})
elif stype == 'subtitle':
if type == 'video': info['subtitle'].append({"lang": lang, "codec": codec})
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 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): def open_create_folder(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(): with ui.dialog() as dialog, ui.card():
ui.label('Excluir item?').classes('font-bold') ui.label('Nova Pasta')
ui.label(os.path.basename(path)) name = ui.input('Nome')
with ui.row().classes('w-full justify-end'): async def create():
ui.button('Cancelar', on_click=dialog.close).props('flat')
def confirm():
try: try:
if os.path.isdir(path): shutil.rmtree(path) await run.io_bound(os.makedirs, os.path.join(self.path, name.value))
else: os.remove(path)
dialog.close() dialog.close()
self.refresh() await self.refresh()
ui.notify('Excluído!')
except Exception as e: ui.notify(str(e), type='negative') except Exception as e: ui.notify(str(e), type='negative')
ui.button('Excluir', on_click=confirm).props('color=red') ui.button('Criar', on_click=create)
dialog.open() dialog.open()
def open_rename_dialog(self, path): def open_rename_dialog(self, path):
with ui.dialog() as dialog, ui.card(): with ui.dialog() as dialog, ui.card():
ui.label('Renomear') ui.label('Renomear')
name = ui.input('Novo Nome', value=os.path.basename(path)).classes('w-full') name = ui.input('Novo Nome', value=os.path.basename(path)).classes('w-full')
def save(): async def save():
try: try:
os.rename(path, os.path.join(os.path.dirname(path), name.value)) new_path = os.path.join(os.path.dirname(path), name.value)
await run.io_bound(os.rename, path, new_path)
dialog.close() dialog.close()
self.refresh() await self.refresh()
ui.notify('Renomeado!')
except Exception as e: ui.notify(str(e), type='negative') except Exception as e: ui.notify(str(e), type='negative')
ui.button('Salvar', on_click=save) ui.button('Salvar', on_click=save)
dialog.open() dialog.open()
def open_move_dialog(self, path): # --- RENDERIZAÇÃO DE CONTEÚDO ---
folders = get_subfolders(ROOT_DIR) async def refresh(self):
if os.path.isdir(path) and path in folders: folders.remove(path) if self.refreshing: return
opts = {f: f.replace(ROOT_DIR, "Raiz") if f != ROOT_DIR else "Raiz" for f in folders} self.refreshing = True
with ui.dialog() as dialog, ui.card().classes('w-96'):
ui.label('Mover Para') color = 'green' if self.is_selecting else 'grey'
target = ui.select(opts, value=ROOT_DIR, with_input=True).classes('w-full') if self.btn_select_mode:
def confirm(): self.btn_select_mode.props(f'text-color={color}')
if self.container_content:
self.container_content.clear()
try: try:
shutil.move(path, target.value) entries = await run.io_bound(os.scandir, self.path)
dialog.close() entries = sorted(entries, key=lambda e: (not e.is_dir(), e.name.lower()))
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): if self.is_selecting:
with ui.dialog() as dialog, ui.card(): with self.container_content:
ui.label('Nova Pasta') 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'):
name = ui.input('Nome') ui.checkbox(f'Selecionar Todos ({len(entries)})', on_change=lambda: self.select_all(entries)).props('dense size=xs')
def create():
try:
os.makedirs(os.path.join(self.path, name.value))
dialog.close()
self.refresh()
except Exception as e: ui.notify(str(e), type='negative')
ui.button('Criar', on_click=create)
dialog.open()
# --- MENU DE CONTEXTO ---
def bind_context_menu(self, element, entry):
with ui.menu() as m:
if not entry.is_dir and entry.name.lower().endswith(('.mkv', '.mp4', '.avi')):
ui.menu_item('▶️ Reproduzir / Detalhes', on_click=lambda: self.open_player(entry.path))
ui.separator()
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.separator()
ui.menu_item('Excluir', on_click=lambda: self.open_delete_dialog(entry.path)).props('text-color=red')
element.on('contextmenu.prevent', lambda: m.open())
# --- RENDERIZADORES ---
def render_header(self):
with ui.row().classes('w-full items-center bg-gray-100 p-2 rounded-lg gap-2'):
if self.path != ROOT_DIR:
ui.button(icon='arrow_upward', on_click=self.navigate_up).props('flat round dense').tooltip('Subir')
else:
ui.button(icon='home').props('flat round dense disabled text-color=grey')
rel = os.path.relpath(self.path, ROOT_DIR)
parts = rel.split(os.sep) if rel != '.' else []
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')
acc = ROOT_DIR
for part in parts:
acc = os.path.join(acc, part)
ui.label('/')
ui.button(part, on_click=lambda p=acc: self.navigate(p)).props('flat dense no-caps min-w-0 px-2')
ui.space()
ui.button(icon='create_new_folder', on_click=self.open_create_folder).props('flat round dense')
ui.button(icon='view_list' if self.view_mode == 'grid' else 'grid_view', on_click=self.toggle_view).props('flat round dense')
ui.button(icon='refresh', on_click=self.refresh).props('flat round dense')
def render_content(self):
try:
entries = sorted(os.scandir(self.path), key=lambda e: (not e.is_dir(), e.name.lower()))
except: return
with self.container_content:
if not entries: if not entries:
ui.label('Pasta vazia').classes('w-full text-center text-gray-400 mt-10') ui.label('Pasta Vazia').classes('w-full text-center text-gray-400 mt-10')
return
if self.view_mode == 'grid': 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'): 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: 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() is_dir = entry.is_dir()
icon = 'folder' if is_dir else 'description' icon = 'folder' if is_dir else 'description'
if not is_dir and entry.name.lower().endswith(('.mkv', '.mp4', '.avi')): icon = 'movie' if not is_dir and entry.name.lower().endswith(('.mkv', '.mp4', '.avi')): icon = 'movie'
color = 'amber-8' if is_dir else 'blue-grey' color = 'amber-8' if is_dir else 'blue-grey'
if icon == 'movie': color = 'purple-6' if icon == 'movie': color = 'purple-6'
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: bg_cls = 'bg-green-100 ring-2 ring-green-500' if is_selected else 'bg-white hover:shadow-md'
if is_dir: if self.is_selecting and not is_selected: bg_cls += ' border-dashed border-2 border-gray-300'
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))
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) self.bind_context_menu(card, entry)
ui.icon(icon, size='3rem', color=color) if self.is_selecting:
ui.label(entry.name).classes('text-xs text-center leading-tight line-clamp-2 w-full break-all') card.on('click', lambda: self.toggle_selection(entry.path))
if not is_dir: ui.label(get_human_size(entry.stat().st_size)).classes('text-[10px] text-gray-400') 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())
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: else:
with ui.column().classes('w-full gap-0'): if is_dir: card.on('click', lambda e, p=entry.path: self.navigate(p))
for entry in entries: elif icon == 'movie': card.on('click', lambda e, p=entry.path: self.open_inspector(p))
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: ui.icon(icon, size='3rem', color=color)
if is_dir: row.on('click', lambda p=entry.path: self.navigate(p)) ui.label(entry.name).classes('text-xs text-center leading-tight line-clamp-2 w-full break-all mt-2')
elif entry.name.lower().endswith(('.mkv', '.mp4')):
row.on('dblclick', lambda p=entry.path: self.open_player(p)) 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) 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')
ui.icon(icon, color=color).classes('mr-2') def bind_context_menu(self, element, entry):
with ui.column().classes('flex-grow gap-0'): with ui.menu() as m:
ui.label(entry.name).classes('text-sm font-medium break-all') if not entry.is_dir and entry.name.lower().endswith(('.mkv', '.mp4')):
ui.menu_item('Media Info', on_click=lambda: self.open_inspector(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]))
if not is_dir: async def delete_single():
ui.label(get_human_size(entry.stat().st_size)).classes('text-xs text-gray-500 mr-4') 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}")
with ui.button(icon='more_vert').props('flat round dense size=sm').classes('sm:opacity-0 group-hover:opacity-100'): ui.menu_item('Excluir', on_click=delete_single).props('text-color=red')
with ui.menu(): element.on('contextmenu.prevent', lambda: m.open())
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))
# --- INSPECTOR (RESTAURADO E RICO) ---
async def open_inspector(self, path):
dialog = ui.dialog()
with dialog, ui.card().classes('w-full max-w-3xl'):
with ui.row().classes('w-full justify-between items-start'):
ui.label(os.path.basename(path)).classes('text-lg font-bold break-all w-10/12 text-blue-800')
ui.button(icon='close', on_click=dialog.close).props('flat dense round')
content = ui.column().classes('w-full')
with content: ui.spinner('dots').classes('self-center')
dialog.open()
info = await get_media_info_async(path)
content.clear()
if not info:
with content: ui.label('Não foi possível ler os metadados.').classes('text-red font-bold')
return
with content:
# 1. Resumo Geral (Stats)
with ui.row().classes('w-full bg-blue-50 p-2 rounded mb-4 gap-4'):
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')
# 2. Vídeo
ui.label('Vídeo').classes('text-xs font-bold text-gray-500 uppercase mt-2')
for v in info['video']:
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')
# 3. Áudio
ui.label('Áudio').classes('text-xs font-bold text-gray-500 uppercase mt-4')
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')
# 4. Legendas
ui.label('Legendas').classes('text-xs font-bold text-gray-500 uppercase mt-4')
if info['subtitle']:
with ui.row().classes('w-full gap-2'):
for s in info['subtitle']:
color = 'green' if s['codec'] in ['subrip', 'ass'] else 'grey'
ui.chip(f"{s['lang']} ({s['codec']})", color=color).props('dense icon=subtitles')
else:
ui.label('Sem legendas internas').classes('italic text-gray-400')
# --- 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)