ML-Player: Guía para Desarrolladores de Plugins

¡Bienvenido a la guía de desarrollo de plugins para ML-Player! Este documento te proporcionará todo lo que necesitas saber para extender la funcionalidad de ML-Player.

1. Introducción

Un plugin es un archivo de Python que contiene una clase especial que ML-Player puede descubrir y cargar. A través de esta clase, puedes interactuar con el reproductor, controlar la reproducción, modificar la lista de reproducción, añadir elementos a la interfaz de usuario y mucho más.

2. Estructura Básica de un Plugin

Cada plugin debe contener una clase que herede de MLPlayerPlugin e implementar los siguientes métodos abstractos:

Acceso a Metadatos:
Los metadatos del plugin (nombre, versión, autor, descripción, permisos) se cargan automáticamente desde plugin.json y se pasan al constructor de tu clase MLPlayerPlugin. Puedes acceder a ellos a través del atributo self.metadata.

Este es el esqueleto mínimo de un plugin:

# lib/plugins/mi_primer_plugin.py

from utils.plugin_interface import MLPlayerPlugin

class MiPlugin(MLPlayerPlugin):
    def __init__(self, metadata: dict):
        super().__init__(metadata) # Llama al constructor de la clase base
        # Ahora puedes acceder a los metadatos así:
        # self.metadata['name']
        # self.metadata['version']
        # self.metadata['author']
        # self.metadata['description']

    # --- Métodos del Ciclo de Vida ---
    def load(self, app_context):
        """Se llama cuando el plugin es cargado por primera vez."""
        self.app_context = app_context
        print(f"¡Plugin '{self.metadata['name']}' ha sido cargado!")

    def unload(self):
        """Se llama cuando el plugin va a ser descargado."""
        print(f"Plugin '{self.metadata['name']}' ha sido descargado.")

3. El Objeto AppContext: Tu API Principal

Cuando tu plugin se carga, recibe una instancia de AppContext en su método load. Este objeto es tu único punto de acceso para interactuar con ML-Player. A continuación se detallan todos los métodos disponibles en app_context, agrupados por funcionalidad.


3.1. Sistema de Permisos

Por seguridad, los plugins deben declarar los permisos que necesitan para operar. El usuario debe conceder estos permisos para que el plugin pueda acceder a ciertas funcionalidades.

has_permission(permission_name)

Verifica si el usuario ha concedido un permiso específico a tu plugin.

Permisos Disponibles:

# Dentro de la clase de tu plugin
def buscar_letras_online(self):
    if self.app_context.has_permission("network_access"):
        # Lógica para realizar la petición de red
        print("Permiso de red concedido. Buscando letras...")
    else:
        # Informar al usuario que el permiso es necesario
        print("Permiso de red denegado. No se pueden buscar letras.")
        wx.MessageBox(
            "Este plugin necesita acceso a la red para buscar letras. "
            "Por favor, habilita el permiso 'network_access' en el Gestor de Plugins.",
            "Permiso Requerido",
            wx.ICON_WARNING
        )

3.2. Interactuando con la Interfaz de Usuario (UI)

Puedes añadir menús, diálogos y dar feedback de audio.

register_menu_item(menu_name, item_text, callback, help_text="")

Añade una opción a un menú en la barra principal. Si el menú no existe, se crea. El help_text opcional se mostrará en la barra de estado de la aplicación.

# Dentro de la clase de tu plugin
def mi_accion_de_menu(self, event):
    wx.MessageBox("¡Has hecho clic en el menú del plugin!", "Info")

def load(self, app_context):
    self.app_context = app_context
    self.app_context.register_menu_item(
        menu_name="Mis Plugins",
        item_text="Mi Acción Especial",
        callback=self.mi_accion_de_menu,
        help_text="Esta es una descripción para la barra de estado."
    )

unregister_menu_item(menu_name, item_text)

Elimina una opción de un menú previamente registrada por el plugin. Es crucial llamar a este método en el unload de tu plugin para limpiar la interfaz.

Si el menú queda vacío después de eliminar el elemento, también se eliminará el menú completo de la barra de menús.

# Dentro de la clase de tu plugin
def unload(self):
    # Asegúrate de desregistrar todos los elementos de menú que registraste en load()
    self.app_context.unregister_menu_item(
        menu_name="Mis Plugins",
        item_text="Mi Acción Especial"
    )

