Jelajahi Sumber

feat: add "scheduled" run mode to play playlists X times per day

Adds a third playlist run mode alongside single/loop: "scheduled" runs
the full playlist a configurable number of cycles per 24 hours, evenly
spread across the day's *active* hours. Still Sands periods are excluded
from the daily quota — the inter-cycle wait pauses while inside Still
Sands, so X full runs still happen during active time.

Backend:
- New `runs_per_day` parameter on `run_theta_rho_files` and
  `run_playlist`, plumbed through `/run_playlist` and the auto-play
  bootstrap.
- `state.auto_play_runs_per_day` (persisted) for the boot configuration.
- New `_wait_with_still_sands_monitor` helper consolidates the duplicate
  inter-pattern / inter-cycle pause loops and adds the option to skip
  Still Sands time when counting elapsed wait.
- Module-level daily counter resets at midnight in the user-configured
  Still Sands timezone.
- Drive-by fix: treat `run_mode="loop"` the same as `"indefinite"` in
  the playlist runner — auto-play defaulted to "loop" but only
  "indefinite" was actually looping.

Frontend:
- Playlists page run-mode toggle now cycles single → indefinite →
  scheduled, swapping the pause-time picker for a runs/day picker in
  scheduled mode.
- Settings → Auto-play gains a "Scheduled (X times per day)" run-mode
  option with a runs-per-day input.
Claude 2 bulan lalu
induk
melakukan
1dae66ec1e

+ 1 - 1
frontend/src/lib/types.ts

