From c7049cf0b1e18608c45ea5e29a1c7c44e002528d Mon Sep 17 00:00:00 2001 From: "[Ikelene]" Date: Wed, 28 Jan 2026 11:45:47 -0700 Subject: [PATCH 01/13] server storage ext --- .../Ikelene/serverStorageExtension.js | 468 ++++++++++++++++++ 1 file changed, 468 insertions(+) create mode 100644 static/extensions/Ikelene/serverStorageExtension.js diff --git a/static/extensions/Ikelene/serverStorageExtension.js b/static/extensions/Ikelene/serverStorageExtension.js new file mode 100644 index 000000000..1947f45c3 --- /dev/null +++ b/static/extensions/Ikelene/serverStorageExtension.js @@ -0,0 +1,468 @@ +(function (Scratch) { + 'use strict'; + + class ServerStorage { + constructor() { + this.serverUrl = 'https://ikelene.dev/storage/'; + this.apiKey = ''; + this.maxDataSize = 262144; + + /** @type {'live' | 'local'} */ + this.editMode = 'live'; + this.localCache = new Map(); + this.dirtyKeys = new Set(); + } + + serialize() { + return { + serverUrl: this.serverUrl, + apiKey: this.apiKey, + editMode: this.editMode + }; + } + + deserialize(data) { + if (!data || typeof data !== 'object') return; + + if (typeof data.serverUrl === 'string' && data.serverUrl.trim().length) { + const trimmed = data.serverUrl.trim(); + this.serverUrl = trimmed.endsWith('/') ? trimmed : trimmed + '/'; + } + + if (typeof data.apiKey === 'string') { + this.apiKey = data.apiKey; + } + + if (data.editMode === 'local' || data.editMode === 'live') { + this.editMode = data.editMode; + } + } + + getInfo() { + return { + id: 'ikeleneServerStorage', + name: 'Server Storage', + color1: '#ff9bfd', + color2: '#ff9bfd', + color3: '#ff9bfd', + docsURI: this.serverUrl + 'docs.html', + blocks: [ + { + blockType: Scratch.BlockType.LABEL, + text: 'press "Open Docs" to get API key' + }, + { + opcode: 'setServerUrl', + blockType: Scratch.BlockType.COMMAND, + text: 'set server to [SERVER] server', + arguments: { + SERVER: { + type: Scratch.ArgumentType.STRING, + menu: 'serverMenu', + defaultValue: 'global' + } + } + }, + { + opcode: 'setApiKey', + blockType: Scratch.BlockType.COMMAND, + text: 'set API key to [APIKEY]', + arguments: { + APIKEY: { + type: Scratch.ArgumentType.STRING, + defaultValue: 'paste-your-api-key-here' + } + } + }, + { + opcode: 'getAllKeys', + blockType: Scratch.BlockType.REPORTER, + text: 'get all stored keys' + }, + { + opcode: 'saveToServer', + blockType: Scratch.BlockType.COMMAND, + text: 'save [VALUE] to server as [KEY]', + arguments: { + VALUE: { + type: Scratch.ArgumentType.STRING, + defaultValue: 'value' + }, + KEY: { + type: Scratch.ArgumentType.STRING, + defaultValue: 'key' + } + } + }, + { + opcode: 'getFromServer', + blockType: Scratch.BlockType.REPORTER, + text: 'get [KEY] from server', + arguments: { + KEY: { + type: Scratch.ArgumentType.STRING, + defaultValue: 'key' + } + } + }, + { + opcode: 'serverDataExists', + blockType: Scratch.BlockType.BOOLEAN, + text: 'server has [KEY]', + arguments: { + KEY: { + type: Scratch.ArgumentType.STRING, + defaultValue: 'key' + } + } + }, + { + opcode: 'deleteFromServer', + blockType: Scratch.BlockType.COMMAND, + text: 'delete [KEY] from server', + arguments: { + KEY: { + type: Scratch.ArgumentType.STRING, + defaultValue: 'key' + } + } + }, + { + opcode: 'isServerWorking', + blockType: Scratch.BlockType.BOOLEAN, + text: 'is server working?' + }, + { + blockType: Scratch.BlockType.LABEL, + text: 'Local Caching (Faster, but not live)' + }, + { + opcode: 'currentEditMode', + blockType: Scratch.BlockType.REPORTER, + text: 'current editing mode' + }, + { + opcode: 'setEditMode', + blockType: Scratch.BlockType.COMMAND, + text: 'switch to [MODE] editing', + arguments: { + MODE: { + type: Scratch.ArgumentType.STRING, + menu: 'editModeMenu', + defaultValue: 'live' + } + } + }, + { + opcode: 'downloadCache', + blockType: Scratch.BlockType.COMMAND, + text: 'download all keys to local cache' + }, + { + opcode: 'pushCache', + blockType: Scratch.BlockType.COMMAND, + text: 'push local changes to server' + } + ], + menus: { + serverMenu: { + acceptReporters: true, + items: ['global'] + }, + editModeMenu: { + acceptReporters: false, + items: ['live', 'local'] + } + } + }; + } + + setServerUrl(args) { + const server = args.SERVER; + if (server === 'global') { + this.serverUrl = 'https://ikelene.dev/storage/'; + } else { + const trimmed = server.trim(); + if (!trimmed.length) return; + this.serverUrl = trimmed.endsWith('/') ? trimmed : trimmed + '/'; + } + } + + setApiKey(args) { + this.apiKey = args.APIKEY.trim(); + } + + hasValidKey() { + if (!this.apiKey || this.apiKey.length === 0) { + throw new Error('Missing API key'); + } + return true; + } + + currentEditMode() { + return this.editMode; + } + + setEditMode(args) { + const mode = args.MODE === 'local' ? 'local' : 'live'; + this.editMode = mode; + } + + async getAllKeys() { + this.hasValidKey(); + try { + const res = await fetch(this.serverUrl + 'listKeys.php', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify({ apiKey: this.apiKey }) + }); + if (!res.ok) return '[]'; + const result = await res.json(); + if (!result.success || !Array.isArray(result.keys)) return '[]'; + return JSON.stringify(result.keys); + } catch (_) { + return '[]'; + } + } + + async downloadCache() { + this.hasValidKey(); + try { + const res = await fetch(this.serverUrl + 'getAll.php', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify({ apiKey: this.apiKey }) + }); + if (!res.ok) return; + const result = await res.json(); + if (!result.success || !Array.isArray(result.items)) return; + + this.localCache = new Map(); + this.dirtyKeys = new Set(); + + for (const item of result.items) { + this.localCache.set(item.key, item.value); + } + } catch (_) {} + } + + async pushCache() { + this.hasValidKey(); + if (this.dirtyKeys.size === 0) return; + + for (const key of this.dirtyKeys) { + const value = this.localCache.get(key); + if (typeof value === 'undefined') continue; + + const payload = { + apiKey: this.apiKey, + key: key, + value: value, + mimeType: 'application/json' + }; + + try { + const res = await fetch(this.serverUrl + 'store.php', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify(payload) + }); + if (res.status === 401) { + throw new Error('Unauthorized: missing or invalid API key'); + } + } catch (_) { + throw new Error('Server request failed'); + } + } + + this.dirtyKeys = new Set(); + } + + async saveToServer(args) { + this.hasValidKey(); + + const value = args.VALUE; + const key = args.KEY; + const size = new TextEncoder().encode(value).length; + + if (size > this.maxDataSize) { + throw new Error('Data too large'); + } + + if (this.editMode === 'local') { + this.localCache.set(key, value); + this.dirtyKeys.add(key); + return; + } + + const payload = { + apiKey: this.apiKey, + key: key, + value: value, + mimeType: 'application/json' + }; + + try { + const response = await fetch(this.serverUrl + 'store.php', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify(payload) + }); + + if (response.status === 401) { + throw new Error('Unauthorized: missing or invalid API key'); + } + + const result = await response.json(); + if (!result.success) { + throw new Error('Server rejected request'); + } + } catch (_) { + throw new Error('Server request failed'); + } + } + + async getFromServer(args) { + this.hasValidKey(); + + const key = args.KEY; + + if (this.editMode === 'local') { + if (this.localCache.has(key)) { + return this.localCache.get(key); + } + return ''; + } + + try { + const response = await fetch(this.serverUrl + 'get.php', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify({ + apiKey: this.apiKey, + key: key + }) + }); + + if (response.status === 401) { + throw new Error('Unauthorized: missing or invalid API key'); + } + + const result = await response.json(); + if (result.success && result.data) { + return result.data.value || ''; + } + return ''; + } catch (_) { + throw new Error('Server request failed'); + } + } + + async serverDataExists(args) { + this.hasValidKey(); + + const key = args.KEY; + + if (this.editMode === 'local') { + return this.localCache.has(key); + } + + try { + const response = await fetch(this.serverUrl + 'get.php', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify({ + apiKey: this.apiKey, + key: key + }) + }); + + if (response.status === 401) { + throw new Error('Unauthorized: missing or invalid API key'); + } + + const result = await response.json(); + return result.success && !!result.data; + } catch (_) { + throw new Error('Server request failed'); + } + } + + async deleteFromServer(args) { + this.hasValidKey(); + + const key = args.KEY; + + if (this.editMode === 'local') { + this.localCache.delete(key); + this.dirtyKeys.add(key); + return; + } + + try { + const response = await fetch(this.serverUrl + 'delete.php', { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify({ + apiKey: this.apiKey, + key: key + }) + }); + + if (response.status === 401) { + throw new Error('Unauthorized: missing or invalid API key'); + } + + const result = await response.json(); + if (!result.success) { + throw new Error('Server rejected request'); + } + } catch (_) { + throw new Error('Server request failed'); + } + } + + async isServerWorking() { + try { + const response = await fetch(this.serverUrl + 'ping.php', { + method: 'GET', + headers: { + 'Accept': 'application/json' + } + }); + + if (!response.ok) { + return false; + } + + const result = await response.json(); + return result.success === true && result.status === 'ok'; + } catch (_) { + return false; + } + } + } + + Scratch.extensions.register(new ServerStorage()); +})(Scratch); From 12b6fc287f904b232dd833a9329c70032c092cb5 Mon Sep 17 00:00:00 2001 From: "[Ikelene]" Date: Wed, 28 Jan 2026 11:46:16 -0700 Subject: [PATCH 02/13] server storage --- static/images/Ikelene/serverStorageIMG.avif | Bin 0 -> 4466 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 static/images/Ikelene/serverStorageIMG.avif diff --git a/static/images/Ikelene/serverStorageIMG.avif b/static/images/Ikelene/serverStorageIMG.avif new file mode 100644 index 0000000000000000000000000000000000000000..3b8e871ee5c9f123d98c04aba031d9554b77322f GIT binary patch literal 4466 zcma(!2{=^i->)@EnvzKRH%-@)FbiWDMi@d-LJMui%o$E*IWu!+j3SXXsR)%+u6*it zqfKd{QnaYFNz&rBYoXPWZuq}vRP=T4_k3rbIqz@Z-}iYB0Ki;;$75V2Dgf{yRuL+o zi%>4awbqLeoJ-K*Gd_kdl=XoCU?>m&4u4+_F%%c65e@iuM1>L_f%$j>O+Zk9)}g)? z06hw%!DmR_cZyCvM1xRNtPVrXHHHYH7K7ScC5=vK*nA^u5F#Q}ga9sv2~i#wMJUC6hlSG6gAyJ* zOa|+!;Sb@!I2r@yXp zfdfNL(lGD_K&Pp<_mc$xgR%hV&gkv^yP&tXdlwwA2LM!x`}HNi1K`pQ=^6dB-{7DP zO9h~;s-G6M1%P9#0WfXExH7KVj~0Au3gCQv+ylVSZ~%;M0H8mwuV#=(@Ihe%0AWjE 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 literal 0 HcmV?d00001 From 552f9515731a260f31c0f0c4db638a2d99e40b5a Mon Sep 17 00:00:00 2001 From: "[Ikelene]" Date: Wed, 28 Jan 2026 11:46:45 -0700 Subject: [PATCH 03/13] server storage --- src/lib/extensions.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/lib/extensions.js b/src/lib/extensions.js index 5589b0a23..1c08effd8 100644 --- a/src/lib/extensions.js +++ b/src/lib/extensions.js @@ -20,6 +20,14 @@ export default [ isGitHub: true, creator: "TheShovel", }, + { + name: "Server Storage", + description: "Similar to the server storage blocks in the 'Storage' extension, but with a better server uptime and non-global keys.\nDid you know the server is open source?", + code: "Ikelene/serverStorageExtension.js", + banner: "Ikelene/serverStorageIMG.avif", + creator: "Ikelene", + isGitHub: true, + }, { name: "Storage Plus", description: "A storage extension with many more features. Featuring server storage (with a fast server), local storage and more!", From 86a3b962f59e5c3469638f4ed22c2b81e57831f0 Mon Sep 17 00:00:00 2001 From: "[Ikelene]" Date: Mon, 16 Feb 2026 16:10:35 -0700 Subject: [PATCH 04/13] darken server storage ext colors --- static/extensions/Ikelene/serverStorageExtension.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/static/extensions/Ikelene/serverStorageExtension.js b/static/extensions/Ikelene/serverStorageExtension.js index 1947f45c3..c48733fc5 100644 --- a/static/extensions/Ikelene/serverStorageExtension.js +++ b/static/extensions/Ikelene/serverStorageExtension.js @@ -42,9 +42,9 @@ return { id: 'ikeleneServerStorage', name: 'Server Storage', - color1: '#ff9bfd', - color2: '#ff9bfd', - color3: '#ff9bfd', + color1: '#ff6bfd', + color2: '#ff6bfd', + color3: '#ff6bfd', docsURI: this.serverUrl + 'docs.html', blocks: [ { From 4df03354d802569941f1595d81889f735cfd7483 Mon Sep 17 00:00:00 2001 From: "[Ikelene]" Date: Mon, 2 Mar 2026 10:17:54 -0700 Subject: [PATCH 05/13] Add IkeNotifyPenguin extension --- static/extensions/Ikelene/notifyPenguinV2.js | 142 +++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 static/extensions/Ikelene/notifyPenguinV2.js diff --git a/static/extensions/Ikelene/notifyPenguinV2.js b/static/extensions/Ikelene/notifyPenguinV2.js new file mode 100644 index 000000000..c5366de24 --- /dev/null +++ b/static/extensions/Ikelene/notifyPenguinV2.js @@ -0,0 +1,142 @@ +(function(Scratch) { + 'use strict'; + + class IkeNotifyPenguin { + constructor() { + this.apiUrl = 'https://ikelene.dev/notifications/v2'; + this.projectId = null; + } + + serialize() { return { projectId: this.getProjectId() }; } + deserialize(data) { if (data && data.projectId) this.projectId = data.projectId; } + + getProjectId() { + if (!this.projectId) this.projectId = 'proj_' + Math.random().toString(36).substr(2, 8) + '_' + Date.now().toString(36); + return this.projectId; + } + + getInfo() { + return { + id: 'IkeNotifyPenguin', + name: 'NotifyPenguin V2', + color1: '#008080', + color2: '#006666', + blocks: [ + { + opcode: 'registerDevice', + blockType: Scratch.BlockType.COMMAND, + text: 'register this device for notifications as [USER]', + arguments: { USER: { type: Scratch.ArgumentType.STRING, defaultValue: 'Player1' } } + }, + { + opcode: 'openSettings', + blockType: Scratch.BlockType.COMMAND, + text: 'manage notification settings' + }, + { + opcode: 'sendLive', + blockType: Scratch.BlockType.COMMAND, + text: 'send live notification to [USER] title: [TITLE] body: [BODY]', + arguments: { + USER: { type: Scratch.ArgumentType.STRING, defaultValue: 'Player1' }, + TITLE: { type: Scratch.ArgumentType.STRING, defaultValue: 'penguinmod Alert' }, + BODY: { type: Scratch.ArgumentType.STRING, defaultValue: 'Immediate Dispatch.' } + } + }, + { + opcode: 'schedulePush', + blockType: Scratch.BlockType.COMMAND, + text: 'schedule notification to [USER] title: [TITLE] body: [BODY] at date: [DATE] time: [TIME]', + arguments: { + USER: { type: Scratch.ArgumentType.STRING, defaultValue: 'Player1' }, + TITLE: { type: Scratch.ArgumentType.STRING, defaultValue: 'penguinmod Reminder' }, + BODY: { type: Scratch.ArgumentType.STRING, defaultValue: 'Check dispatch.' }, + DATE: { type: Scratch.ArgumentType.STRING, defaultValue: '2026-03-05' }, + TIME: { type: Scratch.ArgumentType.STRING, defaultValue: '14:30' } + } + } + ] + }; + } + + openRegistrationPopup() { + return new Promise((resolve, reject) => { + const width = 400, height = 400; + const left = (window.screen.width / 2) - (width / 2); + const top = (window.screen.height / 2) - (height / 2); + + const popup = window.open(`${this.apiUrl}/subscribe.html`, 'Push Registration', `width=${width},height=${height},top=${top},left=${left}`); + + if (!popup) return reject(new Error("Popup blocked.")); + + const listener = (event) => { + if (event.data && event.data.action) { + if (event.data.action === 'POPUP_SUCCESS') { + window.removeEventListener('message', listener); + resolve(event.data.subscription); + } else if (event.data.action === 'POPUP_ERROR') { + window.removeEventListener('message', listener); + reject(new Error(event.data.error)); + } + } + }; + + window.addEventListener('message', listener); + }); + } + + async registerDevice(args) { + try { + const subscription = await this.openRegistrationPopup(); + const apiRes = await fetch(`${this.apiUrl}/register.php`, { + method: 'POST', + body: JSON.stringify({ + project_id: this.getProjectId(), + username: args.USER, + subscription: subscription + }), + headers: { 'Content-Type': 'application/json' } + }); + + const data = await apiRes.json(); + if (data.error) throw new Error(data.error); + } catch (err) { + throw new Error(`Register Error: ${err.message}`); + } + } + + openSettings() { + window.open(`${this.apiUrl}/index.html?highlight=${this.getProjectId()}`, '_blank'); + } + + async sendLive(args) { + try { + const response = await fetch(`${this.apiUrl}/send_live.php`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ project_id: this.getProjectId(), username: args.USER, title: args.TITLE, body: args.BODY }) + }); + const data = await response.json(); + if (data.error || data.status === 'failed') throw new Error(data.error || 'Unknown error'); + } catch (err) { + throw new Error(`Send Error: ${err.message}`); + } + } + + async schedulePush(args) { + try { + const response = await fetch(`${this.apiUrl}/schedule.php`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ project_id: this.getProjectId(), username: args.USER, title: args.TITLE, body: args.BODY, date: args.DATE, time: args.TIME }) + }); + const data = await response.json(); + if (data.error || data.status === 'failed') throw new Error(data.error); + } catch (err) { + throw new Error(`Schedule Error: ${err.message}`); + } + } + } + + Scratch.extensions.register(new IkeNotifyPenguin()); +})(Scratch); From 0dde822f6273cd980f39e5e37886dc8732180b2f Mon Sep 17 00:00:00 2001 From: "[Ikelene]" Date: Mon, 2 Mar 2026 10:18:53 -0700 Subject: [PATCH 06/13] add notifyPenguin banner i cant export as avif so suck it --- .../images/Ikelene/NotifyPenguinV2Banner.webp | Bin 0 -> 17236 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 static/images/Ikelene/NotifyPenguinV2Banner.webp diff --git a/static/images/Ikelene/NotifyPenguinV2Banner.webp b/static/images/Ikelene/NotifyPenguinV2Banner.webp new file mode 100644 index 0000000000000000000000000000000000000000..b565ff06c7d3bf9e670f1c47f9522aafcaee5fef GIT binary patch literal 17236 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 Date: Mon, 2 Mar 2026 10:19:51 -0700 Subject: [PATCH 07/13] add internet radio banner --- static/images/Ikelene/internetRadio.svg | 88 +++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 static/images/Ikelene/internetRadio.svg diff --git a/static/images/Ikelene/internetRadio.svg b/static/images/Ikelene/internetRadio.svg new file mode 100644 index 000000000..d0ce3539e --- /dev/null +++ b/static/images/Ikelene/internetRadio.svg @@ -0,0 +1,88 @@ + +radio_tower [#009688]Created with Sketch.InternetRadioIKELENE + + From 5db064531d50a09d1f1c4e85e8d56a9bfeb2a198 Mon Sep 17 00:00:00 2001 From: "[Ikelene]" Date: Mon, 2 Mar 2026 10:21:17 -0700 Subject: [PATCH 08/13] Add Internet Radio extension --- static/extensions/Ikelene/internetRadio.js | 407 +++++++++++++++++++++ 1 file changed, 407 insertions(+) create mode 100644 static/extensions/Ikelene/internetRadio.js diff --git a/static/extensions/Ikelene/internetRadio.js b/static/extensions/Ikelene/internetRadio.js new file mode 100644 index 000000000..cd76e2422 --- /dev/null +++ b/static/extensions/Ikelene/internetRadio.js @@ -0,0 +1,407 @@ +(function (Scratch) { + 'use strict'; + + let audioElement = null; + let audioContext = null; + let analyserNode = null; + let sourceNode = null; + let gainNode = null; + + let currentURL = 'https://www.clubfmserver.be/pink.mp3'; + let isPlaying = false; + + let averageVolume = 0; + let frequencyData = new Uint8Array(0); + + let metadataCache = {}; + + class InternetRadioExtension { + constructor() { + if (typeof Scratch !== 'undefined' && Scratch.vm) { + Scratch.vm.runtime.on('PROJECT_STOP_ALL', () => { + this.stopStreaming(); + }); + } + } + + getInfo() { + return { + id: 'internetradio', + name: 'Internet Radio', + color1: '#00BFA5', +color2: '#009688', +color3: '#00796B', + +menuIconURI: 'data:image/svg+xml;base64,PCFET0NUWVBFIHN2ZyBQVUJMSUMgIi0vL1czQy8vRFREIFNWRyAxLjEvL0VOIiAiaHR0cDovL3d3dy53My5vcmcvR3JhcGhpY3MvU1ZHLzEuMS9EVEQvc3ZnMTEuZHRkIj4KPCEtLSBVcGxvYWRlZCB0bzogU1ZHIFJlcG8sIHd3dy5zdmdyZXBvLmNvbSwgVHJhbnNmb3JtZWQgYnk6IFNWRyBSZXBvIE1peGVyIFRvb2xzIC0tPgo8c3ZnIHdpZHRoPSIxMDFweCIgaGVpZ2h0PSIxMDFweCIgdmlld0JveD0iLTIgLTIgMjQuMDAgMjQuMDAiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgZmlsbD0iIzAwOTY4OCIgdHJhbnNmb3JtPSJyb3RhdGUoMCltYXRyaXgoMSwgMCwgMCwgMSwgMCwgMCkiIHN0cm9rZT0iIzAwOTY4OCI+Cgo8ZyBpZD0iU1ZHUmVwb19iZ0NhcnJpZXIiIHN0cm9rZS13aWR0aD0iMCIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCwwKSwgc2NhbGUoMSkiLz4KCjxnIGlkPSJTVkdSZXBvX3RyYWNlckNhcnJpZXIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlPSIjQ0NDQ0NDIiBzdHJva2Utd2lkdGg9IjAuNDgiLz4KCjxnIGlkPSJTVkdSZXBvX2ljb25DYXJyaWVyIj4gPHRpdGxlPnJhZGlvX3Rvd2VyIFsjMDA5Njg4XTwvdGl0bGU+IDxkZXNjPkNyZWF0ZWQgd2l0aCBTa2V0Y2guPC9kZXNjPiA8ZGVmcz4gPC9kZWZzPiA8ZyBpZD0iUGFnZS0xIiBzdHJva2Utd2lkdGg9IjAuMDAwMiIgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj4gPGcgaWQ9IkRyaWJiYmxlLUxpZ2h0LVByZXZpZXciIHRyYW5zZm9ybT0idHJhbnNsYXRlKC02MC4wMDAwMDAsIC0zMzIwLjAwMDAwMCkiIGZpbGw9IiMwMEJGQTUiPiA8ZyBpZD0iaWNvbnMiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDU2LjAwMDAwMCwgMTYwLjAwMDAwMCkiPiA8cGF0aCBkPSJNMTQuMDAwMDg3MSwzMTcxLjAwNyBMMTMuOTk0MDg3MSwzMTcxIEw4LjAwMDA4NzEyLDMxNzcuNCBMOS40OTgwODcxMiwzMTc5IEwxNC4wMDAwODcxLDMxNzQuMTk0IEwxOC41MDIwODcxLDMxNzkgTDIwLjAwMDA4NzEsMzE3Ny40IEwxNC4wMDYwODcxLDMxNzEgTDE0LjAwMDA4NzEsMzE3MS4wMDcgWiBNMTIuNjM2MDg3MSwzMTY4LjU4NiBDMTMuNDE3MDg3MSwzMTY5LjM2NyAxNC43NTYwODcxLDMxNjkuMjk0IDE1LjQ2NDA4NzEsMzE2OC41ODcgQzE2LjEyOTA4NzEsMzE2Ny45MjEgMTYuMjUzMDg3MSwzMTY2LjU0MyAxNS4zNjcwODcxLDMxNjUuNjU3IEwxNS4zNjQwODcxLDMxNjUuNjYgQzE0LjU4MzA4NzEsMzE2NC44NzkgMTMuMjQzMDg3MSwzMTY0Ljk1MiAxMi41MzYwODcxLDMxNjUuNjU5IEMxMS44NzEwODcxLDMxNjYuMzI1IDExLjc0NzA4NzEsMzE2Ny43MDMgMTIuNjMzMDg3MSwzMTY4LjU4OSBMMTIuNjM2MDg3MSwzMTY4LjU4NiBaIE05LjgwNTA4NzEyLDMxNzEuNDE3IEwxMS4yMTkwODcxLDMxNzAuMDAzIEM5LjgwNTA4NzEyLDMxNjguNTg5IDkuNzA2MDg3MTIsMzE2NS42NTcgMTEuMTIwMDg3MSwzMTY0LjI0MyBMOS43MDYwODcxMiwzMTYyLjgyOSBDNy41ODQwODcxMiwzMTY0Ljk1IDcuNjgzMDg3MTIsMzE2OS4yOTYgOS44MDUwODcxMiwzMTcxLjQxNyBMOS44MDUwODcxMiwzMTcxLjQxNyBaIE04LjI5MTA4NzEyLDMxNjEuNDE1IEw2Ljg3NzA4NzEyLDMxNjAgQzIuNjM0MDg3MTIsMzE2NC4yNDMgMy40NDEwODcxMiwzMTcwLjcxIDYuOTc2MDg3MTIsMzE3NC4yNDYgTDguMzkwMDg3MTIsMzE3Mi44MzIgQzUuNTYyMDg3MTIsMzE3MC4wMDMgNS40NjMwODcxMiwzMTY0LjI0MyA4LjI5MTA4NzEyLDMxNjEuNDE1IEw4LjI5MTA4NzEyLDMxNjEuNDE1IFogTTE2Ljg4MDA4NzEsMzE3MC4wMDMgTDE4LjI5NDA4NzEsMzE3MS40MTcgQzIwLjQxNjA4NzEsMzE2OS4yOTYgMjAuMzE3MDg3MSwzMTY0Ljk1IDE4LjE5NTA4NzEsMzE2Mi44MjkgTDE2Ljc4MTA4NzEsMzE2NC4yNDMgQzE4LjE5NTA4NzEsMzE2NS42NTcgMTguMjk0MDg3MSwzMTY4LjU4OSAxNi44ODAwODcxLDMxNzAuMDAzIEwxNi44ODAwODcxLDMxNzAuMDAzIFogTTIxLjEyMzA4NzEsMzE3NC4yNDYgTDE5LjcwOTA4NzEsMzE3Mi44MzIgQzIyLjUzNzA4NzEsMzE3MC4wMDMgMjIuNDM4MDg3MSwzMTY0LjI0MyAxOS42MTAwODcxLDMxNjEuNDE1IEwyMS4wMjQwODcxLDMxNjAgQzI0LjU1OTA4NzEsMzE2My41MzYgMjUuMzY1MDg3MSwzMTcwLjAwMyAyMS4xMjMwODcxLDMxNzQuMjQ2IEwyMS4xMjMwODcxLDMxNzQuMjQ2IFoiIGlkPSJyYWRpb190b3dlci1bIzAwOTY4OF0iPiA8L3BhdGg+IDwvZz4gPC9nPiA8L2c+IDwvZz4KCjwvc3ZnPg==', +blockIconURI: '', + +blocks: [ + { + blockType: Scratch.BlockType.BUTTON, + text: 'Find Radio Streams', + func: 'openRadioBrowser' + }, + { + opcode: 'setStreamURL', + blockType: Scratch.BlockType.COMMAND, + text: 'set stream URL to [URL]', + arguments: { + URL: { + type: Scratch.ArgumentType.STRING, + defaultValue: 'https://www.clubfmserver.be/pink.mp3' + } + } + }, + { + opcode: 'startStreaming', + blockType: Scratch.BlockType.COMMAND, + text: 'start streaming' + }, + { + opcode: 'stopStreaming', + blockType: Scratch.BlockType.COMMAND, + text: 'stop streaming' + }, + { + opcode: 'setVolume', + blockType: Scratch.BlockType.COMMAND, + text: 'set volume to [VOLUME]%', + arguments: { + VOLUME: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 100 + } + } + }, + { + opcode: 'changeVolume', + blockType: Scratch.BlockType.COMMAND, + text: 'change volume by [VOLUME]%', + arguments: { + VOLUME: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 10 + } + } + }, + '---', + { + opcode: 'getCurrentServer', + blockType: Scratch.BlockType.REPORTER, + text: 'current server' + }, + { + opcode: 'getStationData', + blockType: Scratch.BlockType.REPORTER, + text: 'get station [DATA] from url [URL]', + arguments: { + DATA: { + type: Scratch.ArgumentType.STRING, + menu: 'stationInfo', + defaultValue: 'name' + }, + URL: { + type: Scratch.ArgumentType.STRING, + defaultValue: 'https://www.clubfmserver.be/pink.mp3' + } + } + }, + '---', + { + opcode: 'getVolume', + blockType: Scratch.BlockType.REPORTER, + text: 'volume' + }, + { + opcode: 'getAverageVolume', + blockType: Scratch.BlockType.REPORTER, + text: 'average audio volume' + }, + { + opcode: 'getFrequencyVolume', + blockType: Scratch.BlockType.REPORTER, + text: 'volume on [SIDE] side', + arguments: { + SIDE: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 0 + } + } + }, + { + opcode: 'getFrequencyRange', + blockType: Scratch.BlockType.REPORTER, + text: 'frequency [RANGE] volume', + arguments: { + RANGE: { + type: Scratch.ArgumentType.STRING, + menu: 'frequencyRanges', + defaultValue: 'bass' + } + } + }, + { + opcode: 'getFrequencyBand', + blockType: Scratch.BlockType.REPORTER, + text: 'frequency band [BAND] volume', + arguments: { + BAND: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + } + } + }, + { + opcode: 'isStreaming', + blockType: Scratch.BlockType.BOOLEAN, + text: 'is streaming?' + }, + { + opcode: 'isServerWorking', + blockType: Scratch.BlockType.BOOLEAN, + text: 'is server [URL] working?', + arguments: { + URL: { + type: Scratch.ArgumentType.STRING, + defaultValue: 'https://www.clubfmserver.be/pink.mp3' + } + } + } +], +menus: { + frequencyRanges: { + acceptReporters: true, + items: ['bass', 'mid', 'treble', 'sub-bass', 'high'] + }, + stationInfo: { + acceptReporters: true, + items: [ + { text: 'Name', value: 'name' }, + { text: 'Country', value: 'country' }, + { text: 'Tags', value: 'tags' }, + { text: 'Language', value: 'language' }, + { text: 'Homepage', value: 'homepage' }, + { text: 'Bitrate', value: 'bitrate' }, + { text: 'Votes', value: 'votes' } + ] + } +} + }; + } + + + initAudioContext() { + if (!audioContext) { + audioContext = new (window.AudioContext || window.webkitAudioContext)(); + analyserNode = audioContext.createAnalyser(); + analyserNode.fftSize = 512; + analyserNode.smoothingTimeConstant = 0.8; + gainNode = audioContext.createGain(); + gainNode.connect(analyserNode); + analyserNode.connect(audioContext.destination); + + frequencyData = new Uint8Array(analyserNode.frequencyBinCount); + + this.updateVolume(); + } + } + + updateVolume() { + if (analyserNode && isPlaying) { + analyserNode.getByteFrequencyData(frequencyData); + const sum = frequencyData.reduce((a, b) => a + b, 0); + averageVolume = Math.round((sum / frequencyData.length / 255) * 100); + } else { + averageVolume = 0; + } + + if (isPlaying) { + requestAnimationFrame(() => this.updateVolume()); + } + } + + setStreamURL(args) { + currentURL = args.URL; + if (isPlaying && audioElement) { + audioElement.src = currentURL; + audioElement.play().catch(err => { + console.error('Failed to play new stream:', err); + }); + } + } + + startStreaming() { + this.initAudioContext(); + + if (!audioElement) { + audioElement = new Audio(); + audioElement.crossOrigin = 'anonymous'; + + if (!sourceNode) { + sourceNode = audioContext.createMediaElementSource(audioElement); + sourceNode.connect(gainNode); + } + + audioElement.addEventListener('error', () => { isPlaying = false; }); + audioElement.addEventListener('playing', () => { + isPlaying = true; + this.updateVolume(); + }); + audioElement.addEventListener('pause', () => { isPlaying = false; }); + audioElement.addEventListener('ended', () => { isPlaying = false; }); + } + + if (audioContext.state === 'suspended') { + audioContext.resume(); + } + + audioElement.src = currentURL; + audioElement.play().catch(err => { + console.error('Failed to start streaming:', err); + isPlaying = false; + }); + } + + stopStreaming() { + if (audioElement) { + audioElement.pause(); + audioElement.currentTime = 0; + isPlaying = false; + averageVolume = 0; + } + } + + setVolume(args) { + const volume = Math.max(0, Math.min(100, args.VOLUME)); + if (gainNode) gainNode.gain.value = volume / 100; + if (audioElement) audioElement.volume = volume / 100; + } + + changeVolume(args) { + const current = gainNode ? (gainNode.gain.value * 100) : 100; + this.setVolume({ VOLUME: current + args.VOLUME }); + } + + + getCurrentServer() { + return currentURL; + } + + getStationData(args) { + const url = args.URL; + const type = args.DATA; + + if (metadataCache[url] && metadataCache[url][type]) { + return metadataCache[url][type]; + } + + const apiUrl = `https://de1.api.radio-browser.info/json/stations/search?url=${encodeURIComponent(url)}&limit=1`; + + return fetch(apiUrl) + .then(response => response.json()) + .then(data => { + if (data && data.length > 0) { + const station = data[0]; + metadataCache[url] = station; + + if (type === 'votes') return station.votes || 0; + if (type === 'bitrate') return station.bitrate || 0; + return station[type] || 'Unknown'; + } + return 'Unknown'; + }) + .catch(err => { + console.error(err); + return 'Error'; + }); + } + + getVolume() { + if (gainNode) return Math.round(gainNode.gain.value * 100); + return 100; + } + + getAverageVolume() { + return averageVolume; + } + + getFrequencyVolume(args) { + if (!analyserNode || !isPlaying || frequencyData.length === 0) return 0; + + const side = Math.max(-100, Math.min(100, args.SIDE)); + const normalizedPosition = (side + 100) / 200; + + const dataLength = frequencyData.length; + const centerIndex = Math.floor(normalizedPosition * dataLength); + const rangeSize = Math.floor(dataLength / 10); + + const startIndex = Math.max(0, centerIndex - Math.floor(rangeSize / 2)); + const endIndex = Math.min(dataLength, startIndex + rangeSize); + + let sum = 0; + for (let i = startIndex; i < endIndex; i++) sum += frequencyData[i]; + + const avg = sum / (endIndex - startIndex); + return Math.round((avg / 255) * 100); + } + + getFrequencyRange(args) { + if (!analyserNode || !isPlaying || frequencyData.length === 0) return 0; + + const range = args.RANGE.toLowerCase(); + const dataLength = frequencyData.length; + let startIndex, endIndex; + + switch (range) { + case 'sub-bass': startIndex = 0; endIndex = Math.floor(dataLength * 0.05); break; + case 'bass': startIndex = Math.floor(dataLength * 0.05); endIndex = Math.floor(dataLength * 0.2); break; + case 'mid': startIndex = Math.floor(dataLength * 0.2); endIndex = Math.floor(dataLength * 0.5); break; + case 'treble': startIndex = Math.floor(dataLength * 0.5); endIndex = Math.floor(dataLength * 0.8); break; + case 'high': startIndex = Math.floor(dataLength * 0.8); endIndex = dataLength; break; + default: startIndex = 0; endIndex = dataLength; + } + + let sum = 0; + for (let i = startIndex; i < endIndex; i++) sum += frequencyData[i]; + const avg = sum / (endIndex - startIndex); + return Math.round((avg / 255) * 100); + } + + getFrequencyBand(args) { + if (!analyserNode || !isPlaying || frequencyData.length === 0) return 0; + const band = Math.max(1, Math.min(frequencyData.length, Math.floor(args.BAND))); + return Math.round((frequencyData[band - 1] / 255) * 100); + } + + isStreaming() { + return isPlaying; + } + + isServerWorking(args) { + return new Promise((resolve) => { + const testAudio = new Audio(); + testAudio.crossOrigin = 'anonymous'; + let resolved = false; + + const timeout = setTimeout(() => { + if (!resolved) { resolved = true; resolve(false); } + }, 5000); + + testAudio.addEventListener('canplay', () => { + if (!resolved) { resolved = true; clearTimeout(timeout); resolve(true); } + }); + testAudio.addEventListener('error', () => { + if (!resolved) { resolved = true; clearTimeout(timeout); resolve(false); } + }); + + testAudio.src = args.URL; + }); + } + + openRadioBrowser() { + window.open('https://www.radio-browser.info/map', '_blank'); + } + } + + Scratch.extensions.register(new InternetRadioExtension()); +})(Scratch); From 00fa88784c8ebfd656a9fc8ee146d2cb62739413 Mon Sep 17 00:00:00 2001 From: "[Ikelene]" Date: Mon, 2 Mar 2026 10:27:35 -0700 Subject: [PATCH 09/13] Add NotifyPenguin V2 and Internet Radio extensions --- src/lib/extensions.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/lib/extensions.js b/src/lib/extensions.js index ea29f41fd..1d0ec1da3 100644 --- a/src/lib/extensions.js +++ b/src/lib/extensions.js @@ -83,6 +83,15 @@ export default [ banner: "Gen1x/beat_sync.avif", creator: "G1nX", }, + { + name: "NotifyPenguin V2", + description: "Allows sending real time & scheduled notifications to desktops and mobile devices!", + code: "Ikelene/notifyPenguinV2.js", + banner: "Ikelene/NotifyPenguinV2Banner.webp", + creator: "Ikelene", + isGitHub: true, + tags: ["api", "notifications", "messaging"] + }, { name: "Object", description: "Handle large JSON files at an extreme speed.", @@ -125,6 +134,17 @@ export default [ banner: "TheShovel/thumbnail-doodlerec.avif", creator: "TheShovel", isGitHub: true, + }, + { + name: "Internet Radio", + description: "Internet Radio allows you to stream live audio from thousands of internet radio stations while providing data to create music visualizers or anything in-between.", + code: "Ikelene/internetRadio.js", + banner: "Ikelene/internetRadio.svg", + creator: "Ikelene", + isGitHub: true, + unstable: true, + unstableReason: "Some radio stations / URLs may not work due to CORS or not supporting a proper audio format.\nThis is normal, do not report it.", + tags: ["api", "music", "sounds", "visualizer", "radio"] }, { name: "Stylus", From be86d0caaf68b9470f13c8acecc4cff9ab42d58c Mon Sep 17 00:00:00 2001 From: "[Ikelene]" Date: Mon, 2 Mar 2026 11:02:30 -0700 Subject: [PATCH 10/13] notifypenguinv2 friendly names --- static/extensions/Ikelene/notifyPenguinV2.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/static/extensions/Ikelene/notifyPenguinV2.js b/static/extensions/Ikelene/notifyPenguinV2.js index c5366de24..b8a9c7471 100644 --- a/static/extensions/Ikelene/notifyPenguinV2.js +++ b/static/extensions/Ikelene/notifyPenguinV2.js @@ -25,8 +25,11 @@ { opcode: 'registerDevice', blockType: Scratch.BlockType.COMMAND, - text: 'register this device for notifications as [USER]', - arguments: { USER: { type: Scratch.ArgumentType.STRING, defaultValue: 'Player1' } } + text: 'register this device for [PROJECTNAME] notifications as [USER]', + arguments: { + PROJECTNAME: { type: Scratch.ArgumentType.STRING, defaultValue: 'My Game' }, + USER: { type: Scratch.ArgumentType.STRING, defaultValue: 'Player1' } + } }, { opcode: 'openSettings', @@ -66,7 +69,6 @@ const top = (window.screen.height / 2) - (height / 2); const popup = window.open(`${this.apiUrl}/subscribe.html`, 'Push Registration', `width=${width},height=${height},top=${top},left=${left}`); - if (!popup) return reject(new Error("Popup blocked.")); const listener = (event) => { @@ -80,7 +82,6 @@ } } }; - window.addEventListener('message', listener); }); } @@ -92,6 +93,7 @@ method: 'POST', body: JSON.stringify({ project_id: this.getProjectId(), + project_name: Scratch.Cast.toString(args.PROJECTNAME), username: args.USER, subscription: subscription }), From c6bb028734cce1c6efdce0bf12bbcaad548f02ac Mon Sep 17 00:00:00 2001 From: "[Ikelene]" Date: Mon, 10 Aug 2026 16:41:37 -0600 Subject: [PATCH 11/13] update to use new domain --- static/extensions/Ikelene/serverStorageExtension.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/static/extensions/Ikelene/serverStorageExtension.js b/static/extensions/Ikelene/serverStorageExtension.js index c48733fc5..e118e0d4a 100644 --- a/static/extensions/Ikelene/serverStorageExtension.js +++ b/static/extensions/Ikelene/serverStorageExtension.js @@ -3,7 +3,7 @@ class ServerStorage { constructor() { - this.serverUrl = 'https://ikelene.dev/storage/'; + this.serverUrl = 'https://ikelene.net/storage/'; this.apiKey = ''; this.maxDataSize = 262144; @@ -180,7 +180,7 @@ setServerUrl(args) { const server = args.SERVER; if (server === 'global') { - this.serverUrl = 'https://ikelene.dev/storage/'; + this.serverUrl = 'https://ikelene.net/storage/'; } else { const trimmed = server.trim(); if (!trimmed.length) return; From b5f07dd7a84ca8f21d5f96f837b4d316b8eed92e Mon Sep 17 00:00:00 2001 From: "[Ikelene]" Date: Mon, 10 Aug 2026 16:42:16 -0600 Subject: [PATCH 12/13] update to use new domain --- static/extensions/Ikelene/googleAuthExtension.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/static/extensions/Ikelene/googleAuthExtension.js b/static/extensions/Ikelene/googleAuthExtension.js index 7b6dc5fdf..c3c443247 100644 --- a/static/extensions/Ikelene/googleAuthExtension.js +++ b/static/extensions/Ikelene/googleAuthExtension.js @@ -69,7 +69,7 @@ class GoogleAuthExtension { login() { const clientId = '382430967410-3svk456rj8ntlu3d3gd9oma09i96cpr9.apps.googleusercontent.com'; - const redirectUri = 'https://ikelene.dev/google/googleLogin.php'; + const redirectUri = 'https://ikelene.net/google/googleLogin.php'; const scope = 'profile email'; const sourceDomain = window.location.hostname; @@ -81,7 +81,7 @@ class GoogleAuthExtension { this.authWindow = window.open(authUrl, 'Google Login', 'width=500,height=600'); window.addEventListener('message', (event) => { - if (event.origin === 'https://ikelene.dev') { + if (event.origin === 'https://ikelene.net') { const { accountName, fullName, profilePicture, userId, locale, emailVerified } = event.data; this.accountName = accountName; this.fullName = fullName; From 5a3b565fcab770e600b48fe1f034c0cd6d54a75a Mon Sep 17 00:00:00 2001 From: "[Ikelene]" Date: Mon, 10 Aug 2026 16:43:01 -0600 Subject: [PATCH 13/13] Update API URL in IkeNotifyPenguin --- static/extensions/Ikelene/notifyPenguinV2.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/extensions/Ikelene/notifyPenguinV2.js b/static/extensions/Ikelene/notifyPenguinV2.js index b8a9c7471..f798b3718 100644 --- a/static/extensions/Ikelene/notifyPenguinV2.js +++ b/static/extensions/Ikelene/notifyPenguinV2.js @@ -3,7 +3,7 @@ class IkeNotifyPenguin { constructor() { - this.apiUrl = 'https://ikelene.dev/notifications/v2'; + this.apiUrl = 'https://ikelene.net/notifications/v2'; this.projectId = null; }