27 Commits

Author SHA1 Message Date
Idrees Hassan
47b418324c Fix menu item text not working at first 2026-01-04 18:08:26 -05:00
Idrees Hassan
e5956426d5 Add toggle to enable/disable sound 2026-01-04 18:02:47 -05:00
Idrees Hassan
0cc06a8856 Change sound timings 2026-01-04 17:39:38 -05:00
Idrees Hassan
dd4184f642 Refine chirp sound 2026-01-04 17:38:14 -05:00
Idrees Hassan
5a82ba858f Add chirping when pet 2026-01-04 17:34:34 -05:00
Idrees Hassan
b8de14bb94 Create new build for extension update 2026-01-01 14:09:13 -05:00
Idrees Hassan
e0ab21d608 Update README.md 2025-12-13 23:08:46 -05:00
Idrees Hassan
86c254b2cb Merge branch 'main' of https://github.com/IdreesInc/Pocket-Bird 2025-12-13 23:05:37 -05:00
Idrees Hassan
14ef2713d8 Add site to readme 2025-12-13 23:04:56 -05:00
Idrees
bc3f5cce02 Update README.md 2025-11-16 13:13:11 -05:00
Idrees Hassan
c3ff3b39dc Add embed code and instructions 2025-11-16 13:10:57 -05:00
Idrees
8b8aa50cae Update README with new download options
Added download links for Obsidian and TamperMonkey.
2025-11-16 13:02:22 -05:00
Idrees Hassan
f4b598d2dc Remove vencord 2025-11-16 12:09:17 -05:00
Idrees
bccaa0aa15 Merge pull request #5 from IdreesInc/vencord
Add support for Vencord and clean up build and context process
2025-11-16 11:12:51 -05:00
Idrees Hassan
37a30ea509 Add Vencord save support 2025-11-16 11:05:44 -05:00
Idrees Hassan
e7be2b7661 Clean up output 2025-11-16 10:38:11 -05:00
Idrees Hassan
c750bf5560 Move files into src 2025-11-16 10:34:43 -05:00
Idrees Hassan
c927ce23e4 Add separate entry points 2025-11-16 10:25:57 -05:00
Idrees Hassan
6ee9efd5a8 Add browser-specific entry point 2025-11-16 09:51:46 -05:00
Idrees Hassan
a5e81e4265 Allow for set contexts 2025-11-16 09:25:48 -05:00
Idrees Hassan
76e55a3caa Merge branch 'main' of https://github.com/IdreesInc/Pocket-Bird 2025-11-15 18:25:48 -05:00
Idrees Hassan
224fe4aaec Update obsidian manifest 2025-11-15 18:25:44 -05:00
Idrees
764e8311fa Update plugin URL for Obsidian instructions 2025-11-15 18:09:39 -05:00
Idrees Hassan
b66521b7a2 Remove eslint configs 2025-11-15 17:32:18 -05:00
Idrees Hassan
72f2805fa0 Create eslint.config.js 2025-11-15 16:14:12 -05:00
Idrees Hassan
1ca5094536 Add readme for obsidian plugin testing 2025-11-15 15:35:14 -05:00
Idrees Hassan
2ddf10bb01 Update version to remove build number 2025-11-15 15:30:57 -05:00
28 changed files with 3645 additions and 1352 deletions

View File

@@ -1 +0,0 @@
src/

View File

@@ -9,10 +9,14 @@
It's a pet bird that hops around your computer, what more could you want? It's a pet bird that hops around your computer, what more could you want?
### Get it for [Google Chrome](https://chromewebstore.google.com/detail/pocket-bird/lbbdngkbbgaecefacpnhnhleggabghak) ### Get it for [Google Chrome](https://chromewebstore.google.com/detail/pocket-bird/lbbdngkbbgaecefacpnhnhleggabghak)
### Get it for [Mozilla Firefox](https://addons.mozilla.org/en-US/firefox/addon/pocket-bird/) ### Get it for [Mozilla Firefox](https://addons.mozilla.org/en-US/firefox/addon/pocket-bird/)
### Get it for [Obsidian (beta)](https://github.com/IdreesInc/Pocket-Bird#Obsidian)
### Get it for [TamperMonkey](https://github.com/IdreesInc/Pocket-Bird#Userscript)
#### Join the [Discord](https://discord.gg/6yxE9prcNc) to help me beta test new features and suggest ideas! #### Join the [Discord](https://discord.gg/6yxE9prcNc) to help me beta test new features and suggest ideas!
## Features ## Features
@@ -37,7 +41,11 @@ It's a pet bird that hops around your computer, what more could you want?
### Obsidian ### Obsidian
_Coming soon!_ 1. Install the [Beta Plugin Manager (BRAT)](https://obsidian.md/plugins?id=obsidian42-brat) plugin for Obsidian
2. Enable the BRAT plugin and open its settings
3. In the BRAT settings, click "Add Beta Plugin" and enter the following URL: `https://github.com/IdreesInc/PB-Obsidian-Releases`
4. Select "Latest version" and click "Add Plugin"
5. Enjoy a pet bird in your Obsidian notes!
### Userscript ### Userscript
@@ -48,6 +56,14 @@ _Coming soon!_
3. Install my Pocket Bird script by going to this link and clicking install: [https://github.com/IdreesInc/Pocket-Bird/raw/refs/heads/main/dist/userscript/birb.user.js](https://github.com/IdreesInc/Pocket-Bird/raw/refs/heads/main/dist/userscript/birb.user.js) 3. Install my Pocket Bird script by going to this link and clicking install: [https://github.com/IdreesInc/Pocket-Bird/raw/refs/heads/main/dist/userscript/birb.user.js](https://github.com/IdreesInc/Pocket-Bird/raw/refs/heads/main/dist/userscript/birb.user.js)
4. Now any websites you visit will have a little bird hopping around! 4. Now any websites you visit will have a little bird hopping around!
### Your Own Website
Pocket Bird can also be embedded directly into your own website! Just include the following code snippet anywhere in your HTML:
```html
<script src="https://cdn.jsdelivr.net/gh/IdreesInc/Pocket-Bird@main/dist/web/birb.embed.js"></script>
```
## FAQ ## FAQ
### How do I pet the bird? ### How do I pet the bird?
@@ -74,6 +90,15 @@ Open the Pocket Bird menu by clicking the bird and select "Settings". From there
If you are running Pocket bird on a browser, the extension needs these permissions in order to insert the bird and sticky notes into your webpages. Pocket Bird does not collect any of your data or browsing history and all data is stored locally on your device! If you are running Pocket bird on a browser, the extension needs these permissions in order to insert the bird and sticky notes into your webpages. Pocket Bird does not collect any of your data or browsing history and all data is stored locally on your device!
## Sites With Pocket Bird
Here are some websites where you can find Pocket Bird hopping around:
- [https://grepjason.sh](https://grepjason.sh)
*If you've added Pocket Bird to your website, let me know and I'll add it to this list!*
## Getting in Touch ## Getting in Touch
If you'd like to get in touch, check out the [Discord](https://discord.gg/6yxE9prcNc) to suggest features, report bugs, and stay updated on development! If you'd like to get in touch, check out the [Discord](https://discord.gg/6yxE9prcNc) to suggest features, report bugs, and stay updated on development!

202
build.js
View File

@@ -12,19 +12,24 @@ const IMAGES_DIR = "./images";
const FONTS_DIR = "./fonts"; const FONTS_DIR = "./fonts";
const DIST_DIR = "./dist"; const DIST_DIR = "./dist";
const BROWSER_MANIFEST = "./platform-specific/extension/manifest.json"; const WEB_DIR = DIST_DIR + "/web";
const OBSIDIAN_MANIFEST = "./platform-specific/obsidian/manifest.json";
const USERSCRIPT_HEADER = "./platform-specific/userscript/header.txt";
const OBSIDIAN_WRAPPER = "./platform-specific/obsidian/wrapper.js";
const USERSCRIPT_DIR = DIST_DIR + "/userscript"; const USERSCRIPT_DIR = DIST_DIR + "/userscript";
const EXTENSION_DIR = DIST_DIR + "/extension"; const EXTENSION_DIR = DIST_DIR + "/extension";
const OBSIDIAN_DIR = DIST_DIR + "/obsidian"; const OBSIDIAN_DIR = DIST_DIR + "/obsidian";
const STYLESHEET_PATH = SRC_DIR + "/stylesheet.css"; const STYLESHEET_PATH = SRC_DIR + "/stylesheet.css";
const APPLICATION_ENTRY = SRC_DIR + "/application.js";
const BUNDLED_OUTPUT = DIST_DIR + "/birb.bundled.js"; const WEB_ENTRY = SRC_DIR + "/platforms/web/web.js";
const BIRB_OUTPUT = DIST_DIR + "/birb.js"; const USERSCRIPT_ENTRY = SRC_DIR + "/platforms/userscript/userscript.js";
const BROWSER_EXTENSION_ENTRY = SRC_DIR + "/platforms/extension/extension.js";
const OBSIDIAN_ENTRY = SRC_DIR + "/platforms/obsidian/obsidian.js";
const BROWSER_MANIFEST = SRC_DIR + "/platforms/extension/manifest.json";
const OBSIDIAN_MANIFEST = SRC_DIR + "/platforms/obsidian/manifest.json";
const USERSCRIPT_HEADER = SRC_DIR + "/platforms/userscript/header.txt";
const OBSIDIAN_WRAPPER = SRC_DIR + "/platforms/obsidian/wrapper.js";
const TEMP_BUNDLED_OUTPUT = DIST_DIR + "/birb.bundled.js";
const MONOCRAFT_URL = "https://cdn.jsdelivr.net/gh/idreesinc/Monocraft@99b32ab40612ff2533a69d8f14bd8b3d9e604456/dist/Monocraft.otf"; const MONOCRAFT_URL = "https://cdn.jsdelivr.net/gh/idreesinc/Monocraft@99b32ab40612ff2533a69d8f14bd8b3d9e604456/dist/Monocraft.otf";
@@ -67,119 +72,138 @@ if (buildCache.version && buildCache.version.startsWith(versionDate)) {
} }
} }
const version = `${versionDate}.${buildNumber}`; // const version = `${versionDate}.${buildNumber}`;
const version = `${versionDate}`; // Disable build number for now
// Update build cache // Update build cache
buildCache.version = version; buildCache.version = version;
writeFileSync(BUILD_CACHE_PATH, JSON.stringify(buildCache), 'utf8'); writeFileSync(BUILD_CACHE_PATH, JSON.stringify(buildCache), 'utf8');
// ============================================= /**
// Build JavaScript function * @param {string} entryPoint
// ============================================= * @param {boolean} [embedFont]
* @returns {Promise<string>}
*/
async function generateCode(entryPoint, embedFont = false) {
// Bundle with rollup
const bundle = await rollup({
input: entryPoint,
});
// Bundle with rollup await bundle.write({
const bundle = await rollup({ file: TEMP_BUNDLED_OUTPUT,
input: APPLICATION_ENTRY, format: 'iife',
}); });
await bundle.write({ await bundle.close();
file: BUNDLED_OUTPUT,
format: 'iife',
});
await bundle.close(); let birbJs = readFileSync(TEMP_BUNDLED_OUTPUT, 'utf8');
let birbJs = readFileSync(BUNDLED_OUTPUT, 'utf8'); // Delete bundled file
unlinkSync(TEMP_BUNDLED_OUTPUT);
// Delete bundled file // Replace version placeholder
unlinkSync(BUNDLED_OUTPUT); birbJs = birbJs.replaceAll(VERSION_KEY, version);
// Replace version placeholder // Compile and insert sprite sheets
birbJs = birbJs.replaceAll(VERSION_KEY, version); for (const spriteSheet of spriteSheets) {
const dataUri = readFileSync(spriteSheet.path, 'base64');
birbJs = birbJs.replaceAll(spriteSheet.key, `data:image/png;base64,${dataUri}`);
}
// Compile and insert sprite sheets // Insert stylesheet
for (const spriteSheet of spriteSheets) { const stylesheetContent = readFileSync(STYLESHEET_PATH, 'utf8');
const dataUri = readFileSync(spriteSheet.path, 'base64'); birbJs = birbJs.replace(STYLESHEET_KEY, stylesheetContent);
birbJs = birbJs.replaceAll(spriteSheet.key, `data:image/png;base64,${dataUri}`);
if (embedFont) {
// Encode font to data URI
const monocraftFontData = readFileSync(FONTS_DIR + '/Monocraft.otf', 'base64');
const monocraftDataUri = `data:font/otf;base64,${monocraftFontData}`;
birbJs = birbJs.replaceAll(MONOCRAFT_SRC_KEY, monocraftDataUri);
} else {
birbJs = birbJs.replaceAll(MONOCRAFT_SRC_KEY, MONOCRAFT_URL);
}
return birbJs;
} }
// Insert stylesheet async function buildWeb() {
const stylesheetContent = readFileSync(STYLESHEET_PATH, 'utf8'); const birbJs = await generateCode(WEB_ENTRY);
birbJs = birbJs.replace(STYLESHEET_KEY, stylesheetContent).replace(MONOCRAFT_SRC_KEY, MONOCRAFT_URL); mkdirSync(WEB_DIR, { recursive: true });
writeFileSync(WEB_DIR + '/birb.js', birbJs);
writeFileSync(WEB_DIR + '/birb.embed.js', birbJs);
}
async function buildUserscript() {
const birbJs = await generateCode(USERSCRIPT_ENTRY);
// Write bundled JavaScript function // Get userscript header
writeFileSync(BIRB_OUTPUT, birbJs); const userScriptHeader = readFileSync(USERSCRIPT_HEADER, 'utf8').replaceAll(VERSION_KEY, version);
// ============================================= mkdirSync(USERSCRIPT_DIR, { recursive: true });
// Build userscript const userScript = userScriptHeader + "\n" + birbJs;
// ============================================= writeFileSync(USERSCRIPT_DIR + '/birb.user.js', userScript);
}
// Get userscript header async function buildExtension() {
const userScriptHeader = readFileSync(USERSCRIPT_HEADER, 'utf8').replaceAll(VERSION_KEY, version); const birbJs = await generateCode(BROWSER_EXTENSION_ENTRY);
mkdirSync(USERSCRIPT_DIR, { recursive: true }); mkdirSync(EXTENSION_DIR, { recursive: true });
const userScript = userScriptHeader + "\n" + birbJs;
writeFileSync(USERSCRIPT_DIR + '/birb.user.js', userScript);
// ============================================= // Copy birb.js
// Build browser extension writeFileSync(EXTENSION_DIR + '/birb.js', birbJs);
// =============================================
mkdirSync(EXTENSION_DIR, { recursive: true }); // Copy manifest.json
let browserManifest = readFileSync(BROWSER_MANIFEST, 'utf8');
browserManifest = browserManifest.replace(VERSION_KEY, version);
writeFileSync(EXTENSION_DIR + '/manifest.json', browserManifest);
// Copy birb.js // Copy icons folder
writeFileSync(EXTENSION_DIR + '/birb.js', birbJs); mkdirSync(EXTENSION_DIR + '/images/icons', { recursive: true });
cpSync(IMAGES_DIR + '/icons/transparent', EXTENSION_DIR + '/images/icons/transparent', { recursive: true });
// Copy manifest.json // Copy fonts folder
let browserManifest = readFileSync(BROWSER_MANIFEST, 'utf8'); mkdirSync(EXTENSION_DIR + '/fonts', { recursive: true });
browserManifest = browserManifest.replace(VERSION_KEY, version); cpSync(FONTS_DIR, EXTENSION_DIR + '/fonts', { recursive: true });
writeFileSync(EXTENSION_DIR + '/manifest.json', browserManifest);
// Copy icons folder // Compress extension folder into zip
mkdirSync(EXTENSION_DIR + '/images/icons', { recursive: true }); const output = createWriteStream(DIST_DIR + "/extension.zip");
cpSync(IMAGES_DIR + '/icons/transparent', EXTENSION_DIR + '/images/icons/transparent', { recursive: true }); const archive = archiver('zip');
// Copy fonts folder output.on('close', () => {
mkdirSync(EXTENSION_DIR + '/fonts', { recursive: true }); console.log(`Created zip file: ${archive.pointer()} total bytes`);
cpSync(FONTS_DIR, EXTENSION_DIR + '/fonts', { recursive: true }); });
// Compress extension folder into zip archive.on('error', (err) => {
const output = createWriteStream(DIST_DIR + "/extension.zip"); throw err;
const archive = archiver('zip'); });
output.on('close', () => { archive.pipe(output);
console.log(`Created zip file: ${archive.pointer()} total bytes`); archive.directory(EXTENSION_DIR + '/', false);
}); archive.finalize();
}
archive.on('error', (err) => { async function buildObsidian() {
throw err; const birbJs = await generateCode(OBSIDIAN_ENTRY, true);
});
archive.pipe(output); mkdirSync(OBSIDIAN_DIR, { recursive: true });
archive.directory(EXTENSION_DIR + '/', false);
archive.finalize();
// ============================================= // Wrap birb.js with plugin boilerplate
// Build Obsidian plugin let obsidianPlugin = readFileSync(OBSIDIAN_WRAPPER, 'utf8').replace(VERSION_KEY, version).replace(CODE_KEY, birbJs);
// =============================================
mkdirSync(OBSIDIAN_DIR, { recursive: true }); // Create main.js with plugin code
writeFileSync(OBSIDIAN_DIR + '/main.js', obsidianPlugin);
// Wrap birb.js with plugin boilerplate // Copy manifest.json
let obsidianPlugin = readFileSync(OBSIDIAN_WRAPPER, 'utf8').replace(VERSION_KEY, version).replace(CODE_KEY, birbJs); let obsidianManifest = readFileSync(OBSIDIAN_MANIFEST, 'utf8');
obsidianManifest = obsidianManifest.replace(/"version":\s*".*"/, `"version": "${version}"`);
writeFileSync(OBSIDIAN_DIR + '/manifest.json', obsidianManifest);
}
// Encode font to data URI since Obsidian plugins can't have external font files console.log("Starting build...");
const monocraftFontData = readFileSync(FONTS_DIR + '/Monocraft.otf', 'base64');
const monocraftDataUri = `data:font/otf;base64,${monocraftFontData}`;
obsidianPlugin = obsidianPlugin.replace(MONOCRAFT_URL, monocraftDataUri);
// Create main.js with plugin code await buildWeb();
writeFileSync(OBSIDIAN_DIR + '/main.js', obsidianPlugin); await buildUserscript();
await buildExtension();
await buildObsidian();
// Copy manifest.json console.log("Build completed successfully!");
let obsidianManifest = readFileSync(OBSIDIAN_MANIFEST, 'utf8');
obsidianManifest = obsidianManifest.replace(/"version":\s*".*"/, `"version": "${version}"`);
writeFileSync(OBSIDIAN_DIR + '/manifest.json', obsidianManifest);
console.log(`Build complete: ${version}`);

