Skip to content

Instantly share code, notes, and snippets.

@jocheno
Forked from marco79cgn/bbc2-playlist.js
Last active November 11, 2025 06:14
Show Gist options
  • Select an option

  • Save jocheno/8e4bec5b092082c735d0faf22237ebc9 to your computer and use it in GitHub Desktop.

Select an option

Save jocheno/8e4bec5b092082c735d0faf22237ebc9 to your computer and use it in GitHub Desktop.
A custom iOS widget that shows the last 5 songs from BBC2 radio and plays them in Spotify (for Scriptable.app)
// Variables used by Scriptable.
// These must be at the very top of the file. Do not edit.
// icon-color: red; icon-glyph: music;
// insert your Spotify client id and secret here
const clientId = ""
const clientSecret = ""
let widget = new ListWidget()
widget.setPadding(22,10,10,10)
widget.url = "https://www.bbc.co.uk/sounds/play/live:bbc_radio_two"
const logoUrl = "https://pbs.twimg.com/profile_images/1080054947528523776/ZthxLFvg_400x400.jpg"
// Color definitions - explicitly set for iOS 16+ compatibility
const widgetBackground = new Color("#4682B4") //Widget Background
const stackBackground = new Color("#FFFFFF", 1.0) //Smaller Container Background (full opacity)
const textPrimaryColor = new Color("#000000", 1.0) //Primary text color (full opacity)
const textSecondaryColor = new Color("#e15f1a", 1.0) //Secondary text color (full opacity)
// Set explicit background color as fallback for iOS 16+
widget.backgroundColor = widgetBackground
// Load background image (will overlay on backgroundColor)
try {
widget.backgroundImage = await getImage('background.png')
} catch (e) {
console.log("Background image not available, using solid color")
}
const stackSize = new Size(149, 45) //0 means its automatic
let image = await getImage("bbc2.png")
const date = new Date()
const dateNow = Date.now()
let df_Name = new DateFormatter()
let df_Month = new DateFormatter()
df_Name.dateFormat = "EEEE"
df_Month.dateFormat = "MMMM"
const dayName = df_Name.string(date)
const dayNumber = date.getDate().toString()
const monthName = df_Month.string(date)
//Top Row (Date & Weather)
let topRow = widget.addStack()
topRow.layoutHorizontally()
//Top Row Date
let dateStack = topRow.addStack()
dateStack.layoutHorizontally()
dateStack.centerAlignContent()
dateStack.setPadding(7, 7, 7, 7)
dateStack.backgroundColor = stackBackground
dateStack.cornerRadius = 4
dateStack.size = stackSize
dateStack.addSpacer()
let dayNumberTxt = dateStack.addText(dayNumber + ".")
dayNumberTxt.font = Font.semiboldSystemFont(26)
dayNumberTxt.textColor = textPrimaryColor
dateStack.addSpacer(7)
let dateTextStack = dateStack.addStack()
dateTextStack.layoutVertically()
let monthNameTxt = dateTextStack.addText(monthName.toUpperCase())
monthNameTxt.font = Font.boldSystemFont(10)
monthNameTxt.textColor = textPrimaryColor
let dayNameTxt = dateTextStack.addText(dayName)
dayNameTxt.font = Font.boldSystemFont(11)
dayNameTxt.textColor = textSecondaryColor
dateStack.addSpacer()
topRow.addSpacer(6)
let logoStack = topRow.addStack()
logoStack.layoutHorizontally()
logoStack.centerAlignContent()
logoStack.setPadding(7, 7, 7, 7)
logoStack.backgroundColor = stackBackground
logoStack.cornerRadius = 4
logoStack.size = stackSize
let widgetImage = logoStack.addImage(image)
widgetImage.imageSize = new Size(100,38)
widgetImage.centerAlignImage()
let lastSongsJson = new Object()
await loadLastSongs()
await deleteOutdatedFiles()
Script.setWidget(widget)
Script.complete()
widget.presentLarge()
// helper function to load and parse a restful json api
async function loadLastSongs() {
let url = "https://onlineradiobox.com/json/uk/bbcradio2/playlist/"
let req = new Request(url)
let lastSongs = await req.loadJSON()
widget.addSpacer(5)
if(lastSongs != null){
let cachedSongs = await loadCachedSongs()
for(let step = 0; step < 5; step++) {
let currentSong = lastSongs.playlist[step]
let cleanTitle = currentSong.name.split(" - ")[1]
cleanTitle = cleanTitle.split(" (")[0]
let titleBase64 = hashCode(cleanTitle)
let artist
let airTime
let coverImage
let uri
if(cachedSongs.hasOwnProperty(titleBase64)) {
artist = cachedSongs[titleBase64].artist
airTime = cachedSongs[titleBase64].airTime
coverImage = await loadCachedImage(titleBase64)
uri = cachedSongs[titleBase64].uri
} else {
artist = currentSong.name.split(" - ")[0]
let date = new Date(currentSong.created * 1000)
let df = new DateFormatter()
df.useNoDateStyle()
df.useShortTimeStyle()
airTime = df.string(date) + " Uhr"
let coverUrl = ""
// Spotify search api query
let result = await searchCoverAtSpotify(cleanTitle, artist, true)
if (gotResultFromSpotify(result)) {
let item = result.tracks.items[0]
if (item.album.images[1]) {
coverUrl = item.album.images[1].url
} else {
coverUrl = logoUrl
}
uri = item.uri
coverImage = await loadImage(coverUrl)
} else {
// query spotify again with just one simplified search string
result = await searchCoverAtSpotify(cleanTitle, artist, false)
if (gotResultFromSpotify(result)) {
let item = result.tracks.items[0]
if (item.album.images[1]) {
coverUrl = item.album.images[1].url
} else {
coverUrl = logoUrl
}
uri = item.uri
coverImage = await loadImage(coverUrl)
}
}
if(coverImage == null) {
coverImage = await loadImage(logoUrl)
}
await saveAlbumCover(titleBase64, coverImage)
} // end else
// create content in widget
let currentItemStack = widget.addStack()
currentItemStack.layoutHorizontally()
currentItemStack.backgroundColor = stackBackground
currentItemStack.cornerRadius = 4
currentItemStack.addSpacer(2)
currentItemStack.size = new Size(304,50)
let coverStack = currentItemStack.addStack()
coverStack.centerAlignContent()
coverStack.layoutVertically()
coverStack.addSpacer(2)
let cover = coverStack.addImage(coverImage)
cover.imageSize = new Size(46,46)
cover.cornerRadius = 4
cover.centerAlignImage()
currentItemStack.addSpacer(6)
let currentSongStack = currentItemStack.addStack()
currentSongStack.layoutVertically()
currentSongStack.size = new Size(250,50)
currentSongStack.setPadding(3, 3, 3, 3)
let airTimeText = currentSongStack.addText(airTime)
airTimeText.font = Font.mediumSystemFont(10)
airTimeText.textColor = textPrimaryColor
airTimeText.textOpacity = 0.7
let titleText = currentSongStack.addText(cleanTitle)
titleText.font = Font.boldSystemFont(13)
titleText.textColor = textPrimaryColor
let artistText = currentSongStack.addText(artist)
artistText.font = Font.semiboldSystemFont(12)
artistText.textColor = textPrimaryColor
artistText.textOpacity = 1
if(uri != null && uri.length > 0) {
currentItemStack.url = uri
} else {
currentItemStack.url = "spotify://"
}
widget.addSpacer(5)
createJsonEntry(titleBase64, cleanTitle, artist, airTime, uri)
}
}
widget.addSpacer()
await saveLastSongs(JSON.stringify(lastSongsJson))
}
// helper function to download an image
async function loadImage(url) {
let req = new Request(url)
return await req.loadImage()
}
// get images from local filestore or download them once
async function getImage(image) {
let fm = FileManager.iCloud()
let dir = fm.documentsDirectory()
let path = fm.joinPath(dir, image)
if (fm.fileExists(path)) {
await fm.downloadFileFromiCloud(path)
return fm.readImage(path)
} else {
// download once
let imageUrl
switch (image) {
case 'bbc2.png':
imageUrl = "https://upload.wikimedia.org/wikipedia/commons/thumb/6/6e/Logo_BBC_Radio_2.svg/300px-Logo_BBC_Radio_2.svg.png"
break
case 'background.png':
imageUrl = "https://i.imgur.com/0IxsG7C_d.png"
break
default:
console.log(`Sorry, couldn't find ${image}.`);
}
let iconImage = await loadImage(imageUrl)
fm.writeImage(path, iconImage)
return iconImage
}
}
// gets a spotify search token
async function getSpotifySearchToken() {
let url = "https://accounts.spotify.com/api/token";
let req = new Request(url)
req.method = "POST"
req.body = "grant_type=client_credentials"
let authHeader = "Basic " + btoa(clientId + ":" + clientSecret)
req.headers = { "Authorization": authHeader, "Content-Type": "application/x-www-form-urlencoded" }
let token = await req.loadJSON()
return token.access_token
}
// search for the cover art on Spotify
async function searchCoverAtSpotify(title, artist, strict) {
let searchString
let searchToken = await getCachedSpotifyToken(false)
if (strict === true) {
searchString = encodeURIComponent("track:" + title + " artist:" + artist)
} else {
searchString = encodeURIComponent(artist + " " + title)
}
let searchUrl = "https://api.spotify.com/v1/search?q=" + searchString + "&type=track&market=DE&limit=1"
req = new Request(searchUrl)
req.headers = { "Authorization": "Bearer " + searchToken, "Content-Type": "application/json", "Accept": "application/json" }
let result = await req.loadJSON()
// check if token expired
if (req.response.statusCode == 401) {
searchToken = await getCachedSpotifyToken(true)
req.headers = { "Authorization": "Bearer " + searchToken, "Content-Type": "application/json", "Accept": "application/json" }
result = await req.loadJSON()
}
return result
}
// obtain spotify api search token - either cached or new
async function getCachedSpotifyToken(forceRefresh) {
// load json from iCloud Drive
let fm = FileManager.iCloud()
let dir = fm.documentsDirectory()
let path = fm.joinPath(dir, "spotify-token.txt")
let contents = Data.fromFile(path)
if (contents != null && contents.toRawString().length > 0 && !forceRefresh) {
return contents.toRawString()
} else {
console.log("Getting new token from Spotify.")
let token = await getSpotifySearchToken()
fm.writeString(path, token)
return token
}
}
// check whether spotify api search returned a result
function gotResultFromSpotify(result) {
if (result != null && result.tracks != null && result.tracks.items != null && result.tracks.items.length == 1) {
return true
} else {
return false
}
}
async function loadCachedSongs() {
// load last song from iCloud Drive
let fm = FileManager.iCloud()
let dir = fm.documentsDirectory()
let path = fm.joinPath(dir, "bbc2-lastsongs.txt")
if(fm.fileExists(path)) {
await fm.downloadFileFromiCloud(path)
let lastSongs = Data.fromFile(path)
if (lastSongs != null) {
return JSON.parse(lastSongs.toRawString())
} else {
return new Object()
}
} else {
return new Object()
}
}
async function loadCachedImage(imageName) {
let fm = FileManager.iCloud()
let dir = fm.documentsDirectory()
let path = fm.joinPath(dir, "images")
let imagePath = fm.joinPath(path, imageName + ".png")
await fm.downloadFileFromiCloud(imagePath)
return fm.readImage(imagePath)
}
async function saveLastSongs(lastSongsJson) {
let fm = FileManager.iCloud()
let dir = fm.documentsDirectory()
let path = fm.joinPath(dir, "bbc2-lastsongs.txt")
fm.writeString(path, lastSongsJson)
}
async function saveAlbumCover(filename, cover) {
let fm = FileManager.iCloud()
let dir = fm.documentsDirectory()
let path = fm.joinPath(dir, "images")
if(!fm.fileExists(path)) {
fm.createDirectory(path)
}
let imagePath = fm.joinPath(path, filename + ".png")
fm.writeImage(imagePath, cover)
}
function createJsonEntry(titleB64, title, artist, airTime, uri) {
var item = new Object()
item.title = title
item.artist = artist
item.airTime = airTime
if(uri != null && uri.length > 0) {
item.uri = uri
} else {
item.uri = ""
}
lastSongsJson[titleB64] = item
}
function hashCode(string){
var hash = 0;
if (string.length == 0) return hash;
for (i = 0; i < string.length; i++) {
char = string.charCodeAt(i);
hash = ((hash<<5)-hash)+char;
hash = hash & hash; // Convert to 32bit integer
}
return hash;
}
// delete cached album covers older than 60 minutes
async function deleteOutdatedFiles() {
let now = new Date()
let fm = FileManager.iCloud()
let dir = fm.documentsDirectory()
let path = fm.joinPath(dir, "images")
let files = fm.listContents(path)
for(let i = 0; i < files.length; i++) {
let currentCreationDate = fm.creationDate(fm.joinPath(path, files[i]))
if ((now.getTime() - currentCreationDate.getTime()) > (60 * 60 * 1000)) {
fm.remove(fm.joinPath(path, files[i]))
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment