// Fortnite Island CCU Widget for Scriptable (iOS) // Shows live player count for any Fortnite island using Epic's public Ecosystem API. // No login, no API key required. // // Setup: add a Scriptable widget to your home screen, pick this script, // and put your island code (e.g. 1234-5678-9012) in the widget's "Parameter" field. // Dashes are optional - the script cleans the code automatically. // Small widget = name + number. Medium widget = adds a 6-hour sparkline. // // NOTE: this file deliberately avoids backticks, "||", "&&" and "..." so the // code survives copy-paste through apps that autocorrect punctuation. // // by novikit - https://fortnite.com/@novikit // ======================= CONFIG ======================= const DEFAULT_ISLAND = "5371-4743-1449" // used when no widget parameter is set const HOURS_WINDOW = 6 // how far back to look for data / sparkline const BG_TOP = "#0a0e27" const BG_BOTTOM = "#1a1d3a" const ACCENT = "#c8ff00" // island name + trend up const ACCENT_DOWN = "#ff2e88" // trend down // ====================================================== const API = "https://api.fortnite.com/ecosystem/v1/islands" // Island codes are 12 digits. Keep only digits and rebuild XXXX-XXXX-XXXX, // so lookalike dashes, spaces and invisible characters can never break the code. function cleanIslandCode(raw) { const s = String(raw) let digits = "" for (let i = 0; i < s.length; i++) { const ch = s.charAt(i) if (ch >= "0") { if (ch <= "9") digits = digits + ch } } if (digits.length === 12) { return digits.slice(0, 4) + "-" + digits.slice(4, 8) + "-" + digits.slice(8, 12) } return s.trim() } let island = cleanIslandCode(DEFAULT_ISLAND) if (args.widgetParameter != null) { const param = cleanIslandCode(args.widgetParameter) if (param.length > 0) island = param } // --- Island name: fetch once, keep a cached copy so the widget works offline --- async function getIslandInfo(code) { const key = "ccu-widget-info-" + code try { const req = new Request(API + "/" + code) req.timeoutInterval = 15 const json = await req.loadJSON() if (json != null) { if (json.title != null) { const info = { title: json.title, ok: true } Keychain.set(key, JSON.stringify(info)) return info } } } catch (e) {} if (Keychain.contains(key)) { try { const saved = JSON.parse(Keychain.get(key)) saved.ok = true return saved } catch (e) {} } return { title: code, ok: false } } // --- Metrics: array of {value, timestamp}, nulls filtered out --- async function getPoints(code) { const key = "ccu-widget-last-" + code try { const to = new Date() const from = new Date(to.getTime() - HOURS_WINDOW * 3600 * 1000) const url = API + "/" + code + "/metrics/minute/peak-ccu?from=" + from.toISOString() + "&to=" + to.toISOString() const req = new Request(url) req.timeoutInterval = 15 const json = await req.loadJSON() let intervals = [] if (json != null) { if (json.intervals != null) intervals = json.intervals } const points = [] for (let i = 0; i < intervals.length; i++) { const p = intervals[i] if (p != null) { if (p.value != null) points.push(p) } } if (points.length > 0) { Keychain.set(key, JSON.stringify(points[points.length - 1])) return { points: points, cached: false } } } catch (e) {} if (Keychain.contains(key)) { try { return { points: [JSON.parse(Keychain.get(key))], cached: true } } catch (e) {} } return { points: [], cached: false } } // --- Sparkline drawn with DrawContext --- function drawSparkline(points, width, height, colorHex) { const ctx = new DrawContext() ctx.size = new Size(width, height) ctx.opaque = false ctx.respectScreenScale = true let min = points[0].value let max = points[0].value for (let i = 1; i < points.length; i++) { if (points[i].value < min) min = points[i].value if (points[i].value > max) max = points[i].value } let span = max - min if (span === 0) span = 1 const path = new Path() for (let i = 0; i < points.length; i++) { const x = (i / (points.length - 1)) * width const y = height - 2 - ((points[i].value - min) / span) * (height - 4) if (i === 0) path.move(new Point(x, y)) else path.addLine(new Point(x, y)) } ctx.addPath(path) ctx.setStrokeColor(new Color(colorHex)) ctx.setLineWidth(2) ctx.strokePath() return ctx.getImage() } // --- Build the widget --- async function buildWidget() { const info = await getIslandInfo(island) const result = await getPoints(island) const points = result.points const cached = result.cached let latest = null if (points.length > 0) latest = points[points.length - 1] let prev = null if (points.length > 1) prev = points[points.length - 2] let diff = 0 if (latest != null) { if (prev != null) diff = latest.value - prev.value } const w = new ListWidget() const grad = new LinearGradient() grad.colors = [new Color(BG_TOP), new Color(BG_BOTTOM)] grad.locations = [0, 1] w.backgroundGradient = grad w.setPadding(14, 14, 14, 14) w.url = "https://fortnite.gg/island?code=" + island w.refreshAfterDate = new Date(Date.now() + 10 * 60 * 1000) let family = "small" if (config.widgetFamily != null) family = config.widgetFamily let isMedium = false if (family === "medium") isMedium = true if (family === "large") isMedium = true // Island name const titleTxt = w.addText(info.title.toUpperCase()) titleTxt.font = Font.semiboldSystemFont(11) titleTxt.textColor = new Color(ACCENT) titleTxt.lineLimit = isMedium ? 1 : 2 titleTxt.minimumScaleFactor = 0.7 if (!isMedium) titleTxt.centerAlignText() w.addSpacer() // Big number + trend arrow const numRow = w.addStack() numRow.centerAlignContent() if (!isMedium) numRow.addSpacer() const big = numRow.addText(latest != null ? latest.value.toLocaleString() : "—") big.font = Font.boldRoundedSystemFont(isMedium ? 52 : 44) big.textColor = Color.white() big.minimumScaleFactor = 0.4 big.lineLimit = 1 if (diff !== 0) { numRow.addSpacer(6) const arrow = diff > 0 ? "▲" : "▼" const trend = numRow.addText(arrow + Math.abs(diff)) trend.font = Font.semiboldSystemFont(13) trend.textColor = new Color(diff > 0 ? ACCENT : ACCENT_DOWN) } if (!isMedium) numRow.addSpacer() w.addSpacer() // Sparkline (medium/large only, needs at least 2 points) if (isMedium) { if (points.length > 1) { const img = w.addImage(drawSparkline(points, 280, 32, ACCENT)) img.resizable = false w.addSpacer(6) } } // Bottom caption: status + data timestamp const df = new DateFormatter() df.useNoDateStyle() df.useShortTimeStyle() let caption = "no data" if (latest != null) { const t = df.string(new Date(latest.timestamp)) if (cached) caption = "offline · " + t else caption = "last update · " + t } else if (!info.ok) { caption = "check island code" } const cap = w.addText(caption.toUpperCase()) cap.font = Font.mediumSystemFont(9) cap.textColor = Color.white() cap.textOpacity = 0.5 if (!isMedium) cap.centerAlignText() return w } // If anything unexpected breaks, show the error in the widget instead of a blank let widget try { widget = await buildWidget() } catch (e) { widget = new ListWidget() widget.backgroundColor = new Color(BG_TOP) const err = widget.addText("ERROR") err.font = Font.semiboldSystemFont(12) err.textColor = new Color(ACCENT_DOWN) const msg = widget.addText(String(e)) msg.font = Font.mediumSystemFont(10) msg.textColor = Color.white() msg.minimumScaleFactor = 0.5 } Script.setWidget(widget) Script.complete() if (!config.runsInWidget) await widget.presentMedium()