Last active
August 28, 2023 21:02
-
-
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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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])) | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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
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.
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.