BIN
dist/extension.zip vendored

Binary file not shown.

441
dist/extension/birb.js vendored
View File

@@ -7,6 +7,7 @@
}; };
let debugMode = location.hostname === "127.0.0.1"; let debugMode = location.hostname === "127.0.0.1";
let context = null;
/** /**
* @returns {boolean} Whether debug mode is enabled * @returns {boolean} Whether debug mode is enabled
@@ -22,6 +23,17 @@
debugMode = value; debugMode = value;
} }
function getContext() {
if (!context) {
throw new Error("Context requested before being set");
}
return context;
}
function setContext(newContext) {
context = newContext;
}
/** /**
* Create an HTML element with the specified parameters * Create an HTML element with the specified parameters
* @param {string} className * @param {string} className
@@ -845,8 +857,51 @@
} }
} }
// @ts-check
class Birdsong {
/**
* @type {AudioContext}
*/
audioContext;
chirp() {
if (!this.audioContext) {
this.audioContext = new AudioContext();
}
const TIMES = [0, 0.06, 0.10, 0.15];
const FREQUENCIES = [2200,
3500 + Math.random() * 600,
2100 + Math.random() * 200,
1600 + Math.random() * 400];
const VOLUMES = [0.0001, 0.3, 0.3, 0.0001];
const oscillator = this.audioContext.createOscillator();
oscillator.type = "sine";
const gain = this.audioContext.createGain();
oscillator.connect(gain);
gain.connect(this.audioContext.destination);
const now = this.audioContext.currentTime;
for (let i = 0; i < TIMES.length; i++) {
const time = TIMES[i] + now;
if (i === 0) {
oscillator.frequency.setValueAtTime(FREQUENCIES[i], time);
gain.gain.setValueAtTime(VOLUMES[i], time);
} else {
oscillator.frequency.exponentialRampToValueAtTime(FREQUENCIES[i], time);
gain.gain.exponentialRampToValueAtTime(VOLUMES[i], time);
}
}
oscillator.start(now);
oscillator.stop(now + TIMES[TIMES.length - 1]);
}
}
const SAVE_KEY = "birbSaveData"; const SAVE_KEY = "birbSaveData";
const ROOT_PATH = "";
/** /**
* @typedef {import('./application.js').BirbSaveData} BirbSaveData * @typedef {import('./application.js').BirbSaveData} BirbSaveData
@@ -857,14 +912,6 @@
*/ */
class Context { class Context {
/**
* @abstract
* @returns {boolean} Whether this context is applicable
*/
isContextActive() {
throw new Error("Method not implemented");
}
/** /**
* @abstract * @abstract
* @returns {Promise<BirbSaveData|{}>} * @returns {Promise<BirbSaveData|{}>}
@@ -946,96 +993,8 @@
} }
} }
class LocalContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
return window.location.hostname === "127.0.0.1"
|| window.location.hostname === "localhost"
|| window.location.hostname.startsWith("192.168.");
}
/**
* @override
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
log("Loading save data from localStorage");
return JSON.parse(localStorage.getItem(SAVE_KEY) ?? "{}");
}
/**
* @override
* @param {BirbSaveData} saveData
*/
async putSaveData(saveData) {
log("Saving data to localStorage");
localStorage.setItem(SAVE_KEY, JSON.stringify(saveData));
}
/** @override */
resetSaveData() {
log("Resetting save data in localStorage");
localStorage.removeItem(SAVE_KEY);
}
}
class UserScriptContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
// @ts-expect-error
return typeof GM_getValue === "function";
}
/**
* @override
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
log("Loading save data from UserScript storage");
/** @type {BirbSaveData|{}} */
let saveData = {};
// @ts-expect-error
saveData = GM_getValue(SAVE_KEY, {}) ?? {};
return saveData;
}
/**
* @override
* @param {BirbSaveData} saveData
*/
async putSaveData(saveData) {
log("Saving data to UserScript storage");
// @ts-expect-error
GM_setValue(SAVE_KEY, saveData);
}
/** @override */
resetSaveData() {
log("Resetting save data in UserScript storage");
// @ts-expect-error
GM_deleteValue(SAVE_KEY);
}
}
class BrowserExtensionContext extends Context { class BrowserExtensionContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
// @ts-expect-error
return typeof chrome !== "undefined";
}
/** /**
* @override * @override
* @returns {Promise<BirbSaveData|{}>} * @returns {Promise<BirbSaveData|{}>}
@@ -1061,9 +1020,9 @@
// @ts-expect-error // @ts-expect-error
if (chrome.runtime.lastError) { if (chrome.runtime.lastError) {
// @ts-expect-error // @ts-expect-error
console.error(chrome.runtime.lastError); error(chrome.runtime.lastError);
} else { } else {
console.log("Settings saved successfully"); log("Settings saved successfully");
} }
}); });
} }
@@ -1076,111 +1035,6 @@
} }
} }
class ObsidianContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
// @ts-expect-error
return typeof app !== "undefined" && typeof app.vault !== "undefined";
}
/**
* @override
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
return new Promise((resolve) => {
// @ts-expect-error
OBSIDIAN_PLUGIN.loadData().then((data) => {
resolve(data ?? {});
});
});
}
/**
* @override
* @param {BirbSaveData|{}} saveData
*/
async putSaveData(saveData) {
// @ts-expect-error
await OBSIDIAN_PLUGIN.saveData(saveData);
}
/** @override */
resetSaveData() {
this.putSaveData({});
}
/** @override */
getFocusableElements() {
const elements = [
".workspace-leaf",
".cm-callout",
".HyperMD-codeblock-begin",
".status-bar",
".mobile-navbar"
];
return super.getFocusableElements().concat(elements);
}
/** @override */
getPath() {
// @ts-expect-error
const file = app.workspace.getActiveFile();
if (file && this.getActiveEditorElement()) {
return file.path;
} else {
return ROOT_PATH;
}
}
/** @override */
getActivePage() {
if (this.getPath() === ROOT_PATH) {
// Root page, use document element
return document.documentElement
}
return this.getActiveEditorElement() ?? document.documentElement;
}
/** @override */
isPathApplicable(path) {
return path === this.getPath();
}
/** @override */
areStickyNotesEnabled() {
return this.getPath() !== ROOT_PATH;
}
/** @returns {HTMLElement|null} */
getActiveEditorElement() {
// @ts-expect-error
const activeLeaf = app.workspace.activeLeaf;
const leafElement = activeLeaf?.view?.containerEl;
return leafElement?.querySelector(".cm-scroller") ?? null;
}
}
const CONTEXTS = [
new UserScriptContext(),
new ObsidianContext(),
new BrowserExtensionContext(),
new LocalContext()
];
function getContext() {
for (const context of CONTEXTS) {
if (context.isContextActive()) {
return context;
}
}
error("No applicable context found, defaulting to LocalContext");
return new LocalContext();
}
/** /**
* Parse URL parameters into a key-value map * Parse URL parameters into a key-value map
* @param {string} url * @param {string} url
@@ -1355,7 +1209,7 @@
class MenuItem { class MenuItem {
/** /**
* @param {string} text * @param {string|(() => string)} text
* @param {() => void} action * @param {() => void} action
* @param {boolean} [removeMenu] * @param {boolean} [removeMenu]
*/ */
@@ -1404,7 +1258,7 @@
if (item instanceof Separator) { if (item instanceof Separator) {
return makeElement("birb-window-separator"); return makeElement("birb-window-separator");
} }
let menuItem = makeElement("birb-menu-item", item.text); let menuItem = makeElement("birb-menu-item", typeof item.text === "function" ? item.text() : item.text);
onClick(menuItem, () => { onClick(menuItem, () => {
if (item.removeMenu) { if (item.removeMenu) {
removeMenuCallback(); removeMenuCallback();
@@ -1511,7 +1365,8 @@
* @typedef {typeof DEFAULT_SETTINGS} Settings * @typedef {typeof DEFAULT_SETTINGS} Settings
*/ */
const DEFAULT_SETTINGS = { const DEFAULT_SETTINGS = {
birbMode: false birbMode: false,
soundEnabled: true
}; };
// Rendering constants // Rendering constants
@@ -1724,6 +1579,7 @@
.birb-menu-item { .birb-menu-item {
width: calc(100% - var(--birb-double-border-size)); width: calc(100% - var(--birb-double-border-size));
white-space: nowrap;
font-size: 14px; font-size: 14px;
padding-top: 4px; padding-top: 4px;
padding-bottom: 4px; padding-bottom: 4px;
@@ -1883,6 +1739,13 @@
border: none !important; border: none !important;
} }
.birb-sticky-note-input::placeholder {
font-family: "Monocraft", monospace !important;
font-size: 14px !important;
background-color: transparent !important;
color: rgba(0, 0, 0, 0.35) !important;
}
.birb-sticky-note-input:focus { .birb-sticky-note-input:focus {
outline: none !important; outline: none !important;
box-shadow: none !important; box-shadow: none !important;
@@ -1924,68 +1787,24 @@
/** @type {Partial<Settings>} */ /** @type {Partial<Settings>} */
let userSettings = {}; let userSettings = {};
/**
* Load the sprite sheet and return the pixel-map template /**
* @param {string} dataUri * @param {Context} context
* @param {boolean} [templateColors]
* @returns {Promise<string[][]>}
*/ */
function loadSpriteSheetPixels(dataUri, templateColors = true) { async function initializeApplication(context) {
return new Promise((resolve, reject) => { log("birbOS booting up...");
const img = new Image(); setContext(context);
img.src = dataUri; log("Loading sprite sheets...");
img.onload = () => { const birbPixels = await loadSpriteSheetPixels(SPRITE_SHEET);
const canvas = document.createElement('canvas'); const featherPixels = await loadSpriteSheetPixels(FEATHER_SPRITE_SHEET);
canvas.width = img.width; startApplication(birbPixels, featherPixels);
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Failed to get canvas context'));
return;
}
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, img.width, img.height);
const pixels = imageData.data;
const hexArray = [];
for (let y = 0; y < img.height; y++) {
const row = [];
for (let x = 0; x < img.width; x++) {
const index = (y * img.width + x) * 4;
const r = pixels[index];
const g = pixels[index + 1];
const b = pixels[index + 2];
const a = pixels[index + 3];
if (a === 0) {
row.push(Sprite.TRANSPARENT);
continue;
}
const hex = `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
if (!templateColors) {
row.push(hex);
continue;
}
if (SPRITE_SHEET_COLOR_MAP[hex] === undefined) {
error(`Unknown color: ${hex}`);
row.push(Sprite.TRANSPARENT);
}
row.push(SPRITE_SHEET_COLOR_MAP[hex]);
}
hexArray.push(row);
}
resolve(hexArray);
};
img.onerror = (err) => {
reject(err);
};
});
} }
log("Loading sprite sheets..."); /**
* @param {string[][]} birbPixels
Promise.all([ * @param {string[][]} featherPixels
loadSpriteSheetPixels(SPRITE_SHEET), */
loadSpriteSheetPixels(FEATHER_SPRITE_SHEET) function startApplication(birbPixels, featherPixels) {
]).then(([birbPixels, featherPixels]) => {
const SPRITE_SHEET = birbPixels; const SPRITE_SHEET = birbPixels;
const FEATHER_SPRITE_SHEET = featherPixels; const FEATHER_SPRITE_SHEET = featherPixels;
@@ -2033,12 +1852,16 @@
const settingsItems = [ const settingsItems = [
new MenuItem("Go Back", () => switchMenuItems(menuItems, updateMenuLocation), false), new MenuItem("Go Back", () => switchMenuItems(menuItems, updateMenuLocation), false),
new Separator(), new Separator(),
new MenuItem("Toggle Birb Mode", () => { new MenuItem(() => `${settings().soundEnabled ? "Disable" : "Enable"} Sound`, () => {
userSettings.birbMode = !userSettings.birbMode; userSettings.soundEnabled = !settings().soundEnabled;
save();
}),
new MenuItem(() => `Toggle ${birdBirb(true)} Mode`, () => {
userSettings.birbMode = !settings().birbMode;
save(); save();
const message = makeElement("birb-message-content"); const message = makeElement("birb-message-content");
message.appendChild(document.createTextNode(`Your ${birdBirb().toLowerCase()} shall now be referred to as "${birdBirb()}"`)); message.appendChild(document.createTextNode(`Your ${birdBirb().toLowerCase()} shall now be referred to as "${birdBirb()}"`));
if (userSettings.birbMode) { if (settings().birbMode) {
message.appendChild(document.createElement("br")); message.appendChild(document.createElement("br"));
message.appendChild(document.createElement("br")); message.appendChild(document.createElement("br"));
message.appendChild(document.createTextNode("Welcome back to 2012")); message.appendChild(document.createTextNode("Welcome back to 2012"));
@@ -2046,7 +1869,7 @@
insertModal(`${birdBirb()} Mode`, message); insertModal(`${birdBirb()} Mode`, message);
}), }),
new Separator(), new Separator(),
new MenuItem("2025.11.14.205", () => { alert("Thank you for using Pocket Bird! You are on version: 2025.11.14.205"); }, false), new MenuItem("2026.1.4", () => { alert("Thank you for using Pocket Bird! You are on version: 2026.1.4"); }, false),
]; ];
const styleElement = document.createElement("style"); const styleElement = document.createElement("style");
@@ -2060,6 +1883,8 @@
FLYING: "flying", FLYING: "flying",
}; };
const birdsong = new Birdsong();
let frozen = false; let frozen = false;
let stateStart = Date.now(); let stateStart = Date.now();
let currentState = States.IDLE; let currentState = States.IDLE;
@@ -2150,8 +1975,8 @@
/** /**
* Bird or birb, you decide * Bird or birb, you decide
*/ */
function birdBirb() { function birdBirb(invert = false) {
return settings().birbMode ? "Birb" : "Bird"; return settings().birbMode !== invert ? "Birb" : "Bird";
} }
function init() { function init() {
@@ -2662,16 +2487,11 @@
return true; return true;
}); });
/** @type {HTMLElement[]} */ /** @type {HTMLElement[]} */
// @ts-expect-error
const largeElements = Array.from(visible).filter((img) => img instanceof HTMLElement && img !== focusedElement && img.offsetWidth >= MIN_FOCUS_ELEMENT_WIDTH); const largeElements = Array.from(visible).filter((img) => img instanceof HTMLElement && img !== focusedElement && img.offsetWidth >= MIN_FOCUS_ELEMENT_WIDTH);
// Ensure the bird doesn't land on fixed or sticky elements
const fixedAllowed = getContext() instanceof ObsidianContext;
const nonFixedElements = largeElements.filter((el) => { const nonFixedElements = largeElements.filter((el) => {
if (fixedAllowed) { {
return true; return true;
} }
const style = window.getComputedStyle(el);
return style.position !== "fixed" && style.position !== "sticky";
}); });
if (nonFixedElements.length === 0) { if (nonFixedElements.length === 0) {
return false; return false;
@@ -2745,6 +2565,9 @@
function pet() { function pet() {
if (currentState === States.IDLE && birb.getCurrentAnimation() !== Animations.HEART) { if (currentState === States.IDLE && birb.getCurrentAnimation() !== Animations.HEART) {
if (settings().soundEnabled) {
birdsong.chirp();
}
birb.setAnimation(Animations.HEART); birb.setAnimation(Animations.HEART);
lastPetTimestamp = Date.now(); lastPetTimestamp = Date.now();
} }
@@ -2811,8 +2634,64 @@
// Run the birb // Run the birb
init(); init();
draw(); draw();
}).catch((e) => { }
error("Error while loading sprite sheets: ", e);
}); /**
* Load the sprite sheet and return the pixel-map template
* @param {string} dataUri
* @param {boolean} [templateColors]
* @returns {Promise<string[][]>}
*/
function loadSpriteSheetPixels(dataUri, templateColors = true) {
return new Promise((resolve, reject) => {
const img = new Image();
img.src = dataUri;
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Failed to get canvas context'));
return;
}
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, img.width, img.height);
const pixels = imageData.data;
const hexArray = [];
for (let y = 0; y < img.height; y++) {
const row = [];
for (let x = 0; x < img.width; x++) {
const index = (y * img.width + x) * 4;
const r = pixels[index];
const g = pixels[index + 1];
const b = pixels[index + 2];
const a = pixels[index + 3];
if (a === 0) {
row.push(Sprite.TRANSPARENT);
continue;
}
const hex = `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
if (!templateColors) {
row.push(hex);
continue;
}
if (SPRITE_SHEET_COLOR_MAP[hex] === undefined) {
error(`Unknown color: ${hex}`);
row.push(Sprite.TRANSPARENT);
}
row.push(SPRITE_SHEET_COLOR_MAP[hex]);
}
hexArray.push(row);
}
resolve(hexArray);
};
img.onerror = (err) => {
reject(err);
};
});
}
initializeApplication(new BrowserExtensionContext());
})(); })();

View File

@@ -2,7 +2,7 @@
"manifest_version": 3, "manifest_version": 3,
"name": "Pocket Bird", "name": "Pocket Bird",
"description": "It's a pet bird in your browser, what more could you want?", "description": "It's a pet bird in your browser, what more could you want?",
"version": "2025.11.14.205", "version": "2026.1.4",
"homepage_url": "https://idreesinc.com", "homepage_url": "https://idreesinc.com",
"icons": { "icons": {
"48": "images/icons/transparent/48x48x1.png", "48": "images/icons/transparent/48x48x1.png",

401
dist/obsidian/main.js vendored
View File

@@ -1,7 +1,7 @@
const { Plugin, Notice } = require('obsidian'); const { Plugin, Notice } = require('obsidian');
module.exports = class PocketBird extends Plugin { module.exports = class PocketBird extends Plugin {
onload() { onload() {
console.log("Loading Pocket Bird version 2025.11.14.205..."); console.log("Loading Pocket Bird version 2026.1.4...");
const OBSIDIAN_PLUGIN = this; const OBSIDIAN_PLUGIN = this;
(function () { (function () {
'use strict'; 'use strict';
@@ -12,6 +12,7 @@ module.exports = class PocketBird extends Plugin {
}; };
let debugMode = location.hostname === "127.0.0.1"; let debugMode = location.hostname === "127.0.0.1";
let context = null;
/** /**
* @returns {boolean} Whether debug mode is enabled * @returns {boolean} Whether debug mode is enabled
@@ -27,6 +28,17 @@ module.exports = class PocketBird extends Plugin {
debugMode = value; debugMode = value;
} }
function getContext() {
if (!context) {
throw new Error("Context requested before being set");
}
return context;
}
function setContext(newContext) {
context = newContext;
}
/** /**
* Create an HTML element with the specified parameters * Create an HTML element with the specified parameters
* @param {string} className * @param {string} className
@@ -850,7 +862,50 @@ module.exports = class PocketBird extends Plugin {
} }
} }
const SAVE_KEY = "birbSaveData"; // @ts-check
class Birdsong {
/**
* @type {AudioContext}
*/
audioContext;
chirp() {
if (!this.audioContext) {
this.audioContext = new AudioContext();
}
const TIMES = [0, 0.06, 0.10, 0.15];
const FREQUENCIES = [2200,
3500 + Math.random() * 600,
2100 + Math.random() * 200,
1600 + Math.random() * 400];
const VOLUMES = [0.0001, 0.3, 0.3, 0.0001];
const oscillator = this.audioContext.createOscillator();
oscillator.type = "sine";
const gain = this.audioContext.createGain();
oscillator.connect(gain);
gain.connect(this.audioContext.destination);
const now = this.audioContext.currentTime;
for (let i = 0; i < TIMES.length; i++) {
const time = TIMES[i] + now;
if (i === 0) {
oscillator.frequency.setValueAtTime(FREQUENCIES[i], time);
gain.gain.setValueAtTime(VOLUMES[i], time);
} else {
oscillator.frequency.exponentialRampToValueAtTime(FREQUENCIES[i], time);
gain.gain.exponentialRampToValueAtTime(VOLUMES[i], time);
}
}
oscillator.start(now);
oscillator.stop(now + TIMES[TIMES.length - 1]);
}
}
const ROOT_PATH = ""; const ROOT_PATH = "";
/** /**
@@ -862,14 +917,6 @@ module.exports = class PocketBird extends Plugin {
*/ */
class Context { class Context {
/**
* @abstract
* @returns {boolean} Whether this context is applicable
*/
isContextActive() {
throw new Error("Method not implemented");
}
/** /**
* @abstract * @abstract
* @returns {Promise<BirbSaveData|{}>} * @returns {Promise<BirbSaveData|{}>}
@@ -951,145 +998,7 @@ module.exports = class PocketBird extends Plugin {
} }
} }
class LocalContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
return window.location.hostname === "127.0.0.1"
|| window.location.hostname === "localhost"
|| window.location.hostname.startsWith("192.168.");
}
/**
* @override
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
log("Loading save data from localStorage");
return JSON.parse(localStorage.getItem(SAVE_KEY) ?? "{}");
}
/**
* @override
* @param {BirbSaveData} saveData
*/
async putSaveData(saveData) {
log("Saving data to localStorage");
localStorage.setItem(SAVE_KEY, JSON.stringify(saveData));
}
/** @override */
resetSaveData() {
log("Resetting save data in localStorage");
localStorage.removeItem(SAVE_KEY);
}
}
class UserScriptContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
// @ts-expect-error
return typeof GM_getValue === "function";
}
/**
* @override
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
log("Loading save data from UserScript storage");
/** @type {BirbSaveData|{}} */
let saveData = {};
// @ts-expect-error
saveData = GM_getValue(SAVE_KEY, {}) ?? {};
return saveData;
}
/**
* @override
* @param {BirbSaveData} saveData
*/
async putSaveData(saveData) {
log("Saving data to UserScript storage");
// @ts-expect-error
GM_setValue(SAVE_KEY, saveData);
}
/** @override */
resetSaveData() {
log("Resetting save data in UserScript storage");
// @ts-expect-error
GM_deleteValue(SAVE_KEY);
}
}
class BrowserExtensionContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
// @ts-expect-error
return typeof chrome !== "undefined";
}
/**
* @override
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
log("Loading save data from browser extension storage");
return new Promise((resolve) => {
// @ts-expect-error
chrome.storage.sync.get([SAVE_KEY], (result) => {
resolve(result[SAVE_KEY] ?? {});
});
});
}
/**
* @override
* @param {BirbSaveData} saveData
*/
async putSaveData(saveData) {
log("Saving data to browser extension storage");
// @ts-expect-error
chrome.storage.sync.set({ [SAVE_KEY]: saveData }, function () {
// @ts-expect-error
if (chrome.runtime.lastError) {
// @ts-expect-error
console.error(chrome.runtime.lastError);
} else {
console.log("Settings saved successfully");
}
});
}
/** @override */
resetSaveData() {
log("Resetting save data in browser extension storage");
// @ts-expect-error
chrome.storage.sync.clear();
}
}
class ObsidianContext extends Context { class ObsidianContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
// @ts-expect-error
return typeof app !== "undefined" && typeof app.vault !== "undefined";
}
/** /**
* @override * @override
@@ -1169,23 +1078,6 @@ module.exports = class PocketBird extends Plugin {
} }
} }
const CONTEXTS = [
new UserScriptContext(),
new ObsidianContext(),
new BrowserExtensionContext(),
new LocalContext()
];
function getContext() {
for (const context of CONTEXTS) {
if (context.isContextActive()) {
return context;
}
}
error("No applicable context found, defaulting to LocalContext");
return new LocalContext();
}
/** /**
* Parse URL parameters into a key-value map * Parse URL parameters into a key-value map
* @param {string} url * @param {string} url
@@ -1360,7 +1252,7 @@ module.exports = class PocketBird extends Plugin {
class MenuItem { class MenuItem {
/** /**
* @param {string} text * @param {string|(() => string)} text
* @param {() => void} action * @param {() => void} action
* @param {boolean} [removeMenu] * @param {boolean} [removeMenu]
*/ */
@@ -1409,7 +1301,7 @@ module.exports = class PocketBird extends Plugin {
if (item instanceof Separator) { if (item instanceof Separator) {
return makeElement("birb-window-separator"); return makeElement("birb-window-separator");
} }
let menuItem = makeElement("birb-menu-item", item.text); let menuItem = makeElement("birb-menu-item", typeof item.text === "function" ? item.text() : item.text);
onClick(menuItem, () => { onClick(menuItem, () => {
if (item.removeMenu) { if (item.removeMenu) {
removeMenuCallback(); removeMenuCallback();
@@ -1516,7 +1408,8 @@ module.exports = class PocketBird extends Plugin {
* @typedef {typeof DEFAULT_SETTINGS} Settings * @typedef {typeof DEFAULT_SETTINGS} Settings
*/ */
const DEFAULT_SETTINGS = { const DEFAULT_SETTINGS = {
birbMode: false birbMode: false,
soundEnabled: true
}; };
// Rendering constants // Rendering constants
@@ -1729,6 +1622,7 @@ module.exports = class PocketBird extends Plugin {
.birb-menu-item { .birb-menu-item {
width: calc(100% - var(--birb-double-border-size)); width: calc(100% - var(--birb-double-border-size));
white-space: nowrap;
font-size: 14px; font-size: 14px;
padding-top: 4px; padding-top: 4px;
padding-bottom: 4px; padding-bottom: 4px;
@@ -1888,6 +1782,13 @@ module.exports = class PocketBird extends Plugin {
border: none !important; border: none !important;
} }
.birb-sticky-note-input::placeholder {
font-family: "Monocraft", monospace !important;
font-size: 14px !important;
background-color: transparent !important;
color: rgba(0, 0, 0, 0.35) !important;
}
.birb-sticky-note-input:focus { .birb-sticky-note-input:focus {
outline: none !important; outline: none !important;
box-shadow: none !important; box-shadow: none !important;
@@ -1929,68 +1830,24 @@ module.exports = class PocketBird extends Plugin {
/** @type {Partial<Settings>} */ /** @type {Partial<Settings>} */
let userSettings = {}; let userSettings = {};
/**
* Load the sprite sheet and return the pixel-map template /**
* @param {string} dataUri * @param {Context} context
* @param {boolean} [templateColors]
* @returns {Promise<string[][]>}
*/ */
function loadSpriteSheetPixels(dataUri, templateColors = true) { async function initializeApplication(context) {
return new Promise((resolve, reject) => { log("birbOS booting up...");
const img = new Image(); setContext(context);
img.src = dataUri; log("Loading sprite sheets...");
img.onload = () => { const birbPixels = await loadSpriteSheetPixels(SPRITE_SHEET);
const canvas = document.createElement('canvas'); const featherPixels = await loadSpriteSheetPixels(FEATHER_SPRITE_SHEET);
canvas.width = img.width; startApplication(birbPixels, featherPixels);
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Failed to get canvas context'));
return;
}
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, img.width, img.height);
const pixels = imageData.data;
const hexArray = [];
for (let y = 0; y < img.height; y++) {
const row = [];
for (let x = 0; x < img.width; x++) {
const index = (y * img.width + x) * 4;
const r = pixels[index];
const g = pixels[index + 1];
const b = pixels[index + 2];
const a = pixels[index + 3];
if (a === 0) {
row.push(Sprite.TRANSPARENT);
continue;
}
const hex = `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
if (!templateColors) {
row.push(hex);
continue;
}
if (SPRITE_SHEET_COLOR_MAP[hex] === undefined) {
error(`Unknown color: ${hex}`);
row.push(Sprite.TRANSPARENT);
}
row.push(SPRITE_SHEET_COLOR_MAP[hex]);
}
hexArray.push(row);
}
resolve(hexArray);
};
img.onerror = (err) => {
reject(err);
};
});
} }
log("Loading sprite sheets..."); /**
* @param {string[][]} birbPixels
Promise.all([ * @param {string[][]} featherPixels
loadSpriteSheetPixels(SPRITE_SHEET), */
loadSpriteSheetPixels(FEATHER_SPRITE_SHEET) function startApplication(birbPixels, featherPixels) {
]).then(([birbPixels, featherPixels]) => {
const SPRITE_SHEET = birbPixels; const SPRITE_SHEET = birbPixels;
const FEATHER_SPRITE_SHEET = featherPixels; const FEATHER_SPRITE_SHEET = featherPixels;
@@ -2038,12 +1895,16 @@ module.exports = class PocketBird extends Plugin {
const settingsItems = [ const settingsItems = [
new MenuItem("Go Back", () => switchMenuItems(menuItems, updateMenuLocation), false), new MenuItem("Go Back", () => switchMenuItems(menuItems, updateMenuLocation), false),
new Separator(), new Separator(),
new MenuItem("Toggle Birb Mode", () => { new MenuItem(() => `${settings().soundEnabled ? "Disable" : "Enable"} Sound`, () => {
userSettings.birbMode = !userSettings.birbMode; userSettings.soundEnabled = !settings().soundEnabled;
save();
}),
new MenuItem(() => `Toggle ${birdBirb(true)} Mode`, () => {
userSettings.birbMode = !settings().birbMode;
save(); save();
const message = makeElement("birb-message-content"); const message = makeElement("birb-message-content");
message.appendChild(document.createTextNode(`Your ${birdBirb().toLowerCase()} shall now be referred to as "${birdBirb()}"`)); message.appendChild(document.createTextNode(`Your ${birdBirb().toLowerCase()} shall now be referred to as "${birdBirb()}"`));
if (userSettings.birbMode) { if (settings().birbMode) {
message.appendChild(document.createElement("br")); message.appendChild(document.createElement("br"));
message.appendChild(document.createElement("br")); message.appendChild(document.createElement("br"));
message.appendChild(document.createTextNode("Welcome back to 2012")); message.appendChild(document.createTextNode("Welcome back to 2012"));
@@ -2051,7 +1912,7 @@ module.exports = class PocketBird extends Plugin {
insertModal(`${birdBirb()} Mode`, message); insertModal(`${birdBirb()} Mode`, message);
}), }),
new Separator(), new Separator(),
new MenuItem("2025.11.14.205", () => { alert("Thank you for using Pocket Bird! You are on version: 2025.11.14.205"); }, false), new MenuItem("2026.1.4", () => { alert("Thank you for using Pocket Bird! You are on version: 2026.1.4"); }, false),
]; ];
const styleElement = document.createElement("style"); const styleElement = document.createElement("style");
@@ -2065,6 +1926,8 @@ module.exports = class PocketBird extends Plugin {
FLYING: "flying", FLYING: "flying",
}; };
const birdsong = new Birdsong();
let frozen = false; let frozen = false;
let stateStart = Date.now(); let stateStart = Date.now();
let currentState = States.IDLE; let currentState = States.IDLE;
@@ -2155,8 +2018,8 @@ module.exports = class PocketBird extends Plugin {
/** /**
* Bird or birb, you decide * Bird or birb, you decide
*/ */
function birdBirb() { function birdBirb(invert = false) {
return settings().birbMode ? "Birb" : "Bird"; return settings().birbMode !== invert ? "Birb" : "Bird";
} }
function init() { function init() {
@@ -2667,16 +2530,11 @@ module.exports = class PocketBird extends Plugin {
return true; return true;
}); });
/** @type {HTMLElement[]} */ /** @type {HTMLElement[]} */
// @ts-expect-error
const largeElements = Array.from(visible).filter((img) => img instanceof HTMLElement && img !== focusedElement && img.offsetWidth >= MIN_FOCUS_ELEMENT_WIDTH); const largeElements = Array.from(visible).filter((img) => img instanceof HTMLElement && img !== focusedElement && img.offsetWidth >= MIN_FOCUS_ELEMENT_WIDTH);
// Ensure the bird doesn't land on fixed or sticky elements
const fixedAllowed = getContext() instanceof ObsidianContext;
const nonFixedElements = largeElements.filter((el) => { const nonFixedElements = largeElements.filter((el) => {
if (fixedAllowed) { {
return true; return true;
} }
const style = window.getComputedStyle(el);
return style.position !== "fixed" && style.position !== "sticky";
}); });
if (nonFixedElements.length === 0) { if (nonFixedElements.length === 0) {
return false; return false;
@@ -2750,6 +2608,9 @@ module.exports = class PocketBird extends Plugin {
function pet() { function pet() {
if (currentState === States.IDLE && birb.getCurrentAnimation() !== Animations.HEART) { if (currentState === States.IDLE && birb.getCurrentAnimation() !== Animations.HEART) {
if (settings().soundEnabled) {
birdsong.chirp();
}
birb.setAnimation(Animations.HEART); birb.setAnimation(Animations.HEART);
lastPetTimestamp = Date.now(); lastPetTimestamp = Date.now();
} }
@@ -2816,9 +2677,65 @@ module.exports = class PocketBird extends Plugin {
// Run the birb // Run the birb
init(); init();
draw(); draw();
}).catch((e) => { }
error("Error while loading sprite sheets: ", e);
}); /**
* Load the sprite sheet and return the pixel-map template
* @param {string} dataUri
* @param {boolean} [templateColors]
* @returns {Promise<string[][]>}
*/
function loadSpriteSheetPixels(dataUri, templateColors = true) {
return new Promise((resolve, reject) => {
const img = new Image();
img.src = dataUri;
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Failed to get canvas context'));
return;
}
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, img.width, img.height);
const pixels = imageData.data;
const hexArray = [];
for (let y = 0; y < img.height; y++) {
const row = [];
for (let x = 0; x < img.width; x++) {
const index = (y * img.width + x) * 4;
const r = pixels[index];
const g = pixels[index + 1];
const b = pixels[index + 2];
const a = pixels[index + 3];
if (a === 0) {
row.push(Sprite.TRANSPARENT);
continue;
}
const hex = `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
if (!templateColors) {
row.push(hex);
continue;
}
if (SPRITE_SHEET_COLOR_MAP[hex] === undefined) {
error(`Unknown color: ${hex}`);
row.push(Sprite.TRANSPARENT);
}
row.push(SPRITE_SHEET_COLOR_MAP[hex]);
}
hexArray.push(row);
}
resolve(hexArray);
};
img.onerror = (err) => {
reject(err);
};
});
}
initializeApplication(new ObsidianContext());
})(); })();

View File

@@ -1,9 +1,9 @@
{ {
"id": "pocket-bird", "id": "pocket-bird",
"name": "Pocket Bird", "name": "Pocket Bird",
"version": "2025.11.14.205", "version": "2026.1.4",
"minAppVersion": "0.15.0", "minAppVersion": "0.15.0",
"description": "It's a pet bird in your Obsidian, what more could you want?", "description": "Add a pet bird to fly around your notes and keep you company!",
"author": "Idrees Hassan", "author": "Idrees Hassan",
"authorUrl": "https://idreesinc.com", "authorUrl": "https://idreesinc.com",
"isDesktopOnly": false "isDesktopOnly": false

View File

@@ -1,7 +1,7 @@
// ==UserScript== // ==UserScript==
// @name Pocket Bird // @name Pocket Bird
// @namespace https://idreesinc.com // @namespace https://idreesinc.com
// @version 2025.11.14.205 // @version 2026.1.4
// @description It's a pet bird in your browser, what more could you want? // @description It's a pet bird in your browser, what more could you want?
// @author Idrees // @author Idrees
// @downloadURL https://github.com/IdreesInc/Pocket-Bird/raw/refs/heads/main/dist/userscript/birb.user.js // @downloadURL https://github.com/IdreesInc/Pocket-Bird/raw/refs/heads/main/dist/userscript/birb.user.js
@@ -21,6 +21,7 @@
}; };
let debugMode = location.hostname === "127.0.0.1"; let debugMode = location.hostname === "127.0.0.1";
let context = null;
/** /**
* @returns {boolean} Whether debug mode is enabled * @returns {boolean} Whether debug mode is enabled
@@ -36,6 +37,17 @@
debugMode = value; debugMode = value;
} }
function getContext() {
if (!context) {
throw new Error("Context requested before being set");
}
return context;
}
function setContext(newContext) {
context = newContext;
}
/** /**
* Create an HTML element with the specified parameters * Create an HTML element with the specified parameters
* @param {string} className * @param {string} className
@@ -859,8 +871,51 @@
} }
} }
// @ts-check
class Birdsong {
/**
* @type {AudioContext}
*/
audioContext;
chirp() {
if (!this.audioContext) {
this.audioContext = new AudioContext();
}
const TIMES = [0, 0.06, 0.10, 0.15];
const FREQUENCIES = [2200,
3500 + Math.random() * 600,
2100 + Math.random() * 200,
1600 + Math.random() * 400];
const VOLUMES = [0.0001, 0.3, 0.3, 0.0001];
const oscillator = this.audioContext.createOscillator();
oscillator.type = "sine";
const gain = this.audioContext.createGain();
oscillator.connect(gain);
gain.connect(this.audioContext.destination);
const now = this.audioContext.currentTime;
for (let i = 0; i < TIMES.length; i++) {
const time = TIMES[i] + now;
if (i === 0) {
oscillator.frequency.setValueAtTime(FREQUENCIES[i], time);
gain.gain.setValueAtTime(VOLUMES[i], time);
} else {
oscillator.frequency.exponentialRampToValueAtTime(FREQUENCIES[i], time);
gain.gain.exponentialRampToValueAtTime(VOLUMES[i], time);
}
}
oscillator.start(now);
oscillator.stop(now + TIMES[TIMES.length - 1]);
}
}
const SAVE_KEY = "birbSaveData"; const SAVE_KEY = "birbSaveData";
const ROOT_PATH = "";
/** /**
* @typedef {import('./application.js').BirbSaveData} BirbSaveData * @typedef {import('./application.js').BirbSaveData} BirbSaveData
@@ -871,14 +926,6 @@
*/ */
class Context { class Context {
/**
* @abstract
* @returns {boolean} Whether this context is applicable
*/
isContextActive() {
throw new Error("Method not implemented");
}
/** /**
* @abstract * @abstract
* @returns {Promise<BirbSaveData|{}>} * @returns {Promise<BirbSaveData|{}>}
@@ -960,54 +1007,8 @@
} }
} }
class LocalContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
return window.location.hostname === "127.0.0.1"
|| window.location.hostname === "localhost"
|| window.location.hostname.startsWith("192.168.");
}
/**
* @override
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
log("Loading save data from localStorage");
return JSON.parse(localStorage.getItem(SAVE_KEY) ?? "{}");
}
/**
* @override
* @param {BirbSaveData} saveData
*/
async putSaveData(saveData) {
log("Saving data to localStorage");
localStorage.setItem(SAVE_KEY, JSON.stringify(saveData));
}
/** @override */
resetSaveData() {
log("Resetting save data in localStorage");
localStorage.removeItem(SAVE_KEY);
}
}
class UserScriptContext extends Context { class UserScriptContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
// @ts-expect-error
return typeof GM_getValue === "function";
}
/** /**
* @override * @override
* @returns {Promise<BirbSaveData|{}>} * @returns {Promise<BirbSaveData|{}>}
@@ -1039,162 +1040,6 @@
} }
} }
class BrowserExtensionContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
// @ts-expect-error
return typeof chrome !== "undefined";
}
/**
* @override
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
log("Loading save data from browser extension storage");
return new Promise((resolve) => {
// @ts-expect-error
chrome.storage.sync.get([SAVE_KEY], (result) => {
resolve(result[SAVE_KEY] ?? {});
});
});
}
/**
* @override
* @param {BirbSaveData} saveData
*/
async putSaveData(saveData) {
log("Saving data to browser extension storage");
// @ts-expect-error
chrome.storage.sync.set({ [SAVE_KEY]: saveData }, function () {
// @ts-expect-error
if (chrome.runtime.lastError) {
// @ts-expect-error
console.error(chrome.runtime.lastError);
} else {
console.log("Settings saved successfully");
}
});
}
/** @override */
resetSaveData() {
log("Resetting save data in browser extension storage");
// @ts-expect-error
chrome.storage.sync.clear();
}
}
class ObsidianContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
// @ts-expect-error
return typeof app !== "undefined" && typeof app.vault !== "undefined";
}
/**
* @override
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
return new Promise((resolve) => {
// @ts-expect-error
OBSIDIAN_PLUGIN.loadData().then((data) => {
resolve(data ?? {});
});
});
}
/**
* @override
* @param {BirbSaveData|{}} saveData
*/
async putSaveData(saveData) {
// @ts-expect-error
await OBSIDIAN_PLUGIN.saveData(saveData);
}
/** @override */
resetSaveData() {
this.putSaveData({});
}
/** @override */
getFocusableElements() {
const elements = [
".workspace-leaf",
".cm-callout",
".HyperMD-codeblock-begin",
".status-bar",
".mobile-navbar"
];
return super.getFocusableElements().concat(elements);
}
/** @override */
getPath() {
// @ts-expect-error
const file = app.workspace.getActiveFile();
if (file && this.getActiveEditorElement()) {
return file.path;
} else {
return ROOT_PATH;
}
}
/** @override */
getActivePage() {
if (this.getPath() === ROOT_PATH) {
// Root page, use document element
return document.documentElement
}
return this.getActiveEditorElement() ?? document.documentElement;
}
/** @override */
isPathApplicable(path) {
return path === this.getPath();
}
/** @override */
areStickyNotesEnabled() {
return this.getPath() !== ROOT_PATH;
}
/** @returns {HTMLElement|null} */
getActiveEditorElement() {
// @ts-expect-error
const activeLeaf = app.workspace.activeLeaf;
const leafElement = activeLeaf?.view?.containerEl;
return leafElement?.querySelector(".cm-scroller") ?? null;
}
}
const CONTEXTS = [
new UserScriptContext(),
new ObsidianContext(),
new BrowserExtensionContext(),
new LocalContext()
];
function getContext() {
for (const context of CONTEXTS) {
if (context.isContextActive()) {
return context;
}
}
error("No applicable context found, defaulting to LocalContext");
return new LocalContext();
}
/** /**
* Parse URL parameters into a key-value map * Parse URL parameters into a key-value map
* @param {string} url * @param {string} url
@@ -1369,7 +1214,7 @@
class MenuItem { class MenuItem {
/** /**
* @param {string} text * @param {string|(() => string)} text
* @param {() => void} action * @param {() => void} action
* @param {boolean} [removeMenu] * @param {boolean} [removeMenu]
*/ */
@@ -1418,7 +1263,7 @@
if (item instanceof Separator) { if (item instanceof Separator) {
return makeElement("birb-window-separator"); return makeElement("birb-window-separator");
} }
let menuItem = makeElement("birb-menu-item", item.text); let menuItem = makeElement("birb-menu-item", typeof item.text === "function" ? item.text() : item.text);
onClick(menuItem, () => { onClick(menuItem, () => {
if (item.removeMenu) { if (item.removeMenu) {
removeMenuCallback(); removeMenuCallback();
@@ -1525,7 +1370,8 @@
* @typedef {typeof DEFAULT_SETTINGS} Settings * @typedef {typeof DEFAULT_SETTINGS} Settings
*/ */
const DEFAULT_SETTINGS = { const DEFAULT_SETTINGS = {
birbMode: false birbMode: false,
soundEnabled: true
}; };
// Rendering constants // Rendering constants
@@ -1738,6 +1584,7 @@
.birb-menu-item { .birb-menu-item {
width: calc(100% - var(--birb-double-border-size)); width: calc(100% - var(--birb-double-border-size));
white-space: nowrap;
font-size: 14px; font-size: 14px;
padding-top: 4px; padding-top: 4px;
padding-bottom: 4px; padding-bottom: 4px;
@@ -1897,6 +1744,13 @@
border: none !important; border: none !important;
} }
.birb-sticky-note-input::placeholder {
font-family: "Monocraft", monospace !important;
font-size: 14px !important;
background-color: transparent !important;
color: rgba(0, 0, 0, 0.35) !important;
}
.birb-sticky-note-input:focus { .birb-sticky-note-input:focus {
outline: none !important; outline: none !important;
box-shadow: none !important; box-shadow: none !important;
@@ -1938,68 +1792,24 @@
/** @type {Partial<Settings>} */ /** @type {Partial<Settings>} */
let userSettings = {}; let userSettings = {};
/**
* Load the sprite sheet and return the pixel-map template /**
* @param {string} dataUri * @param {Context} context
* @param {boolean} [templateColors]
* @returns {Promise<string[][]>}
*/ */
function loadSpriteSheetPixels(dataUri, templateColors = true) { async function initializeApplication(context) {
return new Promise((resolve, reject) => { log("birbOS booting up...");
const img = new Image(); setContext(context);
img.src = dataUri; log("Loading sprite sheets...");
img.onload = () => { const birbPixels = await loadSpriteSheetPixels(SPRITE_SHEET);
const canvas = document.createElement('canvas'); const featherPixels = await loadSpriteSheetPixels(FEATHER_SPRITE_SHEET);
canvas.width = img.width; startApplication(birbPixels, featherPixels);
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Failed to get canvas context'));
return;
}
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, img.width, img.height);
const pixels = imageData.data;
const hexArray = [];
for (let y = 0; y < img.height; y++) {
const row = [];
for (let x = 0; x < img.width; x++) {
const index = (y * img.width + x) * 4;
const r = pixels[index];
const g = pixels[index + 1];
const b = pixels[index + 2];
const a = pixels[index + 3];
if (a === 0) {
row.push(Sprite.TRANSPARENT);
continue;
}
const hex = `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
if (!templateColors) {
row.push(hex);
continue;
}
if (SPRITE_SHEET_COLOR_MAP[hex] === undefined) {
error(`Unknown color: ${hex}`);
row.push(Sprite.TRANSPARENT);
}
row.push(SPRITE_SHEET_COLOR_MAP[hex]);
}
hexArray.push(row);
}
resolve(hexArray);
};
img.onerror = (err) => {
reject(err);
};
});
} }
log("Loading sprite sheets..."); /**
* @param {string[][]} birbPixels
Promise.all([ * @param {string[][]} featherPixels
loadSpriteSheetPixels(SPRITE_SHEET), */
loadSpriteSheetPixels(FEATHER_SPRITE_SHEET) function startApplication(birbPixels, featherPixels) {
]).then(([birbPixels, featherPixels]) => {
const SPRITE_SHEET = birbPixels; const SPRITE_SHEET = birbPixels;
const FEATHER_SPRITE_SHEET = featherPixels; const FEATHER_SPRITE_SHEET = featherPixels;
@@ -2047,12 +1857,16 @@
const settingsItems = [ const settingsItems = [
new MenuItem("Go Back", () => switchMenuItems(menuItems, updateMenuLocation), false), new MenuItem("Go Back", () => switchMenuItems(menuItems, updateMenuLocation), false),
new Separator(), new Separator(),
new MenuItem("Toggle Birb Mode", () => { new MenuItem(() => `${settings().soundEnabled ? "Disable" : "Enable"} Sound`, () => {
userSettings.birbMode = !userSettings.birbMode; userSettings.soundEnabled = !settings().soundEnabled;
save();
}),
new MenuItem(() => `Toggle ${birdBirb(true)} Mode`, () => {
userSettings.birbMode = !settings().birbMode;
save(); save();
const message = makeElement("birb-message-content"); const message = makeElement("birb-message-content");
message.appendChild(document.createTextNode(`Your ${birdBirb().toLowerCase()} shall now be referred to as "${birdBirb()}"`)); message.appendChild(document.createTextNode(`Your ${birdBirb().toLowerCase()} shall now be referred to as "${birdBirb()}"`));
if (userSettings.birbMode) { if (settings().birbMode) {
message.appendChild(document.createElement("br")); message.appendChild(document.createElement("br"));
message.appendChild(document.createElement("br")); message.appendChild(document.createElement("br"));
message.appendChild(document.createTextNode("Welcome back to 2012")); message.appendChild(document.createTextNode("Welcome back to 2012"));
@@ -2060,7 +1874,7 @@
insertModal(`${birdBirb()} Mode`, message); insertModal(`${birdBirb()} Mode`, message);
}), }),
new Separator(), new Separator(),
new MenuItem("2025.11.14.205", () => { alert("Thank you for using Pocket Bird! You are on version: 2025.11.14.205"); }, false), new MenuItem("2026.1.4", () => { alert("Thank you for using Pocket Bird! You are on version: 2026.1.4"); }, false),
]; ];
const styleElement = document.createElement("style"); const styleElement = document.createElement("style");
@@ -2074,6 +1888,8 @@
FLYING: "flying", FLYING: "flying",
}; };
const birdsong = new Birdsong();
let frozen = false; let frozen = false;
let stateStart = Date.now(); let stateStart = Date.now();
let currentState = States.IDLE; let currentState = States.IDLE;
@@ -2164,8 +1980,8 @@
/** /**
* Bird or birb, you decide * Bird or birb, you decide
*/ */
function birdBirb() { function birdBirb(invert = false) {
return settings().birbMode ? "Birb" : "Bird"; return settings().birbMode !== invert ? "Birb" : "Bird";
} }
function init() { function init() {
@@ -2676,16 +2492,11 @@
return true; return true;
}); });
/** @type {HTMLElement[]} */ /** @type {HTMLElement[]} */
// @ts-expect-error
const largeElements = Array.from(visible).filter((img) => img instanceof HTMLElement && img !== focusedElement && img.offsetWidth >= MIN_FOCUS_ELEMENT_WIDTH); const largeElements = Array.from(visible).filter((img) => img instanceof HTMLElement && img !== focusedElement && img.offsetWidth >= MIN_FOCUS_ELEMENT_WIDTH);
// Ensure the bird doesn't land on fixed or sticky elements
const fixedAllowed = getContext() instanceof ObsidianContext;
const nonFixedElements = largeElements.filter((el) => { const nonFixedElements = largeElements.filter((el) => {
if (fixedAllowed) { {
return true; return true;
} }
const style = window.getComputedStyle(el);
return style.position !== "fixed" && style.position !== "sticky";
}); });
if (nonFixedElements.length === 0) { if (nonFixedElements.length === 0) {
return false; return false;
@@ -2759,6 +2570,9 @@
function pet() { function pet() {
if (currentState === States.IDLE && birb.getCurrentAnimation() !== Animations.HEART) { if (currentState === States.IDLE && birb.getCurrentAnimation() !== Animations.HEART) {
if (settings().soundEnabled) {
birdsong.chirp();
}
birb.setAnimation(Animations.HEART); birb.setAnimation(Animations.HEART);
lastPetTimestamp = Date.now(); lastPetTimestamp = Date.now();
} }
@@ -2825,8 +2639,64 @@
// Run the birb // Run the birb
init(); init();
draw(); draw();
}).catch((e) => { }
error("Error while loading sprite sheets: ", e);
}); /**
* Load the sprite sheet and return the pixel-map template
* @param {string} dataUri
* @param {boolean} [templateColors]
* @returns {Promise<string[][]>}
*/
function loadSpriteSheetPixels(dataUri, templateColors = true) {
return new Promise((resolve, reject) => {
const img = new Image();
img.src = dataUri;
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Failed to get canvas context'));
return;
}
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, img.width, img.height);
const pixels = imageData.data;
const hexArray = [];
for (let y = 0; y < img.height; y++) {
const row = [];
for (let x = 0; x < img.width; x++) {
const index = (y * img.width + x) * 4;
const r = pixels[index];
const g = pixels[index + 1];
const b = pixels[index + 2];
const a = pixels[index + 3];
if (a === 0) {
row.push(Sprite.TRANSPARENT);
continue;
}
const hex = `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
if (!templateColors) {
row.push(hex);
continue;
}
if (SPRITE_SHEET_COLOR_MAP[hex] === undefined) {
error(`Unknown color: ${hex}`);
row.push(Sprite.TRANSPARENT);
}
row.push(SPRITE_SHEET_COLOR_MAP[hex]);
}
hexArray.push(row);
}
resolve(hexArray);
};
img.onerror = (err) => {
reject(err);
};
});
}
initializeApplication(new UserScriptContext());
})(); })();

2682
dist/web/birb.embed.js vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,7 @@
}; };
let debugMode = location.hostname === "127.0.0.1"; let debugMode = location.hostname === "127.0.0.1";
let context = null;
/** /**
* @returns {boolean} Whether debug mode is enabled * @returns {boolean} Whether debug mode is enabled
@@ -22,6 +23,17 @@
debugMode = value; debugMode = value;
} }
function getContext() {
if (!context) {
throw new Error("Context requested before being set");
}
return context;
}
function setContext(newContext) {
context = newContext;
}
/** /**
* Create an HTML element with the specified parameters * Create an HTML element with the specified parameters
* @param {string} className * @param {string} className
@@ -845,8 +857,51 @@
} }
} }
// @ts-check
class Birdsong {
/**
* @type {AudioContext}
*/
audioContext;
chirp() {
if (!this.audioContext) {
this.audioContext = new AudioContext();
}
const TIMES = [0, 0.06, 0.10, 0.15];
const FREQUENCIES = [2200,
3500 + Math.random() * 600,
2100 + Math.random() * 200,
1600 + Math.random() * 400];
const VOLUMES = [0.0001, 0.3, 0.3, 0.0001];
const oscillator = this.audioContext.createOscillator();
oscillator.type = "sine";
const gain = this.audioContext.createGain();
oscillator.connect(gain);
gain.connect(this.audioContext.destination);
const now = this.audioContext.currentTime;
for (let i = 0; i < TIMES.length; i++) {
const time = TIMES[i] + now;
if (i === 0) {
oscillator.frequency.setValueAtTime(FREQUENCIES[i], time);
gain.gain.setValueAtTime(VOLUMES[i], time);
} else {
oscillator.frequency.exponentialRampToValueAtTime(FREQUENCIES[i], time);
gain.gain.exponentialRampToValueAtTime(VOLUMES[i], time);
}
}
oscillator.start(now);
oscillator.stop(now + TIMES[TIMES.length - 1]);
}
}
const SAVE_KEY = "birbSaveData"; const SAVE_KEY = "birbSaveData";
const ROOT_PATH = "";
/** /**
* @typedef {import('./application.js').BirbSaveData} BirbSaveData * @typedef {import('./application.js').BirbSaveData} BirbSaveData
@@ -857,14 +912,6 @@
*/ */
class Context { class Context {
/**
* @abstract
* @returns {boolean} Whether this context is applicable
*/
isContextActive() {
throw new Error("Method not implemented");
}
/** /**
* @abstract * @abstract
* @returns {Promise<BirbSaveData|{}>} * @returns {Promise<BirbSaveData|{}>}
@@ -948,16 +995,6 @@
class LocalContext extends Context { class LocalContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
return window.location.hostname === "127.0.0.1"
|| window.location.hostname === "localhost"
|| window.location.hostname.startsWith("192.168.");
}
/** /**
* @override * @override
* @returns {Promise<BirbSaveData|{}>} * @returns {Promise<BirbSaveData|{}>}
@@ -983,204 +1020,6 @@
} }
} }
class UserScriptContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
// @ts-expect-error
return typeof GM_getValue === "function";
}
/**
* @override
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
log("Loading save data from UserScript storage");
/** @type {BirbSaveData|{}} */
let saveData = {};
// @ts-expect-error
saveData = GM_getValue(SAVE_KEY, {}) ?? {};
return saveData;
}
/**
* @override
* @param {BirbSaveData} saveData
*/
async putSaveData(saveData) {
log("Saving data to UserScript storage");
// @ts-expect-error
GM_setValue(SAVE_KEY, saveData);
}
/** @override */
resetSaveData() {
log("Resetting save data in UserScript storage");
// @ts-expect-error
GM_deleteValue(SAVE_KEY);
}
}
class BrowserExtensionContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
// @ts-expect-error
return typeof chrome !== "undefined";
}
/**
* @override
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
log("Loading save data from browser extension storage");
return new Promise((resolve) => {
// @ts-expect-error
chrome.storage.sync.get([SAVE_KEY], (result) => {
resolve(result[SAVE_KEY] ?? {});
});
});
}
/**
* @override
* @param {BirbSaveData} saveData
*/
async putSaveData(saveData) {
log("Saving data to browser extension storage");
// @ts-expect-error
chrome.storage.sync.set({ [SAVE_KEY]: saveData }, function () {
// @ts-expect-error
if (chrome.runtime.lastError) {
// @ts-expect-error
console.error(chrome.runtime.lastError);
} else {
console.log("Settings saved successfully");
}
});
}
/** @override */
resetSaveData() {
log("Resetting save data in browser extension storage");
// @ts-expect-error
chrome.storage.sync.clear();
}
}
class ObsidianContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
// @ts-expect-error
return typeof app !== "undefined" && typeof app.vault !== "undefined";
}
/**
* @override
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
return new Promise((resolve) => {
// @ts-expect-error
OBSIDIAN_PLUGIN.loadData().then((data) => {
resolve(data ?? {});
});
});
}
/**
* @override
* @param {BirbSaveData|{}} saveData
*/
async putSaveData(saveData) {
// @ts-expect-error
await OBSIDIAN_PLUGIN.saveData(saveData);
}
/** @override */
resetSaveData() {
this.putSaveData({});
}
/** @override */
getFocusableElements() {
const elements = [
".workspace-leaf",
".cm-callout",
".HyperMD-codeblock-begin",
".status-bar",
".mobile-navbar"
];
return super.getFocusableElements().concat(elements);
}
/** @override */
getPath() {
// @ts-expect-error
const file = app.workspace.getActiveFile();
if (file && this.getActiveEditorElement()) {
return file.path;
} else {
return ROOT_PATH;
}
}
/** @override */
getActivePage() {
if (this.getPath() === ROOT_PATH) {
// Root page, use document element
return document.documentElement
}
return this.getActiveEditorElement() ?? document.documentElement;
}
/** @override */
isPathApplicable(path) {
return path === this.getPath();
}
/** @override */
areStickyNotesEnabled() {
return this.getPath() !== ROOT_PATH;
}
/** @returns {HTMLElement|null} */
getActiveEditorElement() {
// @ts-expect-error
const activeLeaf = app.workspace.activeLeaf;
const leafElement = activeLeaf?.view?.containerEl;
return leafElement?.querySelector(".cm-scroller") ?? null;
}
}
const CONTEXTS = [
new UserScriptContext(),
new ObsidianContext(),
new BrowserExtensionContext(),
new LocalContext()
];
function getContext() {
for (const context of CONTEXTS) {
if (context.isContextActive()) {
return context;
}
}
error("No applicable context found, defaulting to LocalContext");
return new LocalContext();
}
/** /**
* Parse URL parameters into a key-value map * Parse URL parameters into a key-value map
* @param {string} url * @param {string} url
@@ -1355,7 +1194,7 @@
class MenuItem { class MenuItem {
/** /**
* @param {string} text * @param {string|(() => string)} text
* @param {() => void} action * @param {() => void} action
* @param {boolean} [removeMenu] * @param {boolean} [removeMenu]
*/ */
@@ -1404,7 +1243,7 @@
if (item instanceof Separator) { if (item instanceof Separator) {
return makeElement("birb-window-separator"); return makeElement("birb-window-separator");
} }
let menuItem = makeElement("birb-menu-item", item.text); let menuItem = makeElement("birb-menu-item", typeof item.text === "function" ? item.text() : item.text);
onClick(menuItem, () => { onClick(menuItem, () => {
if (item.removeMenu) { if (item.removeMenu) {
removeMenuCallback(); removeMenuCallback();
@@ -1511,7 +1350,8 @@
* @typedef {typeof DEFAULT_SETTINGS} Settings * @typedef {typeof DEFAULT_SETTINGS} Settings
*/ */
const DEFAULT_SETTINGS = { const DEFAULT_SETTINGS = {
birbMode: false birbMode: false,
soundEnabled: true
}; };
// Rendering constants // Rendering constants
@@ -1724,6 +1564,7 @@
.birb-menu-item { .birb-menu-item {
width: calc(100% - var(--birb-double-border-size)); width: calc(100% - var(--birb-double-border-size));
white-space: nowrap;
font-size: 14px; font-size: 14px;
padding-top: 4px; padding-top: 4px;
padding-bottom: 4px; padding-bottom: 4px;
@@ -1883,6 +1724,13 @@
border: none !important; border: none !important;
} }
.birb-sticky-note-input::placeholder {
font-family: "Monocraft", monospace !important;
font-size: 14px !important;
background-color: transparent !important;
color: rgba(0, 0, 0, 0.35) !important;
}
.birb-sticky-note-input:focus { .birb-sticky-note-input:focus {
outline: none !important; outline: none !important;
box-shadow: none !important; box-shadow: none !important;
@@ -1924,68 +1772,24 @@
/** @type {Partial<Settings>} */ /** @type {Partial<Settings>} */
let userSettings = {}; let userSettings = {};
/**
* Load the sprite sheet and return the pixel-map template /**
* @param {string} dataUri * @param {Context} context
* @param {boolean} [templateColors]
* @returns {Promise<string[][]>}
*/ */
function loadSpriteSheetPixels(dataUri, templateColors = true) { async function initializeApplication(context) {
return new Promise((resolve, reject) => { log("birbOS booting up...");
const img = new Image(); setContext(context);
img.src = dataUri; log("Loading sprite sheets...");
img.onload = () => { const birbPixels = await loadSpriteSheetPixels(SPRITE_SHEET);
const canvas = document.createElement('canvas'); const featherPixels = await loadSpriteSheetPixels(FEATHER_SPRITE_SHEET);
canvas.width = img.width; startApplication(birbPixels, featherPixels);
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Failed to get canvas context'));
return;
}
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, img.width, img.height);
const pixels = imageData.data;
const hexArray = [];
for (let y = 0; y < img.height; y++) {
const row = [];
for (let x = 0; x < img.width; x++) {
const index = (y * img.width + x) * 4;
const r = pixels[index];
const g = pixels[index + 1];
const b = pixels[index + 2];
const a = pixels[index + 3];
if (a === 0) {
row.push(Sprite.TRANSPARENT);
continue;
}
const hex = `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
if (!templateColors) {
row.push(hex);
continue;
}
if (SPRITE_SHEET_COLOR_MAP[hex] === undefined) {
error(`Unknown color: ${hex}`);
row.push(Sprite.TRANSPARENT);
}
row.push(SPRITE_SHEET_COLOR_MAP[hex]);
}
hexArray.push(row);
}
resolve(hexArray);
};
img.onerror = (err) => {
reject(err);
};
});
} }
log("Loading sprite sheets..."); /**
* @param {string[][]} birbPixels
Promise.all([ * @param {string[][]} featherPixels
loadSpriteSheetPixels(SPRITE_SHEET), */
loadSpriteSheetPixels(FEATHER_SPRITE_SHEET) function startApplication(birbPixels, featherPixels) {
]).then(([birbPixels, featherPixels]) => {
const SPRITE_SHEET = birbPixels; const SPRITE_SHEET = birbPixels;
const FEATHER_SPRITE_SHEET = featherPixels; const FEATHER_SPRITE_SHEET = featherPixels;
@@ -2033,12 +1837,16 @@
const settingsItems = [ const settingsItems = [
new MenuItem("Go Back", () => switchMenuItems(menuItems, updateMenuLocation), false), new MenuItem("Go Back", () => switchMenuItems(menuItems, updateMenuLocation), false),
new Separator(), new Separator(),
new MenuItem("Toggle Birb Mode", () => { new MenuItem(() => `${settings().soundEnabled ? "Disable" : "Enable"} Sound`, () => {
userSettings.birbMode = !userSettings.birbMode; userSettings.soundEnabled = !settings().soundEnabled;
save();
}),
new MenuItem(() => `Toggle ${birdBirb(true)} Mode`, () => {
userSettings.birbMode = !settings().birbMode;
save(); save();
const message = makeElement("birb-message-content"); const message = makeElement("birb-message-content");
message.appendChild(document.createTextNode(`Your ${birdBirb().toLowerCase()} shall now be referred to as "${birdBirb()}"`)); message.appendChild(document.createTextNode(`Your ${birdBirb().toLowerCase()} shall now be referred to as "${birdBirb()}"`));
if (userSettings.birbMode) { if (settings().birbMode) {
message.appendChild(document.createElement("br")); message.appendChild(document.createElement("br"));
message.appendChild(document.createElement("br")); message.appendChild(document.createElement("br"));
message.appendChild(document.createTextNode("Welcome back to 2012")); message.appendChild(document.createTextNode("Welcome back to 2012"));
@@ -2046,7 +1854,7 @@
insertModal(`${birdBirb()} Mode`, message); insertModal(`${birdBirb()} Mode`, message);
}), }),
new Separator(), new Separator(),
new MenuItem("2025.11.14.205", () => { alert("Thank you for using Pocket Bird! You are on version: 2025.11.14.205"); }, false), new MenuItem("2026.1.4", () => { alert("Thank you for using Pocket Bird! You are on version: 2026.1.4"); }, false),
]; ];
const styleElement = document.createElement("style"); const styleElement = document.createElement("style");
@@ -2060,6 +1868,8 @@
FLYING: "flying", FLYING: "flying",
}; };
const birdsong = new Birdsong();
let frozen = false; let frozen = false;
let stateStart = Date.now(); let stateStart = Date.now();
let currentState = States.IDLE; let currentState = States.IDLE;
@@ -2150,8 +1960,8 @@
/** /**
* Bird or birb, you decide * Bird or birb, you decide
*/ */
function birdBirb() { function birdBirb(invert = false) {
return settings().birbMode ? "Birb" : "Bird"; return settings().birbMode !== invert ? "Birb" : "Bird";
} }
function init() { function init() {
@@ -2662,16 +2472,11 @@
return true; return true;
}); });
/** @type {HTMLElement[]} */ /** @type {HTMLElement[]} */
// @ts-expect-error
const largeElements = Array.from(visible).filter((img) => img instanceof HTMLElement && img !== focusedElement && img.offsetWidth >= MIN_FOCUS_ELEMENT_WIDTH); const largeElements = Array.from(visible).filter((img) => img instanceof HTMLElement && img !== focusedElement && img.offsetWidth >= MIN_FOCUS_ELEMENT_WIDTH);
// Ensure the bird doesn't land on fixed or sticky elements
const fixedAllowed = getContext() instanceof ObsidianContext;
const nonFixedElements = largeElements.filter((el) => { const nonFixedElements = largeElements.filter((el) => {
if (fixedAllowed) { {
return true; return true;
} }
const style = window.getComputedStyle(el);
return style.position !== "fixed" && style.position !== "sticky";
}); });
if (nonFixedElements.length === 0) { if (nonFixedElements.length === 0) {
return false; return false;
@@ -2745,6 +2550,9 @@
function pet() { function pet() {
if (currentState === States.IDLE && birb.getCurrentAnimation() !== Animations.HEART) { if (currentState === States.IDLE && birb.getCurrentAnimation() !== Animations.HEART) {
if (settings().soundEnabled) {
birdsong.chirp();
}
birb.setAnimation(Animations.HEART); birb.setAnimation(Animations.HEART);
lastPetTimestamp = Date.now(); lastPetTimestamp = Date.now();
} }
@@ -2811,8 +2619,64 @@
// Run the birb // Run the birb
init(); init();
draw(); draw();
}).catch((e) => { }
error("Error while loading sprite sheets: ", e);
}); /**
* Load the sprite sheet and return the pixel-map template
* @param {string} dataUri
* @param {boolean} [templateColors]
* @returns {Promise<string[][]>}
*/
function loadSpriteSheetPixels(dataUri, templateColors = true) {
return new Promise((resolve, reject) => {
const img = new Image();
img.src = dataUri;
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Failed to get canvas context'));
return;
}
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, img.width, img.height);
const pixels = imageData.data;
const hexArray = [];
for (let y = 0; y < img.height; y++) {
const row = [];
for (let x = 0; x < img.width; x++) {
const index = (y * img.width + x) * 4;
const r = pixels[index];
const g = pixels[index + 1];
const b = pixels[index + 2];
const a = pixels[index + 3];
if (a === 0) {
row.push(Sprite.TRANSPARENT);
continue;
}
const hex = `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
if (!templateColors) {
row.push(hex);
continue;
}
if (SPRITE_SHEET_COLOR_MAP[hex] === undefined) {
error(`Unknown color: ${hex}`);
row.push(Sprite.TRANSPARENT);
}
row.push(SPRITE_SHEET_COLOR_MAP[hex]);
}
hexArray.push(row);
}
resolve(hexArray);
};
img.onerror = (err) => {
reject(err);
};
});
}
initializeApplication(new LocalContext());
})(); })();

View File

@@ -1,10 +0,0 @@
{
"id": "pocket-bird",
"name": "Pocket Bird",
"version": "__VERSION__",
"minAppVersion": "0.15.0",
"description": "It's a pet bird in your Obsidian, what more could you want?",
"author": "Idrees Hassan",
"authorUrl": "https://idreesinc.com",
"isDesktopOnly": false
}

View File

@@ -26,6 +26,6 @@
</div> </div>
<div id="spacer"></div> <div id="spacer"></div>
<!-- <script type="module" src="spritesheet-compiler.js"></script> --> <!-- <script type="module" src="spritesheet-compiler.js"></script> -->
<script src="../dist/birb.js"></script> <script src="../dist/web/birb.js"></script>
</body> </body>
</html> </html>

View File

@@ -2,9 +2,12 @@ import Frame from './frame.js';
import Layer from './layer.js'; import Layer from './layer.js';
import Anim from './anim.js'; import Anim from './anim.js';
import { Birb, Animations } from './birb.js'; import { Birb, Animations } from './birb.js';
import { getContext, ObsidianContext } from './context.js'; import { Birdsong } from './sound.js';
import { Context, ObsidianContext } from './context.js';
import { import {
getContext,
setContext,
Directions, Directions,
isDebug, isDebug,
setDebug, setDebug,
@@ -58,7 +61,8 @@ import {
* @typedef {typeof DEFAULT_SETTINGS} Settings * @typedef {typeof DEFAULT_SETTINGS} Settings
*/ */
const DEFAULT_SETTINGS = { const DEFAULT_SETTINGS = {
birbMode: false birbMode: false,
soundEnabled: true
}; };
// Rendering constants // Rendering constants
@@ -109,68 +113,24 @@ const MIN_FOCUS_ELEMENT_WIDTH = 100;
/** @type {Partial<Settings>} */ /** @type {Partial<Settings>} */
let userSettings = {}; let userSettings = {};
/**
* Load the sprite sheet and return the pixel-map template /**
* @param {string} dataUri * @param {Context} context
* @param {boolean} [templateColors]
* @returns {Promise<string[][]>}
*/ */
function loadSpriteSheetPixels(dataUri, templateColors = true) { export async function initializeApplication(context) {
return new Promise((resolve, reject) => { log("birbOS booting up...");
const img = new Image(); setContext(context);
img.src = dataUri; log("Loading sprite sheets...");
img.onload = () => { const birbPixels = await loadSpriteSheetPixels(SPRITE_SHEET);
const canvas = document.createElement('canvas'); const featherPixels = await loadSpriteSheetPixels(FEATHER_SPRITE_SHEET);
canvas.width = img.width; startApplication(birbPixels, featherPixels);
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Failed to get canvas context'));
return;
}
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, img.width, img.height);
const pixels = imageData.data;
const hexArray = [];
for (let y = 0; y < img.height; y++) {
const row = [];
for (let x = 0; x < img.width; x++) {
const index = (y * img.width + x) * 4;
const r = pixels[index];
const g = pixels[index + 1];
const b = pixels[index + 2];
const a = pixels[index + 3];
if (a === 0) {
row.push(Sprite.TRANSPARENT);
continue;
}
const hex = `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
if (!templateColors) {
row.push(hex);
continue;
}
if (SPRITE_SHEET_COLOR_MAP[hex] === undefined) {
error(`Unknown color: ${hex}`);
row.push(Sprite.TRANSPARENT);
}
row.push(SPRITE_SHEET_COLOR_MAP[hex]);
}
hexArray.push(row);
}
resolve(hexArray);
};
img.onerror = (err) => {
reject(err);
};
});
} }
log("Loading sprite sheets..."); /**
* @param {string[][]} birbPixels
Promise.all([ * @param {string[][]} featherPixels
loadSpriteSheetPixels(SPRITE_SHEET), */
loadSpriteSheetPixels(FEATHER_SPRITE_SHEET) function startApplication(birbPixels, featherPixels) {
]).then(([birbPixels, featherPixels]) => {
const SPRITE_SHEET = birbPixels; const SPRITE_SHEET = birbPixels;
const FEATHER_SPRITE_SHEET = featherPixels; const FEATHER_SPRITE_SHEET = featherPixels;
@@ -218,12 +178,16 @@ Promise.all([
const settingsItems = [ const settingsItems = [
new MenuItem("Go Back", () => switchMenuItems(menuItems, updateMenuLocation), false), new MenuItem("Go Back", () => switchMenuItems(menuItems, updateMenuLocation), false),
new Separator(), new Separator(),
new MenuItem("Toggle Birb Mode", () => { new MenuItem(() => `${settings().soundEnabled ? "Disable" : "Enable"} Sound`, () => {
userSettings.birbMode = !userSettings.birbMode; userSettings.soundEnabled = !settings().soundEnabled;
save();
}),
new MenuItem(() => `Toggle ${birdBirb(true)} Mode`, () => {
userSettings.birbMode = !settings().birbMode;
save(); save();
const message = makeElement("birb-message-content"); const message = makeElement("birb-message-content");
message.appendChild(document.createTextNode(`Your ${birdBirb().toLowerCase()} shall now be referred to as "${birdBirb()}"`)); message.appendChild(document.createTextNode(`Your ${birdBirb().toLowerCase()} shall now be referred to as "${birdBirb()}"`));
if (userSettings.birbMode) { if (settings().birbMode) {
message.appendChild(document.createElement("br")); message.appendChild(document.createElement("br"));
message.appendChild(document.createElement("br")); message.appendChild(document.createElement("br"));
message.appendChild(document.createTextNode("Welcome back to 2012")); message.appendChild(document.createTextNode("Welcome back to 2012"));
@@ -245,6 +209,8 @@ Promise.all([
FLYING: "flying", FLYING: "flying",
}; };
const birdsong = new Birdsong();
let frozen = false; let frozen = false;
let stateStart = Date.now(); let stateStart = Date.now();
let currentState = States.IDLE; let currentState = States.IDLE;
@@ -335,8 +301,8 @@ Promise.all([
/** /**
* Bird or birb, you decide * Bird or birb, you decide
*/ */
function birdBirb() { function birdBirb(invert = false) {
return settings().birbMode ? "Birb" : "Bird"; return settings().birbMode !== invert ? "Birb" : "Bird";
} }
function init() { function init() {
@@ -851,10 +817,11 @@ Promise.all([
return true; return true;
}); });
/** @type {HTMLElement[]} */ /** @type {HTMLElement[]} */
// @ts-expect-error
const largeElements = Array.from(visible).filter((img) => img instanceof HTMLElement && img !== focusedElement && img.offsetWidth >= MIN_FOCUS_ELEMENT_WIDTH); const largeElements = Array.from(visible).filter((img) => img instanceof HTMLElement && img !== focusedElement && img.offsetWidth >= MIN_FOCUS_ELEMENT_WIDTH);
// Ensure the bird doesn't land on fixed or sticky elements // Ensure the bird doesn't land on fixed or sticky elements
const fixedAllowed = getContext() instanceof ObsidianContext; // const fixedAllowed = getContext() instanceof ObsidianContext;
// TODO: FIX
const fixedAllowed = true;
const nonFixedElements = largeElements.filter((el) => { const nonFixedElements = largeElements.filter((el) => {
if (fixedAllowed) { if (fixedAllowed) {
return true; return true;
@@ -938,6 +905,9 @@ Promise.all([
function pet() { function pet() {
if (currentState === States.IDLE && birb.getCurrentAnimation() !== Animations.HEART) { if (currentState === States.IDLE && birb.getCurrentAnimation() !== Animations.HEART) {
if (settings().soundEnabled) {
birdsong.chirp();
}
birb.setAnimation(Animations.HEART); birb.setAnimation(Animations.HEART);
lastPetTimestamp = Date.now(); lastPetTimestamp = Date.now();
} }
@@ -1008,6 +978,60 @@ Promise.all([
// Run the birb // Run the birb
init(); init();
draw(); draw();
}).catch((e) => { }
error("Error while loading sprite sheets: ", e);
}); /**
* Load the sprite sheet and return the pixel-map template
* @param {string} dataUri
* @param {boolean} [templateColors]
* @returns {Promise<string[][]>}
*/
function loadSpriteSheetPixels(dataUri, templateColors = true) {
return new Promise((resolve, reject) => {
const img = new Image();
img.src = dataUri;
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Failed to get canvas context'));
return;
}
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, img.width, img.height);
const pixels = imageData.data;
const hexArray = [];
for (let y = 0; y < img.height; y++) {
const row = [];
for (let x = 0; x < img.width; x++) {
const index = (y * img.width + x) * 4;
const r = pixels[index];
const g = pixels[index + 1];
const b = pixels[index + 2];
const a = pixels[index + 3];
if (a === 0) {
row.push(Sprite.TRANSPARENT);
continue;
}
const hex = `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
if (!templateColors) {
row.push(hex);
continue;
}
if (SPRITE_SHEET_COLOR_MAP[hex] === undefined) {
error(`Unknown color: ${hex}`);
row.push(Sprite.TRANSPARENT);
}
row.push(SPRITE_SHEET_COLOR_MAP[hex]);
}
hexArray.push(row);
}
resolve(hexArray);
};
img.onerror = (err) => {
reject(err);
};
});
}

View File

@@ -1,7 +1,8 @@
import { debug, log, error } from "./shared.js"; import { debug, log, error } from "./shared.js";
const SAVE_KEY = "birbSaveData"; export const SAVE_KEY = "birbSaveData";
const ROOT_PATH = ""; const ROOT_PATH = "";
const SET_CONTEXT = "__CONTEXT__"
/** /**
* @typedef {import('./application.js').BirbSaveData} BirbSaveData * @typedef {import('./application.js').BirbSaveData} BirbSaveData
@@ -12,14 +13,6 @@ const ROOT_PATH = "";
*/ */
export class Context { export class Context {
/**
* @abstract
* @returns {boolean} Whether this context is applicable
*/
isContextActive() {
throw new Error("Method not implemented");
}
/** /**
* @abstract * @abstract
* @returns {Promise<BirbSaveData|{}>} * @returns {Promise<BirbSaveData|{}>}
@@ -103,16 +96,6 @@ export class Context {
export class LocalContext extends Context { export class LocalContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
return window.location.hostname === "127.0.0.1"
|| window.location.hostname === "localhost"
|| window.location.hostname.startsWith("192.168.");
}
/** /**
* @override * @override
* @returns {Promise<BirbSaveData|{}>} * @returns {Promise<BirbSaveData|{}>}
@@ -140,15 +123,6 @@ export class LocalContext extends Context {
export class UserScriptContext extends Context { export class UserScriptContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
// @ts-expect-error
return typeof GM_getValue === "function";
}
/** /**
* @override * @override
* @returns {Promise<BirbSaveData|{}>} * @returns {Promise<BirbSaveData|{}>}
@@ -180,16 +154,7 @@ export class UserScriptContext extends Context {
} }
} }
class BrowserExtensionContext extends Context { export class BrowserExtensionContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
// @ts-expect-error
return typeof chrome !== "undefined";
}
/** /**
* @override * @override
@@ -216,9 +181,9 @@ class BrowserExtensionContext extends Context {
// @ts-expect-error // @ts-expect-error
if (chrome.runtime.lastError) { if (chrome.runtime.lastError) {
// @ts-expect-error // @ts-expect-error
console.error(chrome.runtime.lastError); error(chrome.runtime.lastError);
} else { } else {
console.log("Settings saved successfully"); log("Settings saved successfully");
} }
}); });
} }
@@ -232,14 +197,6 @@ class BrowserExtensionContext extends Context {
} }
export class ObsidianContext extends Context { export class ObsidianContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
// @ts-expect-error
return typeof app !== "undefined" && typeof app.vault !== "undefined";
}
/** /**
* @override * @override
@@ -319,23 +276,6 @@ export class ObsidianContext extends Context {
} }
} }
const CONTEXTS = [
new UserScriptContext(),
new ObsidianContext(),
new BrowserExtensionContext(),
new LocalContext()
];
export function getContext() {
for (const context of CONTEXTS) {
if (context.isContextActive()) {
return context;
}
}
error("No applicable context found, defaulting to LocalContext");
return new LocalContext();
}
/** /**
* Parse URL parameters into a key-value map * Parse URL parameters into a key-value map
* @param {string} url * @param {string} url

View File

@@ -12,7 +12,7 @@ export const MENU_EXIT_ID = "birb-menu-exit";
export class MenuItem { export class MenuItem {
/** /**
* @param {string} text * @param {string|(() => string)} text
* @param {() => void} action * @param {() => void} action
* @param {boolean} [removeMenu] * @param {boolean} [removeMenu]
*/ */
@@ -61,7 +61,7 @@ function makeMenuItem(item, removeMenuCallback) {
if (item instanceof Separator) { if (item instanceof Separator) {
return makeElement("birb-window-separator"); return makeElement("birb-window-separator");
} }
let menuItem = makeElement("birb-menu-item", item.text); let menuItem = makeElement("birb-menu-item", typeof item.text === "function" ? item.text() : item.text);
onClick(menuItem, () => { onClick(menuItem, () => {
if (item.removeMenu) { if (item.removeMenu) {
removeMenuCallback(); removeMenuCallback();

View File

@@ -0,0 +1,4 @@
import { initializeApplication } from "../../application.js";
import { BrowserExtensionContext } from "../../context.js";
initializeApplication(new BrowserExtensionContext());

View File

@@ -1,7 +1,7 @@
{ {
"id": "pocket-bird", "id": "pocket-bird",
"name": "Pocket Bird", "name": "Pocket Bird",
"version": "2025.11.14.205", "version": "__VERSION__",
"minAppVersion": "0.15.0", "minAppVersion": "0.15.0",
"description": "Add a pet bird to fly around your notes and keep you company!", "description": "Add a pet bird to fly around your notes and keep you company!",
"author": "Idrees Hassan", "author": "Idrees Hassan",

View File

@@ -0,0 +1,4 @@
import { initializeApplication } from "../../application.js";
import { ObsidianContext } from "../../context.js";
initializeApplication(new ObsidianContext());

View File

@@ -0,0 +1,4 @@
import { initializeApplication } from "../../application.js";
import { UserScriptContext } from "../../context.js";
initializeApplication(new UserScriptContext());

4
src/platforms/web/web.js Normal file
View File

@@ -0,0 +1,4 @@
import { initializeApplication } from "../../application.js";
import { LocalContext } from "../../context.js";
initializeApplication(new LocalContext());

View File

@@ -4,6 +4,7 @@ export const Directions = {
}; };
let debugMode = location.hostname === "127.0.0.1"; let debugMode = location.hostname === "127.0.0.1";
let context = null;
/** /**
* @returns {boolean} Whether debug mode is enabled * @returns {boolean} Whether debug mode is enabled
@@ -19,6 +20,17 @@ export function setDebug(value) {
debugMode = value; debugMode = value;
} }
export function getContext() {
if (!context) {
throw new Error("Context requested before being set");
}
return context;
}
export function setContext(newContext) {
context = newContext;
}
/** /**
* Create an HTML element with the specified parameters * Create an HTML element with the specified parameters
* @param {string} className * @param {string} className

43
src/sound.js Normal file
View File

@@ -0,0 +1,43 @@
// @ts-check
export class Birdsong {
/**
* @type {AudioContext}
*/
audioContext;
chirp() {
if (!this.audioContext) {
this.audioContext = new AudioContext();
}
const TIMES = [0, 0.06, 0.10, 0.15];
const FREQUENCIES = [2200,
3500 + Math.random() * 600,
2100 + Math.random() * 200,
1600 + Math.random() * 400];
const VOLUMES = [0.0001, 0.3, 0.3, 0.0001];
const oscillator = this.audioContext.createOscillator();
oscillator.type = "sine";
const gain = this.audioContext.createGain();
oscillator.connect(gain);
gain.connect(this.audioContext.destination);
const now = this.audioContext.currentTime;
for (let i = 0; i < TIMES.length; i++) {
const time = TIMES[i] + now;
if (i === 0) {
oscillator.frequency.setValueAtTime(FREQUENCIES[i], time);
gain.gain.setValueAtTime(VOLUMES[i], time);
} else {
oscillator.frequency.exponentialRampToValueAtTime(FREQUENCIES[i], time);
gain.gain.exponentialRampToValueAtTime(VOLUMES[i], time);
}
}
oscillator.start(now);
oscillator.stop(now + TIMES[TIMES.length - 1]);
}
}

View File

@@ -1,9 +1,9 @@
import { import {
getContext,
makeElement, makeElement,
makeDraggable, makeDraggable,
makeClosable makeClosable
} from './shared.js'; } from './shared.js';
import { getContext } from './context.js';
/** /**
* @typedef {Object} SavedStickyNote * @typedef {Object} SavedStickyNote

View File

@@ -198,6 +198,7 @@
.birb-menu-item { .birb-menu-item {
width: calc(100% - var(--birb-double-border-size)); width: calc(100% - var(--birb-double-border-size));
white-space: nowrap;
font-size: 14px; font-size: 14px;
padding-top: 4px; padding-top: 4px;
padding-bottom: 4px; padding-bottom: 4px;
@@ -357,6 +358,13 @@
border: none !important; border: none !important;
} }
.birb-sticky-note-input::placeholder {
font-family: "Monocraft", monospace !important;
font-size: 14px !important;
background-color: transparent !important;
color: rgba(0, 0, 0, 0.35) !important;
}
.birb-sticky-note-input:focus { .birb-sticky-note-input:focus {
outline: none !important; outline: none !important;
box-shadow: none !important; box-shadow: none !important;