speak(message, interrupt=False, context='ui')

Verbaliza un mensaje utilizando el motor de voz configurado en la aplicación (NVDA o SAPI). Es la forma preferida de dar feedback de audio al usuario.

# Dentro de la clase de tu plugin
def mi_accion(self):
    self.app_context.speak("Realizando acción importante.", interrupt=True)
    # ...código de la acción...
    self.app_context.speak("Acción completada.")

show_text_input_dialog(title, message)

Muestra un diálogo para pedirle texto al usuario.

# Dentro de la clase de tu plugin
def preguntar_nombre(self):
    nombre = self.app_context.show_text_input_dialog(
        title="Pregunta",
        message="¿Cómo te llamas?"
    )
    if nombre:
        print(f"El usuario se llama {nombre}")

show_read_only_text_dialog(title, text)

Muestra un diálogo con texto que no se puede editar, útil para mostrar información.

# Dentro de la clase de tu plugin
def mostrar_info(self):
    info = "Esta es una información importante que el usuario debe leer."
    self.app_context.show_read_only_text_dialog("Información del Plugin", info)

register_button(parent_sizer, button_text, callback, tooltip="")

Añade un nuevo botón a un sizer específico en la interfaz principal. El parent_sizer debe ser obtenido de app_context.get_plugin_buttons_sizer().

# Dentro de la clase de tu plugin
def on_mi_boton_click(self, event):
    wx.MessageBox("¡Botón del plugin clickeado!", "Info")

def load(self, app_context):
    self.app_context = app_context
    plugin_sizer = self.app_context.get_plugin_buttons_sizer()
    if plugin_sizer:
        self.app_context.register_button(
            parent_sizer=plugin_sizer,
            button_text="Mi Botón",
            callback=self.on_mi_boton_click,
            tooltip="Este es un botón añadido por mi plugin."
        )

unregister_button(parent_sizer, button_text)

Elimina un botón previamente registrado por el plugin. Es crucial llamar a este método en el unload de tu plugin para limpiar la interfaz.

# Dentro de la clase de tu plugin
def unload(self):
    plugin_sizer = self.app_context.get_plugin_buttons_sizer()
    if plugin_sizer:
        self.app_context.unregister_button(
            parent_sizer=plugin_sizer,
            button_text="Mi Botón" # Debe coincidir con el texto usado en register_button
        )

get_plugin_buttons_sizer()

Devuelve el sizer principal donde los plugins pueden añadir sus propios botones. Utiliza este sizer como parent_sizer para register_button.

# Dentro de la clase de tu plugin
def load(self, app_context):
    self.app_context = app_context
    my_sizer = self.app_context.get_plugin_buttons_sizer()
    if my_sizer:
        # Ahora puedes usar my_sizer con register_button
        pass

create_dialog(title)

Para interfaces más complejas, puedes construir tus propios diálogos personalizados. Este método devuelve un objeto "constructor" que te permite añadir componentes.

register_context_menu_item(target_ui_element_id, item_text, callback)

Añade una opción a un menú contextual de un elemento de la interfaz de usuario.

target_ui_element_id: Un identificador para el elemento de la UI al que se adjuntará el menú contextual. Actualmente, el único valor soportado es "main_panel_context_menu" para el menú contextual del panel principal.

# Dentro de la clase de tu plugin
def on_context_menu_action(self, event):
    wx.MessageBox("¡Has hecho clic en una opción del menú contextual del plugin!", "Info")

def load(self, app_context):
    self.app_context = app_context
    self.app_context.register_context_menu_item(
        target_ui_element_id="main_panel_context_menu",
        item_text="Acción de Plugin (Contextual)",
        callback=self.on_context_menu_action
    )

unregister_context_menu_item(target_ui_element_id, item_text)

Elimina una opción de un menú contextual previamente registrada por el plugin. Es crucial llamar a este método en el unload de tu plugin para limpiar la interfaz.

# Dentro de la clase de tu plugin
def unload(self):
    self.app_context.unregister_context_menu_item(
        target_ui_element_id="main_panel_context_menu",
        item_text="Acción de Plugin (Contextual)"
    )