@@ -22,7 +22,7 @@ export interface Playlist {
 
 export type SortOption = 'name' | 'date' | 'size' | 'favorites' | 'plays' | 'last_played'
 export type PreExecution = 'none' | 'adaptive' | 'clear_from_in' | 'clear_from_out' | 'clear_sideway'
-export type RunMode = 'single' | 'indefinite'
+export type RunMode = 'single' | 'indefinite' | 'scheduled'
 
 export const preExecutionOptions: { value: PreExecution; label: string; description: string }[] = [
   { value: 'adaptive', label: 'Adaptive', description: 'Automatically picks the best clear direction based on where the ball is' },

+ 97 - 45
frontend/src/pages/PlaylistsPage.tsx

@@ -93,7 +93,7 @@ export function PlaylistsPage() {
   // Playback settings - initialized from localStorage
   const [runMode, setRunMode] = useState<RunMode>(() => {
     const cached = localStorage.getItem('playlist-runMode')
-    return (cached === 'single' || cached === 'indefinite') ? cached : 'single'
+    return (cached === 'single' || cached === 'indefinite' || cached === 'scheduled') ? cached : 'single'
   })
   const [shuffle, setShuffle] = useState(() => {
     return localStorage.getItem('playlist-shuffle') === 'true'
@@ -106,6 +106,11 @@ export function PlaylistsPage() {
     const cached = localStorage.getItem('playlist-pauseUnit')
     return (cached === 'sec' || cached === 'min' || cached === 'hr') ? cached : 'min'
   })
+  const [runsPerDay, setRunsPerDay] = useState<number>(() => {
+    const cached = localStorage.getItem('playlist-runsPerDay')
+    const parsed = cached ? Number(cached) : 3
+    return Number.isFinite(parsed) && parsed >= 1 ? parsed : 3
+  })
   const [clearPattern, setClearPattern] = useState<PreExecution>(() => {
     const cached = localStorage.getItem('preExecution')
     return (cached as PreExecution) || 'adaptive'
@@ -124,6 +129,9 @@ export function PlaylistsPage() {
   useEffect(() => {
     localStorage.setItem('playlist-pauseUnit', pauseUnit)
   }, [pauseUnit])
+  useEffect(() => {
+    localStorage.setItem('playlist-runsPerDay', String(runsPerDay))
+  }, [runsPerDay])
   useEffect(() => {
     localStorage.setItem('preExecution', clearPattern)
   }, [clearPattern])
@@ -458,10 +466,11 @@ export function PlaylistsPage() {
     try {
       await apiClient.post('/run_playlist', {
         playlist_name: selectedPlaylist,
-        run_mode: runMode === 'indefinite' ? 'indefinite' : 'single',
+        run_mode: runMode,
         pause_time: getPauseTimeInSeconds(),
         clear_pattern: clearPattern,
         shuffle: shuffle,
+        runs_per_day: runMode === 'scheduled' ? Math.max(1, Math.floor(runsPerDay)) : 1,
       })
       toast.success(`Started playlist: ${selectedPlaylist}`)
       // Trigger Now Playing bar to open
@@ -778,58 +787,101 @@ export function PlaylistsPage() {
                       <span className="material-icons-outlined text-lg sm:text-xl">shuffle</span>
                     </button>
                     <button
-                      onClick={() => setRunMode(runMode === 'indefinite' ? 'single' : 'indefinite')}
+                      onClick={() => {
+                        const order: RunMode[] = ['single', 'indefinite', 'scheduled']
+                        const next = order[(order.indexOf(runMode) + 1) % order.length]
+                        setRunMode(next)
+                      }}
                       className={`w-9 h-9 sm:w-10 sm:h-10 rounded-full flex items-center justify-center transition ${
-                        runMode === 'indefinite'
+                        runMode !== 'single'
                           ? 'text-primary bg-primary/10'
                           : 'text-muted-foreground hover:bg-muted'
                       }`}
-                      title={runMode === 'indefinite' ? 'Loop mode' : 'Play once mode'}
+                      title={
+                        runMode === 'scheduled'
+                          ? 'Scheduled — X runs per day (respects Still Sands)'
+                          : runMode === 'indefinite'
+                          ? 'Loop mode — repeat forever'
+                          : 'Play once mode'
+                      }
                     >
-                      <span className="material-icons-outlined text-lg sm:text-xl">repeat</span>
+                      <span className="material-icons-outlined text-lg sm:text-xl">
+                        {runMode === 'scheduled' ? 'today' : 'repeat'}
+                      </span>
                     </button>
                   </div>
 
-                  {/* Pause Time */}
-                  <div className="flex items-center px-2 sm:px-3 gap-2 sm:gap-3 border-r border-border">
-                    <span className="text-[10px] sm:text-xs font-semibold text-muted-foreground tracking-wider hidden sm:block">Pause</span>
-                    <div className="flex items-center gap-1">
-                      <Button
-                        variant="secondary"
-                        size="icon"
-                        className="w-7 h-7 sm:w-8 sm:h-8"
-                        onClick={() => {
-                          const step = pauseUnit === 'hr' ? 0.5 : 1
-                          setPauseTime(Math.max(0, pauseTime - step))
-                        }}
-                      >
-                        <span className="material-icons-outlined text-sm">remove</span>
-                      </Button>
-                      <button
-                        onClick={() => {
-                          const units: ('sec' | 'min' | 'hr')[] = ['sec', 'min', 'hr']
-                          const currentIndex = units.indexOf(pauseUnit)
-                          setPauseUnit(units[(currentIndex + 1) % units.length])
-                        }}
-                        className="relative flex items-center justify-center min-w-14 sm:min-w-16 px-1 text-xs sm:text-sm font-bold hover:text-primary transition"
-                        title="Click to change unit"
-                      >
-                        {pauseTime}{pauseUnit === 'sec' ? 's' : pauseUnit === 'min' ? 'm' : 'h'}
-                        <span className="material-icons-outlined text-xs opacity-50 scale-75 ml-0.5">swap_vert</span>
-                      </button>
-                      <Button
-                        variant="secondary"
-                        size="icon"
-                        className="w-7 h-7 sm:w-8 sm:h-8"
-                        onClick={() => {
-                          const step = pauseUnit === 'hr' ? 0.5 : 1
-                          setPauseTime(pauseTime + step)
-                        }}
-                      >
-                        <span className="material-icons-outlined text-sm">add</span>
-                      </Button>
+                  {runMode === 'scheduled' ? (
+                    /* Runs per day */
+                    <div className="flex items-center px-2 sm:px-3 gap-2 sm:gap-3 border-r border-border">
+                      <span className="text-[10px] sm:text-xs font-semibold text-muted-foreground tracking-wider hidden sm:block">Runs/day</span>
+                      <div className="flex items-center gap-1">
+                        <Button
+                          variant="secondary"
+                          size="icon"
+                          className="w-7 h-7 sm:w-8 sm:h-8"
+                          onClick={() => setRunsPerDay(Math.max(1, runsPerDay - 1))}
+                        >
+                          <span className="material-icons-outlined text-sm">remove</span>
+                        </Button>
+                        <span
+                          className="relative flex items-center justify-center min-w-14 sm:min-w-16 px-1 text-xs sm:text-sm font-bold"
+                          title="Number of full playlist cycles per day. Still Sands periods are excluded from the daily quota."
+                        >
+                          {runsPerDay}/day
+                        </span>
+                        <Button
+                          variant="secondary"
+                          size="icon"
+                          className="w-7 h-7 sm:w-8 sm:h-8"
+                          onClick={() => setRunsPerDay(Math.min(96, runsPerDay + 1))}
+                        >
+                          <span className="material-icons-outlined text-sm">add</span>
+                        </Button>
+                      </div>
                     </div>
-                  </div>
+                  ) : (
+                    /* Pause Time */
+                    <div className="flex items-center px-2 sm:px-3 gap-2 sm:gap-3 border-r border-border">
+                      <span className="text-[10px] sm:text-xs font-semibold text-muted-foreground tracking-wider hidden sm:block">Pause</span>
+                      <div className="flex items-center gap-1">
+                        <Button
+                          variant="secondary"
+                          size="icon"
+                          className="w-7 h-7 sm:w-8 sm:h-8"
+                          onClick={() => {
+                            const step = pauseUnit === 'hr' ? 0.5 : 1
+                            setPauseTime(Math.max(0, pauseTime - step))
+                          }}
+                        >
+                          <span className="material-icons-outlined text-sm">remove</span>
+                        </Button>
+                        <button
+                          onClick={() => {
+                            const units: ('sec' | 'min' | 'hr')[] = ['sec', 'min', 'hr']
+                            const currentIndex = units.indexOf(pauseUnit)
+                            setPauseUnit(units[(currentIndex + 1) % units.length])
+                          }}
+                          className="relative flex items-center justify-center min-w-14 sm:min-w-16 px-1 text-xs sm:text-sm font-bold hover:text-primary transition"
+                          title="Click to change unit"
+                        >
+                          {pauseTime}{pauseUnit === 'sec' ? 's' : pauseUnit === 'min' ? 'm' : 'h'}
+                          <span className="material-icons-outlined text-xs opacity-50 scale-75 ml-0.5">swap_vert</span>
+                        </button>
+                        <Button
+                          variant="secondary"
+                          size="icon"
+                          className="w-7 h-7 sm:w-8 sm:h-8"
+                          onClick={() => {
+                            const step = pauseUnit === 'hr' ? 0.5 : 1
+                            setPauseTime(pauseTime + step)
+                          }}
+                        >
+                          <span className="material-icons-outlined text-sm">add</span>
+                        </Button>
+                      </div>
+                    </div>
+                  )}
 
                   {/* Clear Pattern Dropdown */}
                   <div className="flex items-center px-1 sm:px-2">

+ 61 - 35
frontend/src/pages/SettingsPage.tsx

@@ -74,10 +74,11 @@ interface StillSandsSettings {
 interface AutoPlaySettings {
   enabled: boolean
   playlist: string
-  run_mode: 'single' | 'loop'
+  run_mode: 'single' | 'loop' | 'scheduled'
   pause_time: number
   clear_pattern: string
   shuffle: boolean
+  runs_per_day: number
 }
 
 interface LedConfig {
@@ -137,6 +138,7 @@ export function SettingsPage() {
     pause_time: 5,
     clear_pattern: 'adaptive',
     shuffle: false,
+    runs_per_day: 3,
   })
   const [autoPlayPauseUnit, setAutoPlayPauseUnit] = useState<'sec' | 'min' | 'hr'>('min')
   const [autoPlayPauseValue, setAutoPlayPauseValue] = useState(5)
@@ -385,6 +387,7 @@ export function SettingsPage() {
           pause_time: pauseSeconds,
           clear_pattern: data.auto_play.clear_pattern || 'adaptive',
           shuffle: data.auto_play.shuffle || false,
+          runs_per_day: Math.max(1, data.auto_play.runs_per_day || 3),
         })
       }
       // Set still sands settings
@@ -1863,7 +1866,7 @@ export function SettingsPage() {
                       onValueChange={(value) =>
                         setAutoPlaySettings({
                           ...autoPlaySettings,
-                          run_mode: value as 'single' | 'loop',
+                          run_mode: value as 'single' | 'loop' | 'scheduled',
                         })
                       }
                     >
@@ -1873,49 +1876,72 @@ export function SettingsPage() {
                       <SelectContent>
                         <SelectItem value="single">Single (play once)</SelectItem>
                         <SelectItem value="loop">Loop (repeat forever)</SelectItem>
+                        <SelectItem value="scheduled">Scheduled (X times per day)</SelectItem>
                       </SelectContent>
                     </Select>
                   </div>
-                  <div className="space-y-3">
-                    <Label>Pause Between Patterns</Label>
-                    <div className="flex gap-2">
+                  {autoPlaySettings.run_mode === 'scheduled' ? (
+                    <div className="space-y-3">
+                      <Label>Runs Per Day</Label>
                       <Input
-                        type="text"
-                        inputMode="numeric"
-                        value={autoPlayPauseInput}
+                        type="number"
+                        min={1}
+                        max={96}
+                        step={1}
+                        value={autoPlaySettings.runs_per_day}
                         onChange={(e) => {
-                          const val = e.target.value.replace(/[^0-9]/g, '')
-                          setAutoPlayPauseInput(val)
+                          const num = Math.max(1, Math.min(96, parseInt(e.target.value) || 1))
+                          setAutoPlaySettings({ ...autoPlaySettings, runs_per_day: num })
                         }}
-                        onBlur={() => {
-                          const num = Math.max(0, parseInt(autoPlayPauseInput) || 0)
-                          setAutoPlayPauseValue(num)
-                          setAutoPlayPauseInput(String(num))
-                        }}
-                        onKeyDown={(e) => {
-                          if (e.key === 'Enter') {
+                        className="w-24"
+                      />
+                      <p className="text-xs text-muted-foreground">
+                        Spread this many full playlist cycles evenly across the day's active hours.
+                        Still Sands periods don't count toward the daily quota.
+                      </p>
+                    </div>
+                  ) : (
+                    <div className="space-y-3">
+                      <Label>Pause Between Patterns</Label>
+                      <div className="flex gap-2">
+                        <Input
+                          type="text"
+                          inputMode="numeric"
+                          value={autoPlayPauseInput}
+                          onChange={(e) => {
+                            const val = e.target.value.replace(/[^0-9]/g, '')
+                            setAutoPlayPauseInput(val)
+                          }}
+                          onBlur={() => {
                             const num = Math.max(0, parseInt(autoPlayPauseInput) || 0)
                             setAutoPlayPauseValue(num)
                             setAutoPlayPauseInput(String(num))
-                          }
-                        }}
-                        className="w-20"
-                      />
-                      <Select
-                        value={autoPlayPauseUnit}
-                        onValueChange={(v) => setAutoPlayPauseUnit(v as 'sec' | 'min' | 'hr')}
-                      >
-                        <SelectTrigger className="w-20">
-                          <SelectValue />
-                        </SelectTrigger>
-                        <SelectContent>
-                          <SelectItem value="sec">sec</SelectItem>
-                          <SelectItem value="min">min</SelectItem>
-                          <SelectItem value="hr">hr</SelectItem>
-                        </SelectContent>
-                      </Select>
+                          }}
+                          onKeyDown={(e) => {
+                            if (e.key === 'Enter') {
+                              const num = Math.max(0, parseInt(autoPlayPauseInput) || 0)
+                              setAutoPlayPauseValue(num)
+                              setAutoPlayPauseInput(String(num))
+                            }
+                          }}
+                          className="w-20"
+                        />
+                        <Select
+                          value={autoPlayPauseUnit}
+                          onValueChange={(v) => setAutoPlayPauseUnit(v as 'sec' | 'min' | 'hr')}
+                        >
+                          <SelectTrigger className="w-20">
+                            <SelectValue />
+                          </SelectTrigger>
+                          <SelectContent>
+                            <SelectItem value="sec">sec</SelectItem>
+                            <SelectItem value="min">min</SelectItem>
+                            <SelectItem value="hr">hr</SelectItem>
+                          </SelectContent>
+                        </Select>
+                      </div>
                     </div>
-                  </div>
+                  )}
                 </div>
 
                 <div className="grid grid-cols-1 md:grid-cols-2 gap-4">

+ 11 - 3
main.py

@@ -144,7 +144,8 @@ async def lifespan(app: FastAPI):
                                 pause_time=state.auto_play_pause_time,
                                 clear_pattern=state.auto_play_clear_pattern,
                                 run_mode=state.auto_play_run_mode,
-                                shuffle=state.auto_play_shuffle
+                                shuffle=state.auto_play_shuffle,
+                                runs_per_day=state.auto_play_runs_per_day,
                             ))
                     except Exception as e:
                         logger.error(f"Failed to auto-play playlist: {str(e)}")
@@ -418,6 +419,7 @@ class PlaylistRequest(BaseModel):
     clear_pattern: Optional[str] = None
     run_mode: str = "single"
     shuffle: bool = False
+    runs_per_day: int = 1  # only used when run_mode == "scheduled"
 
 class PlaylistRunRequest(BaseModel):
     playlist_name: str
@@ -425,6 +427,7 @@ class PlaylistRunRequest(BaseModel):
     clear_pattern: Optional[str] = None
     run_mode: Optional[str] = "single"
     shuffle: Optional[bool] = False
+    runs_per_day: Optional[int] = 1
     start_time: Optional[str] = None
     end_time: Optional[str] = None
 
@@ -480,6 +483,7 @@ class AutoPlaySettingsUpdate(BaseModel):
     pause_time: Optional[float] = None
     clear_pattern: Optional[str] = None
     shuffle: Optional[bool] = None
+    runs_per_day: Optional[int] = None
 
 class ScheduledPauseSettingsUpdate(BaseModel):
     enabled: Optional[bool] = None
@@ -752,7 +756,8 @@ async def get_all_settings():
             "run_mode": state.auto_play_run_mode,
             "pause_time": state.auto_play_pause_time,
             "clear_pattern": state.auto_play_clear_pattern,
-            "shuffle": state.auto_play_shuffle
+            "shuffle": state.auto_play_shuffle,
+            "runs_per_day": state.auto_play_runs_per_day
         },
         "scheduled_pause": {
             "enabled": state.scheduled_pause_enabled,
@@ -934,6 +939,8 @@ async def update_settings(settings_update: SettingsUpdate):
             state.auto_play_clear_pattern = ap.clear_pattern
         if ap.shuffle is not None:
             state.auto_play_shuffle = ap.shuffle
+        if ap.runs_per_day is not None:
+            state.auto_play_runs_per_day = max(1, int(ap.runs_per_day))
         updated_categories.append("auto_play")
 
     # Scheduled pause (Still Sands) settings
@@ -2557,7 +2564,8 @@ async def run_playlist_endpoint(request: PlaylistRequest):
             pause_time=request.pause_time,
             clear_pattern=request.clear_pattern,
             run_mode=request.run_mode,
-            shuffle=request.shuffle
+            shuffle=request.shuffle,
+            runs_per_day=max(1, int(request.runs_per_day or 1)),
         )
         if not success:
             raise HTTPException(status_code=409, detail=message)

+ 209 - 63
modules/core/pattern_manager.py

@@ -4,7 +4,7 @@ import threading
 import time
 import random
 import logging
-from datetime import datetime, time as datetime_time
+from datetime import datetime, time as datetime_time, timedelta
 from tqdm import tqdm
 from modules.connection import connection_manager
 from modules.core.state import state
@@ -320,6 +320,105 @@ def is_in_scheduled_pause_period():
     return False
 
 
+def _scheduled_pause_seconds_in_window(start_dt: datetime, end_dt: datetime) -> float:
+    """Compute total still-sands seconds within [start_dt, end_dt) using configured slots.
+
+    Both datetimes are expected in the user-configured timezone (or naive local time).
+    Walks all configured slots and sums their overlap with the requested window,
+    handling slots that span midnight and weekday filters.
+    """
+    if not state.scheduled_pause_enabled or not state.scheduled_pause_time_slots:
+        return 0.0
+    if end_dt <= start_dt:
+        return 0.0
+
+    weekday_names = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
+
+    def slot_applies_on(weekday_idx: int, slot: dict) -> bool:
+        days_setting = slot.get('days', 'daily')
+        if days_setting == 'daily':
+            return True
+        if days_setting == 'weekdays':
+            return weekday_idx < 5
+        if days_setting == 'weekends':
+            return weekday_idx >= 5
+        if days_setting == 'custom':
+            return weekday_names[weekday_idx] in slot.get('custom_days', [])
+        return False
+
+    # Build per-day intervals (as datetimes) for each calendar day touched by the window.
+    # Start one day earlier so that slots from the prior day that span midnight into our
+    # window (e.g. 22:00–06:00 starting yesterday) are included.
+    intervals = []
+    day_cursor = start_dt.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=1)
+    last_day = end_dt.replace(hour=0, minute=0, second=0, microsecond=0)
+    while day_cursor <= last_day:
+        weekday_idx = day_cursor.weekday()
+        for slot in state.scheduled_pause_time_slots:
+            try:
+                start_t = datetime_time.fromisoformat(slot['start_time'])
+                end_t = datetime_time.fromisoformat(slot['end_time'])
+            except (ValueError, KeyError):
+                continue
+            if not slot_applies_on(weekday_idx, slot):
+                continue
+            slot_start = day_cursor.replace(hour=start_t.hour, minute=start_t.minute, second=start_t.second)
+            if start_t <= end_t:
+                slot_end = day_cursor.replace(hour=end_t.hour, minute=end_t.minute, second=end_t.second)
+                intervals.append((slot_start, slot_end))
+            else:
+                # Spans midnight: split into two intervals
+                end_of_day = day_cursor + timedelta(days=1)
+                intervals.append((slot_start, end_of_day))
+                next_day = day_cursor + timedelta(days=1)
+                intervals.append((next_day, next_day.replace(hour=end_t.hour, minute=end_t.minute, second=end_t.second)))
+        day_cursor += timedelta(days=1)
+
+    # Sum overlap with [start_dt, end_dt), merging is unnecessary because we clip per-interval
+    # but we should merge to avoid double counting overlapping slots.
+    intervals = [(max(s, start_dt), min(e, end_dt)) for s, e in intervals]
+    intervals = [(s, e) for s, e in intervals if e > s]
+    intervals.sort()
+    merged = []
+    for s, e in intervals:
+        if merged and s <= merged[-1][1]:
+            merged[-1] = (merged[-1][0], max(merged[-1][1], e))
+        else:
+            merged.append((s, e))
+    return sum((e - s).total_seconds() for s, e in merged)
+
+
+def _now_in_user_tz() -> datetime:
+    """Return current datetime in the user-configured timezone (Still Sands TZ), or system local time."""
+    tz_info = _get_timezone()
+    if tz_info:
+        return datetime.now(tz_info)
+    return datetime.now()
+
+
+# --- Scheduled run-mode tracker -------------------------------------------------
+# Tracks how many full playlist cycles have completed today (per user TZ).
+# Resets automatically when the calendar day rolls over.
+_scheduled_runs_completed_today: int = 0
+_scheduled_runs_day_token: Optional[str] = None
+
+
+def _refresh_scheduled_day_token() -> str:
+    """Reset the daily run counter if the calendar day has changed. Returns today's token."""
+    global _scheduled_runs_completed_today, _scheduled_runs_day_token
+    today_token = _now_in_user_tz().strftime("%Y-%m-%d")
+    if today_token != _scheduled_runs_day_token:
+        _scheduled_runs_day_token = today_token
+        _scheduled_runs_completed_today = 0
+    return today_token
+
+
+def get_scheduled_runs_completed_today() -> int:
+    """Number of full playlist cycles that have completed today (auto-resets at midnight)."""
+    _refresh_scheduled_day_token()
+    return _scheduled_runs_completed_today
+
+
 async def check_table_is_idle() -> bool:
     """
     Check if the table is currently idle by querying actual machine status.
@@ -1515,11 +1614,66 @@ async def run_theta_rho_file(file_path, is_playlist=False, clear_pattern=None, c
             logger.info("Pattern execution completed, maintaining state for playlist")
             
 
-async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_mode="single", shuffle=False):
+async def _wait_with_still_sands_monitor(total_seconds: float, count_still_sands: bool = True):
+    """Sleep for `total_seconds` while updating LED state across Still Sands transitions.
+
+    If count_still_sands is False, time spent inside a Still Sands period does NOT count
+    toward the elapsed total — useful for the "scheduled" run mode, which targets X runs
+    per active day and excludes Still Sands from its quota.
+
+    Returns early on stop_requested or skip_requested.
+    """
+    if total_seconds <= 0:
+        return
+    state.original_pause_time = total_seconds
+    pause_start = time.time()
+    elapsed = 0.0
+    last_tick = pause_start
+    was_in_still_sands = is_in_scheduled_pause_period() and state.scheduled_pause_control_wled
+    while elapsed < total_seconds:
+        state.pause_time_remaining = max(0.0, total_seconds - elapsed)
+        if state.stop_requested or state.skip_requested:
+            if state.stop_requested:
+                logger.info("Pause interrupted by stop request")
+            else:
+                logger.info("Pause interrupted by skip request")
+            break
+
+        in_still_sands_full = is_in_scheduled_pause_period()
+        in_still_sands_wled = in_still_sands_full and state.scheduled_pause_control_wled
+        if in_still_sands_wled and not was_in_still_sands:
+            logger.info("Still Sands period started during pause, turning off LEDs")
+            if state.led_controller:
+                await state.led_controller.set_power_async(0)
+        elif not in_still_sands_wled and was_in_still_sands:
+            logger.info("Still Sands period ended during pause, restoring idle LED effect")
+            await start_idle_led_timeout(check_still_sands=False)
+        was_in_still_sands = in_still_sands_wled
+
+        await asyncio.sleep(1)
+        now = time.time()
+        delta = now - last_tick
+        last_tick = now
+        # Skip elapsed time accumulation while in Still Sands when caller asked us to.
+        if count_still_sands or not in_still_sands_full:
+            elapsed += delta
+
+    state.pause_time_remaining = 0
+    state.original_pause_time = None
+
+
+async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_mode="single", shuffle=False, runs_per_day=1):
     """Run multiple .thr files in sequence with options.
 
     The playlist now stores only main patterns. Clear patterns are executed dynamically
     before each main pattern based on the clear_pattern option.
+
+    run_mode:
+        - "single": play the playlist once, then stop
+        - "loop"/"indefinite": play the playlist forever, with `pause_time` between cycles
+        - "scheduled": play the full playlist `runs_per_day` times within a 24-hour day,
+          spreading runs evenly across the day's *active* hours (Still Sands periods are
+          excluded from the quota — the gap between cycles pauses while inside Still Sands).
     """
     state.stop_requested = False
 
@@ -1636,34 +1790,7 @@ async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_
                     state.current_playing_file = None
                     # Trigger idle LED state during pause between patterns
                     await start_idle_led_timeout(check_still_sands=True)
-                    state.original_pause_time = pause_time
-                    pause_start = time.time()
-                    # Track Still Sands state for edge detection during long pauses
-                    was_in_still_sands = is_in_scheduled_pause_period() and state.scheduled_pause_control_wled
-                    while time.time() - pause_start < pause_time:
-                        state.pause_time_remaining = pause_start + pause_time - time.time()
-                        if state.skip_requested or state.stop_requested:
-                            if state.stop_requested:
-                                logger.info("Pause interrupted by stop request")
-                            else:
-                                logger.info("Pause interrupted by skip request")
-                            break
-                        # Monitor Still Sands transitions during pause
-                        in_still_sands = is_in_scheduled_pause_period() and state.scheduled_pause_control_wled
-                        if in_still_sands and not was_in_still_sands:
-                            # Entering Still Sands period — turn off LEDs
-                            logger.info("Still Sands period started during pause, turning off LEDs")
-                            if state.led_controller:
-                                await state.led_controller.set_power_async(0)
-                        elif not in_still_sands and was_in_still_sands:
-                            # Leaving Still Sands period — restore idle effect
-                            logger.info("Still Sands period ended during pause, restoring idle LED effect")
-                            await start_idle_led_timeout(check_still_sands=False)
-                        was_in_still_sands = in_still_sands
-                        await asyncio.sleep(1)
-                    # Clear both pause state vars immediately (so UI updates right away)
-                    state.pause_time_remaining = 0
-                    state.original_pause_time = None
+                    await _wait_with_still_sands_monitor(pause_time, count_still_sands=True)
 
                 # Auto-home after pause time, before next clear pattern starts
                 # Only home if there's a next pattern and we haven't been stopped
@@ -1685,41 +1812,60 @@ async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_
                 state.skip_requested = False
                 idx += 1
 
-            if run_mode == "indefinite":
-                logger.info("Playlist completed. Restarting as per 'indefinite' run mode")
-                if pause_time > 0:
-                    # Clear current_playing_file to report "idle" state to MQTT/HA during pause
+            if run_mode in ("indefinite", "loop"):
+                logger.info(f"Playlist completed. Restarting as per '{run_mode}' run mode")
+                if pause_time > 0 and not state.stop_requested:
                     state.current_playing_file = None
-                    # Trigger idle LED state during pause between playlist cycles
                     await start_idle_led_timeout(check_still_sands=True)
-                    state.original_pause_time = pause_time
-                    pause_start = time.time()
-                    # Track Still Sands state for edge detection during long pauses
-                    was_in_still_sands = is_in_scheduled_pause_period() and state.scheduled_pause_control_wled
-                    while time.time() - pause_start < pause_time:
-                        state.pause_time_remaining = pause_start + pause_time - time.time()
-                        if state.skip_requested or state.stop_requested:
-                            if state.stop_requested:
-                                logger.info("Pause interrupted by stop request")
-                            else:
-                                logger.info("Pause interrupted by skip request")
-                            break
-                        # Monitor Still Sands transitions during pause
-                        in_still_sands = is_in_scheduled_pause_period() and state.scheduled_pause_control_wled
-                        if in_still_sands and not was_in_still_sands:
-                            # Entering Still Sands period — turn off LEDs
-                            logger.info("Still Sands period started during pause, turning off LEDs")
-                            if state.led_controller:
-                                await state.led_controller.set_power_async(0)
-                        elif not in_still_sands and was_in_still_sands:
-                            # Leaving Still Sands period — restore idle effect
-                            logger.info("Still Sands period ended during pause, restoring idle LED effect")
-                            await start_idle_led_timeout(check_still_sands=False)
-                        was_in_still_sands = in_still_sands
-                        await asyncio.sleep(1)
-                    # Clear both pause state vars immediately (so UI updates right away)
-                    state.pause_time_remaining = 0
-                    state.original_pause_time = None
+                    await _wait_with_still_sands_monitor(pause_time, count_still_sands=True)
+                continue
+            elif run_mode == "scheduled":
+                # "Run X times per day" mode. Distribute runs evenly across the day's
+                # *active* hours (Still Sands time is excluded from the daily quota).
+                global _scheduled_runs_completed_today
+                _refresh_scheduled_day_token()
+                _scheduled_runs_completed_today += 1
+                target_runs = max(1, int(runs_per_day or 1))
+                logger.info(f"Scheduled mode: completed run {_scheduled_runs_completed_today}/{target_runs} for today")
+
+                if state.stop_requested:
+                    break
+
+                # Compute how long to wait before the next cycle.
+                now_user = _now_in_user_tz()
+                tomorrow_midnight = (now_user + timedelta(days=1)).replace(
+                    hour=0, minute=0, second=0, microsecond=0
+                )
+                seconds_until_midnight = max(0.0, (tomorrow_midnight - now_user).total_seconds())
+                still_sands_until_midnight = _scheduled_pause_seconds_in_window(now_user, tomorrow_midnight)
+                active_seconds_until_midnight = max(0.0, seconds_until_midnight - still_sands_until_midnight)
+
+                runs_remaining_today = target_runs - _scheduled_runs_completed_today
+
+                if runs_remaining_today <= 0:
+                    # All target runs done today: idle until tomorrow's midnight, then resume.
+                    logger.info(
+                        f"Scheduled mode: daily quota of {target_runs} runs reached. "
+                        f"Sleeping {seconds_until_midnight/3600:.2f}h until next day."
+                    )
+                    state.current_playing_file = None
+                    await start_idle_led_timeout(check_still_sands=True)
+                    # Count Still Sands toward this end-of-day wait — we just need to reach midnight.
+                    await _wait_with_still_sands_monitor(seconds_until_midnight, count_still_sands=True)
+                    # Day rolled over: refresh resets the counter on next loop iteration.
+                    _refresh_scheduled_day_token()
+                else:
+                    # Spread the remaining runs evenly across the remaining active time.
+                    # Wait this many *active* seconds before starting the next cycle.
+                    target_active_wait = active_seconds_until_midnight / runs_remaining_today
+                    logger.info(
+                        f"Scheduled mode: {runs_remaining_today} runs remaining, "
+                        f"{active_seconds_until_midnight/3600:.2f}h active time left. "
+                        f"Waiting ~{target_active_wait/60:.1f}m of active time before next run."
+                    )
+                    state.current_playing_file = None
+                    await start_idle_led_timeout(check_still_sands=True)
+                    await _wait_with_still_sands_monitor(target_active_wait, count_still_sands=False)
                 continue
             else:
                 logger.info("Playlist completed")

+ 10 - 3
modules/core/playlist_manager.py

@@ -138,8 +138,13 @@ async def cancel_current_playlist():
             logger.warning(f"Error while cancelling playlist task: {e}")
         _current_playlist_task = None
 
-async def run_playlist(playlist_name, pause_time=0, clear_pattern=None, run_mode="single", shuffle=False):
-    """Run a playlist with the given options."""
+async def run_playlist(playlist_name, pause_time=0, clear_pattern=None, run_mode="single", shuffle=False, runs_per_day=1):
+    """Run a playlist with the given options.
+
+    runs_per_day applies only when run_mode == "scheduled": the playlist will be played
+    that many full cycles per 24 hours, evenly spread across the day's active hours
+    (Still Sands periods are excluded from the daily quota).
+    """
     global _current_playlist_task
 
     # Cancel any existing playlist task first
@@ -163,7 +168,8 @@ async def run_playlist(playlist_name, pause_time=0, clear_pattern=None, run_mode
         return False, "Playlist is empty"
 
     try:
-        logger.info(f"Starting playlist '{playlist_name}' with mode={run_mode}, shuffle={shuffle}")
+        extra = f", runs_per_day={runs_per_day}" if run_mode == "scheduled" else ""
+        logger.info(f"Starting playlist '{playlist_name}' with mode={run_mode}, shuffle={shuffle}{extra}")
         # Set ALL playlist state variables BEFORE creating the async task.
         # This ensures state is correct even if the task doesn't start immediately
         # (important for TestClient which may cancel background tasks).
@@ -178,6 +184,7 @@ async def run_playlist(playlist_name, pause_time=0, clear_pattern=None, run_mode
                 clear_pattern=clear_pattern,
                 run_mode=run_mode,
                 shuffle=shuffle,
+                runs_per_day=runs_per_day,
             )
         )
         return True, f"Playlist '{playlist_name}' is now running."

+ 4 - 1
modules/core/state.py

@@ -151,10 +151,11 @@ class AppState:
         # auto_play mode settings
         self.auto_play_enabled = False
         self.auto_play_playlist = None  # Playlist to auto-play in auto_play mode
-        self.auto_play_run_mode = "loop"  # "single" or "loop"
+        self.auto_play_run_mode = "loop"  # "single", "loop", or "scheduled"
         self.auto_play_pause_time = 5.0  # Pause between patterns in seconds
         self.auto_play_clear_pattern = "adaptive"  # Clear pattern option
         self.auto_play_shuffle = False  # Shuffle playlist
+        self.auto_play_runs_per_day = 1  # Number of full playlist cycles per day (for "scheduled" run mode)
 
         # Still Sands settings
         self.scheduled_pause_enabled = False
@@ -531,6 +532,7 @@ class AppState:
             "auto_play_pause_time": self.auto_play_pause_time,
             "auto_play_clear_pattern": self.auto_play_clear_pattern,
             "auto_play_shuffle": self.auto_play_shuffle,
+            "auto_play_runs_per_day": self.auto_play_runs_per_day,
             "scheduled_pause_enabled": self.scheduled_pause_enabled,
             "scheduled_pause_time_slots": self.scheduled_pause_time_slots,
             "scheduled_pause_control_wled": self.scheduled_pause_control_wled,
@@ -652,6 +654,7 @@ class AppState:
         self.auto_play_pause_time = data.get("auto_play_pause_time", 5.0)
         self.auto_play_clear_pattern = data.get("auto_play_clear_pattern", "adaptive")
         self.auto_play_shuffle = data.get("auto_play_shuffle", False)
+        self.auto_play_runs_per_day = data.get("auto_play_runs_per_day", 1)
         self.scheduled_pause_enabled = data.get("scheduled_pause_enabled", False)
         self.scheduled_pause_time_slots = data.get("scheduled_pause_time_slots", [])
         self.scheduled_pause_control_wled = data.get("scheduled_pause_control_wled", False)