nocoder/nocoder/window.py
28allday f8f012e5a5 Persist user settings across launches
Profile, naming, output folder, audio bit depth and auto-reveal toggle
now round-trip via ~/.config/nocoder/config.json instead of resetting
to defaults on every launch. Alpha is deliberately NOT persisted because
the toggle is conditional on the chosen profile and would create more
confusion than value when restored from a stale session.

* New nocoder/config.py owns the JSON file with read-modify-write
  merging so multiple writers (hwaccel.py + UI prefs) don't clobber
  each other. Atomic write via tempfile + os.replace.
* hwaccel.py refactored to use the shared load_config / update_config
  helpers; CONFIG_PATH still re-exported for backward compat.
* settings_pane.Settings gains to_persistable() that returns the
  subset to round-trip; new load_persisted_settings() validates each
  field against its allowed range and falls back to defaults.
* window.py loads settings on startup and persists them every time
  the settings-changed signal fires (which already covers the folder
  picker via set_output_folder).

Verified: round-trip works, validation rejects bogus values cleanly,
hwaccel survives the merge.
2026-04-21 20:49:29 +01:00

744 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Main window: headerbar + horizontal split (queue, settings) + footer.
Owns the state machine (empty|ready|encoding|complete), file list, and the
background encode worker thread.
"""
from __future__ import annotations
import os
import shutil
import threading
from pathlib import Path
from typing import Optional
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("GdkPixbuf", "2.0")
gi.require_version("Adw", "1")
gi.require_version("Gio", "2.0")
from gi.repository import Adw, GdkPixbuf, Gio, GLib, Gtk
_ASSETS_DIR = Path(__file__).resolve().parent.parent / "assets"
_LOGO_PATH = _ASSETS_DIR / "logo.png"
_HEADER_LOGO_SIZE = 22
from .data import (
PROFILES_BY_ID,
VIDEO_EXTENSIONS,
estimate_output_bytes,
format_bytes as _format_bytes,
is_proxy_dirname,
is_video_path,
)
from .encoder import (
EncodeJob,
detect_prores_encoder,
plan_output_path,
probe_metadata,
run_encode,
)
from .footer import Footer
from .queue_pane import FileEntry, QueuePane
from .config import update_config
from .settings_pane import Settings, SettingsPane, load_persisted_settings
WINDOW_WIDTH = 1280
WINDOW_HEIGHT = 880
# Minimum usable size — deliberately small so tiling WMs (Hyprland/Sway/i3)
# can resize us into narrow tiles without pushing the footer off-screen.
# Both panes have internal scrollbars, so the content copes with compression.
WINDOW_MIN_WIDTH = 560
WINDOW_MIN_HEIGHT = 380
class MainWindow(Adw.ApplicationWindow):
__gtype_name__ = "NoCoderMainWindow"
def __init__(self, app: Adw.Application) -> None:
super().__init__(application=app)
self.set_title("NO-CODER")
self.set_default_size(WINDOW_WIDTH, WINDOW_HEIGHT)
self.set_size_request(WINDOW_MIN_WIDTH, WINDOW_MIN_HEIGHT)
self.add_css_class("nocoder-window")
# App state
self._files: list[FileEntry] = []
self._selected_id: Optional[str] = None
self._state: str = "empty"
self._encoder_kind = detect_prores_encoder()
self._settings = load_persisted_settings()
self._ensure_out_dir()
self._encode_thread: Optional[threading.Thread] = None
self._cancel_event: Optional[threading.Event] = None
self._active_job: Optional[EncodeJob] = None
self._current_idx: int = 0
self._current_speed: Optional[float] = None
# Root layout
root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.set_content(root)
root.append(self._build_headerbar())
split = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
split.set_hexpand(True)
split.set_vexpand(True)
root.append(split)
self._queue = QueuePane()
self._queue.connect("add-files-requested", lambda *_: self._open_files_dialog())
self._queue.connect("add-folder-requested", lambda *_: self._open_folder_dialog())
self._queue.connect("clear-requested", lambda *_: self._clear_files())
self._queue.connect("files-dropped", self._on_files_dropped)
self._queue.connect("selection-changed", self._on_selection_changed)
self._queue.connect("remove-requested", self._on_remove_requested)
split.append(self._queue)
self._settings_pane = SettingsPane(self._settings, self._encoder_kind)
self._settings_pane.connect("settings-changed", lambda *_: self._on_settings_changed())
self._settings_pane.connect("choose-folder-requested", lambda *_: self._open_out_dir_dialog())
split.append(self._settings_pane)
self._footer = Footer()
self._footer.connect("encode-requested", lambda *_: self._start_encode())
self._footer.connect("cancel-requested", lambda *_: self._cancel_encode())
self._footer.connect("reveal-requested", lambda *_: self._reveal_output_dir())
root.append(self._footer)
self._refresh_all()
self.connect("close-request", self._on_close_request)
# Keyboard shortcut: ⌃F focuses the search entry.
accel = Gtk.ShortcutController()
accel.add_shortcut(Gtk.Shortcut.new(
Gtk.ShortcutTrigger.parse_string("<Control>f"),
Gtk.CallbackAction.new(self._focus_search),
))
self.add_controller(accel)
# ---------- headerbar ----------
def _build_headerbar(self) -> Gtk.Widget:
header = Adw.HeaderBar()
header.add_css_class("nocoder-headerbar")
header.set_show_title(True)
# Left cluster: hamburger menu + search pill
left = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
hamburger = Gtk.MenuButton()
hamburger.add_css_class("icon-btn")
hamburger.set_icon_name("open-menu-symbolic")
hamburger.set_menu_model(self._build_menu_model())
left.append(hamburger)
left.append(self._build_search_pill())
header.pack_start(left)
# Center title: logo + app name + status chip
title_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
title_box.set_valign(Gtk.Align.CENTER)
title_box.append(_build_header_logo())
app_name = Gtk.Label(label="NO-CODER")
app_name.add_css_class("app-title")
title_box.append(app_name)
self._status_chip = _StatusChip()
title_box.append(self._status_chip)
header.set_title_widget(title_box)
# Right cluster: toggle-settings button (+ built-in window controls)
sliders = Gtk.ToggleButton()
sliders.add_css_class("icon-btn")
sliders.set_child(Gtk.Image.new_from_icon_name("preferences-system-symbolic"))
sliders.set_tooltip_text("Show/hide settings pane")
sliders.set_active(True)
sliders.connect("toggled", self._on_settings_toggle)
self._settings_toggle = sliders
header.pack_end(sliders)
return header
def _on_settings_toggle(self, btn: Gtk.ToggleButton) -> None:
self._settings_pane.set_visible(btn.get_active())
def _build_menu_model(self) -> Gio.Menu:
menu = Gio.Menu()
menu.append("Add files…", "win.add-files")
menu.append("Add folder…", "win.add-folder")
menu.append("Clear queue", "win.clear-queue")
self._install_menu_actions()
return menu
def _install_menu_actions(self) -> None:
def add(name: str, handler):
action = Gio.SimpleAction.new(name, None)
action.connect("activate", lambda *_: handler())
self.add_action(action)
add("add-files", self._open_files_dialog)
add("add-folder", self._open_folder_dialog)
add("clear-queue", self._clear_files)
def _build_search_pill(self) -> Gtk.Widget:
pill = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
pill.add_css_class("search-pill")
icon = Gtk.Image.new_from_icon_name("system-search-symbolic")
icon.set_pixel_size(13)
pill.append(icon)
entry = Gtk.Entry()
entry.set_placeholder_text("Search files in queue…")
entry.set_has_frame(False)
entry.set_hexpand(True)
entry.set_width_chars(22)
entry.connect("changed", self._on_search_changed)
self._search_entry = entry
# Esc clears the filter and drops focus back to the queue.
esc = Gtk.ShortcutController()
esc.set_scope(Gtk.ShortcutScope.LOCAL)
esc.add_shortcut(Gtk.Shortcut.new(
Gtk.ShortcutTrigger.parse_string("Escape"),
Gtk.CallbackAction.new(self._clear_search_on_escape),
))
entry.add_controller(esc)
pill.append(entry)
kbd = Gtk.Label(label="⌃F")
kbd.add_css_class("search-kbd")
pill.append(kbd)
return pill
def _clear_search_on_escape(self, *_args) -> bool:
if not hasattr(self, "_search_entry"):
return False
if self._search_entry.get_text():
self._search_entry.set_text("")
else:
# Already empty — drop focus so Esc isn't a no-op (lets the user
# leave the search field with the keyboard).
self.grab_focus()
return True
def _focus_search(self, *_args) -> bool:
if hasattr(self, "_search_entry"):
self._search_entry.grab_focus()
return True
return False
def _on_search_changed(self, entry: Gtk.Entry) -> None:
self._queue.set_search_query(entry.get_text())
# ---------- state plumbing ----------
def _compute_state(self) -> str:
if self._state == "encoding":
return "encoding"
if self._state == "complete":
# Stay in complete until user encodes again or clears.
return "complete"
if not self._files:
return "empty"
return "ready"
def _refresh_all(self) -> None:
self._state = self._compute_state() if self._state not in ("encoding", "complete") else self._state
self._status_chip.set_state(self._state)
self._queue.set_files(self._files)
if self._selected_id is not None:
self._queue.set_selected(self._selected_id)
self._queue.set_encoding(self._state == "encoding")
first_name = self._files[0].name if self._files else None
self._settings_pane.set_first_file_name(first_name)
self._settings_pane.set_encoding(self._state == "encoding")
self._settings_pane.refresh()
self._footer.update(
state="ready" if self._state in ("empty", "ready") else self._state,
files=self._files,
profile_id=self._settings.profile,
overall=self._overall_progress(),
current_idx=self._current_idx,
)
def _overall_progress(self) -> float:
if not self._files:
return 0.0
total = 0.0
for f in self._files:
if f.status == "done":
total += 1.0
elif f.status == "encoding":
total += max(0.0, min(1.0, f.progress))
return total / len(self._files)
def _on_settings_changed(self) -> None:
# Recompute est_out using the new profile's bitrate.
mbps = PROFILES_BY_ID[self._settings.profile].mbps
for f in self._files:
f.est_out = estimate_output_bytes(f.meta.duration, mbps)
self._queue.update_file(f)
# Footer and preview need refresh.
self._footer.update(
state="ready" if self._state in ("empty", "ready") else self._state,
files=self._files,
profile_id=self._settings.profile,
overall=self._overall_progress(),
current_idx=self._current_idx,
)
# Persist the new state across launches. Cheap (single small JSON
# file write) and idempotent — if nothing actually changed we still
# round-trip the same dict, no harm.
update_config(self._settings.to_persistable())
def _ensure_out_dir(self) -> None:
if not self._settings.out_dir:
self._settings.out_dir = str(Path.home() / "Footage" / "prores")
try:
Path(self._settings.out_dir).mkdir(parents=True, exist_ok=True)
except OSError:
pass
# ---------- file operations ----------
def _open_files_dialog(self) -> None:
dialog = Gtk.FileDialog()
dialog.set_title("Choose videos")
dialog.set_modal(True)
filters = Gio.ListStore.new(Gtk.FileFilter)
video_filter = Gtk.FileFilter()
video_filter.set_name("Video files")
for ext in VIDEO_EXTENSIONS:
video_filter.add_pattern(f"*{ext}")
video_filter.add_pattern(f"*{ext.upper()}")
filters.append(video_filter)
any_filter = Gtk.FileFilter()
any_filter.set_name("All files")
any_filter.add_pattern("*")
filters.append(any_filter)
dialog.set_filters(filters)
dialog.open_multiple(self, None, self._on_files_chosen)
def _on_files_chosen(self, dialog: Gtk.FileDialog, result) -> None:
try:
model = dialog.open_multiple_finish(result)
except GLib.Error:
return
paths: list[str] = []
for i in range(model.get_n_items()):
f = model.get_item(i)
if f is None:
continue
p = f.get_path()
if p:
paths.append(p)
self._add_paths(paths)
def _open_folder_dialog(self) -> None:
dialog = Gtk.FileDialog()
dialog.set_title("Choose folder")
dialog.set_modal(True)
dialog.select_folder(self, None, self._on_folder_chosen)
def _on_folder_chosen(self, dialog: Gtk.FileDialog, result) -> None:
try:
f = dialog.select_folder_finish(result)
except GLib.Error:
return
if f is None:
return
path = f.get_path()
if not path:
return
paths: list[str] = []
for root, dirs, files in os.walk(path):
# Prune proxy / thumbnail / metadata subdirs in-place so os.walk
# doesn't recurse into them — avoids pulling low-res duplicates
# from Sony SUB/, Panasonic PROXY/ etc. into the queue alongside
# the master clips.
dirs[:] = [d for d in dirs if not is_proxy_dirname(d)]
for name in files:
full = os.path.join(root, name)
if is_video_path(full):
paths.append(full)
paths.sort()
self._add_paths(paths)
def _open_out_dir_dialog(self) -> None:
dialog = Gtk.FileDialog()
dialog.set_title("Choose output folder")
dialog.set_modal(True)
try:
dialog.set_initial_folder(Gio.File.new_for_path(self._settings.out_dir))
except GLib.Error:
pass
dialog.select_folder(self, None, self._on_out_dir_chosen)
def _on_out_dir_chosen(self, dialog: Gtk.FileDialog, result) -> None:
try:
f = dialog.select_folder_finish(result)
except GLib.Error:
return
if f is None:
return
path = f.get_path()
if path:
self._settings_pane.set_output_folder(path)
def _on_files_dropped(self, _pane, paths: list[str]) -> None:
expanded: list[str] = []
for p in paths:
if os.path.isdir(p):
for root, dirs, files in os.walk(p):
# Skip proxy / thumbnail / metadata dirs (Sony SUB,
# Panasonic PROXY, etc.) — see data.PROXY_DIRNAMES.
dirs[:] = [d for d in dirs if not is_proxy_dirname(d)]
for name in files:
full = os.path.join(root, name)
if is_video_path(full):
expanded.append(full)
elif os.path.isfile(p) and is_video_path(p):
expanded.append(p)
self._add_paths(expanded)
def _add_paths(self, paths: list[str]) -> None:
# Dedupe by realpath so the same physical file added via different
# mount points (e.g. /run/media/gav/Card and /run/media/gav/Card1)
# or symlinks doesn't appear twice.
existing = {os.path.realpath(f.path) for f in self._files}
mbps = PROFILES_BY_ID[self._settings.profile].mbps
added: list[FileEntry] = []
for p in paths:
if not p or not os.path.isfile(p):
continue
real = os.path.realpath(p)
if real in existing:
continue
try:
size = os.path.getsize(p)
except OSError:
size = 0
entry = FileEntry(path=p, size=size)
entry.est_out = 0.0
self._files.append(entry)
existing.add(real)
added.append(entry)
if not added:
return
# Move out of "complete" state when user adds more work.
if self._state == "complete":
self._state = "ready"
self._reset_file_statuses()
self._refresh_all()
for entry in added:
self._probe_async(entry, mbps)
def _probe_async(self, entry: FileEntry, mbps: int) -> None:
def worker() -> None:
meta = probe_metadata(entry.path)
def apply() -> bool:
entry.meta = meta
entry.est_out = estimate_output_bytes(meta.duration, mbps)
self._queue.update_file(entry)
self._footer.update(
state="ready" if self._state in ("empty", "ready") else self._state,
files=self._files,
profile_id=self._settings.profile,
overall=self._overall_progress(),
current_idx=self._current_idx,
)
self._settings_pane.set_first_file_name(self._files[0].name if self._files else None)
return False
GLib.idle_add(apply)
t = threading.Thread(target=worker, daemon=True)
t.start()
def _clear_files(self) -> None:
if self._state == "encoding":
return
self._files.clear()
self._selected_id = None
self._state = "empty"
if hasattr(self, "_search_entry"):
self._search_entry.set_text("")
self._refresh_all()
def _reset_file_statuses(self) -> None:
for f in self._files:
f.status = "queued"
f.progress = 0.0
f.error = None
def _on_selection_changed(self, _pane, file_id: str) -> None:
self._selected_id = file_id or None
def _on_remove_requested(self, _pane, file_id: str) -> None:
self._files = [f for f in self._files if f.id != file_id]
if self._selected_id == file_id:
self._selected_id = None
if not self._files:
self._state = "empty"
self._refresh_all()
# ---------- encode ----------
def _start_encode(self) -> None:
if self._state == "encoding" or not self._files:
return
if self._encoder_kind == "none":
self._show_error("No ProRes encoder found.\nInstall ffmpeg with prores_ks or prores support.")
return
try:
Path(self._settings.out_dir).mkdir(parents=True, exist_ok=True)
except OSError as e:
self._show_error(f"Cannot create output folder:\n{e}")
return
if not os.access(self._settings.out_dir, os.W_OK):
self._show_error(f"No write permission for output folder:\n{self._settings.out_dir}")
return
# Disk-space pre-check — sum the estimated output sizes of every
# queued (or queue-able) file and compare against free bytes on the
# output volume. Cheap insurance against a half-finished batch when
# someone forgets the destination is nearly full.
try:
need = sum(f.est_out for f in self._files if f.est_out)
free = shutil.disk_usage(self._settings.out_dir).free
except OSError:
need = 0
free = 0
if need and free and need > free:
self._show_error(
f"Not enough free space in {self._settings.out_dir}\n"
f"Need ≈{_format_bytes(need)}, available {_format_bytes(free)}."
)
return
self._reset_file_statuses()
self._state = "encoding"
self._current_idx = 0
self._cancel_event = threading.Event()
self._refresh_all()
self._encode_thread = threading.Thread(target=self._encode_worker, daemon=True)
self._encode_thread.start()
def _encode_worker(self) -> None:
cancel = self._cancel_event
assert cancel is not None
for idx, entry in enumerate(list(self._files)):
if cancel.is_set():
break
# Source file may have moved/been-deleted between probe time and
# now. Mark it failed instead of letting ffmpeg emit a cryptic
# "no such file" error.
if not os.path.isfile(entry.path):
GLib.idle_add(self._finish_file, entry.id, False, "source file is missing")
continue
GLib.idle_add(self._set_current_encoding, idx, entry.id)
out_path = plan_output_path(
entry.path,
self._settings.out_dir,
self._settings.naming,
self._settings.profile,
)
done_event = threading.Event()
result = {"ok": False, "err": None}
def on_prog(pct: float, _entry=entry) -> None:
GLib.idle_add(self._apply_file_progress, _entry.id, pct)
def on_done(ok: bool, err, _entry=entry) -> None:
result["ok"] = ok
result["err"] = err
done_event.set()
def on_speed(spd: float) -> None:
GLib.idle_add(self._apply_speed, spd)
job = EncodeJob(
src=entry.path,
out=out_path,
duration=entry.meta.duration or 0.0,
on_progress=on_prog,
on_done=on_done,
on_speed=on_speed,
audio_stream_indexes=list(entry.meta.audio_stream_indexes),
cancel_event=cancel,
)
# Track the active job so _on_close_request can cancel the live
# ffmpeg child synchronously rather than relying on the worker's
# next stdout-loop iteration.
self._active_job = job
try:
run_encode(
job, self._settings.profile, self._settings.alpha, self._encoder_kind,
audio_bits=self._settings.audio_bits,
)
done_event.wait(timeout=5)
finally:
self._active_job = None
GLib.idle_add(self._finish_file, entry.id, bool(result["ok"]), result["err"])
GLib.idle_add(self._finish_encoding)
def _set_current_encoding(self, idx: int, file_id: str) -> bool:
self._current_idx = idx
# Reset the live-speed reading at every file boundary so the footer
# doesn't briefly show the previous file's speed before ffmpeg
# publishes the first `speed=` for the new one.
self._current_speed = None
for f in self._files:
if f.id == file_id:
f.status = "encoding"
f.progress = 0.0
self._queue.update_file(f)
break
self._footer.update(
state="encoding",
files=self._files,
profile_id=self._settings.profile,
overall=self._overall_progress(),
current_idx=self._current_idx,
speed=self._current_speed,
)
return False
def _apply_file_progress(self, file_id: str, pct: float) -> bool:
for f in self._files:
if f.id == file_id:
f.progress = max(0.0, min(1.0, pct))
self._queue.update_file(f)
break
self._footer.update(
state="encoding",
files=self._files,
profile_id=self._settings.profile,
overall=self._overall_progress(),
current_idx=self._current_idx,
speed=self._current_speed,
)
return False
def _apply_speed(self, speed: float) -> bool:
self._current_speed = speed
if self._state == "encoding":
self._footer.update(
state="encoding",
files=self._files,
profile_id=self._settings.profile,
overall=self._overall_progress(),
current_idx=self._current_idx,
speed=self._current_speed,
)
return False
def _finish_file(self, file_id: str, ok: bool, err) -> bool:
for f in self._files:
if f.id == file_id:
f.status = "done" if ok else "failed"
f.progress = 1.0 if ok else 0.0
f.error = None if ok else (err or "encode failed")
self._queue.update_file(f)
break
return False
def _finish_encoding(self) -> bool:
cancelled = self._cancel_event is not None and self._cancel_event.is_set()
self._cancel_event = None
self._encode_thread = None
if cancelled:
# Anything still in 'encoding' status becomes 'queued' again.
for f in self._files:
if f.status == "encoding":
f.status = "queued"
f.progress = 0.0
self._state = "ready"
else:
self._state = "complete"
# If at least one file landed AND the user opted in, pop the
# output folder open. Skip on cancel (intent unclear) and on
# full-batch failure (annoying to be shown an empty folder).
if self._settings.auto_reveal and any(f.status == "done" for f in self._files):
self._reveal_output_dir()
self._refresh_all()
return False
def _cancel_encode(self) -> None:
if self._cancel_event is not None:
self._cancel_event.set()
def _reveal_output_dir(self) -> None:
try:
uri = Gio.File.new_for_path(self._settings.out_dir).get_uri()
Gio.AppInfo.launch_default_for_uri(uri, None)
except GLib.Error:
pass
def _on_close_request(self, *_args) -> bool:
# Tell the worker to stop iterating.
if self._cancel_event is not None:
self._cancel_event.set()
# Actively terminate the live ffmpeg child — the worker thread is a
# daemon so it dies with Python on window close, but ffmpeg is its own
# process and would keep running + writing a partial .mov otherwise.
job = getattr(self, "_active_job", None)
if job is not None:
job.cancel()
return False
def _show_error(self, message: str) -> None:
dialog = Adw.MessageDialog.new(self, "NO-CODER", message)
dialog.add_response("ok", "OK")
dialog.set_default_response("ok")
dialog.present()
def _build_header_logo() -> Gtk.Widget:
"""22×22 NO-CODER mark shown in the headerbar.
Pre-scales the PNG to 2× for HiDPI, wraps it in a Gtk.Image and fixes the
display size via set_pixel_size. Falls back to the previous symbolic icon
(with the old orange tile styling) if the asset is missing.
"""
if _LOGO_PATH.exists():
try:
pb = GdkPixbuf.Pixbuf.new_from_file_at_scale(
str(_LOGO_PATH), _HEADER_LOGO_SIZE * 2, _HEADER_LOGO_SIZE * 2, True,
)
img = Gtk.Image.new_from_pixbuf(pb)
img.set_pixel_size(_HEADER_LOGO_SIZE)
img.add_css_class("app-logo-image")
return img
except GLib.Error:
pass
logo = Gtk.Image.new_from_icon_name("video-x-generic-symbolic")
logo.add_css_class("app-logo")
logo.set_pixel_size(14)
return logo
class _StatusChip(Gtk.Box):
"""Small colored pill with a dot + label. States: idle|ready|encoding|done."""
def __init__(self) -> None:
super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
self.add_css_class("status-chip")
self.add_css_class("idle")
self._state = "idle"
inner = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
self._dot = Gtk.Box()
self._dot.add_css_class("dot")
self._dot.set_valign(Gtk.Align.CENTER)
inner.append(self._dot)
self._label = Gtk.Label(label="IDLE")
inner.append(self._label)
self.append(inner)
def set_state(self, state: str) -> None:
mapping = {
"empty": ("idle", "IDLE"),
"ready": ("ready", "READY"),
"encoding": ("encoding", "ENCODING"),
"complete": ("done", "DONE"),
}
cls, text = mapping.get(state, ("idle", "IDLE"))
for c in ("idle", "ready", "encoding", "done"):
self.remove_css_class(c)
self.add_css_class(cls)
self._label.set_label(text)
self._state = cls