Métodos del objeto diálogo:

Parámetros comunes para los métodos add_:

Ejemplo de uso:

# Dentro de la clase de tu plugin

def on_show_dialog(self, event):
    dialog = self.app_context.create_dialog("Ejemplo de Controles UI Avanzados")

    # Sizer vertical para nombre y texto
    vertical_sizer = dialog.add_sizer("vertical_group", wx.VERTICAL, flag=wx.EXPAND | wx.ALL, border=10)
    dialog.add_label("Introduce tu nombre:", parent_sizer=vertical_sizer)
    dialog.add_text_input("user_name", initial_value="Anónimo", parent_sizer=vertical_sizer)

    # Sizer horizontal para checkbox y combobox
    horizontal_sizer = dialog.add_sizer("horizontal_group", wx.HORIZONTAL, flag=wx.EXPAND | wx.ALL, border=10)
    dialog.add_checkbox("enable_feature", "Habilitar Característica", initial_value=True, parent_sizer=horizontal_sizer)
    dialog.add_combobox("options_combo", ["Opción 1", "Opción 2", "Opción 3"], initial_value="Opción 2", parent_sizer=horizontal_sizer)

    # Radio Box
    dialog.add_radio_box("choice_radio", "Elige una opción", ["Primero", "Segundo", "Tercero"], initial_selection=1)

    # Notebook (pestañas)
    notebook = dialog.add_notebook("my_notebook")
    
    # Página 1 del Notebook
    page1_panel, page1_sizer = dialog.add_notebook_page(notebook, "Pestaña Uno")
    dialog.add_label("Contenido de la Pestaña Uno", parent_sizer=page1_sizer)

    # Página 2 del Notebook
    page2_panel, page2_sizer = dialog.add_notebook_page(notebook, "Pestaña Dos")
    dialog.add_label("Contenido de la Pestaña Dos", parent_sizer=page2_sizer)

    # Botón para obtener valores
    dialog.add_button("Obtener Valores", callback=lambda e: self.on_get_values(dialog))
    dialog.add_button("Cerrar", callback=dialog.close)

    dialog.show()

def on_get_values(self, dialog):
    name = dialog.get_value("user_name")
    feature_enabled = dialog.get_value("enable_feature")
    selected_option = dialog.get_value("options_combo")
    radio_choice = dialog.get_value("choice_radio")

    message = (
        f"Nombre: {name}\n"
        f"Característica Habilitada: {feature_enabled}\n"
        f"Opción Seleccionada: {selected_option}\n"
        f"Elección de Radio: {radio_choice}"
    )
    wx.MessageBox(message, "Valores Obtenidos", wx.OK | wx.ICON_INFORMATION)
    # No cerramos el diálogo aquí para que el usuario pueda ver los valores y seguir interactuando

3.2.1. Mejoras en la Creación de Diálogos Personalizados

Se han introducido mejoras significativas para ofrecer mayor flexibilidad y una mejor experiencia de usuario al crear diálogos personalizados para tus plugins.

add_custom_panel(custom_panel: wx.Panel, parent_sizer=None, proportion=1, flag=wx.EXPAND | wx.ALL, border=5)

Este método permite integrar un wx.Panel completamente personalizado, creado por tu plugin, directamente en el PluginDialog. Esto te otorga acceso total a la API de wxPython para diseñar interfaces de usuario complejas y únicas.

Ventajas:

Consideraciones:

# Dentro de la clase de tu plugin

import wx
from utils.plugin_ui import PluginDialog # Asegúrate de importar PluginDialog
from utils.app_context import AppContext # Asegúrate de importar AppContext

class MyComplexCustomPanel(wx.Panel):
    def __init__(self, parent, app_context: AppContext):
        super().__init__(parent)
        self.app_context = app_context
        # ... aquí construyes tu UI compleja con cualquier widget de wxPython ...
        # Por ejemplo, un wx.ListCtrl, wx.GridBagSizer, etc.
        # Puedes acceder a self.app_context para interactuar con la aplicación.

