"""Main window and playback coordination."""

from __future__ import annotations

import json
from pathlib import Path
import re
import shutil

from PySide6.QtCore import (
    QRectF,
    QMimeData,
    QSettings,
    QSize,
    QTemporaryDir,
    QThread,
    Qt,
    QTimer,
    QUrl,
    Signal,
)
from PySide6.QtGui import (
    QAction,
    QActionGroup,
    QCloseEvent,
    QColor,
    QDrag,
    QKeySequence,
    QPainter,
    QPainterPath,
    QPalette,
    QPen,
    QPixmap,
    QShortcut,
)
from PySide6.QtMultimedia import (
    QAudioBuffer,
    QAudioBufferOutput,
    QAudioOutput,
    QMediaDevices,
    QMediaPlayer,
)
from PySide6.QtWidgets import (
    QAbstractItemView,
    QApplication,
    QCheckBox,
    QComboBox,
    QCompleter,
    QDialog,
    QDialogButtonBox,
    QFileDialog,
    QFormLayout,
    QFrame,
    QGraphicsDropShadowEffect,
    QHBoxLayout,
    QGridLayout,
    QGroupBox,
    QHeaderView,
    QInputDialog,
    QLabel,
    QLineEdit,
    QListWidget,
    QListWidgetItem,
    QMainWindow,
    QMessageBox,
    QPlainTextEdit,
    QProgressDialog,
    QPushButton,
    QScrollArea,
    QSlider,
    QSizePolicy,
    QSpinBox,
    QSplitter,
    QStyle,
    QStyleOption,
    QStyleOptionSlider,
    QTableView,
    QTableWidget,
    QTableWidgetItem,
    QToolButton,
    QVBoxLayout,
    QWidget,
)

from .exclusive_player import (
    ExclusivePlaybackError,
    ExclusivePlayer,
    wasapi_output_devices,
)
from .edition import (
    TRIAL_MAX_TRACKS_PER_PLAYLIST,
    TRIAL_MAX_USER_PLAYLISTS,
    is_trial_edition,
    product_name,
)
from . import __version__
from .cue import CueSheetError, discover_cue_files, parse_cue_sheet
from .i18n import normalize_language, set_language, tr, tracks
from .metadata import (
    SUPPORTED_EXTENSIONS,
    Track,
    artwork_file_extension,
    build_tag_filename,
    discover_audio_files,
    embed_artwork,
    format_duration,
    read_artwork,
    remove_artwork,
    save_track_tags,
)
from .lrc import (
    LrcDocument,
    LrcEntry,
    active_lyric_index,
    format_timestamp,
    load_lrc,
    lrc_path_for_track,
    parse_timestamp,
    save_lrc,
)
from .models import (
    GenreCompletionLineEdit,
    PlaylistModel,
    SpreadsheetTableView,
)
from .playback_order import PlaybackOrder
from .resources import application_icon
from .storage import PlaylistStore
from .style import (
    THEME_PALETTES,
    background_paint_values,
    build_stylesheet,
    normalize_theme,
)
from .visualizer import (
    audio_buffer_to_mono,
    native_samples_to_mono,
    normalize_visualizer_mode,
    visualizer_values,
)
from .tag_schema import (
    DEFAULT_BULK_TAG_KEYS,
    TAG_KEYS,
    default_japanese_tag_name,
    default_tag_columns,
    english_tag_name,
    normalize_bulk_tag_keys,
    normalize_tag_columns,
)
from .update_support import (
    UpdatePackageError,
    inspect_update_package,
    launch_update_installer,
    updater_is_available,
)


DEFAULT_TITLE_SUFFIXES = ("(off vocal)", "(instrumental)")
MAX_TITLE_SUFFIXES = 20
DEFAULT_GENRE_SUGGESTIONS = (
    "Anime",
    "SoundTrack",
    "Pop",
    "Rock",
    "Alternative",
    "Indie",
    "Electronic",
    "Dance",
    "House",
    "Techno",
    "Trance",
    "Ambient",
    "Classical",
    "Jazz",
    "Blues",
    "R&B",
    "Soul",
    "Funk",
    "Hip-Hop",
    "Metal",
    "Punk",
    "Folk",
    "Country",
    "Reggae",
    "Latin",
    "World",
    "New Age",
    "Vocal",
    "Instrumental",
    "Game Music",
)


def normalize_title_suffixes(value: object) -> list[str]:
    """Return a clean, ordered and bounded suffix list from QSettings."""

    if isinstance(value, str):
        candidates = [value]
    elif isinstance(value, (list, tuple)):
        candidates = list(value)
    else:
        candidates = []
    normalized: list[str] = []
    seen: set[str] = set()
    for candidate in candidates:
        suffix = str(candidate).strip()
        identity = suffix.casefold()
        if not suffix or identity in seen:
            continue
        normalized.append(suffix)
        seen.add(identity)
        if len(normalized) >= MAX_TITLE_SUFFIXES:
            break
    return normalized or list(DEFAULT_TITLE_SUFFIXES)


def normalize_genre_suggestions(value: object) -> list[str]:
    """Return unique genre suggestions while preserving display order."""

    if isinstance(value, str):
        candidates = [value]
    elif isinstance(value, (list, tuple)):
        candidates = list(value)
    else:
        candidates = []
    normalized: list[str] = []
    seen: set[str] = set()
    for candidate in candidates:
        genre = str(candidate).strip()
        identity = genre.casefold()
        if not genre or identity in seen:
            continue
        normalized.append(genre)
        seen.add(identity)
    return normalized or list(DEFAULT_GENRE_SUGGESTIONS)


def _natural_filename_key(track: Track) -> tuple[tuple[int, object], ...]:
    """Sort filenames case-insensitively while treating digit runs as numbers."""

    filename = track.filename or Path(track.path).name
    return tuple(
        (0, int(part)) if part.isdecimal() else (1, part.casefold())
        for part in re.split(r"(\d+)", filename)
    )


def _audio_filter() -> str:
    patterns = " ".join(
        f"*{extension}"
        for extension in sorted(SUPPORTED_EXTENSIONS | {".cue"})
    )
    return tr(
        f"Audio and CUE files ({patterns});;All files (*.*)",
        f"音声・CUEファイル ({patterns});;すべてのファイル (*.*)",
    )


def _is_probable_file_lock_error(error: BaseException) -> bool:
    """Return whether an exception chain resembles a transient file lock."""

    checked: set[int] = set()
    current: BaseException | None = error
    lock_phrases = (
        "being used by another process",
        "used by another process",
        "sharing violation",
        "permission denied",
        "access is denied",
        "別のプロセス",
        "使用されているため",
        "アクセスが拒否",
    )
    while current is not None and id(current) not in checked:
        checked.add(id(current))
        if isinstance(current, (PermissionError, BlockingIOError)):
            return True
        if getattr(current, "winerror", None) in {5, 32, 33}:
            return True
        if getattr(current, "errno", None) in {13, 16, 26}:
            return True
        message = str(current).casefold()
        if any(phrase.casefold() in message for phrase in lock_phrases):
            return True
        current = current.__cause__ or current.__context__
    return False


class PaddedSpinBox(QSpinBox):
    def textFromValue(self, value: int) -> str:
        return f"{value:02d}"


class SeekSlider(QSlider):
    """Clickable seek control with a subtle real-time audio trace behind it."""

    seekRequested = Signal(int)

    def __init__(self, orientation: Qt.Orientation, parent=None) -> None:
        super().__init__(orientation, parent)
        self._visualizer_mode = "waveform"
        self._visualizer_active = False
        self._visualizer_values: list[float] = []
        self._visualizer_color = QColor("#75c9ff")
        self.setMinimumHeight(34)

    @property
    def visualizer_is_visible(self) -> bool:
        return bool(
            self._visualizer_active
            and self._visualizer_mode != "off"
            and self._visualizer_values
        )

    def set_visualizer_mode(self, mode: object) -> None:
        self._visualizer_mode = normalize_visualizer_mode(mode)
        if self._visualizer_mode == "off":
            self._visualizer_values = []
        self.update()

    def set_visualizer_active(self, active: bool) -> None:
        self._visualizer_active = bool(active)
        if not active:
            self._visualizer_values = []
        self.update()

    def set_visualizer_values(self, values: list[float]) -> None:
        self._visualizer_values = [float(value) for value in values]
        self.update()

    def set_visualizer_color(self, color: object) -> None:
        parsed = QColor(str(color))
        if parsed.isValid():
            self._visualizer_color = parsed
            self.update()

    def paintEvent(self, event) -> None:
        if self.visualizer_is_visible and self.width() > 8:
            painter = QPainter(self)
            painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
            color = QColor(self._visualizer_color)
            color.setAlpha(92)
            painter.setPen(QPen(color, 1.15))
            bounds = self.rect().adjusted(3, 4, -3, -4)
            values = self._visualizer_values
            path = QPainterPath()
            denominator = max(1, len(values) - 1)
            if self._visualizer_mode == "spectrum":
                baseline = bounds.bottom()
                amplitude = max(1.0, bounds.height() * 0.72)
                for index, value in enumerate(values):
                    x = bounds.left() + bounds.width() * index / denominator
                    y = baseline - max(0.0, min(1.0, value)) * amplitude
                    if index:
                        path.lineTo(x, y)
                    else:
                        path.moveTo(x, y)
            else:
                center = bounds.center().y()
                amplitude = max(1.0, bounds.height() * 0.43)
                for index, value in enumerate(values):
                    x = bounds.left() + bounds.width() * index / denominator
                    y = center - max(-1.0, min(1.0, value)) * amplitude
                    if index:
                        path.lineTo(x, y)
                    else:
                        path.moveTo(x, y)
            painter.drawPath(path)
            painter.end()
        # Qt draws the groove and handle after the trace, keeping seeking clear.
        super().paintEvent(event)

    def mousePressEvent(self, event) -> None:
        if event.button() != Qt.MouseButton.LeftButton:
            super().mousePressEvent(event)
            return

        option = QStyleOptionSlider()
        self.initStyleOption(option)
        style = self.style()
        hit_control = style.hitTestComplexControl(
            QStyle.ComplexControl.CC_Slider,
            option,
            event.position().toPoint(),
            self,
        )
        if hit_control == QStyle.SubControl.SC_SliderHandle:
            super().mousePressEvent(event)
            return

        groove = style.subControlRect(
            QStyle.ComplexControl.CC_Slider,
            option,
            QStyle.SubControl.SC_SliderGroove,
            self,
        )
        handle = style.subControlRect(
            QStyle.ComplexControl.CC_Slider,
            option,
            QStyle.SubControl.SC_SliderHandle,
            self,
        )
        if self.orientation() == Qt.Orientation.Horizontal:
            slider_length = handle.width()
            slider_minimum = groove.x()
            slider_maximum = groove.right() - slider_length + 1
            click_position = round(event.position().x()) - slider_minimum
        else:
            slider_length = handle.height()
            slider_minimum = groove.y()
            slider_maximum = groove.bottom() - slider_length + 1
            click_position = round(event.position().y()) - slider_minimum

        available = max(1, slider_maximum - slider_minimum)
        value = QStyle.sliderValueFromPosition(
            self.minimum(),
            self.maximum(),
            click_position - slider_length // 2,
            available,
            option.upsideDown,
        )
        self.setValue(value)
        self.seekRequested.emit(value)
        event.accept()


class ColumnReorderHeader(QHeaderView):
    """Movable header with a high-contrast insertion guide."""

    def __init__(self, orientation: Qt.Orientation, parent=None) -> None:
        super().__init__(orientation, parent)
        self._pressed_position = None
        self._drag_source_logical = -1
        self._drop_indicator_x: int | None = None

    def setSectionsMovable(self, movable: bool) -> None:
        super().setSectionsMovable(movable)
        if not movable:
            self._clear_drop_indicator()

    def mousePressEvent(self, event) -> None:
        self._clear_drop_indicator()
        if (
            event.button() == Qt.MouseButton.LeftButton
            and self.sectionsMovable()
        ):
            self._pressed_position = event.position().toPoint()
            self._drag_source_logical = self.logicalIndexAt(
                event.position().toPoint()
            )
        else:
            self._pressed_position = None
            self._drag_source_logical = -1
        super().mousePressEvent(event)

    def mouseMoveEvent(self, event) -> None:
        super().mouseMoveEvent(event)
        if (
            self._pressed_position is None
            or self._drag_source_logical < 0
            or not event.buttons() & Qt.MouseButton.LeftButton
            or not self.sectionsMovable()
        ):
            self._clear_drop_indicator()
            return
        distance = (
            event.position().toPoint() - self._pressed_position
        ).manhattanLength()
        if distance < QApplication.startDragDistance():
            return
        self._set_drop_indicator(event.position().x())

    def mouseReleaseEvent(self, event) -> None:
        super().mouseReleaseEvent(event)
        self._pressed_position = None
        self._drag_source_logical = -1
        self._clear_drop_indicator()

    def leaveEvent(self, event) -> None:
        self._clear_drop_indicator()
        super().leaveEvent(event)

    def _set_drop_indicator(self, pointer_x: float) -> None:
        candidates: set[int] = set()
        for visual_index in range(self.count()):
            logical_index = self.logicalIndex(visual_index)
            if logical_index < 0 or self.isSectionHidden(logical_index):
                continue
            left = self.sectionViewportPosition(logical_index)
            right = left + self.sectionSize(logical_index)
            candidates.update((left, right))
        if not candidates:
            self._clear_drop_indicator()
            return
        nearest = min(candidates, key=lambda position: abs(pointer_x - position))
        viewport_width = self.viewport().width()
        self._drop_indicator_x = max(2, min(nearest, viewport_width - 3))
        self.viewport().update()

    def _clear_drop_indicator(self) -> None:
        if self._drop_indicator_x is None:
            return
        self._drop_indicator_x = None
        self.viewport().update()

    def paintEvent(self, event) -> None:
        super().paintEvent(event)
        if self._drop_indicator_x is None:
            return
        painter = QPainter(self.viewport())
        background = self.palette().color(QPalette.ColorRole.Window)
        outline = QColor("#101820" if background.lightness() > 140 else "#ffffff")
        accent = self.palette().color(QPalette.ColorRole.Highlight)
        if accent.alpha() < 220:
            accent.setAlpha(255)
        x = self._drop_indicator_x
        bottom = self.viewport().rect().bottom()
        painter.setPen(QPen(outline, 6))
        painter.drawLine(x, 0, x, bottom)
        painter.setPen(QPen(accent, 3))
        painter.drawLine(x, 0, x, bottom)
        painter.end()


class BackgroundCanvas(QWidget):
    """Paint a ratio-preserving image below a theme-aware smoke layer."""

    def __init__(self, parent=None) -> None:
        super().__init__(parent)
        self.setObjectName("backgroundCanvas")
        self.theme = "dark"
        self.image_path = ""
        self.background_pixmap = QPixmap()

    def set_theme(self, theme: object) -> None:
        self.theme = normalize_theme(theme)
        self.update()

    def set_background_image(self, path: object) -> bool:
        image_path = str(path or "")
        pixmap = QPixmap(image_path) if image_path else QPixmap()
        if image_path and pixmap.isNull():
            self.image_path = ""
            self.background_pixmap = QPixmap()
            self.update()
            return False
        self.image_path = image_path
        self.background_pixmap = pixmap
        self.update()
        return bool(image_path)

    def clear_background_image(self) -> None:
        self.image_path = ""
        self.background_pixmap = QPixmap()
        self.update()

    def paintEvent(self, event) -> None:
        del event
        canvas_color, smoke_rgba, image_opacity = background_paint_values(
            self.theme
        )
        painter = QPainter(self)
        painter.fillRect(self.rect(), QColor(canvas_color))
        if not self.background_pixmap.isNull():
            scaled = self.background_pixmap.scaled(
                self.size(),
                Qt.AspectRatioMode.KeepAspectRatioByExpanding,
                Qt.TransformationMode.SmoothTransformation,
            )
            x = (self.width() - scaled.width()) // 2
            y = (self.height() - scaled.height()) // 2
            painter.setOpacity(image_opacity)
            painter.drawPixmap(x, y, scaled)
            painter.setOpacity(1.0)
        painter.fillRect(self.rect(), QColor(*smoke_rgba))


class FiveClickLabel(QLabel):
    """A normal-looking label that emits only after five left clicks."""

    activated = Signal()

    def __init__(self, text: str, parent=None) -> None:
        super().__init__(text, parent)
        self._click_count = 0
        self.setCursor(Qt.CursorShape.ArrowCursor)
        self.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction)

    def mouseReleaseEvent(self, event) -> None:
        if event.button() == Qt.MouseButton.LeftButton:
            self._click_count += 1
            if self._click_count >= 5:
                self._click_count = 0
                self.activated.emit()
        super().mouseReleaseEvent(event)


class AboutDialog(QDialog):
    """English-only product information with a concealed activation label."""

    secretActivated = Signal()

    def __init__(self, version: str, parent=None, *, trial: bool = False) -> None:
        super().__init__(parent)
        product = "MusicGridStudio Trial" if trial else "MusicGridStudio"
        self.setWindowTitle(f"About {product}")
        self.setMinimumWidth(460)
        layout = QVBoxLayout(self)
        layout.setContentsMargins(28, 24, 28, 20)
        layout.setSpacing(12)

        self.product_label = FiveClickLabel(product)
        self.product_label.setObjectName("titleLabel")
        self.product_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self.product_label.activated.connect(self.secretActivated.emit)
        layout.addWidget(self.product_label)

        edition_line = "Windows x64 Trial Edition" if trial else "Windows x64 Edition"
        trial_notice = (
            "This free trial has playlist, playback and customization limits.\n"
            "Playlists are cleared when the application closes.\n\n"
            if trial
            else ""
        )
        information = QLabel(
            f"Version {version}\n"
            f"{edition_line}\n\n"
            "Built with Python, PySide6 and Qt Multimedia.\n"
            "Designed for high-quality playback, playlist management,\n"
            "and efficient audio metadata editing.\n\n"
            f"{trial_notice}"
            "Developed by LeLien Consulting\n"
            "Copyright © 2026 LeLien Consulting. All rights reserved."
        )
        information.setAlignment(Qt.AlignmentFlag.AlignCenter)
        information.setWordWrap(True)
        layout.addWidget(information)

        buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
        buttons.button(QDialogButtonBox.StandardButton.Close).setText("Close")
        buttons.rejected.connect(self.reject)
        layout.addWidget(buttons)


class LyricsDialog(QDialog):
    """Focused viewer/editor for an unsynchronized lyrics tag."""

    def __init__(
        self,
        track_title: str,
        lyrics: str,
        *,
        editable: bool,
        parent=None,
    ) -> None:
        super().__init__(parent)
        self.editable = bool(editable)
        self.setWindowTitle(
            tr("Edit Lyrics", "歌詞を編集")
            if self.editable
            else tr("Lyrics", "歌詞を表示")
        )
        self.setMinimumSize(650, 480)
        self.resize(760, 580)

        layout = QVBoxLayout(self)
        layout.setContentsMargins(18, 16, 18, 16)
        layout.setSpacing(10)

        self.track_title_label = QLabel()
        self.track_title_label.setObjectName("titleLabel")
        self.track_title_label.setWordWrap(True)
        layout.addWidget(self.track_title_label)

        self.mode_label = QLabel(
            tr(
                "Enter the unsynchronized lyrics below. Line breaks are preserved.",
                "非同期歌詞を入力してください。改行もそのまま保存されます。",
            )
            if self.editable
            else tr(
                "Unsynchronized lyrics saved in this track.",
                "この楽曲に保存されている非同期歌詞です。",
            )
        )
        self.mode_label.setObjectName("mutedLabel")
        self.mode_label.setWordWrap(True)
        layout.addWidget(self.mode_label)

        self.lyrics_editor = QPlainTextEdit()
        self.lyrics_editor.setObjectName("lyricsEditor")
        self.lyrics_editor.setReadOnly(not self.editable)
        self.lyrics_editor.setPlaceholderText(
            tr(
                "Enter unsynchronized lyrics here…",
                "ここに非同期歌詞を入力…",
            )
            if self.editable
            else tr("No lyrics have been entered.", "歌詞は登録されていません。")
        )
        layout.addWidget(self.lyrics_editor, 1)

        if self.editable:
            self.button_box = QDialogButtonBox(
                QDialogButtonBox.StandardButton.Save
                | QDialogButtonBox.StandardButton.Cancel
            )
            self.button_box.button(QDialogButtonBox.StandardButton.Save).setText(
                tr("Apply", "反映する")
            )
            self.button_box.button(QDialogButtonBox.StandardButton.Cancel).setText(
                tr("Cancel", "キャンセル")
            )
            self.button_box.accepted.connect(self.accept)
            self.button_box.rejected.connect(self.reject)
        else:
            self.button_box = QDialogButtonBox(
                QDialogButtonBox.StandardButton.Close
            )
            self.button_box.button(QDialogButtonBox.StandardButton.Close).setText(
                tr("Close", "閉じる")
            )
            self.button_box.rejected.connect(self.reject)
        layout.addWidget(self.button_box)
        self.set_track(track_title, lyrics)

    def lyrics(self) -> str:
        return self.lyrics_editor.toPlainText()

    def set_track(self, track_title: str, lyrics: str) -> None:
        """Replace the displayed track without creating another window."""

        self.track_title_label.setText(
            track_title or tr("Untitled Track", "タイトル未設定")
        )
        self.lyrics_editor.setPlainText(str(lyrics or ""))
        self.lyrics_editor.verticalScrollBar().setValue(0)


class SyncedLyricsCanvas(QWidget):
    """Artwork-backed canvas with a theme-aware readability overlay."""

    def __init__(self, parent=None) -> None:
        super().__init__(parent)
        self._artwork = QPixmap()
        self._theme = "dark"

    def set_artwork(self, artwork: QPixmap) -> None:
        self._artwork = QPixmap(artwork)
        self.update()

    def set_theme(self, theme: object) -> None:
        self._theme = normalize_theme(theme)
        self.update()

    def paintEvent(self, event) -> None:
        painter = QPainter(self)
        painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, True)
        palette = THEME_PALETTES[self._theme]
        painter.fillRect(self.rect(), QColor(str(palette["canvas"])))
        if not self._artwork.isNull():
            expanded = self._artwork.scaled(
                self.size(),
                Qt.AspectRatioMode.KeepAspectRatioByExpanding,
                Qt.TransformationMode.SmoothTransformation,
            )
            x = (self.width() - expanded.width()) // 2
            y = (self.height() - expanded.height()) // 2
            painter.setOpacity(0.58)
            painter.drawPixmap(x, y, expanded)
            painter.setOpacity(1.0)
        smoke = QColor(str(palette["canvas"]))
        smoke.setAlpha(158 if self._theme == "white" else 142)
        painter.fillRect(self.rect(), smoke)
        painter.end()
        super().paintEvent(event)


class SyncedLyricsViewer(QDialog):
    """Modeless, fullscreen-capable synchronized lyrics presentation."""

    seekRequested = Signal(int)
    playPauseRequested = Signal()
    stopRequested = Signal()
    previousRequested = Signal()
    nextRequested = Signal()

    def __init__(self, track: Track, theme: str, visualizer_mode: str, parent=None) -> None:
        super().__init__(parent)
        self.setWindowTitle(tr("Synchronized Lyrics", "同期歌詞"))
        self.setWindowIcon(application_icon())
        self.setMinimumSize(680, 560)
        self.resize(880, 720)
        self._theme = normalize_theme(theme)
        self._entries: list[LrcEntry] = []
        self._track: Track | None = None
        self._artwork = QPixmap()
        self._duration_ms = 0
        self._slider_dragging = False

        root = QVBoxLayout(self)
        root.setContentsMargins(0, 0, 0, 0)
        self.canvas = SyncedLyricsCanvas()
        root.addWidget(self.canvas)
        layout = QVBoxLayout(self.canvas)
        self.content_layout = layout
        layout.setContentsMargins(24, 18, 24, 18)
        layout.setSpacing(9)

        header = QHBoxLayout()
        track_info_layout = QVBoxLayout()
        track_info_layout.setContentsMargins(0, 0, 0, 0)
        track_info_layout.setSpacing(2)
        self.track_title_label = QLabel()
        self.track_title_label.setObjectName("titleLabel")
        self.track_title_label.setWordWrap(True)
        self.track_artist_label = QLabel()
        self.track_artist_label.setWordWrap(True)
        self.track_album_label = QLabel()
        self.track_album_label.setWordWrap(True)
        self.track_quality_label = QLabel()
        for label in (
            self.track_title_label,
            self.track_artist_label,
            self.track_album_label,
            self.track_quality_label,
        ):
            track_info_layout.addWidget(label)
        header.addLayout(track_info_layout, 1)
        self.fullscreen_button = QPushButton(tr("Full Screen  F11", "全画面  F11"))
        self.fullscreen_button.setMinimumHeight(30)
        header.addWidget(self.fullscreen_button)
        layout.addLayout(header)

        # Keep artwork in a flexible upper stage. The lyrics, seek bar, and
        # transport controls live in a separate bottom stage so fullscreen can
        # devote the extra height to a much larger cover without leaving a
        # large unused area below the controls.
        self.artwork_area = QWidget()
        self.artwork_area.setStyleSheet("background: transparent;")
        artwork_layout = QVBoxLayout(self.artwork_area)
        artwork_layout.setContentsMargins(0, 0, 0, 0)
        artwork_layout.addStretch(1)
        self.artwork_label = QLabel()
        self.artwork_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self.artwork_label.setMinimumSize(150, 150)
        self.artwork_shadow = QGraphicsDropShadowEffect(self.artwork_label)
        self.artwork_shadow.setBlurRadius(16.0)
        self.artwork_shadow.setOffset(6.0, 6.0)
        self.artwork_shadow.setColor(QColor(0, 0, 0, 105))
        self.artwork_shadow.setEnabled(False)
        self.artwork_label.setGraphicsEffect(self.artwork_shadow)
        artwork_layout.addWidget(
            self.artwork_label, 0, Qt.AlignmentFlag.AlignCenter
        )
        artwork_layout.addStretch(1)
        layout.addWidget(self.artwork_area, 1)

        self.bottom_panel = QWidget()
        self.bottom_panel.setStyleSheet("background: transparent;")
        bottom_layout = QVBoxLayout(self.bottom_panel)
        bottom_layout.setContentsMargins(0, 0, 0, 0)
        bottom_layout.setSpacing(7)

        self.lyrics_panel = QFrame()
        lyrics_layout = QVBoxLayout(self.lyrics_panel)
        self.lyrics_layout = lyrics_layout
        lyrics_layout.setContentsMargins(18, 10, 18, 10)
        lyrics_layout.setSpacing(5)
        self.previous_lyric_label = QLabel(" ")
        self.current_lyric_label = QLabel(" ")
        self.next_lyric_label = QLabel(" ")
        for label in (
            self.previous_lyric_label,
            self.current_lyric_label,
            self.next_lyric_label,
        ):
            label.setAlignment(Qt.AlignmentFlag.AlignCenter)
            label.setWordWrap(True)
            label.setMinimumHeight(28)
            lyrics_layout.addWidget(label)
        bottom_layout.addWidget(self.lyrics_panel)

        self.position_slider = SeekSlider(Qt.Orientation.Horizontal)
        self.position_slider.setRange(0, 0)
        self.position_slider.set_visualizer_mode(visualizer_mode)
        bottom_layout.addWidget(self.position_slider)
        time_row = QHBoxLayout()
        self.elapsed_label = QLabel("0:00")
        self.remaining_label = QLabel("−0:00")
        time_row.addWidget(self.elapsed_label)
        time_row.addStretch(1)
        time_row.addWidget(self.remaining_label)
        bottom_layout.addLayout(time_row)

        controls = QHBoxLayout()
        controls.addStretch(1)
        self.previous_button = self._transport_button("◀◀", "skipButton", 36)
        self.play_button = self._transport_button("▶", "playButton", 40)
        self.stop_button = self._transport_button("■", "skipButton", 36)
        self.next_button = self._transport_button("▶▶", "skipButton", 36)
        for button in (
            self.previous_button,
            self.play_button,
            self.stop_button,
            self.next_button,
        ):
            controls.addWidget(button)
        controls.addStretch(1)
        bottom_layout.addLayout(controls)
        layout.addWidget(self.bottom_panel, 0)

        self.fullscreen_button.clicked.connect(self.toggle_fullscreen)
        self.previous_button.clicked.connect(self.previousRequested.emit)
        self.play_button.clicked.connect(self.playPauseRequested.emit)
        self.stop_button.clicked.connect(self.stopRequested.emit)
        self.next_button.clicked.connect(self.nextRequested.emit)
        self.position_slider.sliderPressed.connect(self._slider_pressed)
        self.position_slider.sliderMoved.connect(self._slider_moved)
        self.position_slider.sliderReleased.connect(self._slider_released)
        self.position_slider.seekRequested.connect(self._seek_clicked)
        self.fullscreen_shortcut = QShortcut(QKeySequence("F11"), self)
        self.fullscreen_shortcut.activated.connect(self.toggle_fullscreen)
        self.escape_shortcut = QShortcut(QKeySequence("Esc"), self)
        self.escape_shortcut.activated.connect(self.exit_fullscreen)

        self.set_theme(theme)
        self.set_track(track)

    @staticmethod
    def _transport_button(text: str, object_name: str, size: int) -> QPushButton:
        button = QPushButton(text)
        button.setObjectName(object_name)
        button.setFixedSize(size, size)
        return button

    def set_track(self, track: Track) -> None:
        self._track = track
        self.track_title_label.setText(track.title or Path(track.path).stem)
        self.track_artist_label.setText(
            track.artist or tr("Unknown artist", "アーティスト不明")
        )
        self.track_album_label.setText(
            track.album or tr("Unknown album", "アルバム不明")
        )
        self.track_quality_label.setText(track.bitrate_frequency_text)
        self._duration_ms = max(0, int(track.duration_ms))
        self.position_slider.setRange(0, self._duration_ms)
        try:
            document = load_lrc(lrc_path_for_track(track))
            self._entries = document.synchronized_entries()
        except (OSError, UnicodeError):
            self._entries = []
        self.lyrics_panel.setVisible(bool(self._entries))
        self._artwork = QPixmap()
        try:
            artwork = read_artwork(track.path)
        except Exception:
            artwork = None
        if artwork:
            self._artwork.loadFromData(artwork[0])
        self.canvas.set_artwork(self._artwork)
        self._apply_presentation_layout()
        QTimer.singleShot(0, self._apply_presentation_layout)
        self.set_position(0, self._duration_ms)

    def reload_lyrics(self) -> None:
        if self._track is not None:
            self.set_track(self._track)

    def set_theme(self, theme: object) -> None:
        self._theme = normalize_theme(theme)
        palette = THEME_PALETTES[self._theme]
        text = str(palette["text"])
        muted = str(palette["muted"])
        accent = str(palette["accent"])
        panel = str(palette["panel_strong"])
        panel_components = re.fullmatch(
            r"rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*\d+\s*\)",
            panel,
        )
        if panel_components:
            red, green, blue = panel_components.groups()
            panel = f"rgba({red}, {green}, {blue}, 204)"
        self.canvas.set_theme(self._theme)
        self.track_title_label.setStyleSheet(
            f"color: {text}; font-size: 15pt; font-weight: 700;"
        )
        self.track_artist_label.setStyleSheet(
            f"color: {text}; font-size: 11pt; background: transparent;"
        )
        self.track_album_label.setStyleSheet(
            f"color: {muted}; font-size: 10pt; background: transparent;"
        )
        self.track_quality_label.setStyleSheet(
            f"color: {muted}; font-size: 9pt; background: transparent;"
        )
        self.lyrics_panel.setStyleSheet(
            f"QFrame {{ background-color: {panel}; border-radius: 9px; }}"
        )
        self._apply_lyrics_styles()
        self.elapsed_label.setStyleSheet(f"color: {text};")
        self.remaining_label.setStyleSheet(f"color: {text};")
        self.position_slider.set_visualizer_color(accent)
        self.play_button.setStyleSheet(
            "color: #111111;" if self._theme == "white" else "color: #ffffff;"
        )
        self._resize_foreground_artwork()

    def _apply_lyrics_styles(self) -> None:
        palette = THEME_PALETTES[self._theme]
        muted = str(palette["muted"])
        accent = str(palette["accent"])
        secondary_size = 16 if self.isFullScreen() else 12
        current_size = 22 if self.isFullScreen() else 16
        secondary_style = (
            f"color: {muted}; font-size: {secondary_size}pt; "
            "background: transparent;"
        )
        self.previous_lyric_label.setStyleSheet(secondary_style)
        self.current_lyric_label.setStyleSheet(
            f"color: {accent}; font-size: {current_size}pt; font-weight: 700; "
            "background: transparent;"
        )
        self.next_lyric_label.setStyleSheet(secondary_style)

    def set_visualizer_mode(self, mode: object) -> None:
        self.position_slider.set_visualizer_mode(mode)

    def set_visualizer_values(self, values: list[float]) -> None:
        self.position_slider.set_visualizer_values(values)

    def set_visualizer_active(self, active: bool) -> None:
        self.position_slider.set_visualizer_active(active)

    def set_playing(self, playing: bool) -> None:
        self.play_button.setText("❚❚" if playing else "▶")

    def set_position(self, position_ms: int, duration_ms: int | None = None) -> None:
        if duration_ms is not None and int(duration_ms) > 0:
            self._duration_ms = int(duration_ms)
            self.position_slider.setRange(0, self._duration_ms)
        position = max(0, int(position_ms))
        if not self._slider_dragging:
            self.position_slider.setValue(position)
        self._set_time_labels(position)
        if not self._entries:
            self.previous_lyric_label.setText(" ")
            self.current_lyric_label.setText(" ")
            self.next_lyric_label.setText(" ")
            return
        active = active_lyric_index(self._entries, position)
        if active < 0:
            previous = ""
            current = "♪"
            following = self._entries[0].text
        else:
            previous = self._entries[active - 1].text if active > 0 else ""
            current = self._entries[active].text or "♪"
            following = (
                self._entries[active + 1].text
                if active + 1 < len(self._entries)
                else ""
            )
        self.previous_lyric_label.setText(previous or " ")
        self.current_lyric_label.setText(current or " ")
        self.next_lyric_label.setText(following or " ")

    def _set_time_labels(self, position: int) -> None:
        self.elapsed_label.setText(format_duration(position))
        self.remaining_label.setText(
            f"−{format_duration(max(0, self._duration_ms - position))}"
        )

    def _slider_pressed(self) -> None:
        self._slider_dragging = True

    def _slider_moved(self, value: int) -> None:
        self._set_time_labels(value)

    def _slider_released(self) -> None:
        value = self.position_slider.value()
        self._slider_dragging = False
        self.seekRequested.emit(value)

    def _seek_clicked(self, value: int) -> None:
        self.seekRequested.emit(value)

    def toggle_fullscreen(self) -> None:
        if self.isFullScreen():
            self.showNormal()
        else:
            self.showFullScreen()
        self._update_fullscreen_button()
        QTimer.singleShot(0, self._apply_presentation_layout)

    def exit_fullscreen(self) -> None:
        if self.isFullScreen():
            self.showNormal()
            self._update_fullscreen_button()
            QTimer.singleShot(0, self._apply_presentation_layout)

    def _update_fullscreen_button(self) -> None:
        self.fullscreen_button.setText(
            tr("Windowed  F11", "ウィンドウ表示  F11")
            if self.isFullScreen()
            else tr("Full Screen  F11", "全画面  F11")
        )

    def resizeEvent(self, event) -> None:
        super().resizeEvent(event)
        self._apply_presentation_layout()

    def _apply_presentation_layout(self) -> None:
        has_lyrics = bool(self._entries)
        self.lyrics_panel.setVisible(has_lyrics)
        if self.isFullScreen():
            self.content_layout.setContentsMargins(24, 14, 24, 24)
            self.content_layout.setSpacing(7)
            self.lyrics_panel.setMinimumHeight(195 if has_lyrics else 0)
            self.lyrics_layout.setContentsMargins(28, 18, 28, 18)
            self.lyrics_layout.setSpacing(9)
            lyric_heights = (44, 58, 44)
        else:
            self.content_layout.setContentsMargins(24, 18, 24, 18)
            self.content_layout.setSpacing(9)
            self.lyrics_panel.setMinimumHeight(0)
            self.lyrics_layout.setContentsMargins(18, 10, 18, 10)
            self.lyrics_layout.setSpacing(5)
            lyric_heights = (28, 32, 28)
        for label, height in zip(
            (
                self.previous_lyric_label,
                self.current_lyric_label,
                self.next_lyric_label,
            ),
            lyric_heights,
        ):
            label.setMinimumHeight(height)
        self._apply_lyrics_styles()
        self._resize_foreground_artwork()

    def _resize_foreground_artwork(self) -> None:
        available_height = max(150, self.artwork_area.height())
        if self.isFullScreen():
            maximum = 560 if self._entries else 650
            width_limit = self.width() * (0.46 if self._entries else 0.55)
            height_limit = available_height * (0.90 if self._entries else 0.94)
        else:
            maximum = 360 if self._entries else 420
            width_limit = self.width() * (0.36 if self._entries else 0.46)
            height_limit = available_height * (0.86 if self._entries else 0.92)
        side = max(150, min(maximum, int(min(width_limit, height_limit))))
        self.artwork_label.setFixedSize(side, side)
        if self._artwork.isNull():
            self.artwork_shadow.setEnabled(False)
            self.artwork_label.setText("♪")
            self.artwork_label.setStyleSheet(
                "font-size: 48pt; background-color: rgba(0, 0, 0, 90);"
            )
            return
        self.artwork_shadow.setEnabled(True)
        self.artwork_label.setText("")
        border = str(THEME_PALETTES[self._theme]["border"])
        self.artwork_label.setStyleSheet(
            f"QLabel {{ background: transparent; border: 1px solid {border}; }}"
        )
        image_side = max(1, side - 2)
        self.artwork_label.setPixmap(
            self._artwork.scaled(
                image_side,
                image_side,
                Qt.AspectRatioMode.KeepAspectRatio,
                Qt.TransformationMode.SmoothTransformation,
            )
        )


