// EventSaver - shopfloor event-advert screensaver. // Two source modes, set in EventSaver.ini next to the .scr (no recompile): // url=https://.../shopdb/api/slides/feed?surface=shopfloor -> pull from shopdb over // HTTP, cache images locally, rotate the cache (no file share needed). // folder=\\server\share\path -> read an SMB/local folder. // url wins if both set. Strict order + per-slide seconds via order.txt (or the // API's slides[].seconds). Cache survives a network blip (keeps last-good). // // Screensaver arg contract: // /s show (fullscreen) // /p preview (we no-op - keeps Windows happy) // /c config (points user at the ini) // // Build (in-box .NET Framework, no SDK): // C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe ^ // /target:winexe /out:EventSaver.scr ^ // /reference:System.dll,System.Drawing.dll,System.Windows.Forms.dll,System.Web.Extensions.dll ^ // EventSaver.cs using System; using System.Collections.Generic; using System.Drawing; using System.Drawing.Drawing2D; using System.IO; using System.Net; using System.Runtime.InteropServices; using System.Text; using System.Web.Script.Serialization; using System.Windows.Forms; namespace EventSaver { internal static class Program { [STAThread] private static void Main(string[] args) { string mode = "/s"; if (args.Length > 0) mode = args[0].ToLowerInvariant().Trim(); // strip a trailing ":hwnd" some callers append (e.g. /p:12345) if (mode.StartsWith("/p")) mode = "/p"; if (mode.StartsWith("/c")) mode = "/c"; if (mode.StartsWith("/s")) mode = "/s"; if (mode == "/test") { // headless self-test: print the resolved playlist order and exit. // lets CI / a display-less VM verify folder-read + order.txt + sort. // winexe has no console in session 0, so write results to a // file next to the exe (and Console too, for interactive runs). Config tc = Config.Load(); string tf = tc.SourceFolder(true); // http mode: sync cache first List pl = Playlist.Build(tf, tc.Shuffle); List lines = new List(); lines.Add((tc.Url.Length > 0 ? "url=" + tc.Url + " cache=" : "folder=") + tf); lines.Add("interval=" + tc.IntervalSeconds + " shuffle=" + tc.Shuffle); lines.Add("count=" + pl.Count); for (int i = 0; i < pl.Count; i++) lines.Add(string.Format("{0,2}: {1} (secs={2})", i + 1, Path.GetFileName(pl[i].Path), pl[i].Seconds)); foreach (string l in lines) Console.WriteLine(l); try { string outDir = Path.GetDirectoryName(Application.ExecutablePath); File.WriteAllLines(Path.Combine(outDir, "eventsaver-test-out.txt"), lines.ToArray()); } catch { } return; } if (mode == "/render") { // headless render check: draw the first slide onto a 1280x720 // black canvas with the same fit logic as OnPaint, save a PNG. // Lets a display-less VM prove decode + letterbox actually work. Config rc = Config.Load(); List rpl = Playlist.Build(rc.SourceFolder(true), rc.Shuffle); string outPng = args.Length > 1 ? args[1] : Path.Combine( Path.GetDirectoryName(Application.ExecutablePath), "eventsaver-render.png"); using (Bitmap canvas = new Bitmap(1280, 720)) using (Graphics g = Graphics.FromImage(canvas)) { g.Clear(Color.Black); if (rpl.Count > 0) { using (FileStream fs = new FileStream(rpl[0].Path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) using (Image img = Image.FromStream(fs)) { g.InterpolationMode = InterpolationMode.HighQualityBicubic; Rectangle r = SaverForm.FitZoomPublic(img.Size, canvas.Size); g.DrawImage(img, r); } } canvas.Save(outPng, System.Drawing.Imaging.ImageFormat.Png); } return; } if (mode == "/c") { MessageBox.Show( "Edit EventSaver.ini next to EventSaver.scr to set the image folder, interval, and order.", "EventSaver", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } if (mode == "/p") { // preview pane - do nothing, exit clean return; } Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); Config cfg = Config.Load(); // one form per screen: primary shows slideshow, others stay black List
forms = new List(); Screen primary = Screen.PrimaryScreen; foreach (Screen scr in Screen.AllScreens) { bool isPrimary = scr.Equals(primary); SaverForm f = new SaverForm(scr, isPrimary ? cfg : null); forms.Add(f); } foreach (Form f in forms) f.Show(); // Keep the monitor awake while the screensaver shows, so a shorter // monitor-sleep policy can't blank the ads out from under us. Held // for the life of the message loop, released on exit. SetThreadExecutionState(ES_CONTINUOUS | ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED); Application.Run(forms[0]); SetThreadExecutionState(ES_CONTINUOUS); } [DllImport("kernel32.dll")] private static extern uint SetThreadExecutionState(uint esFlags); private const uint ES_CONTINUOUS = 0x80000000; private const uint ES_DISPLAY_REQUIRED = 0x00000002; private const uint ES_SYSTEM_REQUIRED = 0x00000001; } // ------------------------------------------------------------------ config internal sealed class Config { // No default: a share path belongs to a site, not to this program // (ADR-015). Folder mode is the fallback for a site with no HTTP // reach to ShopDB, and it must name its own path in EventSaver.ini. public string Folder = ""; public string Url = ""; // set -> HTTP mode (pull from shopdb) public string CacheDir = ""; // local cache for HTTP mode (computed) public int IntervalSeconds = 10; public bool Shuffle = false; public int FadeMs = 600; public static Config Load() { Config c = new Config(); try { string dir = Path.GetDirectoryName(Application.ExecutablePath); string ini = Path.Combine(dir, "EventSaver.ini"); if (!File.Exists(ini)) return c; foreach (string raw in File.ReadAllLines(ini)) { string line = raw.Trim(); if (line.Length == 0 || line.StartsWith("#") || line.StartsWith(";")) continue; int eq = line.IndexOf('='); if (eq <= 0) continue; string key = line.Substring(0, eq).Trim().ToLowerInvariant(); string val = line.Substring(eq + 1).Trim(); if (key == "url" && val.Length > 0) c.Url = val; else if (key == "folder" && val.Length > 0) c.Folder = val; else if (key == "interval") { int n; if (int.TryParse(val, out n) && n > 0) c.IntervalSeconds = n; } else if (key == "shuffle") c.Shuffle = (val == "1" || val.ToLowerInvariant() == "true"); else if (key == "fadems") { int n; if (int.TryParse(val, out n) && n >= 0) c.FadeMs = n; } } } catch { /* bad ini - fall back to defaults */ } return c; } // Folder the playlist reads: the local cache in HTTP mode (synced first // when sync=true), else the configured share/folder. HTTP failures leave // the last-good cache in place. public string SourceFolder(bool sync) { if (Url.Length == 0) return Folder; if (CacheDir.Length == 0) CacheDir = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "EventSaver", "cache"); if (sync) { try { HttpSync.Sync(Url, CacheDir); } catch { } } return CacheDir; } } // ------------------------------------------------------------- http sync // Pull the slide list from shopdb (/api/slides/feed), download images into a // local cache dir, and write order.txt there so the normal Playlist logic // reads the cache exactly like a folder. Idempotent: only downloads images // not already cached, prunes ones no longer listed, keeps last-good on error. internal static class HttpSync { public static void Sync(string apiUrl, string cacheDir) { try { ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12 | SecurityProtocolType.Tls11 | SecurityProtocolType.Tls; } catch { } if (!Directory.Exists(cacheDir)) Directory.CreateDirectory(cacheDir); string json; using (WebClient wc = new WebClient()) { wc.Encoding = Encoding.UTF8; json = wc.DownloadString(apiUrl); } JavaScriptSerializer js = new JavaScriptSerializer(); IDictionary root = js.DeserializeObject(json) as IDictionary; if (root == null) return; object ok; if (!root.TryGetValue("success", out ok) || !(ok is bool) || !((bool)ok)) return; string basepath = root.ContainsKey("basepath") ? Convert.ToString(root["basepath"]) : ""; object slidesObj; if (!root.TryGetValue("slides", out slidesObj)) return; object[] arr = slidesObj as object[]; if (arr == null) return; Uri apiUri = new Uri(apiUrl); // The feed's basepath is host-absolute (/api/slides/img/...) and omits // the app's mount (e.g. /shopdb) - the web client adds it via withBase, // so we must too, else images resolve to the host root and 404. Derive // the mount from the feed URL's path (everything before "/api/"). string mount = ""; int apiIdx = apiUri.AbsolutePath.IndexOf("/api/", StringComparison.OrdinalIgnoreCase); if (apiIdx > 0) mount = apiUri.AbsolutePath.Substring(0, apiIdx); HashSet keep = new HashSet(StringComparer.OrdinalIgnoreCase); List order = new List(); order.Add("# generated by EventSaver from " + apiUrl); foreach (object o in arr) { IDictionary s = o as IDictionary; if (s == null) continue; string fn = s.ContainsKey("filename") ? Convert.ToString(s["filename"]) : null; if (string.IsNullOrEmpty(fn)) continue; string safe = Path.GetFileName(fn); // strip any path component if (safe.Length == 0) continue; int secs = 0; if (s.ContainsKey("seconds")) { int n; if (int.TryParse(Convert.ToString(s["seconds"]), out n) && n > 0) secs = n; } string local = Path.Combine(cacheDir, safe); if (!File.Exists(local)) { try { // Host-absolute basepath -> prepend the mount; a full URL passes through. string imgRef = basepath.StartsWith("/") ? mount + basepath : basepath; Uri img = new Uri(apiUri, imgRef + Uri.EscapeDataString(safe)); using (WebClient wc = new WebClient()) { byte[] b = wc.DownloadData(img); File.WriteAllBytes(local, b); } } catch { continue; } // couldn't fetch this one - skip it this round } keep.Add(safe); order.Add(secs > 0 ? safe + "|" + secs : safe); } try { File.WriteAllLines(Path.Combine(cacheDir, "order.txt"), order.ToArray()); } catch { } // prune cache images no longer referenced foreach (string f in Directory.GetFiles(cacheDir)) { string n = Path.GetFileName(f); if (string.Equals(n, "order.txt", StringComparison.OrdinalIgnoreCase)) continue; if (!keep.Contains(n)) { try { File.Delete(f); } catch { } } } } } // ------------------------------------------------------------- playlist // Builds ordered file list. order.txt wins (strict sequence, one name per // line, optional "name|seconds" per-slide duration). Else sort by name. internal sealed class Slide { public string Path; public int Seconds; // 0 = use default interval } internal static class Playlist { private static readonly string[] Exts = { ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp" }; public static List Build(string folder, bool shuffle) { List list = new List(); try { if (!Directory.Exists(folder)) return list; string orderFile = Path.Combine(folder, "order.txt"); if (File.Exists(orderFile)) { // strict sequence from order.txt foreach (string raw in File.ReadAllLines(orderFile)) { string line = raw.Trim(); if (line.Length == 0 || line.StartsWith("#") || line.StartsWith(";")) continue; int secs = 0; string name = line; int bar = line.IndexOf('|'); if (bar > 0) { name = line.Substring(0, bar).Trim(); int n; if (int.TryParse(line.Substring(bar + 1).Trim(), out n) && n > 0) secs = n; } string full = Path.Combine(folder, name); if (IsImage(full) && File.Exists(full)) list.Add(new Slide { Path = full, Seconds = secs }); } return list; // order.txt is authoritative - do not append extras } // no order.txt - all images, sorted by filename List files = new List(); foreach (string f in Directory.GetFiles(folder)) if (IsImage(f)) files.Add(f); files.Sort(StringComparer.OrdinalIgnoreCase); if (shuffle) Shuf(files); foreach (string f in files) list.Add(new Slide { Path = f, Seconds = 0 }); } catch { /* share unreachable - return what we have (maybe empty) */ } return list; } private static bool IsImage(string path) { string e = Path.GetExtension(path).ToLowerInvariant(); foreach (string x in Exts) if (x == e) return true; return false; } // deterministic-enough shuffle; screensaver so exact randomness irrelevant private static void Shuf(List l) { Random r = new Random(); for (int i = l.Count - 1; i > 0; i--) { int j = r.Next(i + 1); string t = l[i]; l[i] = l[j]; l[j] = t; } } } // ------------------------------------------------------------- saver form internal sealed class SaverForm : Form { private readonly Config cfg; // null on non-primary screens (black only) private readonly Timer timer; private List slides = new List(); private int idx = -1; private Image current; private Point lastMouse = Point.Empty; private bool mouseSeen = false; private DateTime lastScan = DateTime.MinValue; public SaverForm(Screen screen, Config c) { cfg = c; FormBorderStyle = FormBorderStyle.None; Bounds = screen.Bounds; StartPosition = FormStartPosition.Manual; BackColor = Color.Black; TopMost = true; ShowInTaskbar = false; DoubleBuffered = true; Cursor.Hide(); KeyPreview = true; // Left/Right step through the slides by hand; ANY other key still // wakes the machine, which is what a screensaver must do. Without // that exception an operator tapping an arrow to get back to work // would be stuck watching slides. KeyDown += (s, e) => { if (e.KeyCode == Keys.Left) { e.Handled = true; Step(-1); return; } if (e.KeyCode == Keys.Right) { e.Handled = true; Step(1); return; } Quit(); }; MouseDown += (s, e) => Quit(); MouseMove += OnMove; if (cfg != null) { cfg.SourceFolder(true); // HTTP mode: initial sync + set CacheDir Rescan(); timer = new Timer(); timer.Interval = 1000; // tick every second; advance when slide's time is up timer.Tick += OnTick; timer.Start(); Advance(); // show first immediately } } private int slideElapsed = 0; private DateTime lastSync = DateTime.Now; // ctor already did the first sync private void OnTick(object sender, EventArgs e) { // HTTP mode: re-pull from shopdb every 60s so manager edits propagate. if (cfg.Url.Length > 0 && (DateTime.Now - lastSync).TotalSeconds >= 60) { lastSync = DateTime.Now; try { HttpSync.Sync(cfg.Url, cfg.CacheDir); } catch { } lastScan = DateTime.MinValue; // force the rescan below } // periodic rescan so edits appear without restarting the saver if ((DateTime.Now - lastScan).TotalSeconds >= 30) Rescan(); slideElapsed++; int want = (slides.Count > 0 && idx >= 0 && slides[idx].Seconds > 0) ? slides[idx].Seconds : cfg.IntervalSeconds; if (slideElapsed >= want) Advance(); } private void Rescan() { lastScan = DateTime.Now; List fresh = Playlist.Build(cfg.SourceFolder(false), cfg.Shuffle); slides = fresh; if (idx >= slides.Count) idx = -1; } private void Advance() { Step(1); } // delta of +1 is the timer advancing, -1 is the operator going back. // Resets the dwell timer either way: stepping by hand and then having it // move again a moment later, because the tick was nearly up, reads as // the screensaver ignoring the keypress. private void Step(int delta) { slideElapsed = 0; if (slides.Count == 0) { SetImage(null); return; } if (idx < 0) idx = (delta < 0) ? 0 : -1; // first Step lands on slide 0 idx = ((idx + delta) % slides.Count + slides.Count) % slides.Count; try { // load without locking the file on the share Image img; using (FileStream fs = new FileStream(slides[idx].Path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) img = Image.FromStream(fs); SetImage(img); } catch { SetImage(null); } } private void SetImage(Image img) { Image old = current; current = img; if (old != null) old.Dispose(); Invalidate(); } protected override void OnPaint(PaintEventArgs e) { e.Graphics.Clear(Color.Black); if (current == null) return; e.Graphics.InterpolationMode = InterpolationMode.HighQualityBicubic; Rectangle r = FitZoom(current.Size, ClientSize); e.Graphics.DrawImage(current, r); } // test hook - same math as OnPaint, exposed for the /render self-check. public static Rectangle FitZoomPublic(Size img, Size box) { return FitZoom(img, box); } // scale image to fit while preserving aspect (letterbox) private static Rectangle FitZoom(Size img, Size box) { if (img.Width == 0 || img.Height == 0) return new Rectangle(0, 0, box.Width, box.Height); double s = Math.Min((double)box.Width / img.Width, (double)box.Height / img.Height); int w = (int)(img.Width * s); int h = (int)(img.Height * s); return new Rectangle((box.Width - w) / 2, (box.Height - h) / 2, w, h); } private void OnMove(object sender, MouseEventArgs e) { // ignore the first synthetic move; require real movement to exit if (!mouseSeen) { mouseSeen = true; lastMouse = e.Location; return; } if (Math.Abs(e.X - lastMouse.X) > 8 || Math.Abs(e.Y - lastMouse.Y) > 8) Quit(); } private void Quit() { try { Cursor.Show(); } catch { } Application.Exit(); } } }