class MiPlugin(MLPlayerPlugin):
    # ...
    def show_mi_dialogo_avanzado(self, event):
        dialog = PluginDialog(self.app_context.get_main_window(), "Mi Diálogo Avanzado")
        
        # Crear una instancia de tu panel personalizado, pasándole el app_context
        custom_panel = MyComplexCustomPanel(dialog.panel, self.app_context)
        
        # Añadir el panel personalizado al PluginDialog
        dialog.add_custom_panel(custom_panel, proportion=1, flag=wx.EXPAND | wx.ALL, border=10)
        
        # Puedes seguir añadiendo botones estándar del PluginDialog si lo deseas
        dialog.add_button("Cerrar", lambda e: dialog.close(), parent_sizer=dialog.main_sizer)
        
        dialog.show()

Persistencia en Memoria de Diálogos

Ahora, los diálogos creados con PluginDialog pueden persistir en memoria mientras el plugin esté cargado, en lugar de ser destruidos y recreados cada vez que se abren. Esto permite que el estado de los controles y los datos dentro del diálogo se mantengan entre aperturas.

Para aprovechar esta característica, tu plugin debe almacenar una referencia a la instancia de PluginDialog y reutilizarla:

# Dentro de la clase de tu plugin

class MiPlugin(MLPlayerPlugin):
    def __init__(self, metadata: dict):
        super().__init__(metadata)
        self.my_dialog_instance = None # Almacenar la instancia del diálogo

    def load(self, app_context: AppContext):
        self.app_context = app_context
        # ... registrar menú para abrir el diálogo ...

    def unload(self):
        # Es crucial destruir el diálogo cuando el plugin se descarga
        if self.my_dialog_instance and self.my_dialog_instance.dialog:
            self.my_dialog_instance.dialog.Destroy()
            self.my_dialog_instance = None

    def show_mi_dialogo(self, event):
        if not self.my_dialog_instance or not self.my_dialog_instance.dialog or not self.my_dialog_instance.dialog.IsBeingDeleted():
            # Crear una nueva instancia si no existe o ha sido destruida
            dialog = PluginDialog(self.app_context.get_main_window(), "Mi Diálogo Persistente")
            # ... añadir controles al diálogo ...
            self.my_dialog_instance = dialog
        
        # Mostrar el diálogo (ya sea nuevo o existente)
        self.my_dialog_instance.show()

Manejador de Tecla Escape por Defecto

Todos los diálogos creados con PluginDialog ahora incluyen un manejador por defecto para la tecla Escape. Al presionar Escape, el diálogo se cerrará automáticamente, mejorando la usabilidad.

No necesitas implementar esto manualmente en tus diálogos; el comportamiento ya está integrado.


3.3. Control de Reproducción

Puedes controlar todos los aspectos de la reproducción.

get_playback_status()

Devuelve un diccionario con el estado actual del reproductor.

# Dentro de la clase de tu plugin
def chequear_estado(self):
    status = self.app_context.get_playback_status()
    # status -> {'state': 'playing', 'position_ms': 34500, 'duration_ms': 240000}
    if status and status['state'] == 'playing':
        print(f"La canción va por el segundo {status['position_ms'] // 1000}")

toggle_playback()

Pausa o reanuda la reproducción.

self.app_context.toggle_playback()

play_media(path)

Reproduce un nuevo archivo o URL, reemplazando la lista actual. Requiere permiso file_system_read para archivos locales.

# Reproducir un archivo local
self.app_context.play_media("C:\\Music\\song.mp3")

# Reproducir una URL
self.app_context.play_media("https://example.com/stream.ogg")

play_url_from_plugin(url)

Permite a un plugin reproducir una URL directamente. Requiere permiso network_access.

# Reproducir una URL desde el plugin
self.app_context.play_url_from_plugin("https://www.youtube.com/watch?v=dQw4w9WgXcQ")

stop()

Detiene la reproducción por completo.

self.app_context.stop()

seek(milliseconds)

Salta a un punto específico de la canción.

# Saltar al segundo 30
self.app_context.seek(30000)

set_volume(level)

Ajusta el volumen de 0 a 100.

# Poner el volumen al 75%
self.app_context.set_volume(75)

3.4. Gestión de la Lista de Reproducción

Puedes leer y manipular la lista de reproducción actual.

get_playlist_items()

Devuelve la lista actual como una lista de tuplas (ruta, es_url, titulo).