class SyncedLyricsEditor(QDialog):
    """Create and correct sidecar LRC timestamps while previewing a track."""

    def __init__(self, track: Track, parent=None) -> None:
        super().__init__(parent)
        self.track = track
        self.lrc_path = lrc_path_for_track(track)
        self.setWindowTitle(tr("Edit Synchronized Lyrics", "同期歌詞を編集"))
        self.setWindowIcon(application_icon())
        self.setMinimumSize(880, 620)
        self.resize(1050, 720)
        self._slider_dragging = False
        self._stamp_history: list[tuple[int, str]] = []
        self._metadata: list[tuple[str, str]] = []

        self.player = QMediaPlayer(self)
        self.audio_output = QAudioOutput(self)
        self.audio_output.setVolume(0.8)
        self.player.setAudioOutput(self.audio_output)
        self.player.setSource(QUrl.fromLocalFile(track.path))

        layout = QVBoxLayout(self)
        self.title_label = QLabel(track.title or Path(track.path).stem)
        self.title_label.setObjectName("titleLabel")
        layout.addWidget(self.title_label)
        self.path_label = QLabel(str(self.lrc_path))
        self.path_label.setObjectName("mutedLabel")
        self.path_label.setWordWrap(True)
        layout.addWidget(self.path_label)
        note = QLabel(
            tr(
                "1. Enter or paste one lyric phrase per line.  2. Prepare the lines.  "
                "3. Play the track and press Enter or Stamp at each phrase.",
                "1. 1行に1つの歌詞を入力・貼り付け　2. 行を準備　"
                "3. 楽曲を再生し、各フレーズの位置でEnterまたは打刻ボタンを押します。",
            )
        )
        note.setWordWrap(True)
        layout.addWidget(note)

        splitter = QSplitter(Qt.Orientation.Horizontal)
        input_panel = QWidget()
        input_layout = QVBoxLayout(input_panel)
        input_layout.setContentsMargins(0, 0, 4, 0)
        input_layout.addWidget(QLabel(tr("Lyrics Text", "歌詞テキスト")))
        self.lyrics_input = QPlainTextEdit()
        self.lyrics_input.setObjectName("lyricsEditor")
        self.lyrics_input.setPlaceholderText(
            tr(
                "Enter one phrase per line…",
                "1行に1つのフレーズを入力…",
            )
        )
        input_layout.addWidget(self.lyrics_input, 1)
        self.prepare_button = QPushButton(
            tr("Prepare Lines for Stamping →", "打刻用の行を準備 →")
        )
        input_layout.addWidget(self.prepare_button)
        splitter.addWidget(input_panel)

        table_panel = QWidget()
        table_layout = QVBoxLayout(table_panel)
        table_layout.setContentsMargins(4, 0, 0, 0)
        table_layout.addWidget(QLabel(tr("Timestamped Lines", "タイムスタンプ一覧")))
        self.line_table = QTableWidget(0, 2)
        self.line_table.setHorizontalHeaderLabels(
            [tr("Time", "時刻"), tr("Lyrics", "歌詞")]
        )
        self.line_table.horizontalHeader().setSectionResizeMode(
            0, QHeaderView.ResizeMode.ResizeToContents
        )
        self.line_table.horizontalHeader().setSectionResizeMode(
            1, QHeaderView.ResizeMode.Stretch
        )
        self.line_table.setSelectionBehavior(
            QAbstractItemView.SelectionBehavior.SelectRows
        )
        self.line_table.setSelectionMode(
            QAbstractItemView.SelectionMode.ExtendedSelection
        )
        table_layout.addWidget(self.line_table, 1)
        correction_row = QHBoxLayout()
        self.clear_time_button = QPushButton(tr("Clear Time", "時刻を消去"))
        self.minus_time_button = QPushButton("−0.10 s")
        self.plus_time_button = QPushButton("＋0.10 s")
        self.undo_stamp_button = QPushButton(tr("Undo Stamp", "打刻を戻す"))
        for button in (
            self.clear_time_button,
            self.minus_time_button,
            self.plus_time_button,
            self.undo_stamp_button,
        ):
            correction_row.addWidget(button)
        table_layout.addLayout(correction_row)
        splitter.addWidget(table_panel)
        splitter.setSizes([390, 620])
        layout.addWidget(splitter, 1)

        transport = QHBoxLayout()
        self.play_button = QPushButton("▶")
        self.stop_button = QPushButton("■")
        self.play_button.setObjectName("playButton")
        self.stop_button.setObjectName("skipButton")
        self.play_button.setFixedSize(38, 38)
        self.stop_button.setFixedSize(34, 34)
        self.elapsed_label = QLabel("0:00")
        self.position_slider = SeekSlider(Qt.Orientation.Horizontal)
        self.position_slider.set_visualizer_mode("off")
        self.position_slider.setRange(0, max(0, track.duration_ms))
        self.remaining_label = QLabel(f"−{format_duration(track.duration_ms)}")
        transport.addWidget(self.play_button)
        transport.addWidget(self.stop_button)
        transport.addWidget(self.elapsed_label)
        transport.addWidget(self.position_slider, 1)
        transport.addWidget(self.remaining_label)
        layout.addLayout(transport)

        stamp_row = QHBoxLayout()
        self.status_label = QLabel()
        self.status_label.setObjectName("mutedLabel")
        stamp_row.addWidget(self.status_label, 1)
        self.stamp_button = QPushButton(
            tr("Stamp Current Time  Enter", "現在時刻を打刻  Enter")
        )
        self.stamp_button.setMinimumHeight(38)
        stamp_row.addWidget(self.stamp_button)
        layout.addLayout(stamp_row)

        self.button_box = QDialogButtonBox(
            QDialogButtonBox.StandardButton.Save
            | QDialogButtonBox.StandardButton.Cancel
        )
        self.button_box.button(QDialogButtonBox.StandardButton.Save).setText(
            tr("Save .lrc", ".lrcを保存")
        )
        self.button_box.button(QDialogButtonBox.StandardButton.Cancel).setText(
            tr("Cancel", "キャンセル")
        )
        layout.addWidget(self.button_box)

        self.prepare_button.clicked.connect(self.prepare_lines)
        self.stamp_button.clicked.connect(self.stamp_current_line)
        self.clear_time_button.clicked.connect(self.clear_selected_times)
        self.minus_time_button.clicked.connect(lambda: self.shift_selected_times(-100))
        self.plus_time_button.clicked.connect(lambda: self.shift_selected_times(100))
        self.undo_stamp_button.clicked.connect(self.undo_last_stamp)
        self.play_button.clicked.connect(self.toggle_playback)
        self.stop_button.clicked.connect(self.stop_playback)
        self.position_slider.sliderPressed.connect(self._slider_pressed)
        self.position_slider.sliderMoved.connect(self._slider_moved)
        self.position_slider.sliderReleased.connect(self._slider_released)
        self.position_slider.seekRequested.connect(self._seek_to)
        self.player.positionChanged.connect(self._position_changed)
        self.player.durationChanged.connect(self._duration_changed)
        self.player.playbackStateChanged.connect(self._playback_state_changed)
        self.player.errorOccurred.connect(self._playback_error)
        self.button_box.accepted.connect(self.accept)
        self.button_box.rejected.connect(self.reject)
        self._load_document()

    def _load_document(self) -> None:
        try:
            document = load_lrc(self.lrc_path)
        except (OSError, UnicodeError) as exc:
            QMessageBox.warning(
                self,
                tr("LRC Read Error", "LRC読込エラー"),
                str(exc),
            )
            document = LrcDocument()
        self._metadata = list(document.metadata)
        self.lyrics_input.setPlainText(
            "\n".join(entry.text for entry in document.entries)
        )
        self._populate_table(document.entries)

    def _populate_table(self, entries: list[LrcEntry]) -> None:
        self.line_table.setRowCount(0)
        for entry in entries:
            row = self.line_table.rowCount()
            self.line_table.insertRow(row)
            time_text = (
                format_timestamp(entry.timestamp_ms, brackets=False)
                if entry.timestamp_ms is not None
                else ""
            )
            self.line_table.setItem(row, 0, QTableWidgetItem(time_text))
            self.line_table.setItem(row, 1, QTableWidgetItem(entry.text))
        if self.line_table.rowCount():
            self.line_table.selectRow(0)

    def prepare_lines(self) -> None:
        old_times = [
            self.line_table.item(row, 0).text()
            if self.line_table.item(row, 0) is not None
            else ""
            for row in range(self.line_table.rowCount())
        ]
        lines = [
            line.strip()
            for line in self.lyrics_input.toPlainText().splitlines()
            if line.strip()
        ]
        entries = [
            LrcEntry(
                parse_timestamp(old_times[row]) if row < len(old_times) else None,
                text,
            )
            for row, text in enumerate(lines)
        ]
        self._populate_table(entries)
        self._stamp_history.clear()
        self.status_label.setText(
            tr(
                f"Prepared {len(lines)} lines. Start playback and stamp the first line.",
                f"{len(lines)} 行を準備しました。再生して先頭行から打刻してください。",
            )
        )

    def current_position_ms(self) -> int:
        return max(0, int(self.player.position()) - self._cue_start_ms())

    def stamp_current_line(self) -> None:
        if not self.line_table.rowCount():
            self.prepare_lines()
        if not self.line_table.rowCount():
            self.status_label.setText(
                tr("Enter lyrics first.", "先に歌詞を入力してください。")
            )
            return
        row = self.line_table.currentRow()
        if row < 0:
            row = next(
                (
                    candidate
                    for candidate in range(self.line_table.rowCount())
                    if not self.line_table.item(candidate, 0).text().strip()
                ),
                0,
            )
        item = self.line_table.item(row, 0)
        old_value = item.text()
        self._stamp_history.append((row, old_value))
        if len(self._stamp_history) > 100:
            del self._stamp_history[:-100]
        item.setText(format_timestamp(self.current_position_ms(), brackets=False))
        next_row = min(row + 1, self.line_table.rowCount() - 1)
        self.line_table.selectRow(next_row)
        self.line_table.scrollToItem(self.line_table.item(next_row, 1))
        self.status_label.setText(
            tr(
                f"Stamped line {row + 1}. Press Enter at the next phrase.",
                f"{row + 1} 行目を打刻しました。次のフレーズでEnterを押してください。",
            )
        )

    def selected_rows(self) -> list[int]:
        return sorted({index.row() for index in self.line_table.selectedIndexes()})

    def clear_selected_times(self) -> None:
        for row in self.selected_rows() or [self.line_table.currentRow()]:
            if row < 0:
                continue
            item = self.line_table.item(row, 0)
            self._stamp_history.append((row, item.text()))
            item.setText("")

    def shift_selected_times(self, delta_ms: int) -> None:
        for row in self.selected_rows() or [self.line_table.currentRow()]:
            if row < 0:
                continue
            item = self.line_table.item(row, 0)
            value = parse_timestamp(item.text())
            if value is None:
                continue
            self._stamp_history.append((row, item.text()))
            item.setText(
                format_timestamp(max(0, value + int(delta_ms)), brackets=False)
            )

    def undo_last_stamp(self) -> None:
        if not self._stamp_history:
            self.status_label.setText(
                tr("There is no timestamp change to undo.", "戻せる打刻操作はありません。")
            )
            return
        row, previous = self._stamp_history.pop()
        if 0 <= row < self.line_table.rowCount():
            self.line_table.item(row, 0).setText(previous)
            self.line_table.selectRow(row)

    def _cue_start_ms(self) -> int:
        return self.track.cue_start_ms if self.track.is_cue_track else 0

    def _relative_duration(self, source_duration: int = 0) -> int:
        if self.track.is_cue_track:
            return max(0, self.track.duration_ms)
        return max(0, int(source_duration) or self.track.duration_ms)

    def toggle_playback(self) -> None:
        if self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState:
            self.player.pause()
            return
        if self.track.is_cue_track and self.player.position() < self.track.cue_start_ms:
            self.player.setPosition(self.track.cue_start_ms)
        self.player.play()

    def stop_playback(self) -> None:
        self.player.stop()
        self.player.setPosition(self._cue_start_ms())
        self.position_slider.setValue(0)
        self._set_time_labels(0)

    def _slider_pressed(self) -> None:
        self._slider_dragging = True

    def _slider_moved(self, value: int) -> None:
        self._set_time_labels(value)

    def _slider_released(self) -> None:
        value = self.position_slider.value()
        self._slider_dragging = False
        self._seek_to(value)

    def _seek_to(self, position: int) -> None:
        position = max(0, min(int(position), self.position_slider.maximum()))
        self.player.setPosition(self._cue_start_ms() + position)
        self._set_time_labels(position)

    def _position_changed(self, source_position: int) -> None:
        position = max(0, int(source_position) - self._cue_start_ms())
        if not self._slider_dragging:
            self.position_slider.setValue(position)
        self._set_time_labels(position)
        if (
            self.track.is_cue_track
            and self.track.cue_end_ms > self.track.cue_start_ms
            and int(source_position) >= self.track.cue_end_ms
        ):
            self.stop_playback()

    def _duration_changed(self, source_duration: int) -> None:
        duration = self._relative_duration(source_duration)
        self.position_slider.setRange(0, duration)
        self._set_time_labels(self.position_slider.value())

    def _set_time_labels(self, position: int) -> None:
        duration = self.position_slider.maximum()
        self.elapsed_label.setText(format_duration(position))
        self.remaining_label.setText(
            f"−{format_duration(max(0, duration - position))}"
        )

    def _playback_state_changed(self, state: QMediaPlayer.PlaybackState) -> None:
        self.play_button.setText(
            "❚❚" if state == QMediaPlayer.PlaybackState.PlayingState else "▶"
        )

    def _playback_error(self, error: QMediaPlayer.Error, message: str) -> None:
        if error != QMediaPlayer.Error.NoError:
            self.status_label.setText(
                tr(f"Playback error: {message}", f"再生エラー: {message}")
            )

    def _document_from_table(self) -> LrcDocument | None:
        entries: list[LrcEntry] = []
        for row in range(self.line_table.rowCount()):
            time_item = self.line_table.item(row, 0)
            lyric_item = self.line_table.item(row, 1)
            time_text = time_item.text().strip() if time_item else ""
            timestamp = parse_timestamp(time_text) if time_text else None
            if time_text and timestamp is None:
                QMessageBox.warning(
                    self,
                    tr("Invalid Timestamp", "タイムスタンプ形式エラー"),
                    tr(
                        f"Line {row + 1} has an invalid time. Use mm:ss.xx.",
                        f"{row + 1} 行目の時刻が不正です。mm:ss.xx形式で入力してください。",
                    ),
                )
                return None
            entries.append(
                LrcEntry(timestamp, lyric_item.text() if lyric_item else "")
            )
        return LrcDocument(entries=entries, metadata=list(self._metadata))

    def save(self) -> bool:
        if not self.line_table.rowCount() and self.lyrics_input.toPlainText().strip():
            self.prepare_lines()
        document = self._document_from_table()
        if document is None:
            return False
        try:
            save_lrc(self.lrc_path, document)
        except OSError as exc:
            QMessageBox.critical(
                self,
                tr("LRC Save Error", "LRC保存エラー"),
                str(exc),
            )
            return False
        return True

    def accept(self) -> None:
        if not self.save():
            return
        self._release_player()
        super().accept()

    def reject(self) -> None:
        self._release_player()
        super().reject()

    def _release_player(self) -> None:
        self.player.stop()
        self.player.setSource(QUrl())
        QApplication.processEvents()

    def keyPressEvent(self, event) -> None:
        focus = QApplication.focusWidget()
        text_editing = isinstance(focus, (QPlainTextEdit, QLineEdit))
        if (
            event.key() in {Qt.Key.Key_Return, Qt.Key.Key_Enter}
            and not text_editing
        ):
            self.stamp_current_line()
            event.accept()
            return
        if event.key() == Qt.Key.Key_Space and not text_editing:
            self.toggle_playback()
            event.accept()
            return
        if (
            event.matches(QKeySequence.StandardKey.Undo)
            and not text_editing
        ):
            self.undo_last_stamp()
            event.accept()
            return
        super().keyPressEvent(event)


class AutoNumberDialog(QDialog):
    def __init__(self, parent=None) -> None:
        super().__init__(parent)
        self.setWindowTitle(tr("Auto-number Disc / Track", "Disc / Track 自動連番"))
        layout = QVBoxLayout(self)
        explanation = QLabel(
            tr(
                "Numbers are assigned in the currently displayed order.\n"
                "The same value is applied to Disc and Disc Total for every track.",
                "現在表示されている曲順で付番します。\n"
                "Disc番号とDisc合計には、同じ番号が全曲へ設定されます。",
            )
        )
        explanation.setWordWrap(True)
        layout.addWidget(explanation)
        form = QFormLayout()
        self.disc_number = PaddedSpinBox()
        self.disc_number.setRange(1, 999)
        self.disc_number.setValue(1)
        self.track_start = PaddedSpinBox()
        self.track_start.setRange(1, 9999)
        self.track_start.setValue(1)
        form.addRow(tr("Disc number:", "Disc番号:"), self.disc_number)
        form.addRow(tr("First track number:", "Track開始番号:"), self.track_start)
        layout.addLayout(form)
        buttons = QDialogButtonBox(
            QDialogButtonBox.StandardButton.Ok
            | QDialogButtonBox.StandardButton.Cancel
        )
        buttons.button(QDialogButtonBox.StandardButton.Ok).setText(
            tr("Apply", "付番する")
        )
        buttons.button(QDialogButtonBox.StandardButton.Cancel).setText(
            tr("Cancel", "キャンセル")
        )
        buttons.accepted.connect(self.accept)
        buttons.rejected.connect(self.reject)
        layout.addWidget(buttons)


class TitleSuffixDialog(QDialog):
    def __init__(
        self,
        track_count: int,
        suffixes: list[str] | tuple[str, ...],
        insert_space: bool = False,
        parent=None,
    ) -> None:
        super().__init__(parent)
        self.setWindowTitle(tr("Add title suffix", "タイトル接尾辞を付加"))
        self.setMinimumWidth(430)
        layout = QVBoxLayout(self)
        explanation = QLabel(
            tr(
                f"Choose the suffix to add to the selected {tracks(track_count)}.",
                f"選択した {track_count} 曲のタイトル末尾へ付加する文字を選んでください。",
            )
        )
        explanation.setWordWrap(True)
        layout.addWidget(explanation)

        form = QFormLayout()
        self.suffix_combo = QComboBox()
        self.suffix_combo.addItems(normalize_title_suffixes(suffixes))
        self.suffix_combo.setMinimumWidth(260)
        form.addRow(tr("Suffix:", "接尾辞:"), self.suffix_combo)
        layout.addLayout(form)

        self.insert_space_checkbox = QCheckBox(
            tr(
                "Insert a half-width space before the suffix",
                "接尾辞の手前に半角スペースを入れる",
            )
        )
        self.insert_space_checkbox.setChecked(bool(insert_space))
        layout.addWidget(self.insert_space_checkbox)

        self.preview_label = QLabel()
        self.preview_label.setObjectName("mutedLabel")
        layout.addWidget(self.preview_label)
        self.suffix_combo.currentTextChanged.connect(self._update_preview)
        self.insert_space_checkbox.toggled.connect(self._update_preview)
        self._update_preview()

        note = QLabel(
            tr(
                "An existing configured suffix at the end of a title will be replaced.",
                "タイトル末尾に設定済みの接尾辞がある場合は、選択した接尾辞へ置き換えます。",
            )
        )
        note.setObjectName("mutedLabel")
        note.setWordWrap(True)
        layout.addWidget(note)
        buttons = QDialogButtonBox(
            QDialogButtonBox.StandardButton.Ok
            | QDialogButtonBox.StandardButton.Cancel
        )
        buttons.button(QDialogButtonBox.StandardButton.Ok).setText(
            tr("Add", "付加する")
        )
        buttons.button(QDialogButtonBox.StandardButton.Cancel).setText(
            tr("Cancel", "キャンセル")
        )
        buttons.accepted.connect(self.accept)
        buttons.rejected.connect(self.reject)
        layout.addWidget(buttons)

    def selected_suffix(self) -> str:
        return self.suffix_combo.currentText().strip()

    def inserts_space(self) -> bool:
        return self.insert_space_checkbox.isChecked()

    def _update_preview(self, *_args) -> None:
        separator = " " if self.inserts_space() else ""
        self.preview_label.setText(
            tr("Preview: ", "プレビュー: ")
            + f"Song Title{separator}{self.selected_suffix()}"
        )


class TitleSuffixSettingsDialog(QDialog):
    """Manage the ordered suffix choices shown in TitleSuffixDialog."""

    def __init__(self, suffixes: list[str], parent=None) -> None:
        super().__init__(parent)
        self.setWindowTitle(
            tr("Title Suffix Settings", "タイトル接尾辞の設定")
        )
        self.setMinimumSize(520, 430)
        layout = QVBoxLayout(self)

        explanation = QLabel(
            tr(
                "Create up to 20 suffixes. Their order here is the order in the dropdown. "
                "Drag items or use Move Up / Move Down to change the order.",
                "接尾辞は最大20件まで登録できます。ここでの順番がドロップダウンの表示順です。"
                "ドラッグ、または「上へ」「下へ」で順番を変更できます。",
            )
        )
        explanation.setWordWrap(True)
        layout.addWidget(explanation)

        self.suffix_list = QListWidget()
        self.suffix_list.setSelectionMode(
            QAbstractItemView.SelectionMode.SingleSelection
        )
        self.suffix_list.setDragDropMode(
            QAbstractItemView.DragDropMode.InternalMove
        )
        self.suffix_list.setDefaultDropAction(Qt.DropAction.MoveAction)
        self.suffix_list.setEditTriggers(
            QAbstractItemView.EditTrigger.DoubleClicked
            | QAbstractItemView.EditTrigger.EditKeyPressed
        )
        for suffix in normalize_title_suffixes(suffixes):
            self._append_item(suffix)
        layout.addWidget(self.suffix_list, 1)

        button_row = QHBoxLayout()
        self.add_button = QPushButton(tr("Add…", "追加…"))
        self.edit_button = QPushButton(tr("Edit…", "編集…"))
        self.delete_button = QPushButton(tr("Delete", "削除"))
        self.move_up_button = QPushButton(tr("Move Up", "上へ"))
        self.move_down_button = QPushButton(tr("Move Down", "下へ"))
        button_row.addWidget(self.add_button)
        button_row.addWidget(self.edit_button)
        button_row.addWidget(self.delete_button)
        button_row.addStretch(1)
        button_row.addWidget(self.move_up_button)
        button_row.addWidget(self.move_down_button)
        layout.addLayout(button_row)

        self.count_label = QLabel()
        self.count_label.setObjectName("mutedLabel")
        layout.addWidget(self.count_label)

        buttons = QDialogButtonBox(
            QDialogButtonBox.StandardButton.Save
            | QDialogButtonBox.StandardButton.Cancel
        )
        buttons.button(QDialogButtonBox.StandardButton.Save).setText(
            tr("Save", "保存")
        )
        buttons.button(QDialogButtonBox.StandardButton.Cancel).setText(
            tr("Cancel", "キャンセル")
        )
        buttons.accepted.connect(self.accept)
        buttons.rejected.connect(self.reject)
        layout.addWidget(buttons)

        self.add_button.clicked.connect(self.add_suffix)
        self.edit_button.clicked.connect(self.edit_suffix)
        self.delete_button.clicked.connect(self.delete_suffix)
        self.move_up_button.clicked.connect(lambda: self.move_selected(-1))
        self.move_down_button.clicked.connect(lambda: self.move_selected(1))
        self.suffix_list.currentRowChanged.connect(self._update_controls)
        self.suffix_list.model().rowsMoved.connect(self._update_controls)
        if self.suffix_list.count():
            self.suffix_list.setCurrentRow(0)
        self._update_controls()

    def _append_item(self, suffix: str) -> None:
        item = QListWidgetItem(suffix)
        item.setFlags(item.flags() | Qt.ItemFlag.ItemIsEditable)
        self.suffix_list.addItem(item)

    def suffixes(self) -> list[str]:
        return [
            self.suffix_list.item(row).text().strip()
            for row in range(self.suffix_list.count())
        ]

    def _prompt_for_suffix(self, title: str, value: str = "") -> str | None:
        text, accepted = QInputDialog.getText(
            self,
            title,
            tr("Suffix:", "接尾辞:"),
            text=value,
        )
        if not accepted:
            return None
        suffix = text.strip()
        if not suffix:
            QMessageBox.warning(
                self,
                tr("Title Suffix", "タイトル接尾辞"),
                tr("Enter a suffix.", "接尾辞を入力してください。"),
            )
            return None
        return suffix

    def _contains(self, suffix: str, ignore_row: int = -1) -> bool:
        identity = suffix.casefold()
        return any(
            row != ignore_row
            and self.suffix_list.item(row).text().strip().casefold()
            == identity
            for row in range(self.suffix_list.count())
        )

    def add_suffix(self) -> None:
        if self.suffix_list.count() >= MAX_TITLE_SUFFIXES:
            QMessageBox.information(
                self,
                tr("Title Suffixes", "タイトル接尾辞"),
                tr(
                    "Up to 20 suffixes can be registered.",
                    "接尾辞は最大20件まで登録できます。",
                ),
            )
            return
        suffix = self._prompt_for_suffix(
            tr("Add Title Suffix", "タイトル接尾辞を追加")
        )
        if suffix is None:
            return
        if self._contains(suffix):
            self._show_duplicate_warning()
            return
        self._append_item(suffix)
        self.suffix_list.setCurrentRow(self.suffix_list.count() - 1)
        self._update_controls()

    def edit_suffix(self) -> None:
        row = self.suffix_list.currentRow()
        if row < 0:
            return
        item = self.suffix_list.item(row)
        suffix = self._prompt_for_suffix(
            tr("Edit Title Suffix", "タイトル接尾辞を編集"),
            item.text(),
        )
        if suffix is None:
            return
        if self._contains(suffix, ignore_row=row):
            self._show_duplicate_warning()
            return
        item.setText(suffix)
        self._update_controls()

    def delete_suffix(self) -> None:
        row = self.suffix_list.currentRow()
        if row < 0 or self.suffix_list.count() <= 1:
            return
        self.suffix_list.takeItem(row)
        if self.suffix_list.count():
            self.suffix_list.setCurrentRow(
                min(row, self.suffix_list.count() - 1)
            )
        self._update_controls()

    def move_selected(self, offset: int) -> None:
        row = self.suffix_list.currentRow()
        target = row + offset
        if row < 0 or not 0 <= target < self.suffix_list.count():
            return
        item = self.suffix_list.takeItem(row)
        self.suffix_list.insertItem(target, item)
        self.suffix_list.setCurrentRow(target)
        self._update_controls()

    def _show_duplicate_warning(self) -> None:
        QMessageBox.warning(
            self,
            tr("Title Suffix", "タイトル接尾辞"),
            tr(
                "That suffix is already registered.",
                "同じ接尾辞が既に登録されています。",
            ),
        )

    def _update_controls(self, *_args) -> None:
        count = self.suffix_list.count()
        row = self.suffix_list.currentRow()
        self.count_label.setText(f"{count} / {MAX_TITLE_SUFFIXES}")
        self.add_button.setEnabled(count < MAX_TITLE_SUFFIXES)
        self.edit_button.setEnabled(row >= 0)
        self.delete_button.setEnabled(row >= 0 and count > 1)
        self.move_up_button.setEnabled(row > 0)
        self.move_down_button.setEnabled(0 <= row < count - 1)

    def accept(self) -> None:
        suffixes = self.suffixes()
        if not suffixes or any(not suffix for suffix in suffixes):
            QMessageBox.warning(
                self,
                tr("Title Suffixes", "タイトル接尾辞"),
                tr(
                    "Keep at least one non-empty suffix.",
                    "空欄ではない接尾辞を1件以上残してください。",
                ),
            )
            return
        if len({suffix.casefold() for suffix in suffixes}) != len(suffixes):
            self._show_duplicate_warning()
            return
        if len(suffixes) > MAX_TITLE_SUFFIXES:
            return
        super().accept()


