Initial project structure for Klangkiste
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,167 @@
|
||||
"""Central orchestration placeholder for the Box runtime."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from core.state import BoxState, RuntimeState
|
||||
from player.base_player import BasePlayer
|
||||
|
||||
|
||||
class BoxController:
|
||||
def __init__(
|
||||
self,
|
||||
state: RuntimeState,
|
||||
player: BasePlayer,
|
||||
config: dict[str, Any],
|
||||
config_path: str,
|
||||
) -> None:
|
||||
self._state = state
|
||||
self._player = player
|
||||
self._config = config
|
||||
self._config_path = Path(config_path)
|
||||
|
||||
@property
|
||||
def state(self) -> RuntimeState:
|
||||
return self._state
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the main loop (not implemented in Phase 1a)."""
|
||||
return None
|
||||
|
||||
def handle_nfc_on(self, uid: str) -> None:
|
||||
tag = self._resolve_tag(uid)
|
||||
if tag is None:
|
||||
print(f"error: uid '{uid}' not found in config tags")
|
||||
return None
|
||||
|
||||
playlist = self._build_playlist(tag)
|
||||
if not playlist:
|
||||
path_value = tag.get("path", "-")
|
||||
resolved = self._resolve_path(path_value)
|
||||
print(
|
||||
f"error: no playable files found for uid '{uid}' at path '{resolved}'"
|
||||
)
|
||||
return None
|
||||
|
||||
if self._state.active_uid == uid and uid in self._state._resume:
|
||||
file_index, position = self._state._resume[uid]
|
||||
else:
|
||||
file_index, position = 0, 0
|
||||
|
||||
if file_index < 0 or file_index >= len(playlist):
|
||||
file_index, position = 0, 0
|
||||
|
||||
media_ref = playlist[file_index]
|
||||
duration = self._duration_for(media_ref)
|
||||
self._state.active_uid = uid
|
||||
self._state.playback_type = tag.get("type")
|
||||
self._state.playback_path = tag.get("path")
|
||||
self._state.current_file = Path(media_ref).name
|
||||
self._state.current_duration = duration
|
||||
self._state.file_index = file_index
|
||||
self._state.position = position
|
||||
self._state._playlist = playlist
|
||||
|
||||
self._player.play(media_ref)
|
||||
self._state.current_media = media_ref
|
||||
self._state.state = BoxState.PLAYING
|
||||
|
||||
def pause(self) -> None:
|
||||
self._player.pause()
|
||||
if self._state.state == BoxState.PLAYING:
|
||||
self._state.state = BoxState.PAUSED
|
||||
|
||||
def stop(self) -> None:
|
||||
self._player.stop()
|
||||
self._state.current_media = None
|
||||
self._state.state = BoxState.IDLE
|
||||
|
||||
def handle_nfc_off(self, uid: str) -> None:
|
||||
if self._state.active_uid != uid:
|
||||
return None
|
||||
self._store_progress(uid)
|
||||
self.stop()
|
||||
|
||||
def _resolve_tag(self, uid: str) -> dict[str, Any] | None:
|
||||
tags = self._config.get("tags", {})
|
||||
if not isinstance(tags, dict):
|
||||
return None
|
||||
tag = tags.get(uid)
|
||||
if not isinstance(tag, dict):
|
||||
return None
|
||||
if "type" not in tag or "path" not in tag:
|
||||
return None
|
||||
return tag
|
||||
|
||||
def tick(self, seconds: int) -> None:
|
||||
if self._state.state != BoxState.PLAYING:
|
||||
return None
|
||||
if self._state.position is None or self._state.file_index is None:
|
||||
return None
|
||||
if not self._state._playlist:
|
||||
return None
|
||||
if seconds <= 0:
|
||||
return None
|
||||
|
||||
remaining = seconds
|
||||
file_index = self._state.file_index
|
||||
position = self._state.position
|
||||
|
||||
while remaining > 0 and file_index < len(self._state._playlist):
|
||||
media_ref = self._state._playlist[file_index]
|
||||
duration = self._duration_for(media_ref)
|
||||
if duration <= 0:
|
||||
break
|
||||
time_left = duration - position
|
||||
if remaining < time_left:
|
||||
position += remaining
|
||||
remaining = 0
|
||||
else:
|
||||
remaining -= time_left
|
||||
file_index += 1
|
||||
position = 0
|
||||
|
||||
if file_index >= len(self._state._playlist):
|
||||
self._state.file_index = len(self._state._playlist) - 1
|
||||
self._state.position = duration
|
||||
self._state.current_media = None
|
||||
self._state.current_file = None
|
||||
self._state.current_duration = None
|
||||
self._state.state = BoxState.IDLE
|
||||
return None
|
||||
|
||||
self._state.file_index = file_index
|
||||
self._state.position = position
|
||||
media_ref = self._state._playlist[file_index]
|
||||
self._state.current_media = media_ref
|
||||
self._state.current_file = Path(media_ref).name
|
||||
self._state.current_duration = self._duration_for(media_ref)
|
||||
|
||||
def _build_playlist(self, tag: dict[str, Any]) -> list[str]:
|
||||
path_value = tag.get("path")
|
||||
if not isinstance(path_value, str):
|
||||
return []
|
||||
path = self._resolve_path(path_value)
|
||||
if not path.exists() or not path.is_dir():
|
||||
return []
|
||||
files = [p for p in path.iterdir() if p.is_file()]
|
||||
files_sorted = sorted(files, key=lambda p: p.name)
|
||||
return [str(p) for p in files_sorted]
|
||||
|
||||
def _resolve_path(self, path_value: str) -> Path:
|
||||
path = Path(path_value)
|
||||
if not path.is_absolute():
|
||||
return (self._config_path.parent / path).resolve()
|
||||
return path
|
||||
|
||||
def _store_progress(self, uid: str) -> None:
|
||||
file_index = self._state.file_index if self._state.file_index is not None else 0
|
||||
position = self._state.position if self._state.position is not None else 0
|
||||
self._state._resume[uid] = (file_index, position)
|
||||
|
||||
def _duration_for(self, media_ref: str) -> int:
|
||||
name = Path(media_ref).name
|
||||
durations = {"01.mp3": 180, "02.mp3": 200, "03.mp3": 220}
|
||||
return durations.get(name, 0)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""State model for the Box runtime (placeholder)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class BoxState(str, Enum):
|
||||
IDLE = "IDLE"
|
||||
PLAYING = "PLAYING"
|
||||
PAUSED = "PAUSED"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RuntimeState:
|
||||
box_id: str
|
||||
state: BoxState = BoxState.IDLE
|
||||
current_media: Optional[str] = None
|
||||
volume: int = 0
|
||||
active_uid: Optional[str] = None
|
||||
playback_type: Optional[str] = None
|
||||
playback_path: Optional[str] = None
|
||||
current_file: Optional[str] = None
|
||||
current_duration: Optional[int] = None
|
||||
file_index: Optional[int] = None
|
||||
position: Optional[int] = None
|
||||
_resume: dict[str, tuple[int, int]] = field(default_factory=dict)
|
||||
_playlist: list[str] = field(default_factory=list)
|
||||
Reference in New Issue
Block a user