diff --git a/app/modules/__pycache__/file_manager.cpython-310.pyc b/app/modules/__pycache__/file_manager.cpython-310.pyc index a82a080..c687372 100644 Binary files a/app/modules/__pycache__/file_manager.cpython-310.pyc and b/app/modules/__pycache__/file_manager.cpython-310.pyc differ diff --git a/app/modules/file_manager.py b/app/modules/file_manager.py index 79c0077..a2febe2 100755 --- a/app/modules/file_manager.py +++ b/app/modules/file_manager.py @@ -1,327 +1,453 @@ -from nicegui import ui, app +from nicegui import ui, run import os import shutil +import asyncio import datetime import subprocess import json ROOT_DIR = "/downloads" -# --- UTILITÁRIOS --- -def get_human_size(size): +# --- UTILITÁRIOS ASSÍNCRONOS --- +async def get_human_size(size): for unit in ['B', 'KB', 'MB', 'GB']: if size < 1024: return f"{size:.2f} {unit}" size /= 1024 return f"{size:.2f} TB" -def get_subfolders(root): - folders = [root] - 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) +async def get_media_info_async(filepath): + def _probe(): + cmd = ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", "-show_format", filepath] + try: + res = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + return json.loads(res.stdout) + except: return None -# --- 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] - try: - res = subprocess.run(cmd, capture_output=True, text=True) - data = json.loads(res.stdout) - - info = { - "duration": float(data['format'].get('duration', 0)), - "bitrate": int(data['format'].get('bit_rate', 0)), - "video": [], - "audio": [], - "subtitle": [] - } + data = await run.io_bound(_probe) + if not data: return None + + info = { + "filename": os.path.basename(filepath), + "size": int(data['format'].get('size', 0)), + "duration": float(data['format'].get('duration', 0)), + "bitrate": int(data['format'].get('bit_rate', 0)), + "video": [], "audio": [], "subtitle": [] + } - for s in data.get('streams', []): - type = s['codec_type'] - lang = s.get('tags', {}).get('language', 'und') - title = s.get('tags', {}).get('title', '') - codec = s.get('codec_name', 'unknown') + for s in data.get('streams', []): + stype = s['codec_type'] + lang = s.get('tags', {}).get('language', 'und').upper() + codec = s.get('codec_name', 'unknown').upper() + 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}" - 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 + return info # --- CLASSE GERENCIADORA --- class FileManager: def __init__(self): self.path = ROOT_DIR 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): 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: - 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) + 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: - 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): self.view_mode = 'list' if self.view_mode == 'grid' else 'grid' - self.refresh() - - 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() + ui.timer(0, self.refresh, once=True) def open_create_folder(self): with ui.dialog() as dialog, ui.card(): ui.label('Nova Pasta') name = ui.input('Nome') - def create(): + async def create(): 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() - self.refresh() + await self.refresh() except Exception as e: ui.notify(str(e), type='negative') ui.button('Criar', on_click=create) 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): 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() - + 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)) - ui.separator() - ui.menu_item('Excluir', on_click=lambda: self.open_delete_dialog(entry.path)).props('text-color=red') - + ui.menu_item('Mover Para...', on_click=lambda: self.open_move_dialog([entry.path])) + + 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()) - # --- 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') + # --- 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') - 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') + 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 - 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') + 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') - def render_content(self): - try: - entries = sorted(os.scandir(self.path), key=lambda e: (not e.is_dir(), e.name.lower())) - except: return + # 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') - if not entries: - ui.label('Pasta vazia').classes('w-full text-center text-gray-400 mt-10') - return + # 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') - if 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: - 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' - - 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)) + # 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(): fm = FileManager() - fm.container = ui.column().classes('w-full h-full p-2 md:p-4 gap-4') - fm.refresh() \ No newline at end of file + fm.create_layout() + ui.timer(0, fm.refresh, once=True) \ No newline at end of file