class GenreSettingsDialog(QDialog):
    """Manage ordered, English-language genre autocomplete choices."""

    def __init__(self, genres: list[str], parent=None) -> None:
        super().__init__(parent)
        self.setWindowTitle(
            tr("Genre Suggestion Settings", "ジャンル候補の設定")
        )
        self.setMinimumSize(520, 500)
        layout = QVBoxLayout(self)

        explanation = QLabel(
            tr(
                "Edit the genre suggestions shown while typing. Their order here is the suggestion order. "
                "Drag items or use Move Up / Move Down to rearrange them. Custom genre text can still be entered directly.",
                "入力中に表示するジャンル候補を編集します。ここでの順番が候補の表示順です。"
                "ドラッグ、または「上へ」「下へ」で順番を変更できます。"
                "候補にないジャンルも引き続き直接入力できます。",
            )
        )
        explanation.setWordWrap(True)
        layout.addWidget(explanation)

        self.genre_list = QListWidget()
        self.genre_list.setSelectionMode(
            QAbstractItemView.SelectionMode.SingleSelection
        )
        self.genre_list.setDragDropMode(
            QAbstractItemView.DragDropMode.InternalMove
        )
        self.genre_list.setDefaultDropAction(Qt.DropAction.MoveAction)
        self.genre_list.setEditTriggers(
            QAbstractItemView.EditTrigger.DoubleClicked
            | QAbstractItemView.EditTrigger.EditKeyPressed
        )
        for genre in normalize_genre_suggestions(genres):
            self._append_item(genre)
        layout.addWidget(self.genre_list, 1)

        button_row = QHBoxLayout()
        self.add_button = QPushButton(tr("Add…", "追加…"))
        self.edit_button = QPushButton(tr("Edit…", "編集…"))
        self.delete_button = QPushButton(tr("Delete", "削除"))
        self.move_up_button = QPushButton(tr("Move Up", "上へ"))
        self.move_down_button = QPushButton(tr("Move Down", "下へ"))
        button_row.addWidget(self.add_button)
        button_row.addWidget(self.edit_button)
        button_row.addWidget(self.delete_button)
        button_row.addStretch(1)
        button_row.addWidget(self.move_up_button)
        button_row.addWidget(self.move_down_button)
        layout.addLayout(button_row)

        self.count_label = QLabel()
        self.count_label.setObjectName("mutedLabel")
        layout.addWidget(self.count_label)

        buttons = QDialogButtonBox(
            QDialogButtonBox.StandardButton.Save
            | QDialogButtonBox.StandardButton.Cancel
        )
        buttons.button(QDialogButtonBox.StandardButton.Save).setText(
            tr("Save", "保存")
        )
        buttons.button(QDialogButtonBox.StandardButton.Cancel).setText(
            tr("Cancel", "キャンセル")
        )
        buttons.accepted.connect(self.accept)
        buttons.rejected.connect(self.reject)
        layout.addWidget(buttons)

        self.add_button.clicked.connect(self.add_genre)
        self.edit_button.clicked.connect(self.edit_genre)
        self.delete_button.clicked.connect(self.delete_genre)
        self.move_up_button.clicked.connect(lambda: self.move_selected(-1))
        self.move_down_button.clicked.connect(lambda: self.move_selected(1))
        self.genre_list.currentRowChanged.connect(self._update_controls)
        self.genre_list.model().rowsMoved.connect(self._update_controls)
        if self.genre_list.count():
            self.genre_list.setCurrentRow(0)
        self._update_controls()

    def _append_item(self, genre: str) -> None:
        item = QListWidgetItem(genre)
        item.setFlags(item.flags() | Qt.ItemFlag.ItemIsEditable)
        self.genre_list.addItem(item)

    def genres(self) -> list[str]:
        return [
            self.genre_list.item(row).text().strip()
            for row in range(self.genre_list.count())
        ]

    def _prompt_for_genre(self, title: str, value: str = "") -> str | None:
        text, accepted = QInputDialog.getText(
            self,
            title,
            tr("Genre:", "ジャンル:"),
            text=value,
        )
        if not accepted:
            return None
        genre = text.strip()
        if not genre:
            QMessageBox.warning(
                self,
                tr("Genre Suggestions", "ジャンル候補"),
                tr("Enter a genre.", "ジャンルを入力してください。"),
            )
            return None
        return genre

    def _contains(self, genre: str, ignore_row: int = -1) -> bool:
        identity = genre.casefold()
        return any(
            row != ignore_row
            and self.genre_list.item(row).text().strip().casefold()
            == identity
            for row in range(self.genre_list.count())
        )

    def _show_duplicate_warning(self) -> None:
        QMessageBox.warning(
            self,
            tr("Genre Suggestions", "ジャンル候補"),
            tr(
                "That genre is already registered.",
                "同じジャンルが既に登録されています。",
            ),
        )

    def add_genre(self) -> None:
        genre = self._prompt_for_genre(
            tr("Add Genre Suggestion", "ジャンル候補を追加")
        )
        if genre is None:
            return
        if self._contains(genre):
            self._show_duplicate_warning()
            return
        self._append_item(genre)
        self.genre_list.setCurrentRow(self.genre_list.count() - 1)
        self._update_controls()

    def edit_genre(self) -> None:
        row = self.genre_list.currentRow()
        if row < 0:
            return
        item = self.genre_list.item(row)
        genre = self._prompt_for_genre(
            tr("Edit Genre Suggestion", "ジャンル候補を編集"),
            item.text(),
        )
        if genre is None:
            return
        if self._contains(genre, ignore_row=row):
            self._show_duplicate_warning()
            return
        item.setText(genre)
        self._update_controls()

    def delete_genre(self) -> None:
        row = self.genre_list.currentRow()
        if row < 0 or self.genre_list.count() <= 1:
            return
        self.genre_list.takeItem(row)
        self.genre_list.setCurrentRow(
            min(row, self.genre_list.count() - 1)
        )
        self._update_controls()

    def move_selected(self, offset: int) -> None:
        row = self.genre_list.currentRow()
        target = row + offset
        if row < 0 or not 0 <= target < self.genre_list.count():
            return
        item = self.genre_list.takeItem(row)
        self.genre_list.insertItem(target, item)
        self.genre_list.setCurrentRow(target)
        self._update_controls()

    def _update_controls(self, *_args) -> None:
        count = self.genre_list.count()
        row = self.genre_list.currentRow()
        self.count_label.setText(
            tr(f"{count} suggestions", f"{count} 件")
        )
        self.edit_button.setEnabled(row >= 0)
        self.delete_button.setEnabled(row >= 0 and count > 1)
        self.move_up_button.setEnabled(row > 0)
        self.move_down_button.setEnabled(0 <= row < count - 1)

    def accept(self) -> None:
        genres = self.genres()
        if not genres or any(not genre for genre in genres):
            QMessageBox.warning(
                self,
                tr("Genre Suggestions", "ジャンル候補"),
                tr(
                    "Keep at least one non-empty genre suggestion.",
                    "空欄ではないジャンル候補を1件以上残してください。",
                ),
            )
            return
        if len({genre.casefold() for genre in genres}) != len(genres):
            self._show_duplicate_warning()
            return
        super().accept()


class TagColumnSettingsDialog(QDialog):
    """Choose visible tag columns, their order, and Japanese headings."""

    def __init__(
        self,
        columns: list[dict[str, str]],
        parent=None,
        *,
        bulk_tag_keys: list[str] | None = None,
    ) -> None:
        super().__init__(parent)
        self.setWindowTitle(tr("Tag Settings", "タグの設定"))
        self.setMinimumSize(820, 700)
        layout = QVBoxLayout(self)

        explanation = QLabel(
            tr(
                "Choose which tag columns are shown. Items at the top of the configured list appear on the left of the table. "
                "Filename, Format, Quality, and Duration remain as system columns.",
                "表示するタグ列を選択します。「現在表示するタグ」の上にある項目ほど、表では左側に表示されます。"
                "ファイル名・形式・音質・長さはシステム列として常に表示されます。",
            )
        )
        explanation.setWordWrap(True)
        layout.addWidget(explanation)

        lists_layout = QGridLayout()
        available_heading = QLabel(
            tr("Available Tags", "登録可能なタグ")
        )
        configured_heading = QLabel(
            tr("Displayed Tags (left to right)", "現在表示するタグ（左から順）")
        )
        available_heading.setObjectName("titleLabel")
        configured_heading.setObjectName("titleLabel")
        lists_layout.addWidget(available_heading, 0, 0)
        lists_layout.addWidget(configured_heading, 0, 2)

        self.search_input = QLineEdit()
        self.search_input.setPlaceholderText(
            tr("Filter available tags", "登録可能なタグを検索")
        )
        lists_layout.addWidget(self.search_input, 1, 0)
        lists_layout.addWidget(QWidget(), 1, 1)
        lists_layout.addWidget(QWidget(), 1, 2)

        self.available_list = QListWidget()
        self.available_list.setSelectionMode(
            QAbstractItemView.SelectionMode.SingleSelection
        )
        self.configured_list = QListWidget()
        self.configured_list.setSelectionMode(
            QAbstractItemView.SelectionMode.SingleSelection
        )
        self.configured_list.setDragDropMode(
            QAbstractItemView.DragDropMode.InternalMove
        )
        self.configured_list.setDefaultDropAction(Qt.DropAction.MoveAction)

        middle = QWidget()
        middle_layout = QVBoxLayout(middle)
        middle_layout.setContentsMargins(4, 0, 4, 0)
        middle_layout.addStretch(1)
        self.add_tag_button = QPushButton(tr("Add →", "追加 →"))
        self.remove_tag_button = QPushButton(tr("← Remove", "← 外す"))
        middle_layout.addWidget(self.add_tag_button)
        middle_layout.addWidget(self.remove_tag_button)
        middle_layout.addStretch(1)

        lists_layout.addWidget(self.available_list, 2, 0)
        lists_layout.addWidget(middle, 2, 1)
        lists_layout.addWidget(self.configured_list, 2, 2)
        lists_layout.setColumnStretch(0, 1)
        lists_layout.setColumnStretch(2, 1)
        layout.addLayout(lists_layout, 1)

        order_row = QHBoxLayout()
        order_row.addStretch(1)
        self.move_up_button = QPushButton(tr("Move Up", "上へ"))
        self.move_down_button = QPushButton(tr("Move Down", "下へ"))
        order_row.addWidget(self.move_up_button)
        order_row.addWidget(self.move_down_button)
        layout.addLayout(order_row)

        japanese_row = QFormLayout()
        self.japanese_name_input = QLineEdit()
        self.japanese_name_input.setPlaceholderText(
            tr(
                "Column name used in Japanese mode",
                "日本語モードで表示する列名",
            )
        )
        japanese_row.addRow(
            tr("Japanese column name:", "日本語の列名:"),
            self.japanese_name_input,
        )
        layout.addLayout(japanese_row)

        self.bulk_tags_group = QGroupBox(
            tr("Tags Available in Bulk Edit", "一括入力に表示するタグ")
        )
        bulk_layout = QVBoxLayout(self.bulk_tags_group)
        self.bulk_tags_note = QLabel(
            tr(
                "Select the fields shown in the Bulk Edit window. "
                "A bulk-edit tag must also be included in Displayed Tags above.",
                "一括入力ウィンドウに表示する項目を選択します。"
                "一括入力するタグは、上の「現在表示するタグ」に設定されている必要があります。",
            )
        )
        self.bulk_tags_note.setObjectName("mutedLabel")
        self.bulk_tags_note.setWordWrap(True)
        bulk_layout.addWidget(self.bulk_tags_note)
        self.bulk_tags_list = QListWidget()
        self.bulk_tags_list.setMinimumHeight(115)
        self.bulk_tags_list.setSelectionMode(
            QAbstractItemView.SelectionMode.NoSelection
        )
        bulk_layout.addWidget(self.bulk_tags_list)
        layout.addWidget(self.bulk_tags_group)

        bottom_row = QHBoxLayout()
        self.restore_defaults_button = QPushButton(
            tr("Restore Defaults", "初期設定へ戻す")
        )
        bottom_row.addWidget(self.restore_defaults_button)
        bottom_row.addStretch(1)
        buttons = QDialogButtonBox(
            QDialogButtonBox.StandardButton.Save
            | QDialogButtonBox.StandardButton.Cancel
        )
        buttons.button(QDialogButtonBox.StandardButton.Save).setText(
            tr("Save", "保存")
        )
        buttons.button(QDialogButtonBox.StandardButton.Cancel).setText(
            tr("Cancel", "キャンセル")
        )
        bottom_row.addWidget(buttons)
        layout.addLayout(bottom_row)

        normalized_columns = normalize_tag_columns(columns)
        initial_bulk_keys = normalize_bulk_tag_keys(
            bulk_tag_keys,
            normalized_columns,
        )
        for entry in normalized_columns:
            self._append_configured(entry)
        self._refresh_available()
        self._sync_bulk_tag_list(initial_bulk_keys)
        if self.configured_list.count():
            self.configured_list.setCurrentRow(0)

        self.search_input.textChanged.connect(self._refresh_available)
        self.available_list.currentItemChanged.connect(self._update_controls)
        self.add_tag_button.clicked.connect(self.add_selected_tag)
        self.remove_tag_button.clicked.connect(self.remove_selected_tag)
        self.available_list.itemDoubleClicked.connect(
            lambda _item: self.add_selected_tag()
        )
        self.configured_list.itemDoubleClicked.connect(
            lambda _item: self.remove_selected_tag()
        )
        self.move_up_button.clicked.connect(lambda: self.move_selected(-1))
        self.move_down_button.clicked.connect(lambda: self.move_selected(1))
        self.configured_list.currentRowChanged.connect(
            self._configured_selection_changed
        )
        self.configured_list.model().rowsMoved.connect(
            self._configured_rows_moved
        )
        self.japanese_name_input.textEdited.connect(
            self._japanese_name_edited
        )
        self.restore_defaults_button.clicked.connect(self.restore_defaults)
        buttons.accepted.connect(self.accept)
        buttons.rejected.connect(self.reject)
        self._configured_selection_changed(self.configured_list.currentRow())

    def _configured_keys(self) -> set[str]:
        return {
            str(self.configured_list.item(row).data(Qt.ItemDataRole.UserRole)["key"])
            for row in range(self.configured_list.count())
        }

    def _format_configured_text(self, entry: dict[str, str]) -> str:
        return f"{english_tag_name(entry['key'])}  —  {entry['ja']}"

    def _append_configured(self, entry: dict[str, str], row: int = -1) -> None:
        normalized = {
            "key": entry["key"],
            "ja": entry.get("ja", "")
            or default_japanese_tag_name(entry["key"]),
        }
        item = QListWidgetItem(self._format_configured_text(normalized))
        item.setData(Qt.ItemDataRole.UserRole, normalized)
        if row < 0:
            self.configured_list.addItem(item)
        else:
            self.configured_list.insertItem(row, item)

    def _refresh_available(self, *_args) -> None:
        selected_key = ""
        current = self.available_list.currentItem()
        if current is not None:
            selected_key = str(current.data(Qt.ItemDataRole.UserRole))
        configured = self._configured_keys()
        query = self.search_input.text().strip().casefold()
        self.available_list.clear()
        for key in TAG_KEYS:
            label = english_tag_name(key)
            if key in configured or (query and query not in label.casefold()):
                continue
            item = QListWidgetItem(label)
            item.setData(Qt.ItemDataRole.UserRole, key)
            self.available_list.addItem(item)
            if key == selected_key:
                self.available_list.setCurrentItem(item)
        self._update_controls()

    def columns(self) -> list[dict[str, str]]:
        return [
            dict(self.configured_list.item(row).data(Qt.ItemDataRole.UserRole))
            for row in range(self.configured_list.count())
        ]

    def bulk_tag_keys(self) -> list[str]:
        checked = {
            str(self.bulk_tags_list.item(row).data(Qt.ItemDataRole.UserRole))
            for row in range(self.bulk_tags_list.count())
            if self.bulk_tags_list.item(row).checkState()
            == Qt.CheckState.Checked
        }
        return normalize_bulk_tag_keys(checked, self.columns())

    def _sync_bulk_tag_list(
        self,
        checked_keys: list[str] | set[str] | None = None,
    ) -> None:
        if checked_keys is None:
            checked_keys = {
                str(self.bulk_tags_list.item(row).data(Qt.ItemDataRole.UserRole))
                for row in range(self.bulk_tags_list.count())
                if self.bulk_tags_list.item(row).checkState()
                == Qt.CheckState.Checked
            }
        checked = set(checked_keys)
        self.bulk_tags_list.clear()
        for entry in self.columns():
            item = QListWidgetItem(self._format_configured_text(entry))
            item.setData(Qt.ItemDataRole.UserRole, entry["key"])
            item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
            item.setCheckState(
                Qt.CheckState.Checked
                if entry["key"] in checked
                else Qt.CheckState.Unchecked
            )
            self.bulk_tags_list.addItem(item)

    def _configured_rows_moved(self, *_args) -> None:
        self._sync_bulk_tag_list()
        self._update_controls()

    def add_selected_tag(self) -> None:
        item = self.available_list.currentItem()
        if item is None:
            return
        key = str(item.data(Qt.ItemDataRole.UserRole))
        self._append_configured(
            {"key": key, "ja": default_japanese_tag_name(key)}
        )
        self.configured_list.setCurrentRow(self.configured_list.count() - 1)
        self._refresh_available()
        self._sync_bulk_tag_list()

    def remove_selected_tag(self) -> None:
        row = self.configured_list.currentRow()
        if row < 0:
            return
        self.configured_list.takeItem(row)
        if self.configured_list.count():
            self.configured_list.setCurrentRow(
                min(row, self.configured_list.count() - 1)
            )
        self._refresh_available()
        self._sync_bulk_tag_list()

    def move_selected(self, offset: int) -> None:
        row = self.configured_list.currentRow()
        target = row + offset
        if row < 0 or not 0 <= target < self.configured_list.count():
            return
        item = self.configured_list.takeItem(row)
        self.configured_list.insertItem(target, item)
        self.configured_list.setCurrentRow(target)
        self._sync_bulk_tag_list()
        self._update_controls()

    def _configured_selection_changed(self, row: int) -> None:
        self.japanese_name_input.blockSignals(True)
        if row < 0:
            self.japanese_name_input.clear()
        else:
            entry = self.configured_list.item(row).data(Qt.ItemDataRole.UserRole)
            self.japanese_name_input.setText(entry["ja"])
        self.japanese_name_input.blockSignals(False)
        self._update_controls()

    def _japanese_name_edited(self, text: str) -> None:
        item = self.configured_list.currentItem()
        if item is None:
            return
        entry = dict(item.data(Qt.ItemDataRole.UserRole))
        entry["ja"] = text
        item.setData(Qt.ItemDataRole.UserRole, entry)
        item.setText(self._format_configured_text(entry))
        self._sync_bulk_tag_list()

    def _update_controls(self, *_args) -> None:
        row = self.configured_list.currentRow()
        self.add_tag_button.setEnabled(self.available_list.currentItem() is not None)
        self.remove_tag_button.setEnabled(row >= 0)
        self.move_up_button.setEnabled(row > 0)
        self.move_down_button.setEnabled(
            0 <= row < self.configured_list.count() - 1
        )
        self.japanese_name_input.setEnabled(row >= 0)

    def restore_defaults(self) -> None:
        self.configured_list.clear()
        for entry in default_tag_columns():
            self._append_configured(entry)
        if self.configured_list.count():
            self.configured_list.setCurrentRow(0)
        self._refresh_available()
        self._sync_bulk_tag_list(set(DEFAULT_BULK_TAG_KEYS))

    def accept(self) -> None:
        columns = self.columns()
        if any(not entry["ja"].strip() for entry in columns):
            QMessageBox.warning(
                self,
                tr("Tag Columns", "タグ列"),
                tr(
                    "Japanese column names cannot be empty.",
                    "日本語の列名は空欄にできません。",
                ),
            )
            return
        super().accept()


class BulkEditDialog(QDialog):
    """Apply configured visible tag fields and artwork to selected tracks."""

    selectAllRequested = Signal()

    def __init__(
        self,
        columns: list[dict[str, str]],
        bulk_tag_keys: list[str],
        genre_suggestions: list[str],
        selected_count: int,
        total_count: int,
        parent=None,
    ) -> None:
        super().__init__(parent)
        self.setWindowTitle(tr("Bulk Edit", "一括入力"))
        self.setMinimumSize(700, 480)
        self._selected_count = max(0, int(selected_count))
        self._total_count = max(0, int(total_count))
        self.fields: dict[str, tuple[QCheckBox, QWidget]] = {}

        layout = QVBoxLayout(self)
        selection_row = QHBoxLayout()
        self.selection_label = QLabel()
        self.selection_label.setObjectName("titleLabel")
        self.select_all_button = QPushButton(
            tr("Select All Tracks", "すべての楽曲を選択")
        )
        selection_row.addWidget(self.selection_label)
        selection_row.addStretch(1)
        selection_row.addWidget(self.select_all_button)
        layout.addLayout(selection_row)

        scroll = QScrollArea()
        scroll.setWidgetResizable(True)
        scroll.setFrameShape(QFrame.Shape.NoFrame)
        fields_widget = QWidget()
        fields_layout = QGridLayout(fields_widget)
        fields_layout.setContentsMargins(4, 4, 4, 4)
        fields_layout.setHorizontalSpacing(10)
        fields_layout.setVerticalSpacing(7)

        configured = {
            entry["key"]: entry for entry in normalize_tag_columns(columns)
        }
        visible_bulk_keys = normalize_bulk_tag_keys(
            bulk_tag_keys,
            list(configured.values()),
        )
        for row, key in enumerate(visible_bulk_keys):
            entry = configured[key]
            checkbox = QCheckBox(
                tr(english_tag_name(key), entry["ja"])
            )
            if key == "rating wmp":
                editor = QComboBox()
                for rating in range(6):
                    editor.addItem(
                        "★" * rating + "☆" * (5 - rating),
                        rating,
                    )
                editor.activated.connect(
                    lambda _index, target=checkbox: target.setChecked(True)
                )
            elif key == "unsycedlyrics":
                editor = QPushButton(tr("Open Lyrics Editor…", "歌詞入力を開く…"))
                editor.setProperty("lyricsValue", "")
                editor.clicked.connect(
                    lambda _checked=False, target=editor, check=checkbox: self._edit_bulk_lyrics(
                        target, check
                    )
                )
            else:
                editor = (
                    GenreCompletionLineEdit()
                    if key == "genre"
                    else QLineEdit()
                )
                editor.setPlaceholderText(tr("Enter a value", "入力欄"))
                editor.textEdited.connect(
                    lambda _text, target=checkbox: target.setChecked(True)
                )
                editor.returnPressed.connect(self.accept)
                if key == "genre":
                    self._configure_genre_completer(
                        editor,
                        genre_suggestions,
                        checkbox,
                    )
            self.fields[key] = (checkbox, editor)
            fields_layout.addWidget(checkbox, row, 0)
            fields_layout.addWidget(editor, row, 1)

        artwork_row = len(visible_bulk_keys)
        self.artwork_check = QCheckBox(tr("Artwork", "ジャケット画像"))
        self.artwork_input = ArtworkPathDropEdit()
        self.artwork_input.setPlaceholderText(
            tr(
                "Drop a JPEG / PNG image here",
                "JPEG / PNG画像をここへドロップ",
            )
        )
        self.artwork_browse_button = QPushButton(tr("Browse…", "参照…"))
        fields_layout.addWidget(self.artwork_check, artwork_row, 0)
        fields_layout.addWidget(self.artwork_input, artwork_row, 1)
        fields_layout.addWidget(self.artwork_browse_button, artwork_row, 2)
        fields_layout.setColumnStretch(1, 1)
        fields_layout.setRowStretch(artwork_row + 1, 1)
        scroll.setWidget(fields_widget)
        layout.addWidget(scroll, 1)

        self.note_label = QLabel(
            tr(
                "Typing or dropping an image checks that field automatically. "
                "Checked text fields overwrite selected tracks, including with a blank value. "
                "Artwork is saved immediately.",
                "入力や画像ドロップで自動的にチェックが入ります。"
                "チェック済みの文字項目は、空欄も含めて選択曲を上書きします。"
                "ジャケット画像は即時保存されます。",
            )
        )
        self.note_label.setObjectName("mutedLabel")
        self.note_label.setWordWrap(True)
        layout.addWidget(self.note_label)

        buttons = QDialogButtonBox(
            QDialogButtonBox.StandardButton.Ok
            | QDialogButtonBox.StandardButton.Cancel
        )
        buttons.button(QDialogButtonBox.StandardButton.Ok).setText(
            tr("Apply to Selected Tracks", "選択曲へ一括反映")
        )
        buttons.button(QDialogButtonBox.StandardButton.Cancel).setText(
            tr("Cancel", "キャンセル")
        )
        buttons.accepted.connect(self.accept)
        buttons.rejected.connect(self.reject)
        layout.addWidget(buttons)

        self.select_all_button.clicked.connect(self.selectAllRequested.emit)
        self.artwork_input.imageDropped.connect(
            self._artwork_path_selected
        )
        self.artwork_browse_button.clicked.connect(self.select_artwork)
        self._update_selection_label()

    @staticmethod
    def _configure_genre_completer(
        editor: GenreCompletionLineEdit,
        suggestions: list[str],
        checkbox: QCheckBox,
    ) -> None:
        completer = QCompleter(list(suggestions), editor)
        completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
        completer.setFilterMode(Qt.MatchFlag.MatchStartsWith)
        completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion)
        completer.setWrapAround(False)
        completer.setMaxVisibleItems(12)
        completer.activated[str].connect(
            lambda _genre: checkbox.setChecked(True)
        )
        editor.setCompleter(completer)

    def _update_selection_label(self) -> None:
        self.selection_label.setText(
            tr(
                f"Selected: {self._selected_count} / {self._total_count} tracks",
                f"選択中: {self._selected_count} / {self._total_count} 曲",
            )
        )

    def set_selected_count(self, count: int) -> None:
        self._selected_count = max(0, int(count))
        self._update_selection_label()

    def metadata_values(self) -> dict[str, object]:
        values: dict[str, object] = {}
        for key, (checkbox, editor) in self.fields.items():
            if not checkbox.isChecked():
                continue
            values[key] = (
                editor.currentData()
                if isinstance(editor, QComboBox)
                else str(editor.property("lyricsValue") or "")
                if key == "unsycedlyrics"
                else editor.text()
            )
        return values

    def _edit_bulk_lyrics(
        self, editor: QPushButton, checkbox: QCheckBox
    ) -> None:
        current = str(editor.property("lyricsValue") or "")
        dialog = LyricsDialog(
            tr("Selected Tracks", "選択した楽曲"),
            current,
            editable=True,
            parent=self,
        )
        if dialog.exec() != QDialog.DialogCode.Accepted:
            return
        value = dialog.lyrics()
        editor.setProperty("lyricsValue", value)
        editor.setText(
            tr("Lyrics entered — Edit…", "歌詞入力済み・編集…")
            if value
            else tr("Blank lyrics — Edit…", "空の歌詞・編集…")
        )
        checkbox.setChecked(True)

    def artwork_requested(self) -> bool:
        return self.artwork_check.isChecked()

    def artwork_path(self) -> str:
        return self.artwork_input.text().strip()

    def _artwork_path_selected(self, image_path: str) -> None:
        if image_path:
            self.artwork_input.setText(image_path)
            self.artwork_check.setChecked(True)

    def select_artwork(self) -> None:
        image_path, _ = QFileDialog.getOpenFileName(
            self,
            tr("Select Artwork Image", "ジャケット画像を選択"),
            "",
            tr(
                "Images (*.jpg *.jpeg *.png);;All files (*.*)",
                "画像 (*.jpg *.jpeg *.png);;すべてのファイル (*.*)",
            ),
        )
        if image_path:
            self._artwork_path_selected(image_path)

    def accept(self) -> None:
        if self._selected_count <= 0:
            QMessageBox.information(
                self,
                tr("Bulk Edit", "一括入力"),
                tr(
                    "Select tracks to update, or click Select All Tracks.",
                    "変更する楽曲を選択するか、「すべての楽曲を選択」を押してください。",
                ),
            )
            return
        if not self.metadata_values() and not self.artwork_requested():
            QMessageBox.information(
                self,
                tr("Bulk Edit", "一括入力"),
                tr(
                    "Check at least one bulk-edit field.",
                    "一括入力する項目にチェックを入れてください。",
                ),
            )
            return
        if self.artwork_requested():
            preview = QPixmap(self.artwork_path())
            if not self.artwork_path() or preview.isNull():
                QMessageBox.information(
                    self,
                    tr("Bulk Artwork", "ジャケット画像の一括入力"),
                    tr(
                        "Drop or select a JPEG / PNG image first.",
                        "先にJPEG / PNG画像をドロップまたは選択してください。",
                    ),
                )
                return
        super().accept()