# Dentro de la clase de tu plugin
def imprimir_lista(self):
    items = self.app_context.get_playlist_items()
    for ruta, es_url, titulo in items:
        print(f"- {titulo}")

add_to_playlist(path)

Añade una nueva pista al final de la lista actual. Requiere permiso file_system_read para archivos locales.

self.app_context.add_to_playlist("D:\\podcast.mp3")

clear_playlist()

Vacía por completo la lista de reproducción y detiene la reproducción.

self.app_context.clear_playlist()

play_playlist_item(index)

Reproduce una canción de la lista usando su índice (empezando en 0).

# Reproducir la primera canción de la lista
self.app_context.play_playlist_item(0)

3.5. Sistema de Eventos

Tu plugin puede reaccionar a eventos que ocurren en la aplicación.

subscribe_to_event(event_name, callback)

Registra una función de tu plugin para que sea llamada cuando ocurra un evento.

Eventos Disponibles:

Ejemplo de uso:

# Dentro de la clase de tu plugin

def load(self, app_context):
    self.app_context = app_context
    # Nos suscribimos al evento de carga de un nuevo medio
    self.app_context.subscribe_to_event('media_loaded', self.on_nueva_cancion)
    self.app_context.subscribe_to_event('app_closing', self.on_cerrar_app)

def unload(self):
    # Es MUY importante desuscribirse de los eventos al descargar el plugin
    self.app_context.unsubscribe_from_event('media_loaded', self.on_nueva_cancion)
    self.app_context.unsubscribe_from_event('app_closing', self.on_cerrar_app)

def on_nueva_cancion(self, path, title):
    print(f"El plugin dice: Empezó a sonar '{title}'")

def on_cerrar_app(self):
    print("El plugin dice: ¡La aplicación se está cerrando! Adiós.")

3.6. Gestión de Información y Configuración

Puedes obtener información de la pista actual y guardar la configuración de tu plugin.

get_current_track_info()

Devuelve un diccionario con metadatos de la pista actual.

# Dentro de la clase de tu plugin
def obtener_artista(self):
    info = self.app_context.get_current_track_info()
    if info and 'artist' in info:
        print(f"El artista es {info['artist']}")
    else:
        print("No se encontró información del artista.")

get_app_config_value(key)

Permite a los plugins leer valores de configuración global de la aplicación. Solo se pueden acceder a las claves de configuración permitidas por seguridad.

Claves de configuración permitidas (whitelist):

# Dentro de la clase de tu plugin
def verificar_configuracion(self):
    idioma_app = self.app_context.get_app_config_value('language')
    if idioma_app:
        print(f"El idioma actual de la aplicación es: {idioma_app}")
    else:
        print("No se pudo obtener el idioma de la aplicación o la clave no está permitida.")

    mostrar_adulto = self.app_context.get_app_config_value('show_adult_content')
    print(f"Mostrar contenido para adultos: {mostrar_adulto}")

save_plugin_config(config_dict) y load_plugin_config()

Permiten a tu plugin guardar y cargar un diccionario de configuración, que persistirá entre sesiones.

Ejemplo de uso:

# Dentro de la clase de tu plugin

def load(self, app_context):
    self.app_context = app_context
    # Cargar nuestra configuración
    self.settings = self.app_context.load_plugin_config()
    if not self.settings: # Si no hay configuración, usar valores por defecto
        self.settings = {'api_key': None, 'user_name': 'default'}

    self.app_context.register_menu_item("Mi Plugin", "Guardar API Key", self.guardar_api_key)

def guardar_api_key(self, event):
    key = self.app_context.show_text_input_dialog("API Key", "Introduce tu API Key:")
    if key:
        self.settings['api_key'] = key
        # Guardar el diccionario de configuración
        self.app_context.save_plugin_config(self.settings)
        print("API Key guardada.")

