-
-
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)
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 = "" | |
| 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