class MarqueeLabel(QLabel):
    """Center short text and slowly loop overflowing text from right to left."""

    TIMER_INTERVAL_MS = 40
    PIXELS_PER_TICK = 0.65
    LOOP_GAP = 48
    PAUSE_TICKS = 25

    def __init__(self, text: str = "", parent=None) -> None:
        super().__init__(text, parent)
        self._offset = 0.0
        self._pause_ticks = self.PAUSE_TICKS
        self._timer = QTimer(self)
        self._timer.setInterval(self.TIMER_INTERVAL_MS)
        self._timer.timeout.connect(self._advance)
        self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
        self.setSizePolicy(
            QSizePolicy.Policy.Ignored,
            QSizePolicy.Policy.Fixed,
        )
        self.setMinimumWidth(0)
        self.setMinimumHeight(28)
        self.setToolTip(text)

    def setText(self, text: str) -> None:
        super().setText(text)
        self.setToolTip(text)
        self._offset = 0.0
        self._pause_ticks = self.PAUSE_TICKS
        self._sync_animation()
        self.update()

    def _text_width(self) -> int:
        return self.fontMetrics().horizontalAdvance(self.text())

    def _content_rect(self):
        return self.rect().adjusted(8, 1, -8, -1)

    def is_overflowing(self) -> bool:
        return self._text_width() > max(0, self._content_rect().width())

    @property
    def scroll_offset(self) -> float:
        return self._offset

    def _sync_animation(self) -> None:
        if self.isVisible() and self.is_overflowing():
            self._timer.start()
            return
        self._timer.stop()
        if not self.is_overflowing():
            self._offset = 0.0

    def _advance(self) -> None:
        if not self.is_overflowing():
            self._sync_animation()
            self.update()
            return
        if self._pause_ticks:
            self._pause_ticks -= 1
            return
        cycle_width = self._text_width() + self.LOOP_GAP
        self._offset += self.PIXELS_PER_TICK
        if self._offset >= cycle_width:
            self._offset %= cycle_width
            self._pause_ticks = self.PAUSE_TICKS
        self.update()

    def resizeEvent(self, event) -> None:
        super().resizeEvent(event)
        self._sync_animation()

    def showEvent(self, event) -> None:
        super().showEvent(event)
        self._sync_animation()

    def hideEvent(self, event) -> None:
        self._timer.stop()
        super().hideEvent(event)

    def paintEvent(self, event) -> None:
        del event
        painter = QPainter(self)
        option = QStyleOption()
        option.initFrom(self)
        self.style().drawPrimitive(
            QStyle.PrimitiveElement.PE_Widget,
            option,
            painter,
            self,
        )
        content = self._content_rect()
        if content.width() <= 0 or not self.text():
            return
        painter.setClipRect(content)
        painter.setFont(self.font())
        painter.setPen(self.palette().color(self.foregroundRole()))
        flags = (
            Qt.AlignmentFlag.AlignVCenter
            | Qt.AlignmentFlag.AlignLeft
            | Qt.TextFlag.TextSingleLine
        )
        text_width = self._text_width()
        if text_width <= content.width():
            painter.drawText(
                QRectF(content),
                Qt.AlignmentFlag.AlignCenter | Qt.TextFlag.TextSingleLine,
                self.text(),
            )
            return
        first_x = content.left() - self._offset
        for x in (first_x, first_x + text_width + self.LOOP_GAP):
            painter.drawText(
                QRectF(x, content.top(), text_width, content.height()),
                flags,
                self.text(),
            )


class VerticalScrollArea(QScrollArea):
    """Keep content fitted to the viewport while scrolling only vertically."""

    def __init__(self, parent=None) -> None:
        super().__init__(parent)
        self.verticalScrollBar().rangeChanged.connect(
            lambda _minimum, _maximum: QTimer.singleShot(
                0, self._fit_widget_width
            )
        )

    def setWidget(self, widget: QWidget) -> None:
        super().setWidget(widget)
        self._fit_widget_width()

    def resizeEvent(self, event) -> None:
        super().resizeEvent(event)
        self._fit_widget_width()
        QTimer.singleShot(0, self._fit_widget_width)

    def _fit_widget_width(self) -> None:
        content = self.widget()
        if content is None:
            return
        width = max(0, self.viewport().width())
        if content.width() != width:
            content.setFixedWidth(width)


class ArtworkDropLabel(QLabel):
    imageDropped = Signal(str)
    SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png"}

    def __init__(self, parent=None) -> None:
        super().__init__(parent)
        self.editing_enabled = False
        self._drag_start_position = None
        self._artwork_bytes = b""
        self._artwork_mime = ""
        self._artwork_source_name = ""
        self._export_directory = QTemporaryDir()
        self.setObjectName("artworkDropLabel")
        self.setProperty("dropActive", False)
        self.setAcceptDrops(True)
        self.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self.retranslate()
        self.show_placeholder()

    def _set_drop_active(self, active: bool) -> None:
        self.setProperty("dropActive", bool(active))
        self.style().unpolish(self)
        self.style().polish(self)
        self.update()

    def retranslate(self) -> None:
        if self._artwork_bytes:
            tooltip = tr(
                "Drag this artwork to a folder to extract the original image. "
                + (
                    "Drop a JPEG or PNG image here to replace it."
                    if self.editing_enabled
                    else "Enable Edit Mode to replace it."
                ),
                "この画像をフォルダーへドラッグすると元画像を無劣化で取り出せます。"
                + (
                    "JPEGまたはPNG画像をここへドロップすると置き換えます。"
                    if self.editing_enabled
                    else "画像を置き換えるには編集モードを有効にします。"
                ),
            )
        else:
            tooltip = tr(
                "Select tracks in the playlist, then drop a JPEG or PNG image here."
                if self.editing_enabled
                else "Enable Edit Mode to change artwork.",
                "プレイリストで曲を選択し、JPEGまたはPNG画像をここへドロップします。"
                if self.editing_enabled
                else "画像を変更するには編集モードを有効にします。",
            )
        self.setToolTip(tooltip)
        pixmap = self.pixmap()
        if pixmap is None or pixmap.isNull():
            self.show_placeholder()

    def set_editing_enabled(self, enabled: bool) -> None:
        self.editing_enabled = enabled
        self.setAcceptDrops(enabled)
        self.retranslate()
        pixmap = self.pixmap()
        if pixmap is None or pixmap.isNull():
            self.show_placeholder()

    @classmethod
    def image_path_from_mime(cls, mime_data) -> str | None:
        if not mime_data.hasUrls():
            return None
        for url in mime_data.urls():
            if not url.isLocalFile():
                continue
            path = Path(url.toLocalFile())
            if path.is_file() and path.suffix.lower() in cls.SUPPORTED_EXTENSIONS:
                return str(path.resolve())
        return None

    def show_placeholder(self) -> None:
        self._drag_start_position = None
        self._artwork_bytes = b""
        self._artwork_mime = ""
        self._artwork_source_name = ""
        self.setCursor(Qt.CursorShape.ArrowCursor)
        self.setPixmap(QPixmap())
        instruction = (
            tr("Drop JPEG / PNG here", "JPEG / PNGをここへドロップ")
            if self.editing_enabled
            else tr(
                "Enable Edit Mode to change artwork",
                "編集モードで画像を変更",
            )
        )
        self.setText(f"NO ARTWORK\n\n{instruction}")

    def show_embedded_artwork(
        self,
        pixmap: QPixmap,
        data: bytes,
        mime: str,
        source_name: str,
    ) -> None:
        """Display artwork while retaining its untouched embedded bytes."""

        self._artwork_bytes = bytes(data)
        self._artwork_mime = str(mime or "")
        self._artwork_source_name = str(source_name or "artwork")
        self.setText("")
        self.setPixmap(
            pixmap.scaled(
                self.contentsRect().size(),
                Qt.AspectRatioMode.KeepAspectRatio,
                Qt.TransformationMode.SmoothTransformation,
            )
        )
        self.setCursor(Qt.CursorShape.OpenHandCursor)
        self.retranslate()

    def _prepare_artwork_export_file(self) -> Path | None:
        if not self._artwork_bytes or not self._export_directory.isValid():
            return None
        source = Path(self._artwork_source_name)
        stem = source.stem.strip() or "artwork"
        extension = artwork_file_extension(
            self._artwork_bytes,
            self._artwork_mime,
        )
        export_path = Path(self._export_directory.path()) / f"{stem}{extension}"
        try:
            export_path.write_bytes(self._artwork_bytes)
        except OSError:
            return None
        return export_path

    def _start_artwork_drag(self) -> bool:
        export_path = self._prepare_artwork_export_file()
        if export_path is None:
            return False
        mime_data = QMimeData()
        mime_data.setUrls([QUrl.fromLocalFile(str(export_path))])
        drag = QDrag(self)
        drag.setMimeData(mime_data)
        preview = self.pixmap()
        if preview is not None and not preview.isNull():
            drag.setPixmap(
                preview.scaled(
                    QSize(96, 96),
                    Qt.AspectRatioMode.KeepAspectRatio,
                    Qt.TransformationMode.SmoothTransformation,
                )
            )
        drag.exec(Qt.DropAction.CopyAction)
        return True

    def mousePressEvent(self, event) -> None:
        if event.button() == Qt.MouseButton.LeftButton and self._artwork_bytes:
            self._drag_start_position = event.position().toPoint()
        else:
            self._drag_start_position = None
        super().mousePressEvent(event)

    def mouseMoveEvent(self, event) -> None:
        if (
            self._drag_start_position is not None
            and event.buttons() & Qt.MouseButton.LeftButton
            and (
                event.position().toPoint() - self._drag_start_position
            ).manhattanLength()
            >= QApplication.startDragDistance()
        ):
            self._drag_start_position = None
            if self._start_artwork_drag():
                event.accept()
                return
        super().mouseMoveEvent(event)

    def mouseReleaseEvent(self, event) -> None:
        self._drag_start_position = None
        super().mouseReleaseEvent(event)

    def dragEnterEvent(self, event) -> None:
        if self.image_path_from_mime(event.mimeData()):
            self._set_drop_active(True)
            event.acceptProposedAction()
        else:
            event.ignore()

    def dragMoveEvent(self, event) -> None:
        if self.image_path_from_mime(event.mimeData()):
            event.acceptProposedAction()
        else:
            event.ignore()

    def dragLeaveEvent(self, event) -> None:
        self._set_drop_active(False)
        super().dragLeaveEvent(event)

    def dropEvent(self, event) -> None:
        self._set_drop_active(False)
        image_path = self.image_path_from_mime(event.mimeData())
        if not image_path:
            event.ignore()
            return
        self.imageDropped.emit(image_path)
        event.acceptProposedAction()


class ArtworkPathDropEdit(QLineEdit):
    imageDropped = Signal(str)

    def __init__(self, parent=None) -> None:
        super().__init__(parent)
        self.setAcceptDrops(True)
        self.setReadOnly(True)
        self.setClearButtonEnabled(True)

    def dragEnterEvent(self, event) -> None:
        if ArtworkDropLabel.image_path_from_mime(event.mimeData()):
            event.acceptProposedAction()
        else:
            event.ignore()

    def dragMoveEvent(self, event) -> None:
        if ArtworkDropLabel.image_path_from_mime(event.mimeData()):
            event.acceptProposedAction()
        else:
            event.ignore()

    def dropEvent(self, event) -> None:
        image_path = ArtworkDropLabel.image_path_from_mime(event.mimeData())
        if not image_path:
            event.ignore()
            return
        self.setText(image_path)
        self.imageDropped.emit(image_path)
        event.acceptProposedAction()


class PlaylistListWidget(QListWidget):
    """Playlist list that accepts copies of rows dragged from the track table."""

    tracksDropped = Signal(str, list)

    def __init__(self, parent=None) -> None:
        super().__init__(parent)
        self.setAcceptDrops(True)
        self.viewport().setAcceptDrops(True)
        self.setDragDropMode(QAbstractItemView.DragDropMode.DropOnly)
        self.setDefaultDropAction(Qt.DropAction.CopyAction)
        self.setDropIndicatorShown(False)
        self._drop_row = -1

    def _set_drop_row(self, row: int) -> None:
        if row == self._drop_row:
            return
        self._drop_row = row
        self.viewport().update()

    def dragEnterEvent(self, event) -> None:
        if event.mimeData().hasFormat(PlaylistModel.INTERNAL_MIME):
            event.setDropAction(Qt.DropAction.CopyAction)
            event.accept()
            return
        event.ignore()

    def dragMoveEvent(self, event) -> None:
        item = self.itemAt(event.position().toPoint())
        if (
            event.mimeData().hasFormat(PlaylistModel.INTERNAL_MIME)
            and item is not None
        ):
            self._set_drop_row(self.row(item))
            event.setDropAction(Qt.DropAction.CopyAction)
            event.accept()
            return
        self._set_drop_row(-1)
        event.ignore()

    def dragLeaveEvent(self, event) -> None:
        self._set_drop_row(-1)
        event.accept()

    def dropEvent(self, event) -> None:
        item = self.itemAt(event.position().toPoint())
        try:
            rows = sorted(
                {
                    int(row)
                    for row in json.loads(
                        bytes(
                            event.mimeData().data(
                                PlaylistModel.INTERNAL_MIME
                            )
                        ).decode("utf-8")
                    )
                    if int(row) >= 0
                }
            )
        except (TypeError, ValueError, UnicodeDecodeError, json.JSONDecodeError):
            rows = []
        self._set_drop_row(-1)
        if item is None or not rows:
            event.ignore()
            return
        playlist_name = str(item.data(Qt.ItemDataRole.UserRole) or "")
        if not playlist_name:
            event.ignore()
            return
        self.tracksDropped.emit(playlist_name, rows)
        event.setDropAction(Qt.DropAction.CopyAction)
        event.accept()

    def paintEvent(self, event) -> None:
        super().paintEvent(event)
        item = self.item(self._drop_row) if self._drop_row >= 0 else None
        if item is None:
            return
        rect = self.visualItemRect(item).adjusted(1, 1, -2, -2)
        painter = QPainter(self.viewport())
        color = self.palette().color(QPalette.ColorRole.Highlight)
        fill = QColor(color)
        fill.setAlpha(55)
        painter.fillRect(rect, fill)
        painter.setPen(QPen(color, 2))
        painter.drawRect(rect)