4. Ciclo de Vida del Plugin

  1. Instalación: El usuario selecciona un paquete .ml-plugin. Se le presenta la información del plugin y los permisos que solicita. Si acepta, el plugin se añade al sistema.
  2. Descubrimiento: ML-Player encuentra tu archivo en lib/plugins/.
  3. Carga (load): Si el plugin está habilitado, se crea una instancia de tu clase y se llama al método load(). Aquí es donde debes registrar menús, suscribirte a eventos, etc.
  4. Ejecución: Tu plugin está activo y sus funciones son llamadas por las acciones del usuario o los eventos de la aplicación, siempre que tenga los permisos necesarios.
  5. Descarga (unload): Cuando la aplicación se cierra o el plugin se deshabilita/desinstala, se llama al método unload(). Debes usar este método para limpiar cualquier recurso, y especialmente para desuscribirte de los eventos a los que te hayas suscrito.

5. Distribución de Plugins: Paquetes .ml-plugin

Para facilitar la distribución a usuarios finales, es recomendable empaquetar tu plugin en un archivo .ml-plugin. Esto agrupa todos los archivos necesarios en un solo paquete, es más seguro y se ve más profesional.

El sistema puede cargar tanto archivos .py sueltos (para desarrollo) como paquetes .ml-plugin.

5.1. Estructura del Paquete

Un paquete es simplemente un archivo .zip con la extensión cambiada a .ml-plugin. El contenido del paquete debe estar estructurado de la siguiente manera:

5.2. El Manifiesto plugin.json

Este archivo es obligatorio y debe estar en la raíz del paquete.

Formato del plugin.json:

{
  "name": "Nombre Descriptivo de tu Plugin",
  "version": "1.0.0",
  "author": "Tu Nombre o Nickname",
  "description": "Una breve descripción de lo que hace tu plugin.",
  "main_file": "mi_codigo.py",
  "permissions": {
    "network_access": "Necesita acceso a internet para buscar letras de canciones.",
    "file_system_read": "Necesita leer archivos para añadir canciones a la lista de reproducción."
  },
  "dependencies": [
    "requests",
    "beautifulsoup4"
  ]
}

5.3. Gestión de Dependencias

Para asegurar la compatibilidad y evitar conflictos de versiones, los plugins de ML-Player deben empaquetar sus propias dependencias externas. Esto significa que cada plugin es autocontenido y no depende de las librerías instaladas globalmente en el sistema del usuario o por otros plugins.

La clave "dependencies" en tu archivo plugin.json es ahora **informativa**. Puedes listar las librerías que tu plugin utiliza, pero ML-Player no las instalará automáticamente. Es responsabilidad del desarrollador incluir estas librerías dentro del paquete del plugin.

¿Cómo funciona?

  1. **Empaquetado de Dependencias:** Dentro de tu paquete .ml-plugin, debes crear una carpeta llamada plugin_deps/. Todas las librerías de Python que tu plugin necesite y que no estén incluidas en la instalación base de ML-Player deben copiarse en esta carpeta.
  2. **Aislamiento de Dependencias:** Cuando ML-Player carga tu plugin, añade temporalmente la carpeta plugin_deps/ de tu plugin al `sys.path` de Python. Esto permite que tu plugin importe sus propias versiones de las librerías sin interferir con otros plugins o con ML-Player. Una vez que tu plugin se descarga, esta ruta se elimina del `sys.path`.
  3. **Ventajas:** Este enfoque garantiza que tu plugin funcione de manera consistente, independientemente del entorno del usuario, y previene "dependency hell" (problemas de compatibilidad entre versiones de librerías).

Recomendación para desarrolladores: Utiliza un entorno virtual (venv) para desarrollar tu plugin. Instala tus dependencias en ese entorno y luego copia las carpetas de las librerías (por ejemplo, colorama/, requests/) desde el directorio venv/Lib/site-packages/ de tu entorno virtual a la carpeta plugin_deps/ de tu plugin.

5.4. Cómo Crear un Paquete .ml-plugin

  1. Crea tu carpeta: Organiza todos los archivos de tu plugin en una carpeta.
  2. Crea el manifiesto: Añade el archivo plugin.json a la carpeta, incluyendo las secciones de permissions y dependencies si son necesarias.
  3. Comprime: Selecciona todos los archivos y carpetas dentro de la carpeta de tu plugin (incluyendo la carpeta plugin_deps/) y comprímelos en un archivo .zip. No comprimas la carpeta contenedora, sino su contenido.
  4. Renombra: Cambia la extensión del archivo de .zip a .ml-plugin.

¡Y listo! Ese archivo .ml-plugin es el que puedes compartir con otros usuarios.