feat: overhaul video player controller

This commit is contained in:
Media
2026-08-06 20:08:12 -05:00
parent 72cc66df92
commit 5e81403be3
10 changed files with 2019 additions and 122 deletions
+110 -33
View File
@@ -18,6 +18,7 @@ from .store import ControllerStore
TERMINAL_REASONS = frozenset(
{"stop", "finished", "mpv_error", "sigterm", "sigint", "shutdown"}
)
TERMINAL_ERROR_REASONS = frozenset({"startup_error", "worker_start_error"})
CONTROL_ACTIONS = frozenset({"play", "stop", "pause", "resume", "toggle"})
@@ -485,13 +486,31 @@ class PlaybackReducer:
return effects
if state == "busy":
if slot is not None or path is not None:
previous = self.store.player_observation()
if (
slot is not None
or path is not None
or previous.active_slot is not None
or previous.active_path is not None
):
self._active_evidence(
slot=slot,
path=path,
paused=None,
status=payload,
)
else:
self.store.set_player_observation(
availability="online",
phase="unknown",
active_slot=None,
active_path=None,
paused=None,
reconciliation_state="unknown",
reconciliation_due_at=None,
reconciliation_id=None,
status_payload=payload,
)
effects.append(PlaybackEffect("player_busy", slot=slot, message=payload.get("message")))
if self.pending is not None and self.pending.action == "play":
effects.append(self._finish_operation("failed", "Player is busy"))
@@ -499,40 +518,87 @@ class PlaybackReducer:
if state == "error":
previous = self.store.player_observation()
message = str(payload.get("message") or "Player error")
reason = payload.get("reason")
if reason in TERMINAL_ERROR_REASONS:
terminal_slot = slot if slot is not None else previous.active_slot
self._idle_evidence(status=payload)
effects.extend(
(
PlaybackEffect(
"player_error",
slot=terminal_slot,
message=message,
),
PlaybackEffect(
"player_idle",
slot=terminal_slot,
message=str(reason),
),
)
)
if self.pending is not None:
effects.append(self._finish_operation("failed", message))
return effects
active_slot = slot if slot is not None else previous.active_slot
active_path = path if path is not None else previous.active_path
due = None
if self.config.storage.uncertain_review_after_seconds > 0:
due = (
self._wall_clock()
+ self.config.storage.uncertain_review_after_seconds
)
if active_slot is not None:
self.store.reconcile_active_guard(
guard = self.store.mark_slot_uncertain(
active_slot,
kind="player_active",
details={"error": payload.get("message")},
review_after=due,
details={"reason": "Player error", "message": message},
)
due = guard.review_after
self.store.set_player_observation(
availability="online",
phase="error",
active_slot=active_slot,
active_path=active_path,
paused=(
previous.paused
if self._same_playback_identity(
previous,
active_slot,
active_path,
)
else None
),
reconciliation_state=(
"operator_review"
if guard.state == "review_required"
else "awaiting_evidence"
),
reconciliation_due_at=due,
reconciliation_id=guard.guard_id,
status_payload=payload,
)
elif active_path is not None:
self.store.reconcile_external_active()
self.store.set_player_observation(
availability="online",
phase="error",
active_slot=active_slot,
active_path=active_path,
paused=(
previous.paused
if self._same_playback_identity(
previous,
active_slot,
active_path,
)
else None
),
reconciliation_state=(
"confirmed"
if active_slot is not None or active_path is not None
else "unknown"
),
reconciliation_due_at=None,
reconciliation_id=None,
status_payload=payload,
)
message = str(payload.get("message") or "Player error")
self.store.mark_external_uncertain(
active_path,
review_after=due,
availability="online",
details={"reason": "Player error", "message": message},
status_payload=payload,
)
else:
self.store.set_player_observation(
availability="online",
phase="unknown",
active_slot=None,
active_path=None,
paused=None,
reconciliation_state="unknown",
reconciliation_due_at=None,
reconciliation_id=None,
status_payload=payload,
)
effects.append(PlaybackEffect("player_error", slot=active_slot, message=message))
if self.pending is not None:
effects.append(self._finish_operation("failed", message))
@@ -645,6 +711,19 @@ class PlaybackReducer:
"reason": "stop timeout",
},
)
elif operation.action in {"pause", "resume", "toggle"}:
previous = self.store.player_observation()
if previous.active_slot is not None or previous.active_path is not None:
self.store.set_player_observation(
availability=previous.availability,
phase="unknown",
active_slot=previous.active_slot,
active_path=previous.active_path,
paused=None,
reconciliation_state=previous.reconciliation_state,
reconciliation_due_at=previous.reconciliation_due_at,
reconciliation_id=previous.reconciliation_id,
)
return self._finish_operation("timed_out", "no authoritative Player confirmation")
def _mark_play_uncertain(self, operation: PlayerOperation, reason: str) -> None:
@@ -753,8 +832,6 @@ class PlaybackReducer:
def projected_slot_state(self, slot: int) -> str:
record = self.store.get_slot(slot)
if record.media_state != "ready":
return record.media_state
observation = self.store.player_observation()
if observation.active_slot == slot:
if observation.reconciliation_state == "operator_review":
@@ -771,9 +848,9 @@ class PlaybackReducer:
)
if guard is not None:
if guard.kind == "pending_play":
return "ready"
return record.media_state
return "operator_review" if guard.state == "review_required" else "unknown"
return "ready"
return record.media_state
def mark_due_reconciliation(self) -> list[PlaybackEffect]:
now = self._wall_clock()