class MainWindow(QMainWindow):
    PLAYBACK_QUEUE = "再生キュー"

    def __init__(self, settings=None) -> None:
        super().__init__()
        self.trial_edition = is_trial_edition()
        self.product_name = product_name()
        self.setWindowTitle(self.product_name)
        self.setWindowIcon(application_icon())
        self.setMinimumSize(980, 640)
        self.resize(1420, 840)

        self.settings = settings or QSettings()
        self.language = set_language(
            normalize_language(self.settings.value("ui/language", "en"))
        )
        self.store = PlaylistStore(persistent=not self.trial_edition)
        self.active_playlist = self.PLAYBACK_QUEUE
        self.current_row = -1
        self.current_track: Track | None = None
        self.edit_mode = False
        self._lyrics_viewer: LyricsDialog | None = None
        self._synced_lyrics_viewer: SyncedLyricsViewer | None = None
        self._edit_checkpoints: dict[int, dict[str, object]] = {}
        self.theme = "dark"
        self.title_suffixes = list(DEFAULT_TITLE_SUFFIXES)
        self.title_suffix_insert_space = False
        self.genre_suggestions = (
            [] if self.trial_edition else list(DEFAULT_GENRE_SUGGESTIONS)
        )
        self.tag_columns = default_tag_columns()
        self.bulk_tag_keys = list(DEFAULT_BULK_TAG_KEYS)
        self.visualizer_mode = "waveform"
        self._shared_visualizer_samples = None
        self._shared_visualizer_sample_rate = 0
        self._slider_dragging = False
        self._loading_playlist = False
        self._audio_devices = []
        self._exclusive_devices: list[dict] = []
        self.playback_order: PlaybackOrder[Track] = PlaybackOrder()
        self._handling_track_end = False
        self._cue_segment_end_pending = False
        self._preloaded_track: Track | None = None
        self._pending_playback_ratings: dict[int, Track] = {}

        self.model = PlaylistModel(self.tag_columns)
        self.media_devices = QMediaDevices(self)
        self.player = QMediaPlayer(self)
        self.audio_output = QAudioOutput(self)
        self.audio_output.setVolume(0.8)
        self.player.setAudioOutput(self.audio_output)
        self.audio_buffer_output = QAudioBufferOutput(self)
        self.player.setAudioBufferOutput(self.audio_buffer_output)
        self.preload_player = QMediaPlayer(self)
        self.preload_audio_output = QAudioOutput(self)
        self.preload_audio_output.setVolume(0.8)
        self.preload_player.setAudioOutput(self.preload_audio_output)
        self.preload_audio_buffer_output = QAudioBufferOutput(self)
        self.preload_player.setAudioBufferOutput(
            self.preload_audio_buffer_output
        )
        self.exclusive_player = ExclusivePlayer(self)

        self._build_actions()
        self._build_ui()
        self._visualizer_timer = QTimer(self)
        self._visualizer_timer.setInterval(65)
        self._visualizer_timer.timeout.connect(self._update_visualizer)
        self._connect_signals()
        self.retranslate_ui(announce=False)
        self.set_edit_mode(False, announce=False)
        self._restore_state()
        self._refresh_audio_devices()
        self._populate_playlist_list()
        self._load_playlist(self.active_playlist)

    # ---------- UI construction ----------

    def _build_actions(self) -> None:
        self.add_files_action = QAction("ファイルを追加…", self)
        self.add_files_action.setShortcut(QKeySequence.StandardKey.Open)
        self.add_folder_action = QAction("フォルダーを追加…", self)
        self.import_playlist_action = QAction(
            "M3U/M3U8プレイリストを読み込む…", self
        )
        self.export_playlist_action = QAction("M3U/M3U8で保存…", self)
        self.quit_action = QAction("終了", self)
        self.quit_action.setShortcut(QKeySequence.StandardKey.Quit)

        self.play_pause_action = QAction("再生 / 一時停止", self)
        self.play_pause_action.setShortcuts(
            [QKeySequence("Space"), QKeySequence("Ctrl+Space")]
        )
        self.play_pause_action.setShortcutContext(
            Qt.ShortcutContext.WindowShortcut
        )
        self.stop_action = QAction("停止", self)
        self.stop_action.setShortcut(QKeySequence("Ctrl+."))
        self.previous_action = QAction("前の曲", self)
        self.next_action = QAction("次の曲", self)
        self.fullscreen_action = QAction("全画面表示", self)
        self.fullscreen_action.setShortcut(Qt.Key.Key_F11)
        self.undo_action = QAction("元に戻す", self)
        self.undo_action.setShortcut(QKeySequence.StandardKey.Undo)
        self.save_tags_action = QAction("選択曲のタグを保存", self)
        self.save_tags_action.setShortcut(QKeySequence.StandardKey.Save)
        self.auto_number_action = QAction("Disc / Trackを自動連番…", self)
        self.auto_number_action.setShortcut(QKeySequence("Ctrl+Shift+N"))
        self.title_suffix_action = QAction("タイトル接尾辞を付加…", self)
        self.title_suffix_action.setShortcut(QKeySequence("Ctrl+Shift+T"))
        self.artwork_action = QAction("選択曲にアートワークを埋め込む…", self)
        self.remove_artwork_action = QAction(
            "選択曲のアートワークを削除…", self
        )
        self.edit_mode_action = QAction("編集モード", self)
        self.edit_mode_action.setCheckable(True)
        self.edit_mode_action.setShortcut(QKeySequence("Ctrl+E"))
        self.filename_pattern_group = QActionGroup(self)
        self.filename_pattern_group.setExclusive(True)
        self.filename_pattern_actions: dict[str, QAction] = {}
        for pattern, label in (
            ("off", "自動変更しない"),
            ("disc_track_title", "「D_T_タイトル」へ自動変更"),
            ("track_title", "「T_タイトル」へ自動変更"),
        ):
            action = QAction(label, self)
            action.setCheckable(True)
            action.setData(pattern)
            self.filename_pattern_group.addAction(action)
            self.filename_pattern_actions[pattern] = action
        self.filename_pattern_actions["off"].setChecked(True)
        # Kept as an internal compatibility alias for older integrations.
        self.auto_rename_files_action = self.filename_pattern_actions[
            "track_title"
        ]
        self.title_suffix_settings_action = QAction(
            "タイトル接尾辞の設定…", self
        )
        self.genre_settings_action = QAction("ジャンル候補の設定…", self)
        self.tag_columns_settings_action = QAction("タグの設定…", self)
        self.theme_group = QActionGroup(self)
        self.theme_group.setExclusive(True)
        self.theme_actions: dict[str, QAction] = {}
        for theme_key, label in (
            ("dark", "Dark (Default)"),
            ("white", "White"),
            ("blue", "Blue"),
            ("pink", "Pink"),
            ("yellow", "Yellow"),
        ):
            action = QAction(label, self)
            action.setCheckable(True)
            action.setData(theme_key)
            self.theme_group.addAction(action)
            self.theme_actions[theme_key] = action
        self.theme_actions["dark"].setChecked(True)
        self.visualizer_group = QActionGroup(self)
        self.visualizer_group.setExclusive(True)
        self.visualizer_actions: dict[str, QAction] = {}
        for mode, label in (
            ("off", "Hidden"),
            ("spectrum", "Frequency Spectrum (Low → High)"),
            ("waveform", "Oscilloscope (Time Waveform)"),
        ):
            action = QAction(label, self)
            action.setCheckable(True)
            action.setData(mode)
            self.visualizer_group.addAction(action)
            self.visualizer_actions[mode] = action
        self.visualizer_actions["waveform"].setChecked(True)
        self.about_action = QAction("About MusicGridStudio…", self)
        self.apply_update_action = QAction("Apply Update Package…", self)
        self.apply_update_action.setEnabled(updater_is_available())
        self.language_group = QActionGroup(self)
        self.language_group.setExclusive(True)
        self.english_language_action = QAction("English", self)
        self.english_language_action.setCheckable(True)
        self.english_language_action.setData("en")
        self.japanese_language_action = QAction("Japanese", self)
        self.japanese_language_action.setCheckable(True)
        self.japanese_language_action.setData("ja")
        self.language_group.addAction(self.english_language_action)
        self.language_group.addAction(self.japanese_language_action)
        (
            self.japanese_language_action
            if self.language == "ja"
            else self.english_language_action
        ).setChecked(True)

        self.file_menu = self.menuBar().addMenu("ファイル")
        self.file_menu.addAction(self.add_files_action)
        self.file_menu.addAction(self.add_folder_action)
        self.file_menu.addSeparator()
        self.file_menu.addAction(self.import_playlist_action)
        self.file_menu.addAction(self.export_playlist_action)
        self.file_menu.addSeparator()
        self.file_menu.addAction(self.quit_action)

        self.edit_menu = self.menuBar().addMenu("編集")
        self.edit_menu.addAction(self.undo_action)

        self.playback_menu = self.menuBar().addMenu("再生")
        self.playback_menu.addAction(self.play_pause_action)
        self.playback_menu.addAction(self.stop_action)
        self.playback_menu.addAction(self.previous_action)
        self.playback_menu.addAction(self.next_action)

        self.tag_menu = self.menuBar().addMenu("タグ")
        self.tag_menu.addAction(self.auto_number_action)
        self.tag_menu.addAction(self.title_suffix_action)
        self.tag_menu.addSeparator()
        self.tag_menu.addAction(self.save_tags_action)
        self.tag_menu.addAction(self.artwork_action)
        self.tag_menu.addAction(self.remove_artwork_action)

        self.view_menu = self.menuBar().addMenu("表示")
        self.view_menu.addAction(self.edit_mode_action)
        self.view_menu.addSeparator()
        self.view_menu.addAction(self.fullscreen_action)

        self.settings_menu = self.menuBar().addMenu("Settings")
        self.language_menu = self.settings_menu.addMenu("Language")
        self.language_menu.addAction(self.english_language_action)
        self.language_menu.addAction(self.japanese_language_action)
        self.theme_menu = self.settings_menu.addMenu("Theme")
        for action in self.theme_actions.values():
            self.theme_menu.addAction(action)
        self.visualizer_menu = self.settings_menu.addMenu(
            "Seek Bar Visualizer"
        )
        for action in self.visualizer_actions.values():
            self.visualizer_menu.addAction(action)
        self.settings_menu.addSeparator()
        self.settings_menu.addAction(self.title_suffix_settings_action)
        self.settings_menu.addAction(self.genre_settings_action)
        self.settings_menu.addAction(self.tag_columns_settings_action)
        self.filename_pattern_menu = self.settings_menu.addMenu(
            "ファイル名の自動変更"
        )
        for action in self.filename_pattern_actions.values():
            self.filename_pattern_menu.addAction(action)

        if self.trial_edition:
            self.import_playlist_action.setVisible(False)
            self.export_playlist_action.setVisible(False)
            self.theme_menu.menuAction().setVisible(False)
            self.title_suffix_settings_action.setVisible(False)
            self.genre_settings_action.setVisible(False)
            self.tag_columns_settings_action.setVisible(False)
            self.filename_pattern_menu.menuAction().setVisible(False)

        self.information_menu = self.menuBar().addMenu("Information")
        self.information_menu.addAction(self.apply_update_action)
        self.information_menu.addSeparator()
        self.information_menu.addAction(self.about_action)

    def _build_ui(self) -> None:
        central = BackgroundCanvas()
        self.background_canvas = central
        root_layout = QVBoxLayout(central)
        root_layout.setContentsMargins(0, 0, 0, 0)
        root_layout.setSpacing(0)
        self.setCentralWidget(central)

        top_bar = QWidget()
        top_bar.setObjectName("topBar")
        top_layout = QHBoxLayout(top_bar)
        top_layout.setContentsMargins(14, 10, 14, 10)
        self.app_title = QLabel(
            "Music Grid Studio Trial" if self.trial_edition else "Music Grid Studio"
        )
        self.app_title.setObjectName("titleLabel")
        top_layout.addWidget(self.app_title)
        top_layout.addStretch()
        self.cancel_edit_button = QPushButton("編集をキャンセル")
        self.cancel_edit_button.setObjectName("dangerButton")
        self.cancel_edit_button.setVisible(False)
        self.bulk_edit_button = QPushButton("一括入力")
        self.bulk_edit_button.setVisible(False)
        self.add_button = QPushButton("＋ 音楽を追加")
        self.fit_columns_button = QPushButton("全列幅を調整")
        self.fit_columns_button.setToolTip(
            "すべての列幅を、その列で最も長い内容に合わせます。"
        )
        self.edit_mode_button = QPushButton("編集モード")
        self.edit_mode_button.setObjectName("editModeButton")
        self.edit_mode_button.setCheckable(True)
        self.edit_mode_button.setToolTip("再生を停止し、セル編集を有効にします（Ctrl+E）")
        self.auto_number_button = QPushButton("Disc / Track 自動連番")
        self.title_suffix_button = QPushButton("タイトル接尾辞")
        self.save_tags_button = QPushButton("タグを保存")
        self.fullscreen_button = QPushButton("全画面  F11")
        self.synced_lyrics_button = QPushButton("同期歌詞")
        for button in (
            self.cancel_edit_button,
            self.bulk_edit_button,
            self.add_button,
            self.fit_columns_button,
            self.synced_lyrics_button,
            self.edit_mode_button,
            self.auto_number_button,
            self.title_suffix_button,
            self.save_tags_button,
            self.fullscreen_button,
        ):
            button.setMinimumHeight(32)
        top_layout.addWidget(self.cancel_edit_button)
        top_layout.addWidget(self.bulk_edit_button)
        top_layout.addWidget(self.add_button)
        top_layout.addWidget(self.fit_columns_button)
        top_layout.addWidget(self.synced_lyrics_button)
        top_layout.addWidget(self.edit_mode_button)
        top_layout.addWidget(self.auto_number_button)
        top_layout.addWidget(self.title_suffix_button)
        top_layout.addWidget(self.save_tags_button)
        top_layout.addWidget(self.fullscreen_button)
        root_layout.addWidget(top_bar)

        splitter = QSplitter(Qt.Orientation.Horizontal)
        splitter.setChildrenCollapsible(False)
        splitter.addWidget(self._create_playlist_panel())
        splitter.addWidget(self._create_table_panel())
        splitter.addWidget(self._create_now_playing_panel())
        splitter.setSizes([205, 925, 290])
        root_layout.addWidget(splitter, 1)
        root_layout.addWidget(self._create_transport_bar())

    def _create_playlist_panel(self) -> QWidget:
        panel = QWidget()
        panel.setObjectName("playlistPanel")
        panel.setMinimumWidth(170)
        layout = QVBoxLayout(panel)
        layout.setContentsMargins(10, 12, 8, 10)
        self.playlist_heading = QLabel("プレイリスト")
        self.playlist_heading.setObjectName("titleLabel")
        layout.addWidget(self.playlist_heading)
        self.playlist_list = PlaylistListWidget()
        self.playlist_list.setSelectionMode(
            QAbstractItemView.SelectionMode.SingleSelection
        )
        layout.addWidget(self.playlist_list, 1)
        button_layout = QHBoxLayout()
        self.new_playlist_button = QToolButton()
        self.new_playlist_button.setText("＋ 新規")
        self.remove_playlist_button = QToolButton()
        self.remove_playlist_button.setText("－ 削除")
        button_layout.addWidget(self.new_playlist_button)
        button_layout.addWidget(self.remove_playlist_button)
        layout.addLayout(button_layout)
        self.rename_playlist_button = QToolButton()
        self.rename_playlist_button.setSizePolicy(
            QSizePolicy.Policy.Expanding,
            QSizePolicy.Policy.Fixed,
        )
        self.rename_playlist_button.setText("名称変更")
        layout.addWidget(self.rename_playlist_button)
        self.clear_playlist_button = QToolButton()
        self.clear_playlist_button.setObjectName("dangerButton")
        self.clear_playlist_button.setSizePolicy(
            QSizePolicy.Policy.Expanding,
            QSizePolicy.Policy.Fixed,
        )
        self.clear_playlist_button.setText("× 曲を全消去")
        layout.addWidget(self.clear_playlist_button)
        self.playlist_help = QLabel("曲やフォルダーを中央の一覧へ\nドロップして追加できます。")
        self.playlist_help.setWordWrap(True)
        self.playlist_help.setObjectName("mutedLabel")
        layout.addWidget(self.playlist_help)
        return panel

    def _create_table_panel(self) -> QWidget:
        panel = QWidget()
        panel.setObjectName("tablePanel")
        layout = QVBoxLayout(panel)
        layout.setContentsMargins(4, 0, 4, 0)
        self.table = SpreadsheetTableView()
        self.table.setHorizontalHeader(
            ColumnReorderHeader(Qt.Orientation.Horizontal, self.table)
        )
        self.table.setModel(self.model)
        self.table.horizontalHeader().setSortIndicator(
            0, Qt.SortOrder.AscendingOrder
        )
        self.table.setSortingEnabled(True)
        self.table.set_editing_enabled(False)
        self.table.setDragDropOverwriteMode(False)
        self.table.verticalHeader().setDefaultSectionSize(29)
        self.table.verticalHeader().setVisible(False)
        self.table.horizontalHeader().setStretchLastSection(False)
        self.table.horizontalHeader().setSectionsMovable(True)
        self._apply_table_column_widths()
        layout.addWidget(self.table)
        self.table_hint = QLabel(
            "再生モード: ダブルクリックで再生　／　列見出しで並べ替え　／　Ctrl+Eで編集モード"
        )
        self.table_hint.setObjectName("tableHint")
        self.table_hint.setContentsMargins(8, 4, 8, 6)
        layout.addWidget(self.table_hint)
        return panel

    def _apply_table_column_widths(self) -> None:
        widths = {
            "number": 42,
            "title": 210,
            "artist": 155,
            "album": 170,
            "genre": 110,
            "discnumber": 66,
            "disctotal": 66,
            "track": 66,
            "tracktotal": 66,
            "rating wmp": 120,
            "filename": 210,
            "codec": 80,
            "quality_text": 145,
            "duration_text": 72,
        }
        for column in range(self.model.columnCount()):
            key = self.model.column_key(column)
            self.table.setColumnWidth(column, widths.get(key, 135))

    def fit_all_columns_to_contents(self) -> None:
        """Resize every visible column using the widest header or cell value."""

        header = self.table.horizontalHeader()
        previous_precision = header.resizeContentsPrecision()
        # A negative precision asks Qt to inspect every row, rather than only
        # a sample, so the result is equivalent to auto-fitting each divider.
        header.setResizeContentsPrecision(-1)
        try:
            self.table.resizeColumnsToContents()
        finally:
            header.setResizeContentsPrecision(previous_precision)
        self.settings.setValue("table/header_state", header.saveState())
        self.statusBar().showMessage(
            tr(
                "Fitted all columns to their contents.",
                "すべての列幅を内容に合わせて調整しました。",
            ),
            4000,
        )

    def _create_now_playing_panel(self) -> QWidget:
        panel = QWidget()
        panel.setObjectName("nowPlayingPanel")
        panel.setMinimumWidth(250)
        panel_layout = QVBoxLayout(panel)
        panel_layout.setContentsMargins(0, 0, 0, 0)
        self.now_playing_scroll = VerticalScrollArea()
        self.now_playing_scroll.setObjectName("nowPlayingScroll")
        self.now_playing_scroll.setWidgetResizable(True)
        self.now_playing_scroll.setFrameShape(QFrame.Shape.NoFrame)
        self.now_playing_scroll.setHorizontalScrollBarPolicy(
            Qt.ScrollBarPolicy.ScrollBarAlwaysOff
        )
        self.now_playing_scroll.setVerticalScrollBarPolicy(
            Qt.ScrollBarPolicy.ScrollBarAsNeeded
        )
        self.now_playing_content = QWidget()
        self.now_playing_content.setObjectName("nowPlayingContent")
        layout = QVBoxLayout(self.now_playing_content)
        layout.setContentsMargins(10, 14, 10, 14)
        self.now_playing_heading = QLabel("再生中")
        self.now_playing_heading.setObjectName("mutedLabel")
        layout.addWidget(self.now_playing_heading)
        self.artwork_label = ArtworkDropLabel()
        self.artwork_label.setMinimumSize(210, 210)
        self.artwork_label.setMaximumHeight(310)
        layout.addWidget(self.artwork_label)
        self.remove_artwork_button = QPushButton(
            "選択曲のアートワークを削除"
        )
        self.remove_artwork_button.setObjectName("dangerButton")
        self.remove_artwork_button.setMinimumHeight(32)
        self.remove_artwork_button.setToolTip(
            "プレイリストで選択している曲から埋め込み画像だけを削除します。"
        )
        layout.addWidget(self.remove_artwork_button)
        self.now_title = QLabel("曲を選択してください")
        self.now_title.setObjectName("titleLabel")
        self.now_title.setWordWrap(True)
        self.now_artist = QLabel("—")
        self.now_artist.setObjectName("mutedLabel")
        self.now_artist.setWordWrap(True)
        self.now_album = QLabel("—")
        self.now_album.setObjectName("mutedLabel")
        self.now_album.setWordWrap(True)
        self.quality_badge = MarqueeLabel("SOURCE  —")
        self.quality_badge.setObjectName("qualityBadge")
        layout.addSpacing(8)
        layout.addWidget(self.now_title)
        layout.addWidget(self.now_artist)
        layout.addWidget(self.now_album)
        layout.addWidget(self.quality_badge)
        layout.addStretch()

        self.playback_options_group = QGroupBox("再生オプション")
        options_layout = QGridLayout(self.playback_options_group)
        options_layout.setContentsMargins(9, 8, 9, 8)
        options_layout.setHorizontalSpacing(8)
        options_layout.setVerticalSpacing(6)
        self.play_order_label = QLabel("再生順")
        self.play_order_combo = QComboBox()
        self.play_order_combo.addItem("連続再生", "continuous")
        self.play_order_combo.addItem("シャッフル再生", "shuffle")
        self.repeat_mode_label = QLabel("リピート")
        self.repeat_mode_combo = QComboBox()
        self.repeat_mode_combo.addItem("オフ", "off")
        self.repeat_mode_combo.addItem("全曲", "all")
        self.repeat_mode_combo.addItem("1曲", "one")
        self.gapless_checkbox = QCheckBox("ギャップレス再生")
        options_layout.addWidget(self.play_order_label, 0, 0)
        options_layout.addWidget(self.play_order_combo, 0, 1)
        options_layout.addWidget(self.repeat_mode_label, 1, 0)
        options_layout.addWidget(self.repeat_mode_combo, 1, 1)
        options_layout.addWidget(self.gapless_checkbox, 2, 0, 1, 2)
        self.gapless_checkbox.setVisible(not self.trial_edition)
        options_layout.setColumnStretch(1, 1)
        layout.addWidget(self.playback_options_group)

        self.output_device_label = QLabel("音声出力デバイス")
        self.output_device_label.setObjectName("mutedLabel")
        self.playback_mode_label = QLabel("音声出力モード")
        self.playback_mode_label.setObjectName("mutedLabel")
        layout.addWidget(self.playback_mode_label)
        self.output_mode_combo = QComboBox()
        self.output_mode_combo.addItem("標準（Windows共有）", "shared")
        if not self.trial_edition:
            self.output_mode_combo.addItem("WASAPI排他（ネイティブ）", "exclusive")
        self.output_mode_combo.setToolTip(
            "排他モードではWindowsミキサーを迂回し、音源本来の形式でDACを開きます。"
        )
        layout.addWidget(self.output_mode_combo)
        layout.addWidget(self.output_device_label)
        self.device_combo = QComboBox()
        self.device_combo.setToolTip(
            "Windowsに認識されているUSB DACを選べます（現バージョンはWindows共有モード）。"
        )
        layout.addWidget(self.device_combo)
        self.output_note = QLabel("Windows共有モード")
        self.output_note.setObjectName("mutedLabel")
        layout.addWidget(self.output_note)
        self.now_playing_scroll.setWidget(self.now_playing_content)
        panel_layout.addWidget(self.now_playing_scroll)
        return panel

    def _create_transport_bar(self) -> QWidget:
        self.transport_frame = QFrame()
        self.transport_frame.setObjectName("transportFrame")
        self.transport_frame.setFrameShape(QFrame.Shape.StyledPanel)
        layout = QHBoxLayout(self.transport_frame)
        layout.setContentsMargins(14, 10, 14, 10)
        layout.setSpacing(10)
        # Use ordinary monochrome text glyphs instead of the Windows emoji
        # presentation used by ⏮/⏭. This lets every theme control their color.
        self.previous_button = QPushButton("◀◀")
        self.previous_button.setObjectName("skipButton")
        self.previous_button.setFixedSize(38, 38)
        self.play_button = QPushButton("▶")
        self.play_button.setObjectName("playButton")
        self.play_button.setFixedSize(44, 44)
        self.stop_button = QPushButton("■")
        self.stop_button.setToolTip("停止（Ctrl+.）")
        self.stop_button.setFixedSize(38, 38)
        self.next_button = QPushButton("▶▶")
        self.next_button.setObjectName("skipButton")
        self.next_button.setFixedSize(38, 38)
        self.elapsed_label = QLabel("0:00")
        self.position_slider = SeekSlider(Qt.Orientation.Horizontal)
        self.position_slider.setRange(0, 0)
        self.remaining_label = QLabel("0:00")
        self.volume_label = QLabel("音量")
        self.volume_slider = QSlider(Qt.Orientation.Horizontal)
        self.volume_slider.setRange(0, 100)
        self.volume_slider.setValue(80)
        self.volume_slider.setFixedWidth(120)
        layout.addWidget(self.previous_button)
        layout.addWidget(self.play_button)
        layout.addWidget(self.stop_button)
        layout.addWidget(self.next_button)
        layout.addSpacing(8)
        layout.addWidget(self.elapsed_label)
        layout.addWidget(self.position_slider, 1)
        layout.addWidget(self.remaining_label)
        layout.addSpacing(12)
        layout.addWidget(self.volume_label)
        layout.addWidget(self.volume_slider)
        return self.transport_frame

    # ---------- signal setup and state ----------

    def _playlist_display_name(self, name: str) -> str:
        if name == self.PLAYBACK_QUEUE:
            return tr("Playback Queue", "再生キュー")
        return name

    def _language_selected(self, action: QAction) -> None:
        self.change_language(str(action.data()))

    def _theme_selected(self, action: QAction) -> None:
        self.apply_theme(str(action.data()))

    def _visualizer_mode_selected(self, action: QAction) -> None:
        self.apply_visualizer_mode(str(action.data()))

    def apply_visualizer_mode(
        self,
        mode: object,
        *,
        persist: bool = True,
        announce: bool = True,
    ) -> None:
        self.visualizer_mode = normalize_visualizer_mode(mode)
        action = self.visualizer_actions[self.visualizer_mode]
        action.setChecked(True)
        self.position_slider.set_visualizer_mode(self.visualizer_mode)
        if self._synced_lyrics_viewer is not None:
            self._synced_lyrics_viewer.set_visualizer_mode(
                self.visualizer_mode
            )
        if persist:
            self.settings.setValue(
                "appearance/seek_visualizer",
                self.visualizer_mode,
            )
        self._sync_visualizer_playback(self._audio_is_playing())
        if announce:
            self.statusBar().showMessage(
                tr(
                    f"Seek-bar visualizer: {action.text()}",
                    f"シークバー・ビジュアライザー: {action.text()}",
                ),
                5000,
            )

    def apply_theme(
        self, theme: object, *, persist: bool = True, announce: bool = True
    ) -> None:
        self.theme = "dark" if self.trial_edition else normalize_theme(theme)
        action = self.theme_actions[self.theme]
        action.setChecked(True)
        app = QApplication.instance()
        if app is not None and app.property("musicgridstudioTheme") != self.theme:
            app.setStyleSheet(build_stylesheet(self.theme))
            app.setProperty("musicgridstudioTheme", self.theme)
        self.background_canvas.set_theme(self.theme)
        table_palette = self.table.palette()
        link_color = QColor(str(THEME_PALETTES[self.theme]["accent"]))
        table_palette.setColor(QPalette.ColorRole.Link, link_color)
        table_palette.setColor(QPalette.ColorRole.LinkVisited, link_color)
        self.table.setPalette(table_palette)
        self.position_slider.set_visualizer_color(
            THEME_PALETTES[self.theme]["accent"]
        )
        if self._synced_lyrics_viewer is not None:
            self._synced_lyrics_viewer.set_theme(self.theme)
        if persist:
            self.settings.setValue("appearance/theme", self.theme)
        if announce:
            self.statusBar().showMessage(
                tr(
                    f"Theme changed to {action.text()}.",
                    f"配色テーマを「{action.text()}」へ変更しました。",
                ),
                5000,
            )

    def show_about_dialog(self) -> None:
        dialog = AboutDialog(__version__, self, trial=self.trial_edition)
        if not self.trial_edition:
            dialog.secretActivated.connect(
                lambda: self._show_background_image_options(dialog)
            )
        dialog.exec()

    def apply_update_package(self) -> None:
        filename, _ = QFileDialog.getOpenFileName(
            self,
            tr(
                "Select MusicGridStudio Update",
                "MusicGridStudioのアップデートを選択",
            ),
            "",
            tr(
                "MusicGridStudio updates (*.zip);;All files (*.*)",
                "MusicGridStudio更新ZIP (*.zip);;すべてのファイル (*.*)",
            ),
        )
        if not filename:
            return
        try:
            package = inspect_update_package(filename, __version__)
        except UpdatePackageError as exc:
            QMessageBox.critical(
                self,
                tr("Invalid Update", "無効なアップデート"),
                str(exc),
            )
            return
        payload_megabytes = package.payload_size / (1024 * 1024)
        reply = QMessageBox.question(
            self,
            tr("Apply Update", "アップデートを適用"),
            tr(
                f"Update MusicGridStudio from {package.from_version} "
                f"to {package.to_version}?\n\n"
                f"{len(package.files)} files ({payload_megabytes:.1f} MB) "
                "will be replaced. The application will close.\n"
                "A backup will be kept in your local application data.",
                f"MusicGridStudioを {package.from_version} から "
                f"{package.to_version} へ更新しますか？\n\n"
                f"{len(package.files)}ファイル（{payload_megabytes:.1f} MB）"
                "を置き換え、アプリを終了します。\n"
                "バックアップはローカルのアプリデータへ保存されます。",
            ),
            QMessageBox.StandardButton.Yes
            | QMessageBox.StandardButton.No,
            QMessageBox.StandardButton.No,
        )
        if reply != QMessageBox.StandardButton.Yes:
            return
        try:
            launch_update_installer(package, __version__)
        except UpdatePackageError as exc:
            QMessageBox.critical(
                self,
                tr("Update Error", "アップデートエラー"),
                str(exc),
            )
            return
        self.statusBar().showMessage(
            tr(
                "The updater has started. Closing MusicGridStudio…",
                "アップデーターを起動しました。MusicGridStudioを終了します…",
            )
        )
        self.synced_lyrics_button.setText(
            tr("Synchronized Lyrics", "同期歌詞")
        )
        self.synced_lyrics_button.setToolTip(
            tr(
                "Playback Mode: open the synchronized lyrics viewer. "
                "Edit Mode: create or timestamp the sidecar .lrc file.",
                "再生モードでは同期歌詞を表示し、編集モードでは同名の.lrcを作成・打刻します。",
            )
        )
        QTimer.singleShot(100, self.close)

    def _show_background_image_options(self, parent=None) -> None:
        has_background = not self.background_canvas.background_pixmap.isNull()
        box = QMessageBox(parent or self)
        box.setWindowTitle("Background Image")
        box.setIcon(QMessageBox.Icon.Information)
        box.setText(
            "Choose a background image for MusicGridStudio.\n"
            "The image will keep its aspect ratio and a smoke overlay will "
            "preserve text readability."
        )
        choose_button = box.addButton(
            "Choose / Replace Image…", QMessageBox.ButtonRole.ActionRole
        )
        remove_button = (
            box.addButton(
                "Remove Current Image", QMessageBox.ButtonRole.DestructiveRole
            )
            if has_background
            else None
        )
        box.addButton(QMessageBox.StandardButton.Cancel)
        box.exec()
        clicked = box.clickedButton()
        if clicked is choose_button:
            filename, _ = QFileDialog.getOpenFileName(
                parent or self,
                "Choose Background Image",
                "",
                "Images (*.jpg *.jpeg *.png *.bmp *.webp);;All files (*.*)",
            )
            if filename:
                self._install_background_image(filename, parent or self)
        elif remove_button is not None and clicked is remove_button:
            self._remove_background_image(parent or self)

    def _install_background_image(self, filename: str, parent=None) -> bool:
        source = Path(filename)
        if not source.is_file() or QPixmap(str(source)).isNull():
            QMessageBox.warning(
                parent or self,
                "Background Image",
                "The selected file could not be loaded as an image.",
            )
            return False
        suffix = source.suffix.lower()
        if suffix not in {".jpg", ".jpeg", ".png", ".bmp", ".webp"}:
            QMessageBox.warning(
                parent or self,
                "Background Image",
                "Please select a JPG, PNG, BMP, or WebP image.",
            )
            return False
        try:
            self.store.directory.mkdir(parents=True, exist_ok=True)
            target = self.store.directory / f"custom_background{suffix}"
            if source.resolve() != target.resolve():
                temporary = self.store.directory / f".background_pending{suffix}"
                shutil.copy2(source, temporary)
                temporary.replace(target)
            for old_image in self.store.directory.glob("custom_background.*"):
                if old_image != target and old_image.is_file():
                    old_image.unlink()
            stored_path = str(target.resolve())
            self.settings.setValue("appearance/background_image", stored_path)
            self._sync_settings()
            self.background_canvas.set_background_image(stored_path)
        except OSError as exc:
            QMessageBox.warning(
                parent or self,
                "Background Image",
                f"The background image could not be saved.\n\n{exc}",
            )
            return False
        self.statusBar().showMessage(
            tr(
                "Custom background image applied.",
                "カスタム背景画像を設定しました。",
            ),
            5000,
        )
        return True

    def _migrate_saved_background_image(self, filename: str) -> str:
        source = Path(filename)
        if not source.is_file():
            return filename
        try:
            source_parent = source.resolve().parent
            legacy_directories = {
                directory.resolve()
                for directory in self.store.legacy_directories
            }
            if source_parent not in legacy_directories:
                return filename
            self.store.directory.mkdir(parents=True, exist_ok=True)
            target = self.store.directory / source.name
            if source.resolve() != target.resolve():
                temporary = self.store.directory / (
                    f".background_migration{source.suffix.lower()}"
                )
                shutil.copy2(source, temporary)
                temporary.replace(target)
            stored_path = str(target.resolve())
            self.settings.setValue("appearance/background_image", stored_path)
            self._sync_settings()
            return stored_path
        except OSError:
            return filename

    def _remove_background_image(self, parent=None) -> bool:
        saved_path = str(
            self.settings.value("appearance/background_image", "") or ""
        )
        if saved_path:
            try:
                candidate = Path(saved_path).resolve()
                directory = self.store.directory.resolve()
                if (
                    candidate.parent == directory
                    and candidate.name.startswith("custom_background.")
                    and candidate.is_file()
                ):
                    candidate.unlink()
            except OSError as exc:
                QMessageBox.warning(
                    parent or self,
                    "Background Image",
                    f"The background image could not be removed.\n\n{exc}",
                )
                return False
        self.settings.setValue("appearance/background_image", "")
        self._sync_settings()
        self.background_canvas.clear_background_image()
        self.statusBar().showMessage(
            tr(
                "Custom background image removed.",
                "カスタム背景画像を削除しました。",
            ),
            5000,
        )
        return True

    def _sync_settings(self) -> None:
        sync = getattr(self.settings, "sync", None)
        if callable(sync):
            sync()

    def _filename_pattern(self) -> str:
        if self.trial_edition:
            return "off"
        action = self.filename_pattern_group.checkedAction()
        pattern = str(action.data()) if action is not None else "off"
        return (
            pattern
            if pattern in {"off", "disc_track_title", "track_title"}
            else "off"
        )

    def _filename_pattern_selected(self, action: QAction) -> None:
        if self.trial_edition:
            self.filename_pattern_actions["off"].setChecked(True)
            return
        pattern = str(action.data())
        if pattern not in {"off", "disc_track_title", "track_title"}:
            return
        self.settings.setValue("tags/filename_pattern", pattern)
        # Keep the former boolean setting synchronized so older builds can
        # still understand whether automatic filename changes were enabled.
        self.settings.setValue("tags/auto_rename_files", pattern != "off")
        messages = {
            "off": tr(
                "Automatic filename changes disabled.",
                "タグ保存時のファイル名自動変更をオフにしました。",
            ),
            "disc_track_title": tr(
                "Files will be renamed to D_T_Title when tags are saved.",
                "タグ保存時に「D_T_タイトル」へ自動変更します。",
            ),
            "track_title": tr(
                "Files will be renamed to T_Title when tags are saved.",
                "タグ保存時に「T_タイトル」へ自動変更します。",
            ),
        }
        self.statusBar().showMessage(
            messages[pattern],
            5000,
        )

    def configure_title_suffixes(self) -> None:
        if self.trial_edition:
            return
        dialog = TitleSuffixSettingsDialog(self.title_suffixes, self)
        if dialog.exec() != QDialog.DialogCode.Accepted:
            return
        self.title_suffixes = dialog.suffixes()
        self.settings.setValue("tags/title_suffixes", self.title_suffixes)
        self.statusBar().showMessage(
            tr(
                f"Saved {len(self.title_suffixes)} title suffixes.",
                f"タイトル接尾辞を {len(self.title_suffixes)} 件保存しました。",
            ),
            5000,
        )

    def configure_genre_suggestions(self) -> None:
        if self.trial_edition:
            return
        dialog = GenreSettingsDialog(self.genre_suggestions, self)
        if dialog.exec() != QDialog.DialogCode.Accepted:
            return
        self.genre_suggestions = dialog.genres()
        self.settings.setValue(
            "tags/genre_suggestions",
            self.genre_suggestions,
        )
        self._apply_genre_suggestions()
        self.statusBar().showMessage(
            tr(
                f"Saved {len(self.genre_suggestions)} genre suggestions.",
                f"ジャンル候補を {len(self.genre_suggestions)} 件保存しました。",
            ),
            5000,
        )

    def configure_tag_columns(self) -> None:
        if self.trial_edition:
            return
        self.table.setFocus()
        QApplication.processEvents()
        dialog = TagColumnSettingsDialog(
            self.tag_columns,
            self,
            bulk_tag_keys=self.bulk_tag_keys,
        )
        if dialog.exec() != QDialog.DialogCode.Accepted:
            return
        self.tag_columns = normalize_tag_columns(dialog.columns())
        self.bulk_tag_keys = normalize_bulk_tag_keys(
            dialog.bulk_tag_keys(),
            self.tag_columns,
        )
        self.settings.setValue(
            "tags/table_columns",
            json.dumps(self.tag_columns, ensure_ascii=False),
        )
        self.settings.setValue(
            "tags/bulk_edit_tags",
            json.dumps(self.bulk_tag_keys, ensure_ascii=False),
        )
        self.settings.setValue("table/header_state", "")
        self.model.set_tag_columns(self.tag_columns)
        header = self.table.horizontalHeader()
        for logical in range(header.count()):
            visual = header.visualIndex(logical)
            if visual != logical:
                header.moveSection(visual, logical)
        self._apply_table_column_widths()
        self.model.retranslate()
        self.statusBar().showMessage(
            tr(
                f"Updated {len(self.tag_columns)} displayed tag columns.",
                f"表示するタグ列を {len(self.tag_columns)} 件に更新しました。",
            ),
            5000,
        )

    def _apply_genre_suggestions(self) -> None:
        self.table.set_genre_suggestions(self.genre_suggestions)

    def change_language(
        self, language: object, *, persist: bool = True, announce: bool = True
    ) -> None:
        self.language = set_language(normalize_language(language))
        if persist:
            self.settings.setValue("ui/language", self.language)
        action = (
            self.japanese_language_action
            if self.language == "ja"
            else self.english_language_action
        )
        action.setChecked(True)
        self.retranslate_ui(announce=announce)

    def retranslate_ui(self, *, announce: bool = True) -> None:
        self.file_menu.setTitle(tr("File", "ファイル"))
        self.edit_menu.setTitle(tr("Edit", "編集"))
        self.playback_menu.setTitle(tr("Playback", "再生"))
        self.tag_menu.setTitle(tr("Tags", "タグ"))
        self.view_menu.setTitle(tr("View", "表示"))
        self.settings_menu.setTitle(tr("Settings", "設定"))
        self.language_menu.setTitle(tr("Language", "言語"))
        self.theme_menu.setTitle(tr("Theme", "配色テーマ"))
        self.visualizer_menu.setTitle(
            tr("Seek Bar Visualizer", "シークバー・ビジュアライザー")
        )
        self.filename_pattern_menu.setTitle(
            tr("Automatic Filename Change", "ファイル名の自動変更")
        )
        self.information_menu.setTitle(tr("Information", "情報"))
        self.about_action.setText(
            tr("About MusicGridStudio…", "MusicGridStudioについて…")
        )
        self.apply_update_action.setText(
            tr(
                "Apply Update Package…",
                "アップデートパッケージを適用…",
            )
        )
        self.apply_update_action.setStatusTip(
            tr(
                "Select and apply an official MusicGridStudio update ZIP.",
                "公式のMusicGridStudio更新ZIPを選択して適用します。",
            )
        )
        theme_names = {
            "dark": tr("Dark (Default)", "ダーク（初期設定）"),
            "white": tr("White", "白基調"),
            "blue": tr("Blue", "青基調"),
            "pink": tr("Pink", "ピンク基調"),
            "yellow": tr("Yellow", "黄色基調"),
        }
        for theme_key, action in self.theme_actions.items():
            action.setText(theme_names[theme_key])
        visualizer_names = {
            "off": tr("Hidden", "非表示"),
            "spectrum": tr(
                "Frequency Spectrum (Low → High)",
                "スペクトラム（低域 → 高域）",
            ),
            "waveform": tr(
                "Oscilloscope (Time Waveform)",
                "オシロスコープ（時間波形）",
            ),
        }
        for mode, action in self.visualizer_actions.items():
            action.setText(visualizer_names[mode])

        action_texts = (
            (self.add_files_action, "Add Files…", "ファイルを追加…"),
            (self.add_folder_action, "Add Folder…", "フォルダーを追加…"),
            (
                self.import_playlist_action,
                "Import M3U/M3U8 Playlist…",
                "M3U/M3U8プレイリストを読み込む…",
            ),
            (
                self.export_playlist_action,
                "Export as M3U/M3U8…",
                "M3U/M3U8で保存…",
            ),
            (self.quit_action, "Exit", "終了"),
            (self.play_pause_action, "Play / Pause", "再生 / 一時停止"),
            (self.stop_action, "Stop", "停止"),
            (self.previous_action, "Previous Track", "前の曲"),
            (self.next_action, "Next Track", "次の曲"),
            (
                self.save_tags_action,
                "Save Tags for Selected Tracks",
                "選択曲のタグを保存",
            ),
            (
                self.auto_number_action,
                "Auto-number Disc / Track…",
                "Disc / Trackを自動連番…",
            ),
            (
                self.title_suffix_action,
                "Add Title Suffix…",
                "タイトル接尾辞を付加…",
            ),
            (
                self.artwork_action,
                "Embed Artwork in Selected Tracks…",
                "選択曲にアートワークを埋め込む…",
            ),
            (
                self.remove_artwork_action,
                "Remove Artwork from Selected Tracks…",
                "選択曲のアートワークを削除…",
            ),
            (
                self.filename_pattern_actions["off"],
                "Do Not Rename Automatically",
                "自動変更しない",
            ),
            (
                self.filename_pattern_actions["disc_track_title"],
                "Automatically Rename to D_T_Title",
                "「D_T_タイトル」へ自動変更",
            ),
            (
                self.filename_pattern_actions["track_title"],
                "Automatically Rename to T_Title",
                "「T_タイトル」へ自動変更",
            ),
            (
                self.title_suffix_settings_action,
                "Title Suffix Settings…",
                "タイトル接尾辞の設定…",
            ),
            (
                self.genre_settings_action,
                "Genre Suggestion Settings…",
                "ジャンル候補の設定…",
            ),
            (
                self.tag_columns_settings_action,
                "Tag Settings…",
                "タグの設定…",
            ),
        )
        for action, english, japanese in action_texts:
            action.setText(tr(english, japanese))

        self.add_button.setText(tr("＋ Add Music", "＋ 音楽を追加"))
        self.cancel_edit_button.setText(
            tr("Cancel Editing", "編集をキャンセル")
        )
        self.cancel_edit_button.setToolTip(
            tr(
                "Discard changes made after the latest successful tag save and return to playback mode.",
                "最後にタグ保存した時点以降の未保存変更を破棄し、再生モードへ戻ります。",
            )
        )
        self.bulk_edit_button.setText(tr("Bulk Edit", "一括入力"))
        self.bulk_edit_button.setToolTip(
            tr(
                "Open the bulk-edit window for the selected tracks.",
                "選択した楽曲へ一括入力するウィンドウを開きます。",
            )
        )
        self.fit_columns_button.setText(
            tr("Fit All Columns", "全列幅を調整")
        )
        self.fit_columns_button.setToolTip(
            tr(
                "Fit every column to its widest header or cell value.",
                "すべての列幅を、その列で最も長い見出しまたはセル内容に合わせます。",
            )
        )
        self.auto_number_button.setText(
            tr("Auto-number Disc / Track", "Disc / Track 自動連番")
        )
        self.title_suffix_button.setText(
            tr("Title Suffix", "タイトル接尾辞")
        )
        self.save_tags_button.setText(tr("Save Tags", "タグを保存"))
        self.new_playlist_button.setText(tr("＋ New", "＋ 新規"))
        self.remove_playlist_button.setText(tr("－ Delete", "－ 削除"))
        self.rename_playlist_button.setText(tr("Rename", "名称変更"))
        self.rename_playlist_button.setToolTip(
            tr(
                "Rename the selected playlist. Playback Queue cannot be renamed.",
                "選択中のプレイリスト名を変更します。「再生キュー」は変更できません。",
            )
        )
        self.clear_playlist_button.setText(
            tr("× Clear Tracks", "× 曲を全消去")
        )
        self.clear_playlist_button.setToolTip(
            tr(
                "Clear every track in the current playlist. Ctrl+Z restores it.",
                "現在のプレイリスト内の全曲を消去します。Ctrl+Zで元に戻せます。",
            )
        )
        self.playlist_heading.setText(tr("Playlists", "プレイリスト"))
        self.playlist_help.setText(
            tr(
                "Drop tracks or folders onto the center list to add them.\nDrag selected tracks onto another playlist to copy them.",
                "曲やフォルダーを中央の一覧へドロップして追加できます。\n選択曲を別のプレイリストへドロップするとコピーできます。",
            )
        )
        self.now_playing_heading.setText(tr("NOW PLAYING", "再生中"))
        self.output_device_label.setText(
            tr("Audio Output Device", "音声出力デバイス")
        )
        self.playback_options_group.setTitle(
            tr("Playback Options", "再生オプション")
        )
        self.play_order_label.setText(tr("Play Order", "再生順"))
        self.play_order_combo.setItemText(
            self.play_order_combo.findData("continuous"),
            tr("Continuous", "連続再生"),
        )
        self.play_order_combo.setItemText(
            self.play_order_combo.findData("shuffle"),
            tr("Shuffle", "シャッフル再生"),
        )
        self.repeat_mode_label.setText(tr("Repeat", "リピート"))
        self.repeat_mode_combo.setItemText(
            self.repeat_mode_combo.findData("off"), tr("Off", "オフ")
        )
        self.repeat_mode_combo.setItemText(
            self.repeat_mode_combo.findData("all"),
            tr("All Tracks", "全曲"),
        )
        self.repeat_mode_combo.setItemText(
            self.repeat_mode_combo.findData("one"), tr("One Track", "1曲")
        )
        self.gapless_checkbox.setText(
            tr("Gapless Playback", "ギャップレス再生")
        )
        self.gapless_checkbox.setToolTip(
            tr(
                "Preloads the next track. In WASAPI Exclusive mode, seamless "
                "transition requires matching sample rate, channel count, and "
                "native sample format.",
                "次の曲を事前に読み込みます。WASAPI排他で完全につなぐには、"
                "前後の曲のサンプルレート・チャンネル数・ネイティブ形式が"
                "同じである必要があります。",
            )
        )
        self.playback_mode_label.setText(
            tr("Audio Output Mode", "音声出力モード")
        )
        self.remove_artwork_button.setText(
            tr(
                "Remove Artwork from Selected Tracks",
                "選択曲のアートワークを削除",
            )
        )
        self.edit_mode_button.setToolTip(
            tr(
                "Stops playback and enables cell editing (Ctrl+E).",
                "再生を停止し、セル編集を有効にします（Ctrl+E）",
            )
        )
        self.remove_artwork_button.setToolTip(
            tr(
                "Removes only embedded images from the tracks selected in the playlist.",
                "プレイリストで選択している曲から埋め込み画像だけを削除します。",
            )
        )
        self.output_mode_combo.setItemText(
            self.output_mode_combo.findData("shared"),
            tr("Standard (Windows shared)", "標準（Windows共有）"),
        )
        exclusive_index = self.output_mode_combo.findData("exclusive")
        if exclusive_index >= 0:
            self.output_mode_combo.setItemText(
                exclusive_index,
                tr("WASAPI Exclusive (native)", "WASAPI排他（ネイティブ）"),
            )
        self.output_mode_combo.setToolTip(
            tr(
                "Exclusive mode bypasses the Windows mixer and opens the DAC in the source format.",
                "排他モードではWindowsミキサーを迂回し、音源本来の形式でDACを開きます。",
            )
        )
        self.device_combo.setToolTip(
            tr(
                "Select an audio output device recognized by Windows, including a USB DAC.",
                "Windowsに認識されているUSB DACなどの音声出力デバイスを選べます。",
            )
        )
        self.filename_pattern_actions["off"].setStatusTip(
            tr(
                "Keep the current filename when tags are saved.",
                "タグ保存時も現在のファイル名を維持します。",
            )
        )
        self.filename_pattern_actions["disc_track_title"].setStatusTip(
            tr(
                "Rename to DiscNumber_TrackNumber_Title when tags are saved.",
                "タグ保存時に「Disc番号_Track番号_タイトル」へ変更します。",
            )
        )
        self.filename_pattern_actions["track_title"].setStatusTip(
            tr(
                "Rename to TrackNumber_Title when tags are saved.",
                "タグ保存時に「Track番号_タイトル」へ変更します。",
            )
        )
        self.stop_button.setToolTip(tr("Stop (Ctrl+.)", "停止（Ctrl+.）"))
        self.play_button.setToolTip(
            tr("Play / Pause (Space)", "再生／一時停止（Space）")
        )
        self.position_slider.setToolTip(
            tr(
                "Click the seek bar to play from that position.",
                "シークバーをクリックすると、その位置へ移動します。",
            )
        )
        self.artwork_label.retranslate()
        self.model.retranslate()
        self._refresh_undo_action_text()
        self._sync_edit_mode_controls(self.edit_mode)
        self._update_table_hint()
        self._update_fullscreen_controls()
        self._apply_output_mode_ui()
        self._refresh_audio_devices()
        self._populate_playlist_list()
        if self.current_track is not None:
            self.show_track(self.current_track)
        else:
            self.now_title.setText(tr("Select a track", "曲を選択してください"))
        self.setWindowTitle(
            f"{self.product_name} — {self._playlist_display_name(self.active_playlist)}"
        )
        if announce:
            self.statusBar().showMessage(
                tr(
                    "Display language changed to English.",
                    "表示言語を日本語に変更しました。",
                ),
                5000,
            )

    def _update_table_hint(self) -> None:
        self.table_hint.setText(
            tr(
                "Edit Mode: drag to select cells, type directly, Ctrl+Enter to fill, Ctrl+Z to undo",
                "編集モード: マウスで範囲選択・直接入力・Ctrl+Enterで一括確定　／　Ctrl+Zで元に戻す",
            )
            if self.edit_mode
            else tr(
                "Playback mode: editing disabled / Drag headers to reorder columns / Double-click to play / Ctrl+Z to undo playlist changes / Ctrl+E for Edit Mode",
                "再生モード: セル編集は無効　／　列見出しのドラッグで列を入れ替え　／　ダブルクリックで再生　／　Ctrl+Zでプレイリスト操作を元に戻す　／　Ctrl+Eで編集モード",
            )
        )

    def _update_fullscreen_controls(self) -> None:
        if self.isFullScreen():
            self.fullscreen_button.setText(
                tr("Windowed  F11", "ウィンドウ表示  F11")
            )
            self.fullscreen_action.setText(tr("Windowed", "ウィンドウ表示"))
        else:
            self.fullscreen_button.setText(tr("Full Screen  F11", "全画面  F11"))
            self.fullscreen_action.setText(tr("Full Screen", "全画面表示"))

    def _connect_signals(self) -> None:
        self.add_files_action.triggered.connect(self.add_files_dialog)
        self.add_folder_action.triggered.connect(self.add_folder_dialog)
        self.import_playlist_action.triggered.connect(self.import_m3u)
        self.export_playlist_action.triggered.connect(self.export_m3u)
        self.quit_action.triggered.connect(self.close)
        self.play_pause_action.triggered.connect(self.toggle_playback)
        self.stop_action.triggered.connect(self.stop_playback)
        self.previous_action.triggered.connect(self.play_previous)
        self.next_action.triggered.connect(self.play_next)
        self.fullscreen_action.triggered.connect(self.toggle_fullscreen)
        self.undo_action.triggered.connect(self.undo_last_edit)
        self.edit_mode_action.toggled.connect(self.set_edit_mode)
        self.save_tags_action.triggered.connect(self.save_selected_tags)
        self.auto_number_action.triggered.connect(self.auto_number_tracks)
        self.title_suffix_action.triggered.connect(self.append_title_suffix)
        self.artwork_action.triggered.connect(self.set_selected_artwork)
        self.remove_artwork_action.triggered.connect(
            self.remove_selected_artwork
        )
        self.language_group.triggered.connect(self._language_selected)
        self.theme_group.triggered.connect(self._theme_selected)
        self.visualizer_group.triggered.connect(
            self._visualizer_mode_selected
        )
        self.about_action.triggered.connect(self.show_about_dialog)
        self.apply_update_action.triggered.connect(
            self.apply_update_package
        )
        self.filename_pattern_group.triggered.connect(
            self._filename_pattern_selected
        )
        self.title_suffix_settings_action.triggered.connect(
            self.configure_title_suffixes
        )
        self.genre_settings_action.triggered.connect(
            self.configure_genre_suggestions
        )
        self.tag_columns_settings_action.triggered.connect(
            self.configure_tag_columns
        )

        self.add_button.clicked.connect(self.add_files_dialog)
        self.cancel_edit_button.clicked.connect(self.cancel_edit_mode)
        self.bulk_edit_button.clicked.connect(self.open_bulk_edit_dialog)
        self.fit_columns_button.clicked.connect(self.fit_all_columns_to_contents)
        self.synced_lyrics_button.clicked.connect(self.open_synced_lyrics)
        self.edit_mode_button.toggled.connect(self.set_edit_mode)
        self.auto_number_button.clicked.connect(self.auto_number_tracks)
        self.title_suffix_button.clicked.connect(self.append_title_suffix)
        self.save_tags_button.clicked.connect(self.save_selected_tags)
        self.fullscreen_button.clicked.connect(self.toggle_fullscreen)
        self.new_playlist_button.clicked.connect(self.create_playlist)
        self.rename_playlist_button.clicked.connect(self.rename_playlist)
        self.remove_playlist_button.clicked.connect(self.remove_playlist)
        self.clear_playlist_button.clicked.connect(
            self.clear_current_playlist
        )
        self.playlist_list.currentItemChanged.connect(self.playlist_selected)
        self.playlist_list.itemDoubleClicked.connect(
            lambda _item: self.rename_playlist()
        )
        self.playlist_list.tracksDropped.connect(
            self.copy_tracks_to_playlist
        )

        self.table.doubleClicked.connect(self._table_double_clicked)
        self.table.clicked.connect(lambda index: self.show_track(self.model.tracks[index.row()]))
        self.table.pathsDropped.connect(self.add_paths)
        self.table.deleteRequested.connect(self.remove_rows)
        self.table.undoRequested.connect(self.undo_last_edit)
        self.table.ratingChanged.connect(self._rating_changed)
        self.table.lyricsRequested.connect(self.open_lyrics_dialog)
        self.model.tracksChanged.connect(self._model_changed)
        self.model.undoAvailabilityChanged.connect(
            self._undo_availability_changed
        )
        self.model.playlistUndoAvailabilityChanged.connect(
            self._playlist_undo_availability_changed
        )
        self.table.editModeRequired.connect(self._show_edit_mode_required)
        self.artwork_label.imageDropped.connect(self._artwork_dropped)
        self.remove_artwork_button.clicked.connect(self.remove_selected_artwork)

        self.previous_button.clicked.connect(self.play_previous)
        self.play_button.clicked.connect(self.toggle_playback)
        self.stop_button.clicked.connect(self.stop_playback)
        self.next_button.clicked.connect(self.play_next)
        self.volume_slider.valueChanged.connect(self._shared_volume_changed)
        self.position_slider.sliderPressed.connect(self._slider_pressed)
        self.position_slider.sliderReleased.connect(self._slider_released)
        self.position_slider.sliderMoved.connect(
            lambda value: self.elapsed_label.setText(format_duration(value))
        )
        self.position_slider.seekRequested.connect(self._seek_to)
        self.output_mode_combo.currentIndexChanged.connect(
            self._output_mode_selected
        )
        self.play_order_combo.currentIndexChanged.connect(
            self._playback_options_selected
        )
        self.repeat_mode_combo.currentIndexChanged.connect(
            self._playback_options_selected
        )
        self.gapless_checkbox.toggled.connect(self._gapless_toggled)
        self.device_combo.currentIndexChanged.connect(self._audio_device_selected)

        for player in (self.player, self.preload_player):
            player.positionChanged.connect(self._position_changed)
            player.durationChanged.connect(self._duration_changed)
            player.playbackStateChanged.connect(self._playback_state_changed)
            player.mediaStatusChanged.connect(self._media_status_changed)
            player.errorOccurred.connect(self._playback_error)
        self.media_devices.audioOutputsChanged.connect(self._refresh_audio_devices)
        self.exclusive_player.positionChanged.connect(self._position_changed)
        self.exclusive_player.durationChanged.connect(self._duration_changed)
        self.exclusive_player.stateChanged.connect(
            self._exclusive_state_changed
        )
        self.exclusive_player.mediaEnded.connect(self._handle_track_finished)
        self.exclusive_player.gaplessTransition.connect(
            self._exclusive_gapless_transition
        )
        self.exclusive_player.errorOccurred.connect(self._exclusive_error)
        self.audio_buffer_output.audioBufferReceived.connect(
            self._audio_buffer_received
        )
        self.preload_audio_buffer_output.audioBufferReceived.connect(
            self._audio_buffer_received
        )

        self.folder_shortcut = QShortcut(QKeySequence("Ctrl+Shift+O"), self)
        self.folder_shortcut.activated.connect(self.add_folder_dialog)
        self.next_shortcut = QShortcut(QKeySequence("Ctrl+Right"), self)
        self.next_shortcut.activated.connect(self.play_next)
        self.previous_shortcut = QShortcut(QKeySequence("Ctrl+Left"), self)
        self.previous_shortcut.activated.connect(self.play_previous)

    def set_edit_mode(self, enabled: bool, announce: bool = True) -> None:
        enabled = bool(enabled)
        was_editing = self.edit_mode

        if was_editing and not enabled:
            # Move focus away from an active cell editor so its latest value is
            # committed to the model before changed tracks are written to disk.
            self.table.setFocus()
            QApplication.processEvents()
            self._sync_edit_mode_controls(True)
            dirty_rows = sorted(self.model.dirty_rows)
            if dirty_rows and not self._save_tag_rows(dirty_rows):
                self.statusBar().showMessage(
                    tr(
                        "Edit Mode remains enabled because saving did not complete.",
                        "保存が完了しなかったため、編集モードを継続します。",
                    ),
                    8000,
                )
                return
            self.model.clear_undo_history()
            self._edit_checkpoints.clear()

        if enabled:
            if not was_editing:
                self.model.clear_undo_history()
                self._edit_checkpoints = {
                    id(track): track.to_dict() for track in self.model.tracks
                }
                # The editable lyrics dialog is modal. Close the playback
                # viewer first so there can never be two lyrics windows.
                viewer = self._lyrics_viewer
                self._lyrics_viewer = None
                if viewer is not None:
                    viewer.close()
                synced_viewer = self._synced_lyrics_viewer
                self._synced_lyrics_viewer = None
                if synced_viewer is not None:
                    synced_viewer.close()
            self.stop_playback()
        self.edit_mode = enabled
        self.table.set_editing_enabled(enabled)
        self.table.horizontalHeader().setSectionsClickable(not enabled)
        self.table.horizontalHeader().setSectionsMovable(not enabled)
        self.artwork_label.set_editing_enabled(enabled)
        self.transport_frame.setEnabled(not enabled)
        self.playback_options_group.setEnabled(not enabled)
        self.remove_artwork_button.setVisible(enabled)
        self.cancel_edit_button.setVisible(enabled)
        self.bulk_edit_button.setVisible(enabled)

        for action in (
            self.play_pause_action,
            self.stop_action,
            self.previous_action,
            self.next_action,
        ):
            action.setEnabled(not enabled)
        for action in (
            self.auto_number_action,
            self.title_suffix_action,
            self.save_tags_action,
            self.artwork_action,
            self.remove_artwork_action,
        ):
            action.setEnabled(enabled)
        for button in (
            self.bulk_edit_button,
            self.auto_number_button,
            self.title_suffix_button,
            self.save_tags_button,
            self.remove_artwork_button,
        ):
            button.setEnabled(enabled)

        self.undo_action.setEnabled(True)
        self._update_clear_playlist_button()
        self._refresh_undo_action_text()
        self._sync_edit_mode_controls(enabled)
        self._update_table_hint()
        if enabled:
            self.table.setFocus()
        if announce:
            self.statusBar().showMessage(
                tr(
                    "Edit Mode enabled. Playback has stopped.",
                    "編集モードを開始しました。楽曲再生は停止しています。",
                )
                if enabled
                else tr(
                    "Edit Mode closed. Double-click a track to play it.",
                    "編集モードを終了しました。ダブルクリックで再生できます。",
                ),
                6000,
            )

    @staticmethod
    def _restore_track_from_checkpoint(
        track: Track, checkpoint: dict[str, object]
    ) -> None:
        restored = Track.from_dict(checkpoint)
        for key, value in restored.to_dict().items():
            setattr(track, key, value)

    def cancel_edit_mode(self) -> None:
        """Discard metadata changes made after the latest save checkpoints."""

        if not self.edit_mode:
            return
        # Commit a currently open cell editor first, then restore every track
        # from its most recent per-track checkpoint.
        self.table.setFocus()
        QApplication.processEvents()
        self.model.beginResetModel()
        try:
            for track in self.model.tracks:
                checkpoint = self._edit_checkpoints.get(id(track))
                if checkpoint is not None:
                    self._restore_track_from_checkpoint(track, checkpoint)
            self.model.dirty_rows.clear()
        finally:
            self.model.endResetModel()
        self.model.clear_undo_history()
        self.model.tracksChanged.emit()
        if 0 <= self.current_row < len(self.model.tracks):
            self.current_track = self.model.tracks[self.current_row]
            self.show_track(self.current_track)
        self.set_edit_mode(False, announce=False)
        self.statusBar().showMessage(
            tr(
                "Discarded unsaved edits and returned to playback mode.",
                "未保存の編集をキャンセルし、再生モードへ戻りました。",
            ),
            6000,
        )

    def undo_last_edit(self) -> None:
        if not self.edit_mode:
            self.undo_last_playlist_operation()
            return
        # If a cell editor is active, commit its current text first. That edit
        # immediately becomes the newest history item and is then reverted.
        self.table.setFocus()
        QApplication.processEvents()
        if not self.model.undo_last_edit():
            self.statusBar().showMessage(
                tr(
                    "There are no edits to undo.",
                    "元に戻せる編集履歴はありません。",
                ),
                4000,
            )
            return
        if 0 <= self.current_row < len(self.model.tracks):
            self.show_track(self.model.tracks[self.current_row])
        remaining = self.model.undo_count
        self.statusBar().showMessage(
            tr(
                "Undid the last edit."
                + (f" {remaining} undo steps remain." if remaining else ""),
                "直前の編集を元に戻しました。"
                + (f" あと {remaining} 操作戻せます。" if remaining else ""),
            ),
            5000,
        )

    def _undo_availability_changed(self, available: bool) -> None:
        if self.edit_mode:
            self._refresh_undo_action_text()

    def _playlist_undo_availability_changed(self, available: bool) -> None:
        if not self.edit_mode:
            self._refresh_undo_action_text()

    def _refresh_undo_action_text(self) -> None:
        if self.edit_mode:
            available = self.model.undo_count > 0
            self.undo_action.setText(
                tr("Undo", "元に戻す")
                if available
                else tr(
                    "Undo (No History)",
                    "元に戻す（履歴なし）",
                )
            )
            return
        available = self.model.playlist_undo_count > 0
        self.undo_action.setText(
            tr(
                "Undo Playlist Change",
                "プレイリスト操作を元に戻す",
            )
            if available
            else tr(
                "Undo Playlist Change (No History)",
                "プレイリスト操作を元に戻す（履歴なし）",
            )
        )

    def undo_last_playlist_operation(self) -> None:
        if not self.model.playlist_undo_count:
            self.statusBar().showMessage(
                tr(
                    "There are no playlist changes to undo.",
                    "元に戻せるプレイリスト操作はありません。",
                ),
                4000,
            )
            return
        self.stop_playback()
        self.current_track = None
        self.current_row = -1
        if not self.model.undo_last_playlist_operation():
            return
        if self.model.tracks:
            self.table.selectRow(0)
            self.show_track(self.model.tracks[0])
        remaining = self.model.playlist_undo_count
        self.statusBar().showMessage(
            tr(
                "Undid the last playlist change."
                + (f" {remaining} undo steps remain." if remaining else ""),
                "直前のプレイリスト操作を元に戻しました。"
                + (f" あと {remaining} 操作戻せます。" if remaining else ""),
            ),
            5000,
        )

    def _sync_edit_mode_controls(self, enabled: bool) -> None:
        self.tag_menu.menuAction().setVisible(enabled)
        self.edit_mode_button.blockSignals(True)
        self.edit_mode_button.setChecked(enabled)
        self.edit_mode_button.setText(
            tr("Finish Editing", "編集を終了")
            if enabled
            else tr("Edit Mode", "編集モード")
        )
        self.edit_mode_button.blockSignals(False)
        self.edit_mode_action.blockSignals(True)
        self.edit_mode_action.setChecked(enabled)
        self.edit_mode_action.setText(
            tr("Exit Edit Mode", "編集モードを終了")
            if enabled
            else tr("Edit Mode", "編集モード")
        )
        self.edit_mode_action.blockSignals(False)
        self.synced_lyrics_button.setText(
            tr("Edit Synced Lyrics", "同期歌詞を編集")
            if enabled
            else tr("Synchronized Lyrics", "同期歌詞")
        )

    def _show_edit_mode_required(self) -> None:
        self.statusBar().showMessage(
            tr(
                "Click Edit Mode at the top to change cells.",
                "セルを変更するには、上部の「編集モード」を押してください。",
            ),
            5000,
        )

    def _table_double_clicked(self, index) -> None:
        if (
            not self.edit_mode
            and self.model.column_key(index.column())
            not in {"rating wmp", "unsycedlyrics"}
        ):
            self.play_row(index.row())

    def open_lyrics_dialog(self, row: int) -> None:
        """Show or edit one track's long-form unsynchronized lyrics."""

        if not 0 <= row < len(self.model.tracks):
            return
        track = self.model.tracks[row]
        original = str(track.tag_value("unsycedlyrics") or "")
        if not self.edit_mode:
            # Playback lyrics are deliberately modeless: users commonly keep
            # them open while seeking, pausing, or changing tracks. Reuse the
            # sole viewer so rapid/repeated clicks cannot create duplicates.
            if self._lyrics_viewer is None:
                viewer = LyricsDialog(
                    track.title or Path(track.path).stem,
                    original,
                    editable=False,
                    parent=self,
                )
                self._lyrics_viewer = viewer
                viewer.setWindowModality(Qt.WindowModality.NonModal)
                viewer.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
                viewer.finished.connect(
                    lambda _result, target=viewer: self._lyrics_viewer_finished(
                        target
                    )
                )
            else:
                viewer = self._lyrics_viewer
                viewer.set_track(
                    track.title or Path(track.path).stem,
                    original,
                )
            viewer.show()
            viewer.raise_()
            viewer.activateWindow()
            return
        dialog = LyricsDialog(
            track.title or Path(track.path).stem,
            original,
            editable=True,
            parent=self,
        )
        result = dialog.exec()
        if (
            result != QDialog.DialogCode.Accepted
            or dialog.lyrics() == original
        ):
            return
        column = self.model.column_for_key("unsycedlyrics")
        if column is None:
            return
        if self.model.setData(
            self.model.index(row, column),
            dialog.lyrics(),
            Qt.ItemDataRole.EditRole,
        ):
            self.statusBar().showMessage(
                tr(
                    "Lyrics updated. Save Tags or finish Edit Mode to write the change.",
                    "歌詞を更新しました。「タグを保存」または編集モード終了時に保存されます。",
                ),
                6000,
            )

    def _lyrics_viewer_finished(self, viewer: LyricsDialog) -> None:
        if self._lyrics_viewer is viewer:
            self._lyrics_viewer = None

    def _update_open_lyrics_viewer(self, track: Track | None) -> None:
        """Follow the active playback track in the single lyrics viewer."""

        if self._lyrics_viewer is None or track is None:
            return
        self._lyrics_viewer.set_track(
            track.title or Path(track.path).stem,
            str(track.tag_value("unsycedlyrics") or ""),
        )

    def open_synced_lyrics(self) -> None:
        """Open the sole viewer or the LRC timestamp editor for one track."""

        track: Track | None = None
        if not self.edit_mode and self.current_track is not None:
            track = self.current_track
        else:
            current = self.table.currentIndex()
            row = current.row() if current.isValid() else 0
            if 0 <= row < len(self.model.tracks):
                track = self.model.tracks[row]
        if track is None:
            self.statusBar().showMessage(
                tr(
                    "Select a track before opening synchronized lyrics.",
                    "同期歌詞を開く楽曲を選択してください。",
                ),
                5000,
            )
            return

        if self.edit_mode:
            # Edit Mode already closes the modeless viewer. A modal editor with
            # its own preview player prevents duplicate timestamp sessions.
            editor = SyncedLyricsEditor(track, self)
            if editor.exec() == QDialog.DialogCode.Accepted:
                self.statusBar().showMessage(
                    tr(
                        f"Saved synchronized lyrics: {editor.lrc_path}",
                        f"同期歌詞を保存しました: {editor.lrc_path}",
                    ),
                    7000,
                )
            return

        if self._synced_lyrics_viewer is None:
            viewer = SyncedLyricsViewer(
                track,
                self.theme,
                self.visualizer_mode,
                self,
            )
            self._synced_lyrics_viewer = viewer
            viewer.setWindowModality(Qt.WindowModality.NonModal)
            viewer.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
            viewer.seekRequested.connect(self._seek_to)
            viewer.playPauseRequested.connect(self.toggle_playback)
            viewer.stopRequested.connect(self.stop_playback)
            viewer.previousRequested.connect(self.play_previous)
            viewer.nextRequested.connect(self.play_next)
            viewer.finished.connect(
                lambda _result, target=viewer: self._synced_lyrics_viewer_finished(
                    target
                )
            )
        else:
            viewer = self._synced_lyrics_viewer
            viewer.set_track(track)
        viewer.set_theme(self.theme)
        viewer.set_visualizer_mode(self.visualizer_mode)
        position = (
            self._relative_track_position(
                self.exclusive_player.position()
                if self._is_exclusive_mode()
                else self.player.position()
            )
            if track is self.current_track
            else 0
        )
        viewer.set_position(position, track.duration_ms)
        playing = self._audio_is_playing()
        viewer.set_playing(playing)
        viewer.set_visualizer_active(
            playing and self.visualizer_mode != "off"
        )
        viewer.show()
        viewer.raise_()
        viewer.activateWindow()

    def _synced_lyrics_viewer_finished(
        self, viewer: SyncedLyricsViewer
    ) -> None:
        if self._synced_lyrics_viewer is viewer:
            self._synced_lyrics_viewer = None

    def _update_open_synced_lyrics_viewer(self, track: Track | None) -> None:
        if self._synced_lyrics_viewer is None or track is None:
            return
        self._synced_lyrics_viewer.set_track(track)

    def _restore_state(self) -> None:
        geometry = self.settings.value("window/geometry")
        if geometry:
            self.restoreGeometry(geometry)
        self.apply_theme(
            "dark"
            if self.trial_edition
            else self.settings.value("appearance/theme", "dark"),
            persist=False,
            announce=False,
        )
        self.apply_visualizer_mode(
            self.settings.value("appearance/seek_visualizer", "waveform"),
            persist=False,
            announce=False,
        )
        if not self.trial_edition:
            background_path = str(
                self.settings.value("appearance/background_image", "") or ""
            )
            background_path = self._migrate_saved_background_image(background_path)
            if background_path and not self.background_canvas.set_background_image(
                background_path
            ):
                self.settings.setValue("appearance/background_image", "")
        raw_filename_pattern = self.settings.value(
            "tags/filename_pattern", None
        )
        saved_filename_pattern = (
            str(raw_filename_pattern)
            if raw_filename_pattern is not None
            else None
        )
        if saved_filename_pattern in {
            "off",
            "disc_track_title",
            "track_title",
        }:
            filename_pattern = str(saved_filename_pattern)
        else:
            # Migrate the former on/off setting. Its enabled behavior was
            # TrackNumber_Title, so it maps directly to track_title.
            legacy_auto_rename = str(
                self.settings.value("tags/auto_rename_files", "false")
            ).lower()
            filename_pattern = (
                "track_title"
                if legacy_auto_rename in {"1", "true", "yes", "on"}
                else "off"
            )
        if self.trial_edition:
            filename_pattern = "off"
        self.filename_pattern_actions[filename_pattern].setChecked(True)
        self.title_suffixes = (
            list(DEFAULT_TITLE_SUFFIXES)
            if self.trial_edition
            else normalize_title_suffixes(
                self.settings.value(
                    "tags/title_suffixes",
                    list(DEFAULT_TITLE_SUFFIXES),
                )
            )
        )
        suffix_space_value = str(
            self.settings.value("tags/title_suffix_insert_space", "false")
        ).lower()
        self.title_suffix_insert_space = suffix_space_value in {
            "1",
            "true",
            "yes",
            "on",
        }
        self.genre_suggestions = (
            []
            if self.trial_edition
            else normalize_genre_suggestions(
                self.settings.value(
                    "tags/genre_suggestions",
                    list(DEFAULT_GENRE_SUGGESTIONS),
                )
            )
        )
        self._apply_genre_suggestions()
        raw_tag_columns = (
            None
            if self.trial_edition
            else self.settings.value("tags/table_columns", None)
        )
        if isinstance(raw_tag_columns, str):
            try:
                decoded_tag_columns = json.loads(raw_tag_columns)
            except (TypeError, ValueError, json.JSONDecodeError):
                decoded_tag_columns = None
        else:
            decoded_tag_columns = raw_tag_columns
        self.tag_columns = normalize_tag_columns(decoded_tag_columns)
        raw_bulk_tag_keys = (
            None
            if self.trial_edition
            else self.settings.value("tags/bulk_edit_tags", None)
        )
        if isinstance(raw_bulk_tag_keys, str):
            try:
                decoded_bulk_tag_keys = json.loads(raw_bulk_tag_keys)
            except (TypeError, ValueError, json.JSONDecodeError):
                decoded_bulk_tag_keys = None
        else:
            decoded_bulk_tag_keys = raw_bulk_tag_keys
        self.bulk_tag_keys = normalize_bulk_tag_keys(
            decoded_bulk_tag_keys,
            self.tag_columns,
        )
        self.model.set_tag_columns(self.tag_columns)
        self._apply_table_column_widths()
        self.volume_slider.setValue(int(self.settings.value("playback/volume", 80)))
        order_mode = str(
            self.settings.value("playback/order", "continuous")
        )
        repeat_mode = str(self.settings.value("playback/repeat", "off"))
        order_index = self.play_order_combo.findData(order_mode)
        repeat_index = self.repeat_mode_combo.findData(repeat_mode)
        self.play_order_combo.blockSignals(True)
        self.repeat_mode_combo.blockSignals(True)
        self.gapless_checkbox.blockSignals(True)
        self.play_order_combo.setCurrentIndex(max(0, order_index))
        self.repeat_mode_combo.setCurrentIndex(max(0, repeat_index))
        gapless_value = (
            "false"
            if self.trial_edition
            else str(self.settings.value("playback/gapless", "false")).lower()
        )
        self.gapless_checkbox.setChecked(
            gapless_value in {"1", "true", "yes", "on"}
        )
        self.play_order_combo.blockSignals(False)
        self.repeat_mode_combo.blockSignals(False)
        self.gapless_checkbox.blockSignals(False)
        self.playback_order.configure(
            str(self.play_order_combo.currentData()),
            str(self.repeat_mode_combo.currentData()),
        )
        output_mode = (
            "shared"
            if self.trial_edition
            else str(self.settings.value("playback/output_mode", "shared"))
        )
        mode_index = self.output_mode_combo.findData(output_mode)
        if mode_index >= 0:
            self.output_mode_combo.setCurrentIndex(mode_index)
        self._apply_output_mode_ui()
        header_state = self.settings.value("table/header_state")
        if header_state:
            try:
                self.table.horizontalHeader().restoreState(header_state)
            except (TypeError, ValueError):
                pass
        self.table.horizontalHeader().setSectionsMovable(not self.edit_mode)
        playlist = str(
            self.settings.value("playlist/active", self.PLAYBACK_QUEUE)
        )
        if playlist in self.store.playlists:
            self.active_playlist = playlist

    def _populate_playlist_list(self) -> None:
        self._loading_playlist = True
        self.playlist_list.clear()
        for name in self.store.playlists:
            item = QListWidgetItem(self._playlist_display_name(name))
            item.setData(Qt.ItemDataRole.UserRole, name)
            self.playlist_list.addItem(item)
            if name == self.active_playlist:
                self.playlist_list.setCurrentItem(item)
        self._loading_playlist = False
        self._update_playlist_buttons()

    def _load_playlist(self, name: str) -> None:
        entries: list[str | dict] = []
        stored_entries = self.store.playlists.get(name, [])
        if self.trial_edition:
            stored_entries = stored_entries[:TRIAL_MAX_TRACKS_PER_PLAYLIST]
        for entry in stored_entries:
            path = entry.get("path", "") if isinstance(entry, dict) else entry
            if isinstance(path, str) and Path(path).is_file():
                entries.append(entry)
        display_name = self._playlist_display_name(name)
        playlist_tracks = self._read_tracks(
            entries,
            title=tr(
                f"Loading \"{display_name}\"",
                f"「{display_name}」を読み込み中",
            ),
        )
        self._loading_playlist = True
        self.model.set_tracks(playlist_tracks)
        self._loading_playlist = False
        self.active_playlist = name
        self.setWindowTitle(f"{self.product_name} — {display_name}")
        self._update_playlist_buttons()
        self.statusBar().showMessage(tracks(len(playlist_tracks)), 3000)

    def _schedule_playlist_save(self) -> None:
        if self._loading_playlist:
            return
        QTimer.singleShot(250, self._save_active_playlist)

    def _model_changed(self) -> None:
        if self.edit_mode:
            # Music can still be added while Edit Mode is active. Capture newly
            # added tracks before their first metadata edit so Cancel Editing
            # can restore them without removing them from the playlist.
            for track in self.model.tracks:
                self._edit_checkpoints.setdefault(id(track), track.to_dict())
        if self.current_track is not None:
            self.current_row = next(
                (
                    index
                    for index, track in enumerate(self.model.tracks)
                    if track is self.current_track
                ),
                -1,
            )
            if self.current_row < 0 and self._playback_is_active():
                self.stop_playback()
                self.current_track = None
            elif self._playback_is_active():
                if self.playback_order.order_mode == "shuffle":
                    self.playback_order.begin(
                        self.model.tracks, self.current_track
                    )
                self._prepare_gapless_next()
        self._update_clear_playlist_button()
        self._schedule_playlist_save()

    def _update_clear_playlist_button(self) -> None:
        self.clear_playlist_button.setEnabled(
            not self.edit_mode and bool(self.model.tracks)
        )

    def _update_playlist_buttons(self) -> None:
        editable = (
            self.active_playlist != self.PLAYBACK_QUEUE
            and self.active_playlist in self.store.playlists
        )
        self.rename_playlist_button.setEnabled(editable)
        self.remove_playlist_button.setEnabled(editable)

    def _save_active_playlist(self) -> None:
        self.store.playlists[self.active_playlist] = [
            track.to_playlist_entry() for track in self.model.tracks
        ]
        try:
            self.store.save()
        except OSError as exc:
            self.statusBar().showMessage(
                tr(
                    f"Could not save the playlist: {exc}",
                    f"プレイリストを保存できません: {exc}",
                )
            )

    @staticmethod
    def _normalized_playlist_path(path: object) -> str:
        try:
            return str(Path(str(path)).resolve()).casefold()
        except (OSError, ValueError):
            return str(path).casefold()

    @classmethod
    def _cue_playlist_key(cls, track: Track) -> tuple[str, str, int, int]:
        return (
            cls._normalized_playlist_path(track.cue_path),
            cls._normalized_playlist_path(track.path),
            int(track.cue_start_ms),
            int(track.cue_end_ms),
        )

    def _sync_saved_track_across_playlists(
        self, track: Track, previous_path: str
    ) -> None:
        """Update every playlist reference after one shared track is saved."""

        old_path = self._normalized_playlist_path(previous_path)
        new_path = self._normalized_playlist_path(track.path)
        path_changed = old_path != new_path
        cue_key = self._cue_playlist_key(track) if track.is_cue_track else None
        replacement_cue_entry = (
            track.to_playlist_entry() if track.is_cue_track else None
        )

        for playlist_name, entries in list(self.store.playlists.items()):
            synchronized: list[str | dict] = []
            changed = False
            for entry in entries:
                if isinstance(entry, str):
                    if path_changed and self._normalized_playlist_path(entry) == old_path:
                        synchronized.append(track.path)
                        changed = True
                    else:
                        synchronized.append(entry)
                    continue

                entry_copy = dict(entry)
                if cue_key is not None:
                    try:
                        entry_track = Track.from_dict(entry_copy)
                    except (TypeError, ValueError):
                        entry_track = None
                    if (
                        entry_track is not None
                        and entry_track.is_cue_track
                        and self._cue_playlist_key(entry_track) == cue_key
                    ):
                        synchronized.append(dict(replacement_cue_entry))
                        changed = True
                        continue
                if (
                    path_changed
                    and self._normalized_playlist_path(
                        entry_copy.get("path", "")
                    )
                    == old_path
                ):
                    entry_copy["path"] = track.path
                    if "filename" in entry_copy:
                        entry_copy["filename"] = Path(track.path).name
                    changed = True
                synchronized.append(entry_copy)
            if changed:
                self.store.playlists[playlist_name] = synchronized

    # ---------- playlists and loading ----------

    def _read_tracks(self, entries: list[str | dict], title: str) -> list[Track]:
        if not entries:
            return []
        progress = QProgressDialog(
            title, tr("Cancel", "キャンセル"), 0, len(entries), self
        )
        progress.setWindowModality(Qt.WindowModality.WindowModal)
        progress.setMinimumDuration(350)
        tracks: list[Track] = []
        for index, entry in enumerate(entries):
            if progress.wasCanceled():
                break
            path = entry.get("path", "") if isinstance(entry, dict) else entry
            progress.setValue(index)
            progress.setLabelText(f"{title}\n{Path(str(path)).name}")
            QApplication.processEvents()
            tracks.append(
                Track.from_dict(entry)
                if isinstance(entry, dict)
                else Track.from_path(entry)
            )
        progress.setValue(len(entries))
        return tracks

    def add_files_dialog(self) -> None:
        files, _ = QFileDialog.getOpenFileNames(
            self, tr("Add Music Files", "音楽ファイルを追加"), "", _audio_filter()
        )
        if files:
            self.add_paths(files)

    def add_folder_dialog(self) -> None:
        folder = QFileDialog.getExistingDirectory(
            self, tr("Add Music Folder", "音楽フォルダーを追加")
        )
        if folder:
            self.add_paths([folder])

    def add_paths(self, paths: list[str]) -> None:
        files = discover_audio_files(paths)
        cue_files = discover_cue_files(paths)
        cue_tracks: list[Track] = []
        cue_errors: list[str] = []
        for cue_file in cue_files:
            try:
                cue_tracks.extend(parse_cue_sheet(cue_file))
            except CueSheetError as exc:
                cue_errors.append(f"{Path(cue_file).name}: {exc}")

        # When a folder or a multi-file selection contains both a CUE sheet
        # and its referenced disc image, add the virtual tracks only.
        cue_audio_paths = {track.path.casefold() for track in cue_tracks}
        files = [path for path in files if path.casefold() not in cue_audio_paths]
        added_tracks = cue_tracks + self._read_tracks(
            files, title=tr("Loading music", "音楽を読み込み中")
        )
        existing = {track.playlist_identity for track in self.model.tracks}
        unique_tracks: list[Track] = []
        for track in added_tracks:
            identity = track.playlist_identity
            if identity in existing:
                continue
            existing.add(identity)
            unique_tracks.append(track)
        # File dialogs, folder traversal and drag-and-drop can each provide a
        # different OS-dependent order.  Normalize every newly imported batch
        # to the same natural filename order before appending it.  Python's
        # stable sort preserves the track order inside a CUE sheet because its
        # virtual tracks share the same source filename.
        added_tracks = sorted(unique_tracks, key=_natural_filename_key)
        limited_count = 0
        if self.trial_edition:
            available = max(
                0,
                TRIAL_MAX_TRACKS_PER_PLAYLIST - len(self.model.tracks),
            )
            limited_count = max(0, len(added_tracks) - available)
            added_tracks = added_tracks[:available]
        if not added_tracks:
            if cue_errors:
                QMessageBox.warning(
                    self,
                    tr("CUE Import Error", "CUE読み込みエラー"),
                    "\n".join(cue_errors[:8]),
                )
                return
            self.statusBar().showMessage(
                tr(
                    "This Trial playlist is limited to 20 tracks."
                    if limited_count
                    else "No new supported audio or CUE tracks were found.",
                    "試用版では1つのプレイリストにつき20曲までです。"
                    if limited_count
                    else "追加できる新しい音声・CUEトラックがありません。",
                ),
                4000,
            )
            return
        self.model.append_tracks(added_tracks)
        if cue_errors:
            QMessageBox.warning(
                self,
                tr("Some CUE Sheets Could Not Be Read", "一部のCUEを読み込めませんでした"),
                "\n".join(cue_errors[:8]),
            )
        self.statusBar().showMessage(
            tr(
                f"Added {tracks(len(added_tracks))}."
                + (
                    f" Skipped {limited_count} tracks due to the Trial limit."
                    if limited_count
                    else ""
                ),
                f"{len(added_tracks)} 曲を追加しました。"
                + (
                    f" 試用版の上限により {limited_count} 曲は追加していません。"
                    if limited_count
                    else ""
                ),
            ),
            4000,
        )

    def remove_rows(self, rows: list[int]) -> None:
        if self.current_row in rows:
            self.stop_playback()
            self.current_track = None
            self.current_row = -1
        self.model.remove_rows(rows)

    def clear_current_playlist(self) -> None:
        """Clear the active playlist immediately as one undoable operation."""

        if self.edit_mode:
            self.statusBar().showMessage(
                tr(
                    "Finish Edit Mode before clearing the playlist.",
                    "プレイリストを全消去する前に編集モードを終了してください。",
                ),
                5000,
            )
            return
        if not self.model.tracks:
            return
        self.stop_playback()
        self.current_track = None
        self.current_row = -1
        count = self.model.clear_tracks()
        self.table.clearSelection()
        self.statusBar().showMessage(
            tr(
                f"Cleared {count} tracks. Press Ctrl+Z to undo.",
                f"{count} 曲を消去しました。Ctrl+Zで元に戻せます。",
            ),
            6000,
        )

    def playlist_selected(
        self, current: QListWidgetItem | None, previous: QListWidgetItem | None
    ) -> None:
        if self._loading_playlist or current is None:
            return
        name = current.data(Qt.ItemDataRole.UserRole) or current.text()
        if name == self.active_playlist:
            return
        self._save_active_playlist()
        self.stop_playback()
        self.current_row = -1
        self.current_track = None
        self._load_playlist(name)

    def create_playlist(self) -> None:
        if self.trial_edition:
            user_playlist_count = sum(
                name != self.PLAYBACK_QUEUE for name in self.store.playlists
            )
            if user_playlist_count >= TRIAL_MAX_USER_PLAYLISTS:
                QMessageBox.information(
                    self,
                    tr("Trial Limit", "試用版の制限"),
                    tr(
                        "The Trial Edition can create one playlist.",
                        "試用版で作成できるプレイリストは1つまでです。",
                    ),
                )
                return
        name, accepted = QInputDialog.getText(
            self,
            tr("New Playlist", "新しいプレイリスト"),
            tr("Name:", "名前:"),
        )
        name = name.strip()
        if not accepted or not name:
            return
        if name in self.store.playlists:
            QMessageBox.information(
                self,
                tr("Playlist", "プレイリスト"),
                tr(
                    "A playlist with that name already exists.",
                    "同じ名前が既にあります。",
                ),
            )
            return
        self._save_active_playlist()
        self.store.playlists[name] = []
        self.store.save()
        self.active_playlist = name
        self._populate_playlist_list()
        self._load_playlist(name)

    def rename_playlist(self) -> None:
        old_name = self.active_playlist
        if old_name == self.PLAYBACK_QUEUE:
            QMessageBox.information(
                self,
                tr("Playlist", "プレイリスト"),
                tr(
                    "Playback Queue cannot be renamed.",
                    "「再生キュー」の名称は変更できません。",
                ),
            )
            return
        new_name, accepted = QInputDialog.getText(
            self,
            tr("Rename Playlist", "プレイリスト名を変更"),
            tr("New name:", "新しい名前:"),
            QLineEdit.EchoMode.Normal,
            old_name,
        )
        new_name = new_name.strip()
        if not accepted or not new_name or new_name == old_name:
            return
        reserved_names = {
            self.PLAYBACK_QUEUE.casefold(),
            "Playback Queue".casefold(),
        }
        duplicate = any(
            name != old_name and name.casefold() == new_name.casefold()
            for name in self.store.playlists
        )
        if new_name.casefold() in reserved_names or duplicate:
            QMessageBox.information(
                self,
                tr("Playlist", "プレイリスト"),
                tr(
                    "A playlist with that name already exists.",
                    "同じ名前のプレイリストが既にあります。",
                ),
            )
            return
        self._save_active_playlist()
        previous_playlists = self.store.playlists
        renamed_playlists: dict[str, list[str | dict]] = {}
        for name, entries in previous_playlists.items():
            renamed_playlists[new_name if name == old_name else name] = entries
        self.store.playlists = renamed_playlists
        try:
            self.store.save()
        except OSError as exc:
            self.store.playlists = previous_playlists
            QMessageBox.warning(
                self,
                tr("Rename Playlist", "プレイリスト名を変更"),
                tr(
                    f"Could not rename the playlist: {exc}",
                    f"プレイリスト名を変更できませんでした: {exc}",
                ),
            )
            return
        self.active_playlist = new_name
        self.settings.setValue("playlist/active", new_name)
        self._populate_playlist_list()
        self.setWindowTitle(f"{self.product_name} — {new_name}")
        self.statusBar().showMessage(
            tr(
                f'Renamed playlist to "{new_name}".',
                f'プレイリスト名を「{new_name}」へ変更しました。',
            ),
            5000,
        )

    def copy_tracks_to_playlist(
        self, playlist_name: str, rows: list[int]
    ) -> None:
        if playlist_name not in self.store.playlists:
            return
        selected_tracks = [
            self.model.tracks[row]
            for row in sorted(set(rows))
            if 0 <= row < len(self.model.tracks)
        ]
        if not selected_tracks:
            return
        # Save the source playlist first so a drop never loses recent row
        # reordering or deletion changes.
        self._save_active_playlist()
        destination_entries = list(self.store.playlists[playlist_name])
        existing_identities: set[str] = set()
        for entry in destination_entries:
            try:
                if isinstance(entry, dict):
                    identity = Track.from_dict(entry).playlist_identity
                else:
                    identity = str(Path(entry).resolve()).casefold()
            except (TypeError, ValueError):
                continue
            existing_identities.add(identity)

        added_entries: list[str | dict] = []
        for track in selected_tracks:
            if track.playlist_identity in existing_identities:
                continue
            existing_identities.add(track.playlist_identity)
            added_entries.append(track.to_playlist_entry())
        if not added_entries:
            self.statusBar().showMessage(
                tr(
                    "The selected tracks are already in that playlist.",
                    "選択した曲はすでにドロップ先のプレイリストにあります。",
                ),
                5000,
            )
            return
        if self.trial_edition:
            available = max(
                0,
                TRIAL_MAX_TRACKS_PER_PLAYLIST - len(destination_entries),
            )
            added_entries = added_entries[:available]
            if not added_entries:
                self.statusBar().showMessage(
                    tr(
                        "The destination Trial playlist is limited to 20 tracks.",
                        "試用版のドロップ先プレイリストは20曲が上限です。",
                    ),
                    5000,
                )
                return
        self.store.playlists[playlist_name] = [
            *destination_entries,
            *added_entries,
        ]
        try:
            self.store.save()
        except OSError as exc:
            self.store.playlists[playlist_name] = destination_entries
            QMessageBox.warning(
                self,
                tr("Playlist", "プレイリスト"),
                tr(
                    f"Could not add tracks to the playlist: {exc}",
                    f"プレイリストへ曲を追加できませんでした: {exc}",
                ),
            )
            return
        skipped = len(selected_tracks) - len(added_entries)
        self.statusBar().showMessage(
            tr(
                f'Added {tracks(len(added_entries))} to "{self._playlist_display_name(playlist_name)}".'
                + (
                    f" Skipped {skipped} duplicate or Trial-limited tracks."
                    if skipped
                    else ""
                ),
                f'「{self._playlist_display_name(playlist_name)}」の末尾へ {len(added_entries)} 曲追加しました。'
                + (
                    f" 重複または試用版上限の {skipped} 曲は追加していません。"
                    if skipped
                    else ""
                ),
            ),
            7000,
        )

    def remove_playlist(self) -> None:
        if self.active_playlist == self.PLAYBACK_QUEUE:
            QMessageBox.information(
                self,
                tr("Playlist", "プレイリスト"),
                tr(
                    "Playback Queue cannot be deleted.",
                    "「再生キュー」は削除できません。",
                ),
            )
            return
        answer = QMessageBox.question(
            self,
            tr("Delete Playlist", "プレイリストを削除"),
            tr(
                f'Delete "{self.active_playlist}"?\nThe music files will not be deleted.',
                f"「{self.active_playlist}」を削除しますか？\n音楽ファイル自体は削除されません。",
            ),
        )
        if answer != QMessageBox.StandardButton.Yes:
            return
        del self.store.playlists[self.active_playlist]
        self.active_playlist = self.PLAYBACK_QUEUE
        self.store.save()
        self._populate_playlist_list()
        self._load_playlist(self.active_playlist)

    def import_m3u(self) -> None:
        if self.trial_edition:
            return
        filename, _ = QFileDialog.getOpenFileName(
            self,
            tr("Import Playlist", "プレイリストを読み込む"),
            "",
            tr(
                "M3U playlists (*.m3u *.m3u8)",
                "M3Uプレイリスト (*.m3u *.m3u8)",
            ),
        )
        if not filename:
            return
        source = Path(filename)
        try:
            lines = source.read_text(encoding="utf-8-sig").splitlines()
        except UnicodeDecodeError:
            lines = source.read_text(encoding="cp932").splitlines()
        paths = []
        for line in lines:
            line = line.strip()
            if not line or line.startswith("#"):
                continue
            candidate = Path(line)
            if not candidate.is_absolute():
                candidate = source.parent / candidate
            if candidate.is_file() and candidate.suffix.lower() in SUPPORTED_EXTENSIONS:
                paths.append(str(candidate.resolve()))
        name = source.stem
        original = name
        counter = 2
        while name in self.store.playlists:
            name = f"{original} ({counter})"
            counter += 1
        self._save_active_playlist()
        self.store.playlists[name] = paths
        self.store.save()
        self.active_playlist = name
        self._populate_playlist_list()
        self._load_playlist(name)

    def export_m3u(self) -> None:
        if self.trial_edition:
            return
        default = f"{self.active_playlist}.m3u8"
        filename, selected_filter = QFileDialog.getSaveFileName(
            self,
            tr("Export Playlist", "プレイリストを保存"),
            default,
            tr(
                "M3U8 playlists (*.m3u8);;M3U playlists (*.m3u)",
                "M3U8プレイリスト (*.m3u8);;M3Uプレイリスト (*.m3u)",
            ),
        )
        if not filename:
            return
        path = Path(filename)
        if path.suffix.lower() not in {".m3u", ".m3u8"}:
            selected_m3u = (
                "*.m3u)" in selected_filter and "*.m3u8" not in selected_filter
            )
            path = path.with_suffix(".m3u" if selected_m3u else ".m3u8")
        lines = ["#EXTM3U"]
        for track in self.model.tracks:
            seconds = track.duration_ms // 1000 if track.duration_ms else -1
            display = " - ".join(value for value in (track.artist, track.title) if value)
            lines.extend([f"#EXTINF:{seconds},{display}", track.path])
        content = "\n".join(lines) + "\n"
        encoding = "utf-8-sig" if path.suffix.lower() == ".m3u8" else "cp932"
        try:
            try:
                content.encode(encoding)
            except UnicodeEncodeError:
                encoding = "utf-8-sig"
            path.write_text(content, encoding=encoding)
            self.statusBar().showMessage(
                tr(f"Saved: {path}", f"保存しました: {path}"), 5000
            )
        except OSError as exc:
            QMessageBox.critical(
                self, tr("Save Error", "保存エラー"), str(exc)
            )

    # ---------- playback ----------

    def play_row(self, row: int, *, reset_sequence: bool = True) -> None:
        if self.edit_mode:
            return
        if not 0 <= row < len(self.model.tracks):
            return
        self.current_row = row
        self.current_track = self.model.tracks[row]
        self._shared_visualizer_samples = None
        self._shared_visualizer_sample_rate = 0
        self._cue_segment_end_pending = False
        if reset_sequence:
            self.playback_order.begin(
                self.model.tracks, self.current_track
            )
        if self._is_exclusive_mode():
            self.player.stop()
            self._clear_shared_preload()
            try:
                self.exclusive_player.set_source(self.current_track.path)
                if self.current_track.is_cue_track:
                    self.exclusive_player.set_playback_range(
                        self.current_track.cue_start_ms,
                        self.current_track.cue_end_ms,
                    )
                self.exclusive_player.play()
                conversion = self.exclusive_player.conversion_note
                self.output_note.setText(
                    f"{tr('WASAPI Exclusive', 'WASAPI排他')} • "
                    f"{self.exclusive_player.output_format_text()} • "
                    f"{conversion} • {tr('Volume 100%', '音量100%')}"
                )
                self.statusBar().showMessage(
                    f"{tr('Exclusive output', '排他出力')}: "
                    f"{self.exclusive_player.output_format_text()} • "
                    f"{conversion}",
                    5000,
                )
            except ExclusivePlaybackError as exc:
                self._fallback_to_shared(str(exc))
                self.player.setSource(QUrl.fromLocalFile(self.current_track.path))
                if self.current_track.is_cue_track:
                    self.player.setPosition(self.current_track.cue_start_ms)
                self.player.play()
        else:
            self.exclusive_player.stop()
            if not self._activate_shared_preload(self.current_track):
                self._clear_shared_preload()
                self.player.stop()
                self.player.setSource(
                    QUrl.fromLocalFile(self.current_track.path)
                )
                if self.current_track.is_cue_track:
                    self.player.setPosition(self.current_track.cue_start_ms)
                self.player.play()
        self._update_open_lyrics_viewer(self.current_track)
        self._update_open_synced_lyrics_viewer(self.current_track)
        self.show_track(self.current_track)
        self.table.selectRow(row)
        self._prepare_gapless_next()

    def toggle_playback(self) -> None:
        if self.edit_mode:
            return
        if self._is_exclusive_mode():
            if self.exclusive_player.state == "playing":
                self.exclusive_player.pause()
                return
            if self.exclusive_player.state == "paused":
                try:
                    self.exclusive_player.play()
                    self._prepare_gapless_next()
                except ExclusivePlaybackError as exc:
                    self._fallback_and_resume_shared(str(exc))
                return
            current = self.table.currentIndex()
            row = (
                self.current_row
                if 0 <= self.current_row < len(self.model.tracks)
                else current.row()
                if current.isValid()
                else 0
            )
            self.play_row(row)
            return
        if (
            self.player.playbackState()
            == QMediaPlayer.PlaybackState.PlayingState
        ):
            self.player.pause()
            return
        if (
            self.player.source().isEmpty()
            or self.player.playbackState()
            == QMediaPlayer.PlaybackState.StoppedState
        ):
            current = self.table.currentIndex()
            row = (
                self._row_for_track(self.current_track)
                if self.current_track is not None
                else current.row()
                if current.isValid()
                else 0
            )
            self.play_row(row)
        else:
            self.player.play()

    def stop_playback(self) -> None:
        self._cue_segment_end_pending = False
        self._release_playback_sources()
        self._flush_pending_playback_ratings()
        self.playback_order.reset()
        self.position_slider.setValue(0)
        self.elapsed_label.setText("0:00")
        duration = (
            self.current_track.duration_ms if self.current_track is not None else 0
        )
        self.remaining_label.setText(
            f"−{format_duration(duration)}" if duration else "0:00"
        )
        self.play_button.setText("▶")

    def _release_playback_sources(self) -> None:
        """Stop every decoder and release audio-file handles on Windows."""

        self._sync_visualizer_playback(False)
        self._shared_visualizer_samples = None
        self._shared_visualizer_sample_rate = 0
        self.player.stop()
        self.player.setSource(QUrl())
        self.exclusive_player.stop()
        self._clear_shared_preload()
        # Qt Multimedia releases its Windows backend asynchronously in some
        # cases.  Drain the queued source-change events before tag writers or
        # filename changes touch the same file.
        QApplication.processEvents()

    def _save_track_tags_with_retry(self, track: Track) -> str:
        retry_delays_ms = (60, 160, 320)
        for attempt in range(len(retry_delays_ms) + 1):
            try:
                return save_track_tags(track)
            except Exception as exc:
                if (
                    attempt >= len(retry_delays_ms)
                    or not _is_probable_file_lock_error(exc)
                ):
                    raise
                self._release_playback_sources()
                QThread.msleep(retry_delays_ms[attempt])
                QApplication.processEvents()
        raise RuntimeError("Unreachable tag-save retry state.")

    def play_next(self) -> None:
        if self.edit_mode:
            return
        self._advance_playback(automatic=False)

    def play_previous(self) -> None:
        if self.edit_mode:
            return
        source_position = (
            self.exclusive_player.position()
            if self._is_exclusive_mode()
            else self.player.position()
        )
        position = self._relative_track_position(source_position)
        if position > 5000:
            self._seek_to(0)
            return
        if not self.model.tracks:
            return
        previous_track = self.playback_order.previous_track(
            self.model.tracks, self.current_track
        )
        previous_row = self._row_for_track(previous_track)
        if previous_row >= 0:
            self.play_row(previous_row, reset_sequence=False)

    def _row_for_track(self, target: Track | None) -> int:
        if target is None:
            return -1
        return next(
            (
                row
                for row, track in enumerate(self.model.tracks)
                if track is target
            ),
            -1,
        )

    def _advance_playback(self, *, automatic: bool) -> None:
        if not self.model.tracks:
            return
        if self.current_track is None:
            current = self.table.currentIndex()
            self.play_row(current.row() if current.isValid() else 0)
            return
        next_track = self.playback_order.next_track(
            self.model.tracks,
            self.current_track,
            automatic=automatic,
        )
        if next_track is None:
            self.stop_playback()
            self.statusBar().showMessage(
                tr(
                    "Reached the end of the playlist.",
                    "プレイリストの最後まで再生しました。",
                ),
                5000,
            )
            return
        next_row = self._row_for_track(next_track)
        if next_row >= 0:
            self.play_row(next_row, reset_sequence=False)

    def _handle_track_finished(self) -> None:
        if self._handling_track_end:
            return
        self._handling_track_end = True
        try:
            self._advance_playback(automatic=True)
        finally:
            self._handling_track_end = False

    def _clear_shared_preload(self) -> None:
        self.preload_player.stop()
        self.preload_player.setSource(QUrl())
        self._preloaded_track = None

    def _activate_shared_preload(self, track: Track) -> bool:
        if self._preloaded_track is not track:
            return False
        old_player = self.player
        old_audio_output = self.audio_output
        old_buffer_output = self.audio_buffer_output
        self.player = self.preload_player
        self.audio_output = self.preload_audio_output
        self.audio_buffer_output = self.preload_audio_buffer_output
        self.preload_player = old_player
        self.preload_audio_output = old_audio_output
        self.preload_audio_buffer_output = old_buffer_output
        self._preloaded_track = None
        duration = (
            track.duration_ms
            if track.is_cue_track
            else self.player.duration() or track.duration_ms
        )
        self.position_slider.setRange(0, max(0, duration))
        self.position_slider.setValue(0)
        self.elapsed_label.setText("0:00")
        self.remaining_label.setText(f"−{format_duration(duration)}")
        if track.is_cue_track:
            self.player.setPosition(track.cue_start_ms)
        self.player.play()
        self.preload_player.stop()
        self.preload_player.setSource(QUrl())
        return True

    def _prepare_gapless_next(self) -> None:
        if not self.gapless_checkbox.isChecked() or self.current_track is None:
            self._clear_shared_preload()
            self.exclusive_player.clear_queued_source()
            return
        next_track = self.playback_order.peek_next(
            self.model.tracks,
            self.current_track,
            automatic=True,
        )
        if next_track is None:
            self._clear_shared_preload()
            self.exclusive_player.clear_queued_source()
            return
        if self.current_track.is_cue_track or next_track.is_cue_track:
            # Whole-file gapless transitions do not understand CUE intervals.
            # Segment boundaries are instead handled by the position monitor.
            self._clear_shared_preload()
            self.exclusive_player.clear_queued_source()
            return
        if self._is_exclusive_mode():
            self._clear_shared_preload()
            if not self.exclusive_player.queue_source(next_track.path):
                self.statusBar().showMessage(
                    tr(
                        "The next track uses a different audio format; "
                        "WASAPI Exclusive will use a normal transition.",
                        "次の曲は音声形式が異なるため、WASAPI排他では"
                        "通常の曲切替を行います。",
                    ),
                    5000,
                )
            return
        self.exclusive_player.clear_queued_source()
        if self._preloaded_track is next_track:
            return
        self._clear_shared_preload()
        self.preload_player.setSource(QUrl.fromLocalFile(next_track.path))
        self._preloaded_track = next_track

    def _exclusive_gapless_transition(self, path: str) -> None:
        if (
            not self.gapless_checkbox.isChecked()
            or not self._is_exclusive_mode()
            or self.exclusive_player.state != "playing"
        ):
            return
        next_track = self.playback_order.next_track(
            self.model.tracks,
            self.current_track,
            automatic=True,
        )
        if next_track is None:
            return
        try:
            transitioned_path = Path(path).resolve()
            expected_path = Path(next_track.path).resolve()
        except OSError:
            transitioned_path = Path(path)
            expected_path = Path(next_track.path)
        if transitioned_path != expected_path:
            return
        row = self._row_for_track(next_track)
        if row < 0:
            return
        self.current_track = next_track
        self.current_row = row
        self._update_open_lyrics_viewer(next_track)
        self._update_open_synced_lyrics_viewer(next_track)
        self.show_track(next_track)
        self.table.selectRow(row)
        self._prepare_gapless_next()

    def _relative_track_position(self, source_position: int) -> int:
        if self.current_track is None or not self.current_track.is_cue_track:
            return max(0, int(source_position))
        return max(0, int(source_position) - self.current_track.cue_start_ms)

    def _source_position_for_track(self, relative_position: int) -> int:
        if self.current_track is None or not self.current_track.is_cue_track:
            return max(0, int(relative_position))
        return self.current_track.cue_start_ms + max(0, int(relative_position))

    def _audio_is_playing(self) -> bool:
        if self._is_exclusive_mode():
            return self.exclusive_player.state == "playing"
        return (
            self.player.playbackState()
            == QMediaPlayer.PlaybackState.PlayingState
        )

    def _sync_visualizer_playback(self, playing: bool) -> None:
        active = bool(
            playing
            and not self.edit_mode
            and self.visualizer_mode != "off"
        )
        self.position_slider.set_visualizer_active(active)
        if self._synced_lyrics_viewer is not None:
            self._synced_lyrics_viewer.set_visualizer_active(active)
        if active:
            self._visualizer_timer.start()
            self._update_visualizer()
        else:
            self._visualizer_timer.stop()

    def _audio_buffer_received(self, buffer: QAudioBuffer) -> None:
        if (
            self.sender() is not self.audio_buffer_output
            or self.visualizer_mode == "off"
            or self.edit_mode
        ):
            return
        samples, sample_rate = audio_buffer_to_mono(buffer)
        self._shared_visualizer_samples = samples
        self._shared_visualizer_sample_rate = sample_rate

    def _update_visualizer(self) -> None:
        if not self._audio_is_playing() or self.visualizer_mode == "off":
            self._sync_visualizer_playback(False)
            return
        if self._is_exclusive_mode():
            samples = native_samples_to_mono(
                self.exclusive_player.latest_visualizer_samples()
            )
            sample_rate = (
                self.exclusive_player.probe.sample_rate
                if self.exclusive_player.probe is not None
                else 0
            )
        else:
            samples = self._shared_visualizer_samples
            sample_rate = self._shared_visualizer_sample_rate
        if samples is None or not len(samples):
            self.position_slider.set_visualizer_values([])
            if self._synced_lyrics_viewer is not None:
                self._synced_lyrics_viewer.set_visualizer_values([])
            return
        point_count = max(96, min(320, self.position_slider.width() // 3))
        values = visualizer_values(
            samples,
            self.visualizer_mode,
            sample_rate,
            point_count,
        )
        self.position_slider.set_visualizer_values(values)
        if self._synced_lyrics_viewer is not None:
            self._synced_lyrics_viewer.set_visualizer_values(values)

    def _display_track_duration(self, source_duration: int = 0) -> int:
        if self.current_track is not None and self.current_track.is_cue_track:
            return max(0, self.current_track.duration_ms)
        return max(0, int(source_duration))

    def _position_changed(self, position: int) -> None:
        if self.sender() is self.preload_player:
            return
        source_position = int(position)
        position = self._relative_track_position(source_position)
        if not self._slider_dragging:
            self.position_slider.setValue(position)
            self.elapsed_label.setText(format_duration(position))
        source_duration = (
            self.exclusive_player.duration()
            if self._is_exclusive_mode()
            else self.player.duration()
        )
        duration = self._display_track_duration(source_duration)
        self.remaining_label.setText(
            f"−{format_duration(max(0, duration - position))}"
        )
        if self._synced_lyrics_viewer is not None:
            self._synced_lyrics_viewer.set_position(position, duration)
        track = self.current_track
        if (
            track is not None
            and track.is_cue_track
            and track.cue_end_ms > track.cue_start_ms
            and source_position >= track.cue_end_ms
            and self._playback_is_active()
            and not self._cue_segment_end_pending
        ):
            self._cue_segment_end_pending = True
            QTimer.singleShot(
                0,
                lambda expected=track: self._finish_cue_segment(expected),
            )

    def _finish_cue_segment(self, expected_track: Track) -> None:
        self._cue_segment_end_pending = False
        if self.current_track is not expected_track or not self._playback_is_active():
            return
        source_position = (
            self.exclusive_player.position()
            if self._is_exclusive_mode()
            else self.player.position()
        )
        if source_position < expected_track.cue_end_ms:
            return
        self._handle_track_finished()

    def _duration_changed(self, duration: int) -> None:
        if self.sender() is self.preload_player:
            return
        duration = self._display_track_duration(duration)
        self.position_slider.setRange(0, max(0, duration))
        self.remaining_label.setText(f"−{format_duration(duration)}")
        if self._synced_lyrics_viewer is not None:
            self._synced_lyrics_viewer.set_position(
                self.position_slider.value(), duration
            )

    def _slider_pressed(self) -> None:
        self._slider_dragging = True

    def _slider_released(self) -> None:
        value = self.position_slider.value()
        self._slider_dragging = False
        self._seek_to(value)

    def _seek_to(self, position: int) -> None:
        position = max(
            self.position_slider.minimum(),
            min(int(position), self.position_slider.maximum()),
        )
        self.position_slider.setValue(position)
        self.elapsed_label.setText(format_duration(position))
        source_duration = (
            self.exclusive_player.duration()
            if self._is_exclusive_mode()
            else self.player.duration()
        )
        duration = max(
            self.position_slider.maximum(),
            self._display_track_duration(source_duration),
        )
        self.remaining_label.setText(
            f"−{format_duration(max(0, duration - position))}"
        )
        self._cue_segment_end_pending = False
        source_position = self._source_position_for_track(position)
        if self._is_exclusive_mode():
            try:
                self.exclusive_player.set_position(source_position)
                self._prepare_gapless_next()
            except ExclusivePlaybackError as exc:
                self._fallback_and_resume_shared(str(exc))
        else:
            self.player.setPosition(source_position)

    def _playback_state_changed(
        self, state: QMediaPlayer.PlaybackState
    ) -> None:
        if self.sender() is self.preload_player:
            return
        self.play_button.setText(
            "❚❚" if state == QMediaPlayer.PlaybackState.PlayingState else "▶"
        )
        self._sync_visualizer_playback(
            state == QMediaPlayer.PlaybackState.PlayingState
        )
        if self._synced_lyrics_viewer is not None:
            self._synced_lyrics_viewer.set_playing(
                state == QMediaPlayer.PlaybackState.PlayingState
            )

    def _exclusive_state_changed(self, state: str) -> None:
        self.play_button.setText("❚❚" if state == "playing" else "▶")
        self._sync_visualizer_playback(state == "playing")
        if self._synced_lyrics_viewer is not None:
            self._synced_lyrics_viewer.set_playing(state == "playing")

    def _media_status_changed(self, status: QMediaPlayer.MediaStatus) -> None:
        sender = self.sender()
        if sender is not None and sender is not self.player:
            return
        if (
            not self._is_exclusive_mode()
            and self.current_track is not None
            and self.current_track.is_cue_track
            and status
            in {
                QMediaPlayer.MediaStatus.LoadedMedia,
                QMediaPlayer.MediaStatus.BufferedMedia,
            }
            and self.player.position() < self.current_track.cue_start_ms
        ):
            self.player.setPosition(self.current_track.cue_start_ms)
        if (
            not self._is_exclusive_mode()
            and status == QMediaPlayer.MediaStatus.EndOfMedia
        ):
            self._handle_track_finished()

    def _playback_error(
        self, error: QMediaPlayer.Error, error_string: str
    ) -> None:
        if self.sender() is self.preload_player:
            self._clear_shared_preload()
            return
        if error == QMediaPlayer.Error.NoError:
            return
        self.statusBar().showMessage(
            tr(
                f"Playback error: {error_string}",
                f"再生エラー: {error_string}",
            ),
            8000,
        )

    def _exclusive_error(self, error_string: str) -> None:
        self._fallback_and_resume_shared(error_string)

    # ---------- display and audio devices ----------

    def _is_exclusive_mode(self) -> bool:
        return (
            not self.trial_edition
            and self.output_mode_combo.currentData() == "exclusive"
        )

    def _shared_volume_changed(self, value: int) -> None:
        volume = value / 100.0
        self.audio_output.setVolume(volume)
        self.preload_audio_output.setVolume(volume)

    def show_track(self, track: Track) -> None:
        self.now_title.setText(track.title or Path(track.path).stem)
        self.now_artist.setText(
            track.artist or tr("Unknown artist", "アーティスト不明")
        )
        self.now_album.setText(track.album or tr("Unknown album", "アルバム不明"))
        codec = track.codec or Path(track.path).suffix.lstrip(".").upper()
        self.quality_badge.setText(
            f"SOURCE  {codec}  •  {track.quality_text}"
        )
        self.artwork_label.setPixmap(QPixmap())
        self.artwork_label.show_placeholder()
        try:
            artwork = read_artwork(track.path)
        except Exception:
            artwork = None
        if artwork:
            pixmap = QPixmap()
            if pixmap.loadFromData(artwork[0]):
                self.artwork_label.show_embedded_artwork(
                    pixmap,
                    artwork[0],
                    artwork[1],
                    track.filename or Path(track.path).name,
                )

    def _refresh_audio_devices(self) -> None:
        if self._is_exclusive_mode():
            self._refresh_exclusive_devices()
            return
        saved_id = str(self.settings.value("playback/device_id", ""))
        self.device_combo.blockSignals(True)
        self.device_combo.clear()
        self.device_combo.setEnabled(True)
        self._audio_devices = list(QMediaDevices.audioOutputs())
        default_id = bytes(QMediaDevices.defaultAudioOutput().id()).hex()
        selected = 0
        for index, device in enumerate(self._audio_devices):
            device_id = bytes(device.id()).hex()
            prefix = tr("Default • ", "既定 • ") if device_id == default_id else ""
            self.device_combo.addItem(prefix + device.description())
            if saved_id and device_id == saved_id:
                selected = index
        if self._audio_devices:
            self.device_combo.setCurrentIndex(selected)
            self.audio_output.setDevice(self._audio_devices[selected])
            self.preload_audio_output.setDevice(self._audio_devices[selected])
            self._update_output_note(self._audio_devices[selected])
        else:
            self.device_combo.addItem(
                tr("No output devices", "出力デバイスがありません")
            )
            self.device_combo.setEnabled(False)
        self.device_combo.blockSignals(False)

    def _refresh_exclusive_devices(self) -> None:
        saved_name = str(
            self.settings.value("playback/wasapi_device_name", "")
        )
        self.device_combo.blockSignals(True)
        self.device_combo.clear()
        self.device_combo.setEnabled(True)
        self._exclusive_devices = wasapi_output_devices()
        selected = 0
        for index, device in enumerate(self._exclusive_devices):
            self.device_combo.addItem(device["name"])
            if saved_name and device["name"] == saved_name:
                selected = index
        if self._exclusive_devices:
            self.device_combo.setCurrentIndex(selected)
            device = self._exclusive_devices[selected]
            self.exclusive_player.set_device(device["index"])
            self._update_exclusive_output_note(device)
        else:
            self.device_combo.addItem(
                tr("No WASAPI output found", "WASAPI出力が見つかりません")
            )
            self.device_combo.setEnabled(False)
            self.exclusive_player.set_device(None)
            self.output_note.setText(
                tr(
                    "WASAPI Exclusive unavailable • Select Standard mode",
                    "WASAPI排他を使用できません • 通常モードを選択してください",
                )
            )
        self.device_combo.blockSignals(False)

    def _audio_device_selected(self, index: int) -> None:
        if self._is_exclusive_mode():
            if not 0 <= index < len(self._exclusive_devices):
                return
            device = self._exclusive_devices[index]
            try:
                self.exclusive_player.set_device(device["index"])
            except ExclusivePlaybackError as exc:
                self._fallback_and_resume_shared(str(exc))
                return
            self.settings.setValue(
                "playback/wasapi_device_name", device["name"]
            )
            self._update_exclusive_output_note(device)
            self.statusBar().showMessage(
                tr(
                    f"WASAPI Exclusive output: {device['name']}",
                    f"WASAPI排他出力: {device['name']}",
                ),
                4000,
            )
            return
        if not 0 <= index < len(self._audio_devices):
            return
        device = self._audio_devices[index]
        was_playing = (
            self.player.playbackState()
            == QMediaPlayer.PlaybackState.PlayingState
        )
        position = self.player.position()
        self.audio_output.setDevice(device)
        self.preload_audio_output.setDevice(device)
        self._update_output_note(device)
        self.settings.setValue("playback/device_id", bytes(device.id()).hex())
        if was_playing:
            self.player.setPosition(position)
            self.player.play()
        self.statusBar().showMessage(
            tr(
                f"Audio output: {device.description()}",
                f"音声出力: {device.description()}",
            ),
            4000,
        )

    def _update_output_note(self, device) -> None:
        minimum = device.minimumSampleRate()
        maximum = device.maximumSampleRate()
        rate_text = (
            f"{minimum / 1000:g}–{maximum / 1000:g} kHz"
            if minimum and maximum and minimum != maximum
            else f"{maximum / 1000:g} kHz"
            if maximum
            else tr("Rate unknown", "対応レート不明")
        )
        channels = device.maximumChannelCount()
        channel_text = (
            tr(f"Up to {channels} ch", f"最大 {channels} ch")
            if channels
            else tr("Channels unknown", "ch不明")
        )
        self.output_note.setText(
            f"{tr('Windows shared mode', 'Windows共有モード')} • "
            f"{rate_text} • {channel_text}"
        )

    def _update_exclusive_output_note(self, device: dict) -> None:
        default_rate = int(device.get("default_rate", 0))
        rate = (
            tr(
                f"Default {default_rate / 1000:g} kHz",
                f"既定 {default_rate / 1000:g} kHz",
            )
            if default_rate
            else tr("Automatic rate", "レート自動")
        )
        channel_text = tr(
            f"Up to {device['channels']} ch",
            f"最大 {device['channels']} ch",
        )
        self.output_note.setText(
            f"{tr('WASAPI Exclusive', 'WASAPI排他')} • {rate} • "
            f"{channel_text} • "
            f"{tr('Volume 100%', '音量100%')}"
        )

    def _output_mode_selected(self) -> None:
        if self.trial_edition and self.output_mode_combo.currentData() != "shared":
            shared_index = self.output_mode_combo.findData("shared")
            self.output_mode_combo.setCurrentIndex(shared_index)
            return
        self.stop_playback()
        self.settings.setValue(
            "playback/output_mode", self.output_mode_combo.currentData()
        )
        self._apply_output_mode_ui()
        self._refresh_audio_devices()
        mode = (
            tr("WASAPI Exclusive (native)", "WASAPI排他（ネイティブ）")
            if self._is_exclusive_mode()
            else tr("Standard (Windows shared)", "標準（Windows共有）")
        )
        self.statusBar().showMessage(
            tr(f"Playback mode: {mode}", f"再生モード: {mode}"), 4000
        )

    def _playback_options_selected(self) -> None:
        previous_order = self.playback_order.order_mode
        order_mode = str(self.play_order_combo.currentData())
        repeat_mode = str(self.repeat_mode_combo.currentData())
        self.playback_order.configure(order_mode, repeat_mode)
        self.settings.setValue("playback/order", order_mode)
        self.settings.setValue("playback/repeat", repeat_mode)
        if (
            order_mode == "shuffle"
            and previous_order != order_mode
            and self.current_track is not None
        ):
            self.playback_order.begin(
                self.model.tracks, self.current_track
            )
        if self._playback_is_active():
            self._prepare_gapless_next()
        order_text = self.play_order_combo.currentText()
        repeat_text = self.repeat_mode_combo.currentText()
        self.statusBar().showMessage(
            tr(
                f"Play order: {order_text} / Repeat: {repeat_text}",
                f"再生順: {order_text} / リピート: {repeat_text}",
            ),
            4000,
        )

    def _gapless_toggled(self, enabled: bool) -> None:
        if self.trial_edition:
            if enabled:
                self.gapless_checkbox.blockSignals(True)
                self.gapless_checkbox.setChecked(False)
                self.gapless_checkbox.blockSignals(False)
            return
        self.settings.setValue("playback/gapless", bool(enabled))
        if enabled and self._playback_is_active():
            self._prepare_gapless_next()
        else:
            self._clear_shared_preload()
            self.exclusive_player.clear_queued_source()
        self.statusBar().showMessage(
            tr(
                "Gapless playback enabled."
                if enabled
                else "Gapless playback disabled.",
                "ギャップレス再生をオンにしました。"
                if enabled
                else "ギャップレス再生をオフにしました。",
            ),
            4000,
        )

    def _playback_is_active(self) -> bool:
        return (
            self.exclusive_player.state == "playing"
            if self._is_exclusive_mode()
            else self.player.playbackState()
            == QMediaPlayer.PlaybackState.PlayingState
        )

    def _apply_output_mode_ui(self) -> None:
        exclusive = self._is_exclusive_mode()
        self.volume_slider.setEnabled(not exclusive)
        self.volume_label.setText(
            tr("Fixed volume", "音量固定")
            if exclusive
            else tr("Volume", "音量")
        )
        if exclusive:
            self.volume_slider.setToolTip(
                tr(
                    "Volume is fixed at 100% in exclusive mode for bit-perfect output.",
                    "ビットパーフェクト送出のため、排他モードでは100%固定です。",
                )
            )
        else:
            self.volume_slider.setToolTip("")
            self._shared_volume_changed(self.volume_slider.value())

    def _fallback_to_shared(self, reason: str) -> None:
        self.exclusive_player.stop()
        shared_index = self.output_mode_combo.findData("shared")
        self.output_mode_combo.blockSignals(True)
        self.output_mode_combo.setCurrentIndex(shared_index)
        self.output_mode_combo.blockSignals(False)
        self.settings.setValue("playback/output_mode", "shared")
        self._apply_output_mode_ui()
        self._refresh_audio_devices()
        QMessageBox.warning(
            self,
            tr(
                "Switched from WASAPI Exclusive to Standard Playback",
                "WASAPI排他から通常再生へ切り替えました",
            ),
            tr(
                f"{reason}\n\nThis track will play in Windows shared mode.",
                f"{reason}\n\nこの曲はWindows共有モードで再生します。",
            ),
        )

    def _fallback_and_resume_shared(self, reason: str) -> None:
        position = self.exclusive_player.position()
        self._fallback_to_shared(reason)
        if self.current_track is None:
            return
        self.player.setSource(QUrl.fromLocalFile(self.current_track.path))
        self.player.play()
        self._prepare_gapless_next()
        if position:
            QTimer.singleShot(100, lambda: self.player.setPosition(position))

    # ---------- tags and artwork ----------

    def select_all_tracks(self) -> int:
        """Select every track cell in the current playlist."""

        if not self.model.tracks:
            return 0
        self.table.selectAll()
        count = len(self.model.tracks)
        self.statusBar().showMessage(
            tr(
                f"Selected all {tracks(count)}.",
                f"全 {count} 曲を選択しました。",
            ),
            4000,
        )
        return count

    def open_bulk_edit_dialog(self) -> None:
        if not self.edit_mode:
            self._show_edit_mode_required()
            return
        if not self.model.tracks:
            self.statusBar().showMessage(
                tr(
                    "There are no tracks to edit.",
                    "編集する楽曲がありません。",
                ),
                4000,
            )
            return
        dialog = BulkEditDialog(
            self.tag_columns,
            self.bulk_tag_keys,
            self.genre_suggestions,
            len(self._selected_or_current_rows()),
            len(self.model.tracks),
            self,
        )

        def select_every_track() -> None:
            dialog.set_selected_count(self.select_all_tracks())

        dialog.selectAllRequested.connect(select_every_track)
        if dialog.exec() != QDialog.DialogCode.Accepted:
            return
        self._apply_bulk_edit_values(
            self._selected_or_current_rows(),
            dialog.metadata_values(),
            artwork_requested=dialog.artwork_requested(),
            artwork_path=dialog.artwork_path(),
        )

    def _apply_bulk_edit_values(
        self,
        rows: list[int],
        metadata_values: dict[str, object],
        *,
        artwork_requested: bool = False,
        artwork_path: str = "",
    ) -> int:
        allowed_keys = set(self.bulk_tag_keys)
        metadata_values = {
            key: value
            for key, value in metadata_values.items()
            if key in allowed_keys
        }

        changed = self.model.apply_bulk_values(rows, metadata_values)
        if self.current_row in rows and self.current_track is not None:
            self.show_track(self.current_track)

        artwork_saved = 0
        if artwork_requested:
            artwork_saved = self._apply_artwork_to_rows(artwork_path, rows)

        if metadata_values and artwork_requested:
            self.statusBar().showMessage(
                tr(
                    f"Bulk edit applied to {tracks(len(rows))}. "
                    f"Artwork was saved for {tracks(artwork_saved)}; "
                    "save tags to write the text changes.",
                    f"選択した {len(rows)} 曲へ一括反映しました。"
                    f"ジャケット画像は {artwork_saved} 曲へ即時保存済みです。"
                    "文字タグはタグ保存時に書き込まれます。",
                ),
                7000,
            )
        elif metadata_values:
            self.statusBar().showMessage(
                tr(
                    f"Updated metadata on {tracks(changed)}. "
                    "Save tags to write the changes to the files."
                    if changed
                    else "The selected tracks already contain those values.",
                    f"{changed} 曲の情報を一括変更しました。"
                    "「タグを保存」でファイルへ反映されます。"
                    if changed
                    else "選択曲は既に同じ内容です。",
                ),
                7000,
            )
        return changed

    def auto_number_tracks(self) -> None:
        if not self.model.tracks:
            self.statusBar().showMessage(
                tr("There are no tracks to number.", "付番する曲がありません。"),
                4000,
            )
            return
        dialog = AutoNumberDialog(self)
        if dialog.exec() != QDialog.DialogCode.Accepted:
            return
        self.model.auto_number(
            dialog.disc_number.value(),
            dialog.track_start.value(),
        )
        self.table.selectAll()
        final_track = dialog.track_start.value() + len(self.model.tracks) - 1
        self.statusBar().showMessage(
            tr(
                f"Set Disc {dialog.disc_number.value():02d} and "
                f"Track {dialog.track_start.value():02d}–{final_track:02d}. "
                "Save tags to write the changes to the files.",
                f"Disc {dialog.disc_number.value():02d}、"
                f"Track {dialog.track_start.value():02d}～{final_track:02d} "
                "を設定しました。タグを保存するとファイルへ反映されます。",
            ),
            6000,
        )

    def _selected_rows(self) -> list[int]:
        return sorted({index.row() for index in self.table.selectedIndexes()})

    def _selected_or_current_rows(self) -> list[int]:
        rows = self._selected_rows()
        if rows:
            return rows
        current = self.table.currentIndex()
        return [current.row()] if current.isValid() else []

    def append_title_suffix(self) -> None:
        rows = self._selected_or_current_rows()
        if not rows:
            QMessageBox.information(
                self,
                tr("Title Suffix", "タイトル接尾辞"),
                tr(
                    "Select the tracks whose titles you want to change first.\n"
                    "Use Ctrl+A to select every track.",
                    "先に、タイトルを変更する曲を選択してください。\n"
                    "全曲を対象にする場合は Ctrl+A を使用できます。",
                ),
            )
            return
        dialog = TitleSuffixDialog(
            len(rows),
            self.title_suffixes,
            self.title_suffix_insert_space,
            self,
        )
        if dialog.exec() != QDialog.DialogCode.Accepted:
            return
        suffix = dialog.selected_suffix()
        insert_space = dialog.inserts_space()
        self.title_suffix_insert_space = insert_space
        self.settings.setValue(
            "tags/title_suffix_insert_space",
            insert_space,
        )
        changed = self.model.apply_title_suffix(
            rows,
            suffix,
            known_suffixes=self.title_suffixes,
            insert_space=insert_space,
        )
        if self.current_row in rows and self.current_track is not None:
            self.show_track(self.current_track)
        if changed:
            self.statusBar().showMessage(
                tr(
                    f"Added {suffix} to the titles of {tracks(changed)}. "
                    "Use Save Tags to write the changes to the files.",
                    f"{changed} 曲のタイトル末尾に {suffix} を付加しました。"
                    "「タグを保存」でファイルへ反映されます。",
                ),
                7000,
            )
        else:
            self.statusBar().showMessage(
                tr(
                    f"The selected tracks already use {suffix}.",
                    f"選択曲には既に {suffix} が設定されています。",
                ),
                5000,
            )

    def save_selected_tags(self) -> None:
        rows = self._selected_rows()
        if not rows:
            rows = sorted(self.model.dirty_rows)
        if not rows:
            self.statusBar().showMessage(
                tr(
                    "Select tracks to save.",
                    "保存する曲を選択してください。",
                ),
                4000,
            )
            return
        self._save_tag_rows(rows)

    def _rating_changed(
        self, row: int, previous_rating: int, rating: int
    ) -> None:
        """Persist star ratings immediately without interrupting playback."""

        if self.edit_mode or not 0 <= row < len(self.model.tracks):
            return
        track = self.model.tracks[row]
        if track.is_cue_track:
            # A CUE row is virtual, so its rating belongs to the playlist entry
            # rather than the shared disc-image file.
            self._sync_saved_track_across_playlists(track, track.path)
            self.model.dirty_rows.discard(row)
            self._save_active_playlist()
            self.statusBar().showMessage(
                tr(
                    f"Set Rating WMP to {rating}/5 and saved it to the playlist.",
                    f"評価 WMPを {rating}/5 に設定し、プレイリストへ保存しました。",
                ),
                5000,
            )
            return
        source_is_loaded = (
            track is self._preloaded_track
            or (
                track is self.current_track
                and (
                    self.exclusive_player.state in {"playing", "paused"}
                    or not self.player.source().isEmpty()
                )
            )
        )
        if source_is_loaded:
            # Rewriting metadata at the beginning of a FLAC/MP3/etc. while a
            # decoder is reading it can invalidate the decoder even when
            # Windows permits the write. Keep playback gapless and save after
            # every source handle has been released.
            self._pending_playback_ratings[id(track)] = track
            self.statusBar().showMessage(
                tr(
                    f"Set Rating WMP to {rating}/5. It will be saved automatically when playback stops.",
                    f"評価 WMPを {rating}/5 に設定しました。再生停止時に自動保存します。",
                ),
                7000,
            )
            return
        try:
            # Do not use the normal retry helper here: its lock recovery stops
            # every decoder.  A rating click must not interrupt playback.
            save_track_tags(track)
        except Exception as exc:
            if _is_probable_file_lock_error(exc):
                self._pending_playback_ratings[id(track)] = track
                self.statusBar().showMessage(
                    tr(
                        f"Set Rating WMP to {rating}/5. It will be saved automatically when playback stops.",
                        f"評価 WMPを {rating}/5 に設定しました。再生停止時に自動保存します。",
                    ),
                    7000,
                )
                return
            # Unsupported or damaged files must not appear to have been saved.
            track.set_tag_value("rating wmp", previous_rating)
            self.model.dirty_rows.discard(row)
            rating_column = self.model.column_for_key("rating wmp")
            if rating_column is not None:
                index = self.model.index(row, rating_column)
                self.model.dataChanged.emit(
                    index,
                    index,
                    [Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole],
                )
            self.statusBar().showMessage(
                tr(
                    f"Could not save Rating WMP: {exc}",
                    f"評価 WMPを保存できませんでした: {exc}",
                ),
                9000,
            )
            return
        self._pending_playback_ratings.pop(id(track), None)
        self.model.dirty_rows.discard(row)
        self.statusBar().showMessage(
            tr(
                f"Set and saved Rating WMP to {rating}/5.",
                f"評価 WMPを {rating}/5 に設定して保存しました。",
            ),
            5000,
        )

    def _flush_pending_playback_ratings(self, *, announce: bool = True) -> None:
        if not self._pending_playback_ratings:
            return
        saved = 0
        errors: list[str] = []
        for identity, track in list(self._pending_playback_ratings.items()):
            try:
                save_track_tags(track)
            except Exception as exc:
                errors.append(f"{track.filename}: {exc}")
                continue
            self._pending_playback_ratings.pop(identity, None)
            row = self._row_for_track(track)
            if row >= 0:
                self.model.dirty_rows.discard(row)
            saved += 1
        if saved:
            self._save_active_playlist()
        if not announce:
            return
        if errors:
            self.statusBar().showMessage(
                tr(
                    "Some deferred ratings could not be saved: ",
                    "一部の保留中レーティングを保存できませんでした: ",
                )
                + errors[0],
                9000,
            )
        elif saved:
            self.statusBar().showMessage(
                tr(
                    f"Saved deferred ratings for {tracks(saved)}.",
                    f"保留していた {saved} 曲のレーティングを保存しました。",
                ),
                5000,
            )

    def _save_tag_rows(self, rows: list[int]) -> bool:
        rows = sorted(
            {row for row in rows if 0 <= row < len(self.model.tracks)}
        )
        if not rows:
            return True
        # The current track and a gapless-preloaded next track can both retain
        # Windows decoder handles.  Release every playback source even when
        # the edited row is not the currently displayed row.
        self.stop_playback()
        errors: list[str] = []
        saved = 0
        cancelled = False
        progress = QProgressDialog(
            tr("Writing tags", "タグを書き込み中"),
            tr("Cancel", "キャンセル"),
            0,
            len(rows),
            self,
        )
        progress.setWindowModality(Qt.WindowModality.WindowModal)
        for count, row in enumerate(rows):
            if progress.wasCanceled():
                cancelled = True
                break
            progress.setValue(count)
            track = self.model.tracks[row]
            progress.setLabelText(
                tr(
                    f"Writing tags\n{track.filename}",
                    f"タグを書き込み中\n{track.filename}",
                )
            )
            QApplication.processEvents()
            try:
                previous_path = track.path
                if not track.track_number.strip():
                    track.track_number = f"{row + 1:02d}"
                filename_pattern = self._filename_pattern()
                if filename_pattern != "off" and not track.is_cue_track:
                    track.filename = build_tag_filename(
                        track,
                        row + 1,
                        pattern=filename_pattern,
                    )
                self._save_track_tags_with_retry(track)
                self._sync_saved_track_across_playlists(
                    track,
                    previous_path,
                )
                self.model.dirty_rows.discard(row)
                if self.edit_mode:
                    # A successful explicit save becomes the new cancellation
                    # checkpoint for this track only. Unsaved edits in other
                    # rows must remain cancellable.
                    self._edit_checkpoints[id(track)] = track.to_dict()
                saved += 1
            except Exception as exc:
                errors.append(f"{track.filename}: {exc}")
        if progress.wasCanceled():
            cancelled = True
        if cancelled:
            progress.close()
        else:
            progress.setValue(len(rows))
        self.model.layoutChanged.emit()
        self._save_active_playlist()
        if errors:
            QMessageBox.warning(
                self,
                tr(
                    "Some Tags Could Not Be Saved",
                    "一部のタグを保存できませんでした",
                ),
                tr(
                    f"Succeeded: {saved}  Failed: {len(errors)}"
                    + ("  Cancelled" if cancelled else ""),
                    f"成功: {saved} 曲　失敗: {len(errors)} 曲"
                    + ("　キャンセル済み" if cancelled else ""),
                )
                + "\n\n"
                + "\n".join(errors[:8])
                + ("\n…" if len(errors) > 8 else ""),
            )
        elif cancelled:
            QMessageBox.information(
                self,
                tr("Tag Save Cancelled", "タグ保存を中断しました"),
                tr(
                    f"Saved {tracks(saved)} before cancellation.",
                    f"中断するまでに {saved} 曲を保存しました。",
                ),
            )
        else:
            completion = (
                tr(
                    f"Saved tags and filenames for {tracks(saved)}.",
                    f"{saved} 曲のタグとファイル名を保存しました。",
                )
                if self._filename_pattern() != "off"
                else tr(
                    f"Saved tags for {tracks(saved)}. "
                    "Automatic filename changes are off.",
                    f"{saved} 曲のタグを保存しました。"
                    "ファイル名の自動変更は行っていません。",
                )
            )
            QMessageBox.information(
                self,
                tr("Save Complete!", "保存が完了しました！"),
                completion,
            )
        status = (
            tr(
                f"Saved tags for {tracks(saved)}.",
                f"{saved} 曲のタグを保存しました。",
            )
            if not errors and not cancelled
            else tr(
                f"Tag save result: {saved} succeeded, {len(errors)} failed",
                f"タグ保存結果: 成功 {saved} 曲、失敗 {len(errors)} 曲",
            )
        )
        self.statusBar().showMessage(status, 7000)
        return not errors and not cancelled

    def set_selected_artwork(self) -> None:
        rows = self._artwork_target_rows()
        if not rows:
            QMessageBox.information(
                self,
                tr("Artwork", "アートワーク"),
                tr(
                    "Select tracks in which to embed the image.",
                    "画像を埋め込む曲を選択してください。",
                ),
            )
            return
        image_path, _ = QFileDialog.getOpenFileName(
            self,
            tr("Select Artwork Image", "アートワーク画像を選択"),
            "",
            tr(
                "Images (*.jpg *.jpeg *.png);;All files (*.*)",
                "画像 (*.jpg *.jpeg *.png);;すべてのファイル (*.*)",
            ),
        )
        if not image_path:
            return
        self._apply_artwork_to_rows(image_path, rows)

    def _artwork_target_rows(self) -> list[int]:
        return self._selected_or_current_rows()

    def _artwork_dropped(self, image_path: str) -> None:
        rows = self._artwork_target_rows()
        if not rows:
            QMessageBox.information(
                self,
                tr("Artwork", "アートワーク"),
                tr(
                    "Select tracks in the playlist before dropping an image.\n"
                    "Use Ctrl+A to select every track.",
                    "先に、画像を埋め込む曲をプレイリストで選択してください。\n"
                    "全曲を対象にする場合は Ctrl+A を使用できます。",
                ),
            )
            return
        self._apply_artwork_to_rows(image_path, rows)

    def _apply_artwork_to_rows(self, image_path: str, rows: list[int]) -> int:
        preview = QPixmap(image_path)
        if preview.isNull():
            QMessageBox.warning(
                self,
                tr("Artwork", "アートワーク"),
                tr(
                    "The file could not be read as a JPEG or PNG image.",
                    "JPEGまたはPNG画像として読み込めませんでした。",
                ),
            )
            return 0
        if self.current_row in rows:
            self.stop_playback()
        errors: list[str] = []
        saved = 0
        progress = QProgressDialog(
            tr("Embedding artwork", "アートワークを埋め込み中"),
            tr("Cancel", "キャンセル"),
            0,
            len(rows),
            self,
        )
        progress.setWindowModality(Qt.WindowModality.WindowModal)
        for count, row in enumerate(rows):
            if progress.wasCanceled():
                break
            track = self.model.tracks[row]
            progress.setValue(count)
            progress.setLabelText(
                tr(
                    f"Embedding artwork\n{track.filename}",
                    f"アートワークを埋め込み中\n{track.filename}",
                )
            )
            QApplication.processEvents()
            try:
                embed_artwork(track.path, image_path)
                saved += 1
            except Exception as exc:
                errors.append(f"{track.filename}: {exc}")
        progress.setValue(len(rows))
        if rows:
            self.show_track(self.model.tracks[rows[0]])
        if errors:
            QMessageBox.warning(
                self,
                tr(
                    "Some Artwork Could Not Be Embedded",
                    "一部の画像を埋め込めませんでした",
                ),
                "\n".join(errors[:8])
                + ("\n…" if len(errors) > 8 else ""),
            )
        if saved:
            self.statusBar().showMessage(
                tr(
                    f"Embedded artwork in {tracks(saved)} and saved automatically.",
                    f"{saved} 曲にアートワークを埋め込み、自動保存しました。",
                ),
                6000,
            )
        return saved

    def remove_selected_artwork(self) -> None:
        rows = self._artwork_target_rows()
        if not rows:
            QMessageBox.information(
                self,
                tr("Remove Artwork", "アートワークを削除"),
                tr(
                    "Select tracks from which to remove artwork first.\n"
                    "Use Ctrl+A to select every track.",
                    "先に、アートワークを削除する曲を選択してください。\n"
                    "全曲を対象にする場合は Ctrl+A を使用できます。",
                ),
            )
            return
        answer = QMessageBox.question(
            self,
            tr("Remove Artwork", "アートワークを削除"),
            tr(
                f"Remove embedded images from the selected {tracks(len(rows))}?\n"
                "The music files and other tags will not be removed.",
                f"選択した {len(rows)} 曲から埋め込み画像を削除しますか？\n"
                "音楽ファイルと他のタグは削除されません。",
            ),
            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
            QMessageBox.StandardButton.No,
        )
        if answer != QMessageBox.StandardButton.Yes:
            return
        if self.current_row in rows:
            self.stop_playback()
        removed = 0
        without_artwork = 0
        errors: list[str] = []
        progress = QProgressDialog(
            tr("Removing artwork", "アートワークを削除中"),
            tr("Cancel", "キャンセル"),
            0,
            len(rows),
            self,
        )
        progress.setWindowModality(Qt.WindowModality.WindowModal)
        for count, row in enumerate(rows):
            if progress.wasCanceled():
                break
            track = self.model.tracks[row]
            progress.setValue(count)
            progress.setLabelText(
                tr(
                    f"Removing artwork\n{track.filename}",
                    f"アートワークを削除中\n{track.filename}",
                )
            )
            QApplication.processEvents()
            try:
                if remove_artwork(track.path):
                    removed += 1
                else:
                    without_artwork += 1
            except Exception as exc:
                errors.append(f"{track.filename}: {exc}")
        progress.setValue(len(rows))
        self.show_track(self.model.tracks[rows[0]])
        if errors:
            QMessageBox.warning(
                self,
                tr(
                    "Some Artwork Could Not Be Removed",
                    "一部のアートワークを削除できませんでした",
                ),
                "\n".join(errors[:8])
                + ("\n…" if len(errors) > 8 else ""),
            )
        self.statusBar().showMessage(
            tr(
                f"Removed artwork from {tracks(removed)} and saved automatically."
                + (
                    f" {tracks(without_artwork)} had no artwork."
                    if without_artwork
                    else ""
                ),
                f"{removed} 曲のアートワークを削除して自動保存しました。"
                + (
                    f" {without_artwork} 曲には画像がありませんでした。"
                    if without_artwork
                    else ""
                ),
            ),
            7000,
        )

    # ---------- window and lifecycle ----------

    def toggle_fullscreen(self) -> None:
        if self.isFullScreen():
            self.showNormal()
        else:
            self.showFullScreen()
        self._update_fullscreen_controls()

    def closeEvent(self, event: QCloseEvent) -> None:
        self._release_playback_sources()
        self._flush_pending_playback_ratings(announce=False)
        self._save_active_playlist()
        self.settings.setValue("window/geometry", self.saveGeometry())
        self.settings.setValue(
            "table/header_state",
            self.table.horizontalHeader().saveState(),
        )
        self.settings.setValue("playback/volume", self.volume_slider.value())
        self.settings.setValue("playlist/active", self.active_playlist)
        super().closeEvent(event)
