Description & How to Use
This is a versatile, clean iOS Scriptable widget template that displays the latest videos from your favorite YouTube channel directly on your Home Screen. It is fully responsive and automatically adjusts its layout and the number of displayed videos depending on whether you set it as a Small, Medium, Large, or Extra Large widget.
Key Features
The widget supports two main modes of operation. By default, it displays the channel’s most recently uploaded videos (Normal Mode). However, you can also set it to "Random Mode," which uses a fast, two-step API request to shuffle and showcase random videos from the channel's entire history. Tapping on any video inside the widget will open it directly in YouTube, and tapping the header will take you to the channel page.
Setup Guide
First, copy the template code and paste it into a new script within the Scriptable app. You will need to customize the configuration section at the very top of the script with three details:
API_KEY: Insert your free YouTube Data API v3 key, which you can get from the Google Cloud Console.
YOUTUBE_CHANNEL_ID: Insert the target channel's ID (this is the unique ID starting with "UC").
YOUTUBE_CHANNEL_URL: Insert the channel's custom handle URL (e.g., https://youtube.com/@username).
Advanced Customization (Widget Parameters)
You can further customize the widget's behavior by typing a value into the "Parameter" field in the iOS widget settings:
Leave it blank to show the latest videos (Page 1).
Type a number (like 2 or 3) to paginate and show older videos from previous pages.
Type random or rnd to trigger the Random Mode and enjoy a shuffled selection of videos every time the widget refreshes.
Code:
// =========================================================================
// [Required Configuration] Your YouTube API Key and Channel Information
// =========================================================================
// Please input your Google Cloud YouTube Data API v3 key here.
// NEVER distribute this script with your actual API key written inside.
const API_KEY = "YOUR_API_KEY_HERE";
// Your YouTube Channel ID (Starts with "UC...")
const YOUTUBE_CHANNEL_ID = "YOUR_CHANNEL_ID_HERE";
// Your YouTube Custom URL or Handle (e.g., "https://youtube.com/@username")
const YOUTUBE_CHANNEL_URL = "https://youtube.com/@YOUR_CHANNEL_HANDLE";
// Automatically generate the Uploads Playlist ID by replacing the 2nd character 'C' with 'U'
const UPLOADS_PLAYLIST_ID = YOUTUBE_CHANNEL_ID.replace(/^UC/, "UU");
// Channel Icon fetch URL (Using unavatar.io with a fallback default avatar)
const CHANNEL_ICON_URL = `https://unavatar.io/youtube/${YOUTUBE_CHANNEL_ID}?fallback=https://www.youtube.com/s/desktop/28169123/img/avatar_ghost.png`;
// =========================================================================
// ★ Parameter Processing for Widget (Page Number or Random Mode)
// =========================================================================
let pageNum = 1;
let isRandomMode = false;
let widgetParam = args.widgetParameter;
if (widgetParam) {
let paramStr = widgetParam.trim().toLowerCase();
if (paramStr === "rnd" || paramStr === "random") {
isRandomMode = true;
console.log("🎲 [Mode] Random video retrieval mode has been activated.");
} else {
let parsed = parseInt(paramStr, 10);
if (!isNaN(parsed) && parsed > 0) {
pageNum = parsed;
}
}
}
// =========================================================================
// Widget Size and Item Limit Settings
// =========================================================================
let size = config.widgetFamily;
if (config.runsInApp) {
size = "extraLarge";
}
// Set maximum items based on widget size
let maxItems = 2;
if (size === "extraLarge") {
maxItems = 20;
} else if (size === "large") {
maxItems = 6;
} else if (size === "medium") {
maxItems = 4;
} else if (size === "small") {
maxItems = 2;
}
// =========================================================================
// YouTube Data API (v3) Data Fetching
// =========================================================================
let entryTitles = [];
let videoIds = [];
let thumbUrls = [];
let channelName = "YouTube Channel";
let isApiSuccess = false;
let pageToken = "";
let apiErrorOccurred = false;
if (isRandomMode) {
// -----------------------------------------------------------------------
// 🎲 Random Mode: Fast 2-step API Request
// -----------------------------------------------------------------------
try {
console.log("🎲 [1/2] Fetching the first page to determine total video count...");
let firstUrl = `https://www.googleapis.com/youtube/v3/playlistItems?key=${API_KEY}&playlistId=${UPLOADS_PLAYLIST_ID}&part=snippet&maxResults=50`;
let req1 = new Request(firstUrl);
let res1 = await req1.loadJSON();
if (res1 && res1.items && res1.items.length > 0) {
channelName = res1.items[0].snippet.channelTitle || "YouTube Channel";
let totalResults = res1.pageInfo.totalResults;
console.log(`📊 Total videos in channel: ${totalResults}`);
if (totalResults > maxItems) {
// Calculate a random start index
let maxStartIndex = totalResults - maxItems;
let randIndex = Math.floor(Math.random() * maxStartIndex);
console.log(`🎯 Target random video index selected: around ${randIndex}`);
// Calculate target page and offset within that page (50 items per page)
let targetPage = Math.floor(randIndex / 50);
let itemOffsetInPage = randIndex % 50;
// Traverse pages using tokens
let currentToken = "";
let currentRes = res1;
for (let step = 0; step < targetPage; step++) {
currentToken = currentRes.nextPageToken;
if (!currentToken) break;
let pageUrl = `https://www.googleapis.com/youtube/v3/playlistItems?key=${API_KEY}&playlistId=${UPLOADS_PLAYLIST_ID}&part=snippet&maxResults=50&pageToken=${currentToken}`;
let reqStep = new Request(pageUrl);
currentRes = await reqStep.loadJSON();
}
// Extract the specific range of items
let targetItems = currentRes.items.slice(itemOffsetInPage, itemOffsetInPage + maxItems);
// Handle page boundary item shortfall
if (targetItems.length < maxItems && currentRes.nextPageToken) {
let nextPageUrl = `https://www.googleapis.com/youtube/v3/playlistItems?key=${API_KEY}&playlistId=${UPLOADS_PLAYLIST_ID}&part=snippet&maxResults=50&pageToken=${currentRes.nextPageToken}`;
let reqNext = new Request(nextPageUrl);
let nextRes = await reqNext.loadJSON();
if (nextRes && nextRes.items) {
targetItems = targetItems.concat(nextRes.items.slice(0, maxItems - targetItems.length));
}
}
if (targetItems.length > 0) {
isApiSuccess = true;
for (let item of targetItems) {
entryTitles.push(item.snippet.title);
if (item.snippet.resourceId && item.snippet.resourceId.videoId) {
videoIds.push(item.snippet.resourceId.videoId);
} else {
videoIds.push("");
}
let thumb = item.snippet.thumbnails.medium || item.snippet.thumbnails.default;
thumbUrls.push(thumb ? thumb.url : "");
}
}
} else {
// Use first page data directly if total count is less than widget capacity
isApiSuccess = true;
for (let item of res1.items) {
entryTitles.push(item.snippet.title);
videoIds.push(item.snippet.resourceId.videoId || "");
let thumb = item.snippet.thumbnails.medium || item.snippet.thumbnails.default;
thumbUrls.push(thumb ? thumb.url : "");
}
}
} else {
apiErrorOccurred = true;
}
} catch(e) {
console.error("❌ Random Mode Fetch Failed: " + e.message);
apiErrorOccurred = true;
}
} else {
// -----------------------------------------------------------------------
// 📄 Normal Mode: Fetch videos for the specified page number
// -----------------------------------------------------------------------
for (let p = 1; p <= pageNum; p++) {
let tokenParam = pageToken ? `&pageToken=${pageToken}` : "";
let apiUrl = `https://www.googleapis.com/youtube/v3/playlistItems?key=${API_KEY}&playlistId=${UPLOADS_PLAYLIST_ID}&part=snippet&maxResults=${maxItems}${tokenParam}`;
try {
let apiReq = new Request(apiUrl);
let apiResponse = await apiReq.loadJSON();
if (apiResponse && apiResponse.items && apiResponse.items.length > 0) {
if (p === pageNum) {
isApiSuccess = true;
channelName = apiResponse.items[0].snippet.channelTitle || "YouTube Channel";
for (let item of apiResponse.items) {
entryTitles.push(item.snippet.title);
if (item.snippet.resourceId && item.snippet.resourceId.videoId) {
videoIds.push(item.snippet.resourceId.videoId);
} else {
videoIds.push("");
}
let thumb = item.snippet.thumbnails.medium || item.snippet.thumbnails.default;
thumbUrls.push(thumb ? thumb.url : "");
}
}
pageToken = apiResponse.nextPageToken;
if (!pageToken) break;
} else {
apiErrorOccurred = true;
break;
}
} catch (e) {
console.error("❌ Normal Mode Fetch Failed: " + e.message);
apiErrorOccurred = true;
break;
}
}
}
function formatTitle(text, charsPerLine, maxLines) {
let lines = [];
for (let i = 0; i < text.length; i += charsPerLine) {
lines.push(text.substr(i, charsPerLine));
}
return lines.slice(0, maxLines).join("\n");
}
// =========================================================================
// Tap Action Handling
// =========================================================================
if (config.runsInApp && args.queryParameters.url) {
Safari.open(args.queryParameters.url);
Script.complete();
} else {
// =========================================================================
// UI Construction
// =========================================================================
let widget = new ListWidget();
let gradient = new LinearGradient();
gradient.colors = [new Color("#111111"), new Color("#222222")];
gradient.locations = [0.0, 1.0];
widget.backgroundGradient = gradient;
if (size === "large") {
widget.setPadding(10, 4, 10, 4);
} else {
widget.setPadding(8, 4, 8, 4);
}
// --- Header Section ---
let headerStack = widget.addStack();
headerStack.layoutHorizontally();
headerStack.url = YOUTUBE_CHANNEL_URL;
headerStack.setPadding(0, 4, 0, 0);
try {
let iconReq = new Request(CHANNEL_ICON_URL);
let iconImg = await iconReq.loadImage();
let iconElem = headerStack.addImage(iconImg);
iconElem.imageSize = new Size(24, 24);
iconElem.cornerRadius = 12;
headerStack.addSpacer(4);
} catch(e) {
headerStack.addText("📺 ");
}
// Determine Title Text based on settings
let displayTitle = `${channelName}`;
if (isRandomMode) {
displayTitle = `🎲 ${channelName} (Shuffle)`;
} else if (pageNum > 1) {
let startNum = ((pageNum - 1) * maxItems) + 1;
let endNum = pageNum * maxItems;
displayTitle = `${channelName} (#${startNum}-${endNum})`;
} else {
displayTitle = `${channelName} (Latest)`;
}
let titleText = headerStack.addText(displayTitle);
titleText.textColor = new Color("#ff4500");
titleText.font = Font.boldSystemFont(15);
widget.addSpacer((size === "large") ? 10 : 6);
// --- Video List Section ---
if (!isApiSuccess || entryTitles.length === 0) {
let errorText = widget.addText("⚠ YouTube API Error");
errorText.textColor = new Color("#aaaaaa");
errorText.font = Font.systemFont(11);
} else {
let currentCount = Math.min(entryTitles.length, maxItems);
if (size === "extraLarge" || size === "medium") {
let gridStack = widget.addStack();
gridStack.layoutVertically();
let rows = (size === "extraLarge") ? 5 : 2;
let cols = (size === "extraLarge") ? 4 : 2;
let cellWidth = (size === "extraLarge") ? 170 : 155;
let cellHeight = (size === "extraLarge") ? 50 : 45;
for (let r = 0; r < rows; r++) {
let rowGrid = gridStack.addStack();
rowGrid.layoutHorizontally();
for (let c = 0; c < cols; c++) {
let index = r * cols + c;
if (index >= currentCount) {
let emptyCell = rowGrid.addStack();
emptyCell.size = new Size(cellWidth, cellHeight);
if (c < cols - 1) rowGrid.addSpacer(4);
continue;
}
let videoTitle = entryTitles[index];
let videoId = videoIds[index];
let videoUrl = `https://www.youtube.com/watch?v=${videoId}`;
let thumbUrl = thumbUrls[index];
let cellStack = rowGrid.addStack();
cellStack.layoutHorizontally();
cellStack.url = videoUrl;
cellStack.size = new Size(cellWidth, cellHeight);
cellStack.centerAlignContent();
if (thumbUrl) {
try {
let imgReq = new Request(thumbUrl);
let img = await imgReq.loadImage();
let imgElem = cellStack.addImage(img);
imgElem.imageSize = (size === "extraLarge") ? new Size(64, 36) : new Size(48, 27);
imgElem.cornerRadius = 4;
cellStack.addSpacer(6);
} catch(e) {
let dot = cellStack.addText("• ");
dot.textColor = new Color("#888888");
}
}
let titleElem = cellStack.addText(videoTitle);
titleElem.textColor = new Color("#ffffff");
titleElem.font = Font.systemFont((size === "extraLarge") ? 12.5 : 10);
titleElem.lineLimit = 2;
if (c < cols - 1) rowGrid.addSpacer(4);
}
if (r < rows - 1) gridStack.addSpacer(4);
}
} else {
for (let i = 0; i < currentCount; i++) {
let videoTitle = entryTitles[i];
let videoId = videoIds[i];
let videoUrl = `https://www.youtube.com/watch?v=${videoId}`;
let thumbUrl = thumbUrls[i];
let rowStack = widget.addStack();
rowStack.layoutHorizontally();
rowStack.url = videoUrl;
if (size === "large") {
rowStack.setPadding(4, 0, 4, 0);
} else {
rowStack.setPadding(2, 0, 2, 0);
}
if (thumbUrl) {
try {
let imgReq = new Request(thumbUrl);
let img = await imgReq.loadImage();
let imgElem = rowStack.addImage(img);
imgElem.imageSize = (size === "large") ? new Size(56, 31) : new Size(50, 28);
imgElem.cornerRadius = 4;
rowStack.addSpacer((size === "large") ? 8 : 6);
} catch(e) {
rowStack.addText("• ");
}
}
let titleElem = rowStack.addText(videoTitle);
titleElem.textColor = new Color("#ffffff");
titleElem.font = Font.systemFont((size === "large") ? 12 : 11);
titleElem.lineLimit = 2;
if (i < currentCount - 1) {
widget.addSpacer((size === "large") ? 4 : 2);
}
}
}
}
Script.setWidget(widget);
Script.complete();
}