Skip to content

Instantly share code, notes, and snippets.

@marco79cgn
Last active August 28, 2023 21:02
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 3 You must be signed in to fork a gist
  • Save marco79cgn/3afcadd3ee5547ff1f12386a0aadd6a8 to your computer and use it in GitHub Desktop.
Save marco79cgn/3afcadd3ee5547ff1f12386a0aadd6a8 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 = "xxx"
const clientSecret = "xxx"
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"
// 128C7E
const widgetBackground = new Color("#4682B4") //Widget Background
const stackBackground = new Color("#FFFFFF") //Smaller Container Background
const calendarColor = new Color("#EA3323") //Calendar Color
// widget.backgroundColor = widgetBackground
widget.backgroundImage = await getImage('background.png')
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 = Color.black()
dateStack.addSpacer(7)
let dateTextStack = dateStack.addStack()
dateTextStack.layoutVertically()
let monthNameTxt = dateTextStack.addText(monthName.toUpperCase())
monthNameTxt.font = Font.boldSystemFont(10)
monthNameTxt.textColor = Color.black()
let dayNameTxt = dateTextStack.addText(dayName)
dayNameTxt.font = Font.boldSystemFont(11)
dayNameTxt.textColor = new Color("#e15f1a")
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.textOpacity = 0.7
airTimeText.textColor = Color.black()
let titleText = currentSongStack.addText(cleanTitle)
titleText.font = Font.boldSystemFont(13)
titleText.textColor = Color.black()
let artistText = currentSongStack.addText(artist)
artistText.font = Font.semiboldSystemFont(12)
artistText.textOpacity = 1
artistText.textColor = Color.black()
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]))
}
}
}
@marco79cgn
Copy link
Author

marco79cgn commented Nov 16, 2020

Intro

iOS 14 Custom Widget made with the help of the Scriptable app. It shows the last 5 songs from BBC2 radio incl. cover art by using the Spotify Search API.
Upon tapping on a song it opens and plays it on Spotify.

Requirements

  • iOS 14
  • Scriptable version 1.6
  • Spotify Web Developer API
    Please create a client to get your client id and client_secret credentials. They are needed to search for cover art.
    Insert them at the top of the script.
  • Preparations
    Open the Scriptable app on your iPhone, click on the "+" sign on the upper right, copy the code above and paste it inside. Insert your Spotify client id and secret at the top. Save the script by pressing "Done" in the upper left. Go to your homescreen, long press anywhere and configure a new Scriptable widget with maximum size. Assign the created widget.

Thanks

A big Thank you to @simonbs for making great apps like Scriptable, DataJar or Jayson.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment