Repository: playwora/wora Branch: main Commit: 4b8621ac3b44 Files: 97 Total size: 452.8 KB Directory structure: gitextract_a5817xvy/ ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── report_issue.yml │ │ └── request_feature.yml │ └── workflows/ │ └── release.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── bun.lockb ├── components.json ├── electron-builder.yml ├── main/ │ ├── background.ts │ ├── helpers/ │ │ ├── create-window.ts │ │ ├── db/ │ │ │ ├── connectDB.ts │ │ │ ├── createDB.ts │ │ │ └── schema.ts │ │ ├── index.ts │ │ └── lastfm-service.ts │ └── preload.ts ├── package.json ├── renderer/ │ ├── components/ │ │ ├── ErrorBoundary.tsx │ │ ├── LoadingSkeletons.tsx │ │ ├── PageTransition.tsx │ │ ├── PageTransitionMinimal.tsx │ │ ├── main/ │ │ │ ├── lyrics.tsx │ │ │ ├── navbar.tsx │ │ │ └── player.tsx │ │ ├── themeProvider.tsx │ │ └── ui/ │ │ ├── actions.tsx │ │ ├── album.tsx │ │ ├── avatar.tsx │ │ ├── badge.tsx │ │ ├── button.tsx │ │ ├── carousel.tsx │ │ ├── command.tsx │ │ ├── context-menu.tsx │ │ ├── dialog.tsx │ │ ├── form.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── scroll-area.tsx │ │ ├── select.tsx │ │ ├── skeleton.tsx │ │ ├── slider.tsx │ │ ├── songs.tsx │ │ ├── sonner.tsx │ │ ├── spinner.tsx │ │ ├── switch.tsx │ │ ├── tabs.tsx │ │ └── tooltip.tsx │ ├── context/ │ │ └── playerContext.tsx │ ├── hooks/ │ │ ├── useDebounce.ts │ │ └── useScrollAreaRestoration.ts │ ├── lib/ │ │ ├── albumCache.ts │ │ ├── apiConfig.ts │ │ ├── helpers.ts │ │ ├── lastfm-client.ts │ │ ├── lastfm.ts │ │ ├── songCache.ts │ │ └── utils.ts │ ├── next-env.d.ts │ ├── next.config.js │ ├── pages/ │ │ ├── _app.tsx │ │ ├── albums/ │ │ │ └── [slug].tsx │ │ ├── albums.tsx │ │ ├── artists/ │ │ │ ├── [name].tsx │ │ │ └── index.tsx │ │ ├── home.tsx │ │ ├── playlists/ │ │ │ └── [slug].tsx │ │ ├── playlists.tsx │ │ ├── settings.tsx │ │ ├── setup.tsx │ │ └── songs.tsx │ ├── postcss.config.js │ ├── preload.d.ts │ ├── styles/ │ │ └── globals.css │ └── tsconfig.json ├── resources/ │ └── icon.icns ├── tsconfig.json └── vercel/ ├── next-env.d.ts ├── next.config.js ├── package.json ├── pages/ │ ├── _app.tsx │ ├── api/ │ │ ├── config.ts │ │ ├── index.ts │ │ ├── lastfm/ │ │ │ ├── auth.ts │ │ │ ├── now-playing.ts │ │ │ ├── scrobble.ts │ │ │ ├── track-info.ts │ │ │ └── user-info.ts │ │ └── utils/ │ │ └── lastfm.ts │ └── index.tsx ├── tsconfig.json └── vercel.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: hiaaryan patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username ko_fi: # Replace with a single Ko-fi username tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry polar: # Replace with a single Polar username buy_me_a_coffee: # Replace with a single Buy Me a Coffee username custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] ================================================ FILE: .github/ISSUE_TEMPLATE/report_issue.yml ================================================ name: Bug Report 👾 description: File a bug report. title: "[Bug]: " labels: ["bug"] body: - type: markdown attributes: value: | Thanks for taking the time to fill out this bug report! - type: textarea id: what-happened attributes: label: What Happened? description: Also tell us, what did you expect to happen? placeholder: Ex. I expected the page to load, but instead I got a 404 error. validations: required: true - type: input id: version attributes: label: Wora Version description: Which version of Wora did this bug happen on? placeholder: Ex. 0.3.2 validations: required: true - type: input id: os attributes: label: Operating System description: What operating system are you using? placeholder: Ex. Windows 10, macOS 11.2 validations: required: true - type: textarea id: steps-to-reproduce attributes: label: Steps to Reproduce description: Please provide step-by-step instructions to reproduce the issue. placeholder: Ex. 1. Go to '...' 2. Click on '...' 3. Scroll down to '...' validations: required: true - type: textarea id: environment-details attributes: label: Environment Details description: Provide any additional details about your environment that might be relevant (e.g., hardware, network conditions). placeholder: Ex. Running on a high-latency network, using an external sound card. validations: required: false - type: dropdown id: severity attributes: label: Severity description: How severe is this issue? (e.g., Minor, Major, Critical) options: - Minor - Major - Critical default: 0 validations: required: true - type: textarea id: logs attributes: label: Screenshots/Logs description: Attach any screenshots or logs that might help in diagnosing the problem. placeholder: Ex. Drag and drop your screenshots or logs here. validations: required: false - type: input id: contact attributes: label: Discord Username description: How can we get in touch with you if we need more info? placeholder: Ex. charlie3x, bluespin2e validations: required: false - type: checkboxes id: terms attributes: label: Code of Conduct description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/playwora/wora/blob/main/CODE_OF_CONDUCT.md). options: - label: I agree to follow this project's Code of Conduct required: true ================================================ FILE: .github/ISSUE_TEMPLATE/request_feature.yml ================================================ name: Feature Request 🌟 description: Suggest a new feature or enhancement. title: "[Feature]: " labels: ["enhancement"] body: - type: markdown attributes: value: | Thanks for taking the time to suggest a feature! - type: textarea id: feature-description attributes: label: Feature Description description: Describe the feature you would like to see. placeholder: Ex. I would like to have a dark mode option in the settings. validations: required: true - type: textarea id: problem-solution attributes: label: Problem and Solution description: Describe the problem this feature will solve and how you envision the solution. placeholder: Ex. The app is too bright at night, a dark mode would make it easier on the eyes. validations: required: true - type: textarea id: additional-context attributes: label: Additional Context description: Provide any other context or screenshots about the feature request. placeholder: Ex. Similar to how dark mode works in other apps. validations: required: false - type: textarea id: potential-issues attributes: label: Potential Issues description: Are there any potential issues or challenges with this feature? placeholder: Ex. It might be challenging to ensure all UI elements are visible in dark mode. validations: required: false - type: input id: version attributes: label: Wora Version description: Which version of Wora are you using? placeholder: Ex. 0.3.2 validations: required: true - type: input id: contact attributes: label: Discord Username description: How can we get in touch with you if we need more info? placeholder: Ex. charlie3x, bluespin2e validations: required: false - type: checkboxes id: terms attributes: label: Code of Conduct description: By submitting this request, you agree to follow our [Code of Conduct](https://github.com/playwora/wora/blob/main/CODE_OF_CONDUCT.md). options: - label: I agree to follow this project's Code of Conduct required: true ================================================ FILE: .github/workflows/release.yml ================================================ name: Release and Build on: push: branches: - main jobs: check-version-change: runs-on: ubuntu-latest outputs: version_changed: ${{ steps.check.outputs.version_changed }} new_version: ${{ steps.check.outputs.new_version }} steps: - uses: actions/checkout@v4 with: fetch-depth: 2 - name: Check Version Change id: check run: | git diff HEAD^ HEAD --name-only | grep -q '^package.json$' || exit 0 old_version=$(git show HEAD^:package.json | jq -r '.version') new_version=$(jq -r '.version' package.json) if [ "$old_version" != "$new_version" ] && [ $(git diff HEAD^ HEAD --name-only | wc -l) -eq 1 ]; then echo "version_changed=true" >> $GITHUB_OUTPUT echo "new_version=$new_version" >> $GITHUB_OUTPUT else echo "version_changed=false" >> $GITHUB_OUTPUT fi build: needs: check-version-change if: needs.check-version-change.outputs.version_changed == 'true' strategy: matrix: os: [macos-latest, ubuntu-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: "20" - uses: oven-sh/setup-bun@v2 with: bun-version: latest - name: Install Dependencies run: bun install - name: Build for ${{ matrix.os }} run: | if [ "${{ matrix.os }}" == "macos-latest" ]; then bun run build:mac elif [ "${{ matrix.os }}" == "ubuntu-latest" ]; then bun run build:linux elif [ "${{ matrix.os }}" == "windows-latest" ]; then bun run build:win64 fi shell: bash - name: Get Asset Details id: get_asset run: | if [ "${{ matrix.os }}" == "macos-latest" ]; then echo "asset_path=./dist/*.dmg" >> $GITHUB_OUTPUT elif [ "${{ matrix.os }}" == "ubuntu-latest" ]; then echo "asset_path=./dist/*.AppImage" >> $GITHUB_OUTPUT elif [ "${{ matrix.os }}" == "windows-latest" ]; then echo "asset_path=./dist/*.exe" >> $GITHUB_OUTPUT fi shell: bash - name: Release uses: softprops/action-gh-release@v2 if: success() with: tag_name: v${{ needs.check-version-change.outputs.new_version }} name: v${{ needs.check-version-change.outputs.new_version }} draft: false prerelease: false files: ${{ steps.get_asset.outputs.asset_path }} env: GITHUB_TOKEN: ${{ secrets.TOKEN }} ================================================ FILE: .gitignore ================================================ node_modules *.log .next app dist .DS_Store .db # Environment variables .env .env.local .env.development .env.production .vercel ================================================ FILE: .prettierignore ================================================ build coverage app dist .yarn ================================================ FILE: .prettierrc ================================================ { "plugins": ["prettier-plugin-tailwindcss"], "tailwindConfig": "./renderer/tailwind.config.js" } ================================================ FILE: CODE_OF_CONDUCT.md ================================================

Wora Logo

GitHub Actions Workflow Status Last Commit License Discord GitHub Stars GitHub Forks GitHub Watchers

## 🤝 Contributor Covenant Code of Conduct We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation 🌟 We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community 🌈 ## 📄 Our Standards Examples of behavior that contributes to a positive environment for our community include: - Demonstrating empathy and kindness toward other people 🤗 - Being respectful of differing opinions, viewpoints, and experiences 🤝 - Giving and gracefully accepting constructive feedback 🎯 - Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience 🙏 - Focusing on what is best not just for us as individuals, but for the overall community 🌍 Examples of unacceptable behavior include: - The use of sexualized language or imagery, and sexual attention or advances of any kind 🚫 - Trolling, insulting or derogatory comments, and personal or political attacks 🗣️ - Public or private harassment 🔇 - Publishing others' private information, such as a physical or email address, without their explicit permission 🕵️ - Other conduct which could reasonably be considered inappropriate in a professional setting ❌ ## ⭐️ Enforcement Responsibilities Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. ⚖️ Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ✏️ ## 🙌 Scope This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. 🌐 ## 👍 Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [https://discord.gg/CrAbAYMGCe](https://discord.gg/CrAbAYMGCe). 🔗 All complaints will be reviewed and investigated promptly and fairly. ⏱️ All community leaders are obligated to respect the privacy and security of the reporter of any incident. 🔒 ## 🙏 Enforcement Guidelines Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. 📝 ### 2. Warning **Community Impact**: A violation through a single incident or series of actions. **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. ⚠️ ### 3. Temporary Ban **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. ⛔ ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within the community. 🚷 ================================================ FILE: CONTRIBUTING.md ================================================

Wora Logo

GitHub Actions Workflow Status Last Commit License Discord GitHub Stars GitHub Forks GitHub Watchers

## 🤝 Contributing to Wora Thank you for considering contributing to **Wora**! 🎉 We welcome contributions from everyone. We have prepared some guidelines for you to get started ✅ ## 🛠️ Project Setup Wora is an Electron app built with Next.js and TailwindCSS, using BetterSQLite3 with Drizzle ORM for database management. Here's an overview of the database schema: ```mermaid erDiagram settings { int id string name string profilePicture string musicFolder } songs { int id string filePath string name string artist int duration int albumID } albums { int id string name string artist int year string coverArt } playlists { int id string name string description string coverArt } playlistSongs { int playlistId int songId } albums ||--|{ songs : "" playlists ||--o{ playlistSongs : "" songs ||--o{ playlistSongs : "" ``` ## 🎯 **How to Contribute** Once you get hold of the DB, please check out the file structure in the main branch to get yourself more familiar with the project. If you encounter any issues, support for developers is available through our discord server 🛠️ Discord 1. **Fork the Repository** Fork the [repository](https://github.com/playwora/wora) and clone it locally: ```sh git clone https://github.com/your-username/wora.git cd wora ``` 2. **Create a New Branch** Create a new branch for your feature or bugfix: ```sh git checkout -b feature-branch ``` 3. **Install Dependencies** Install the required dependencies: ```sh yarn install ``` 4. **Start Development Server** Run the development server to see your changes: ```sh yarn dev ``` 5. **Commit Your Changes** Commit your changes with a meaningful message: ```sh git commit -am 'Add new feature ✅' ``` 6. **Push to Your Branch** Push the changes to your branch on GitHub: ```sh git push origin feature-branch ``` 7. **Create a Pull Request** Go to the original repository on GitHub and create a new pull request. Please also read our [Code of Conduct](CODE_OF_CONDUCT.md) to understand the expectations for behavior within our community 🙏 ## 💬 Join the Community Join our [Discord server](https://discord.gg/CrAbAYMGCe) to connect with other users and developers 🤝 Discord --- MIT License. Made with ❤️ by [hiaaryan](https://github.com/hiaaryan) and contributors. ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2024 Wora Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ > [!IMPORTANT] > There is a migrated version which is being built with tauri (rust 🦀). During this time contributions to this repo are severely limited and only critical fixes would be merged. Please join our [Discord](https://discord.gg/CrAbAYMGCe) to follow updates on the new version.

Wora Logo

GitHub Actions Workflow Status Last Commit License Discord GitHub Stars GitHub Forks GitHub Downloads

## ⭐️ Description **Wora** is a beautiful player for audiophiles. An open-source lossless music player app that lets you organize and play your favorite tracks seamlessly. With Wora, you can: - Create and manage playlists 🎉 - Stream FLACs, WAVs apart from regular music extensions 🎧 - Quick play using command menu ⌨️ - View synced and unsynced lyrics 💬 - Admire the beautiful UI ✨

Screenshot 1 Screenshot 2 Screenshot 3 Screenshot 4

## 🚀 Getting Started A bit simpler process would be to download the latest build through [here](https://github.com/playwora/wora/releases/). But if you want to fiddle around, then please follow the below steps which would help you get started. If you encounter any issues, support is available through our discord server 🛠️ Discord ### 〽️ Prerequisites - [Node.js](https://nodejs.org/) v14 or higher - [Git](https://git-scm.com/) for obvious reasons - [Bun](https://bun.sh/) for dependencies ### 👾 Installation 1. **Clone the repository:** ```sh git clone https://github.com/playwora/wora.git cd wora ``` 2. **Install the dependencies:** ```sh bun install ``` 3. **Start the application:** ```sh bun run dev ``` 4. **Build the application** ```sh bun run build ``` ## 🤝 Contributing Contributions are always welcome! Please read the [Contributing Guide](CONTRIBUTING.md) to learn about the process and how to submit your contributions. 1. Fork the repository 2. Create a new branch (`git checkout -b feature-branch`) 3. Commit your changes (`git commit -am 'Add new feature'`) 4. Push to the branch (`git push origin feature-branch`) 5. Create a new Pull Request ## 💬 Join the Community Join our [Discord server](https://discord.gg/CrAbAYMGCe) to connect with other users and developers. Discord ---
Vercel OSS Program

MIT License. Made with ❤️ by [hiaaryan](https://github.com/hiaaryan) and contributors. ================================================ FILE: components.json ================================================ { "$schema": "https://ui.shadcn.com/schema.json", "style": "new-york", "rsc": true, "tsx": true, "tailwind": { "config": "tailwind.config.ts", "css": "app/globals.css", "baseColor": "neutral", "cssVariables": false, "prefix": "" }, "aliases": { "components": "@/components", "utils": "@/lib/utils" } } ================================================ FILE: electron-builder.yml ================================================ appId: com.wora.player productName: Wora copyright: Copyright © 2024 Aaryan Kapoor directories: output: dist buildResources: resources files: - from: . filter: - package.json - app publish: null artifactName: Wora [v${version}].${ext} linux: target: - AppImage category: Audio mac: target: - dmg category: public.app-category.music win: target: - nsis fileAssociations: - ext: mp3 name: MP3 Audio File - ext: mpeg name: MPEG Audio File - ext: opus name: Opus Audio File - ext: ogg name: OGG Audio File - ext: oga name: OGA Audio File - ext: wav name: WAV Audio File - ext: aac name: AAC Audio File - ext: caf name: CAF Audio File - ext: m4a name: M4A Audio File - ext: m4b name: M4B Audio File - ext: mp4 name: MP4 Audio File - ext: weba name: WEBA Audio File - ext: webm name: WEBM Audio File - ext: flac name: FLAC Audio File ================================================ FILE: main/background.ts ================================================ import path from "path"; import { Menu, Tray, app, dialog, ipcMain, shell } from "electron"; import serve from "electron-serve"; import { createWindow } from "./helpers"; import { protocol } from "electron"; import { addSongToPlaylist, addToFavourites, createPlaylist, db, getAlbumWithSongs, getAlbums, getAllArtists, getArtistWithAlbums, getLastFmSettings, getLibraryStats, getPlaylistWithSongs, getPlaylists, getRandomLibraryItems, getSettings, initializeData, isSongFavorite, migrateDatabase, removeSongFromPlaylist, searchDB, searchSongs, updateLastFmSettings, deletePlaylist, updatePlaylist, updateSettings, getSongs, getAlbumsWithDuration, } from "./helpers/db/connectDB"; import { initDatabase } from "./helpers/db/createDB"; import { parseFile } from "music-metadata"; import fs from "fs"; import { Client } from "@xhayper/discord-rpc"; import { eq, sql } from "drizzle-orm"; import { initializeLastFmHandlers } from "./helpers/lastfm-service"; import * as electronLog from "electron-log"; // Configure application logging for production electronLog.transports.file.level = "info"; const logger = electronLog.default; // Log application startup logger.info(`Wora starting up - ${new Date().toISOString()}`); logger.info(`Node environment: ${process.env.NODE_ENV}`); logger.info(`Electron version: ${process.versions.electron}`); logger.info(`Chrome version: ${process.versions.chrome}`); logger.info(`OS: ${process.platform} ${process.arch}`); const isProd = process.env.NODE_ENV === "production"; // Set the app user model id for Windows if (process.platform === "win32") { app.setAppUserModelId("com.hiaaryan.wora"); } if (isProd) { logger.info("Running in production mode"); serve({ directory: "app" }); } else { logger.info("Running in development mode"); app.setPath("userData", `${app.getPath("userData")}`); } let mainWindow: any; let settings: any; // Global cache for frequently accessed data const dataCache = { libraryStats: null, randomItems: null, lastUpdated: 0, }; // @hiaaryan: Initialize Database on Startup with optimized loading const initializeLibrary = async () => { try { // Initialize SQLite database await initDatabase(); // Run database migrations for schema updates await migrateDatabase(); // Only load essential data at startup (settings) settings = await getSettings(); if (settings) { // Start a non-blocking initialization of the music library // This allows the app UI to load while data is being processed setTimeout(() => { initializeData(settings.musicFolder, true) .then(() => { // Pre-cache some common data for faster access Promise.all([getLibraryStats(), getRandomLibraryItems()]).then( ([stats, randomItems]) => { dataCache.libraryStats = stats; dataCache.randomItems = randomItems; dataCache.lastUpdated = Date.now(); // Notify renderer that library is fully loaded if (mainWindow) { mainWindow.webContents.send("library-initialized"); } }, ); }) .catch((err) => { console.error("Error initializing music library:", err); }); }, 1000); // Delay initialization to prioritize app UI loading } } catch (error) { console.error("Error initializing library:", error); } }; (async () => { await app.whenReady(); await initializeLibrary(); // Initialize Last.fm IPC handlers initializeLastFmHandlers(); // @hiaaryan: Using Depreciated API [Seeking Not Supported with Net] protocol.registerFileProtocol("wora", (request, callback) => { callback({ path: decodeURIComponent(request.url.replace("wora://", "")) }); }); mainWindow = createWindow("main", { width: 1500, height: 900, titleBarStyle: "hidden", trafficLightPosition: { x: 20, y: 20 }, transparent: true, frame: false, icon: path.join(__dirname, "resources/icon.icns"), webPreferences: { preload: path.join(__dirname, "preload.js"), backgroundThrottling: false, }, }); ipcMain.on("quitApp", async () => { return app.quit(); }); ipcMain.on("minimizeWindow", async () => { return mainWindow.minimize(); }); ipcMain.on("maximizeWindow", async (_, isMaximized: boolean) => { if (isMaximized) { return mainWindow.maximize(isMaximized); } else { return mainWindow.unmaximize(); } }); if (settings) { if (isProd) { await mainWindow.loadURL("app://./home"); } else { const port = process.argv[2]; await mainWindow.loadURL(`http://localhost:${port}/home`); } } else { if (isProd) { await mainWindow.loadURL("app://./setup"); } else { const port = process.argv[2]; await mainWindow.loadURL(`http://localhost:${port}/setup`); } } })(); // @hiaaryan: Initialize Discord RPC const client = new Client({ clientId: "1243707416588320800", }); ipcMain.on( "set-rpc-state", async (_, { details, state, seek, duration, cover }) => { let startTimestamp, endTimestamp; if (duration && seek) { const now = Math.ceil(Date.now()); startTimestamp = now - seek * 1000; endTimestamp = now + (duration - seek) * 1000; } const setActivity = { details, state, largeImageKey: cover, instance: false, type: 2, startTimestamp: startTimestamp, endTimestamp: endTimestamp, buttons: [ { label: "Support Project", url: "https://github.com/playwora/wora" }, ], }; if (!client.isConnected) { try { await client.login(); } catch (error) { console.error("Error logging into Discord:", error); } } if (client.isConnected) { client.user.setActivity(setActivity); } }, ); // @hiaaryan: Called to Rescan Library ipcMain.handle("rescanLibrary", async () => { await initializeLibrary(); }); // @hiaaryan: Called to Set Music Folder ipcMain.handle("scanLibrary", async () => { const diag = await dialog .showOpenDialog({ properties: ["openDirectory", "createDirectory"], }) .then(async (result) => { if (result.canceled) { return result; } await initializeData(result.filePaths[0]); }) .catch((err) => { console.log(err); }); return diag; }); // @hiaaryan: Set Tray for Wora let tray = null; app.whenReady().then(() => { const trayIconPath = !isProd ? path.join(__dirname, `../renderer/public/assets/TrayTemplate.png`) : path.join(__dirname, `../app/assets/TrayTemplate.png`); tray = new Tray(trayIconPath); const contextMenu = Menu.buildFromTemplate([ { label: "About", type: "normal", role: "about" }, { type: "separator" }, { label: "GitHub", type: "normal", click: () => { shell.openExternal("https://github.com/playwora/wora"); }, }, { label: "Discord", type: "normal", click: () => { shell.openExternal("https://discord.gg/CrAbAYMGCe"); }, }, { type: "separator" }, { label: "Quit", type: "normal", role: "quit", accelerator: "Cmd+Q", }, ]); tray.setToolTip("Wora"); tray.setContextMenu(contextMenu); }); // Use cached data when available for frequently accessed endpoints ipcMain.handle("getLibraryStats", async () => { // Check if we have fresh cached data (less than 5 minutes old) if (dataCache.libraryStats && Date.now() - dataCache.lastUpdated < 300000) { return dataCache.libraryStats; } // Otherwise get fresh data and update cache const stats = await getLibraryStats(); dataCache.libraryStats = stats; dataCache.lastUpdated = Date.now(); return stats; }); ipcMain.handle("getRandomLibraryItems", async () => { // Check if we have fresh cached data (less than 5 minutes old) if (dataCache.randomItems && Date.now() - dataCache.lastUpdated < 300000) { return dataCache.randomItems; } // Otherwise get fresh data and update cache const libraryItems = await getRandomLibraryItems(); dataCache.randomItems = libraryItems; dataCache.lastUpdated = Date.now(); return libraryItems; }); // @hiaaryan: IPC Handlers from Renderer ipcMain.handle("getAlbums", async (_, page) => { return await getAlbums(page); }); // Page state reset handlers ipcMain.on("resetAlbumsPageState", () => { // Notify renderer to reset albums page state mainWindow.webContents.send("resetAlbumsState"); }); ipcMain.on("resetSongsPageState", () => { // Notify renderer to reset songs page state mainWindow.webContents.send("resetSongsState"); }); ipcMain.on("resetPlaylistsPageState", () => { // Notify renderer to reset playlists page state mainWindow.webContents.send("resetPlaylistsState"); }); ipcMain.on("resetHomePageState", () => { // Notify renderer to reset home page state mainWindow.webContents.send("resetHomeState"); }); ipcMain.handle("getAllPlaylists", async () => { const playlists = await getPlaylists(); return playlists; }); ipcMain.handle("getAlbumWithSongs", async (_, id: number) => { const albumWithSongs = await getAlbumWithSongs(id); return albumWithSongs; }); ipcMain.handle("getPlaylistWithSongs", async (_, id: number) => { const playlistWithSongs = await getPlaylistWithSongs(id); return playlistWithSongs; }); ipcMain.handle("getSongMetadata", async (_, file: string) => { const metadata = await parseFile(file, { skipPostHeaders: true, skipCovers: true, }); const favourite = await isSongFavorite(file); return { metadata, favourite }; }); ipcMain.on("addToFavourites", async (_, id: number) => { return addToFavourites(id); }); ipcMain.handle("search", async (_, query: string) => { const results = await searchDB(query); return results; }); ipcMain.handle("createPlaylist", async (_, data: any) => { const playlist = await createPlaylist(data); // Invalidate cache when data changes dataCache.lastUpdated = 0; return playlist; }); ipcMain.handle("deletePlaylist", async (_, data: { id: number; coverPath?: string }) => { return deletePlaylist(data); }); ipcMain.handle("updatePlaylist", async (_, data: any) => { const playlist = await updatePlaylist(data); // Invalidate cache when data changes dataCache.lastUpdated = 0; return playlist; }); ipcMain.handle("addSongToPlaylist", async (_, data: any) => { const add = await addSongToPlaylist(data.playlistId, data.songId); // Invalidate cache when data changes dataCache.lastUpdated = 0; return add; }); ipcMain.handle("removeSongFromPlaylist", async (_, data: any) => { const remove = await removeSongFromPlaylist(data.playlistId, data.songId); // Invalidate cache when data changes dataCache.lastUpdated = 0; return remove; }); ipcMain.handle("getSettings", async () => { const settings = await getSettings(); return settings; }); ipcMain.handle("updateSettings", async (_, data: any) => { const settings = await updateSettings(data); mainWindow.webContents.send("confirmSettingsUpdate", settings); return settings; }); ipcMain.handle("uploadProfilePicture", async (_, file) => { const uploadsDir = path.join( app.getPath("userData"), "utilities/uploads/profile", ); if (!fs.existsSync(uploadsDir)) { fs.mkdirSync(uploadsDir, { recursive: true }); } const fileName = `profile_${Date.now()}${path.extname(file.name)}`; const filePath = path.join(uploadsDir, fileName); fs.writeFileSync(filePath, Buffer.from(file.data)); return filePath; }); ipcMain.handle("uploadPlaylistCover", async (_, file) => { const uploadsDir = path.join( app.getPath("userData"), "utilities/uploads/playlists", ); if (!fs.existsSync(uploadsDir)) { fs.mkdirSync(uploadsDir, { recursive: true }); } const fileName = `playlists_${Date.now()}${path.extname(file.name)}`; const filePath = path.join(uploadsDir, fileName); fs.writeFileSync(filePath, Buffer.from(file.data)); return filePath; }); ipcMain.handle("getActionsData", async () => { const isNotMac = process.platform !== "darwin"; const appVersion = app.getVersion(); return { isNotMac, appVersion }; }); ipcMain.handle("getArtistWithAlbums", async (_, artist: string) => { const artistData = await getArtistWithAlbums(artist); return artistData; }); // Handler to get all artists ipcMain.handle("getAllArtists", async () => { try { const allArtists = await getAllArtists(); return allArtists; } catch (error) { console.error("Error getting all artists:", error); return []; } }); // New handler to get all songs for shuffle feature ipcMain.handle("getAllSongs", async () => { try { console.log("Getting all songs for shuffle..."); // Get all songs with their album information in a single query for better performance const songsWithAlbums = await db.query.songs.findMany({ with: { album: true, // This fetches the full album data for each song }, orderBy: sql`RANDOM()`, // Randomize the songs to make shuffling more natural }); // Transform the data to match the expected format in the frontend const formattedSongs = songsWithAlbums.map((song) => { return { id: song.id, name: song.name || "Unknown Title", artist: song.artist || "Unknown Artist", duration: song.duration || 0, filePath: song.filePath, album: song.album ? { id: song.album.id, name: song.album.name || "Unknown Album", artist: song.album.artist || "Unknown Artist", cover: song.album.cover || null, year: song.album.year, } : { id: null, name: "Unknown Album", artist: "Unknown Artist", cover: null, year: null, }, }; }); console.log( `Returning ${formattedSongs.length} songs with complete album data`, ); return formattedSongs; } catch (error) { console.error("Error in getAllSongs:", error); return []; } }); // New handler to get songs with pagination ipcMain.handle("getSongs", async (_, page: number = 1) => { try { console.log(`Getting songs for page ${page}...`); const songsWithAlbums = await getSongs(page); return songsWithAlbums; } catch (error) { console.error("Error in getSongs:", error); return []; } }); // Handler for searching songs with the new searchSongs function ipcMain.handle("searchSongs", async (_, query: string) => { try { console.log(`Searching songs with query: "${query}"`); const results = await searchSongs(query); console.log(`Found ${results.length} song matches`); return results; } catch (error) { console.error("Error in searchSongs:", error); return []; } }); // Handler for getting albums with calculated durations ipcMain.handle("getAlbumsWithDuration", async (_, page: number = 1) => { try { console.log(`Getting albums with durations for page ${page}...`); const albumsWithDurations = await getAlbumsWithDuration(page); console.log(`Found ${albumsWithDurations.length} albums with durations`); return albumsWithDurations; } catch (error) { console.error("Error in getAlbumsWithDuration:", error); return []; } }); // Add LastFM handlers after existing handlers // Get LastFM settings ipcMain.handle("getLastFmSettings", async () => { try { const lastFmSettings = await getLastFmSettings(); return lastFmSettings; } catch (error) { console.error("Error in getLastFmSettings:", error); return { lastFmUsername: null, lastFmSessionKey: null, enableLastFm: false, scrobbleThreshold: 50, }; } }); // Update LastFM settings ipcMain.handle("updateLastFmSettings", async (_, data) => { try { const result = await updateLastFmSettings(data); // Notify all renderer processes that Last.fm settings have changed if (mainWindow) { mainWindow.webContents.send("lastFmSettingsChanged", data); } return result; } catch (error) { console.error("Error in updateLastFmSettings:", error); return false; } }); app.on("window-all-closed", () => { app.quit(); }); ================================================ FILE: main/helpers/create-window.ts ================================================ import { screen, BrowserWindow, BrowserWindowConstructorOptions, Rectangle, } from "electron"; import Store from "electron-store"; export const createWindow = ( windowName: string, options: BrowserWindowConstructorOptions, ): BrowserWindow => { const key = "window-state"; const name = `window-state-${windowName}`; const store = new Store({ name }); const defaultSize = { width: options.width, height: options.height, }; let state = {}; const restore = () => store.get(key, defaultSize); const getCurrentPosition = () => { const position = win.getPosition(); const size = win.getSize(); return { x: position[0], y: position[1], width: size[0], height: size[1], }; }; const windowWithinBounds = (windowState, bounds) => { return ( windowState.x >= bounds.x && windowState.y >= bounds.y && windowState.x + windowState.width <= bounds.x + bounds.width && windowState.y + windowState.height <= bounds.y + bounds.height ); }; const resetToDefaults = () => { const bounds = screen.getPrimaryDisplay().bounds; return Object.assign({}, defaultSize, { x: (bounds.width - defaultSize.width) / 2, y: (bounds.height - defaultSize.height) / 2, }); }; const ensureVisibleOnSomeDisplay = (windowState) => { const visible = screen.getAllDisplays().some((display) => { return windowWithinBounds(windowState, display.bounds); }); if (!visible) { // Window is partially or fully not visible now. // Reset it to safe defaults. return resetToDefaults(); } return windowState; }; const saveState = () => { if (!win.isMinimized() && !win.isMaximized()) { Object.assign(state, getCurrentPosition()); } store.set(key, state); }; state = ensureVisibleOnSomeDisplay(restore()); const win = new BrowserWindow({ ...state, ...options, webPreferences: { nodeIntegration: false, contextIsolation: true, ...options.webPreferences, }, }); win.on("close", saveState); return win; }; ================================================ FILE: main/helpers/db/connectDB.ts ================================================ import { and, eq, like, sql, or, exists, isNotNull } from "drizzle-orm"; import { albums, songs, settings, playlistSongs, playlists } from "./schema"; import fs from "fs"; import { parseFile, selectCover } from "music-metadata"; import path from "path"; import { BetterSQLite3Database, drizzle } from "drizzle-orm/better-sqlite3"; import * as schema from "./schema"; import { sqlite } from "./createDB"; import { app } from "electron"; export const db: BetterSQLite3Database = drizzle(sqlite, { schema, }); const APP_DATA = app.getPath("userData"); const ART_DIR = path.join(APP_DATA, "utilities/uploads/covers"); const audioExtensions = [ ".mp3", ".mpeg", ".opus", ".ogg", ".oga", ".wav", ".aac", ".caf", ".m4a", ".m4b", ".mp4", ".weba", ".webm", ".dolby", ".flac", ]; const imageExtensions = [".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"]; const processedImages = new Map(); function isAudioFile(filePath: string): boolean { return audioExtensions.includes(path.extname(filePath).toLowerCase()); } function findFirstImageInDirectory(dir: string): string | null { if (processedImages.has(dir)) { return processedImages.get(dir); } try { const files = fs.readdirSync(dir); for (const file of files) { const filePath = path.join(dir, file); const stat = fs.statSync(filePath); if ( stat.isFile() && imageExtensions.includes(path.extname(file).toLowerCase()) ) { processedImages.set(dir, filePath); return filePath; } } } catch (error) { console.error(`Error reading directory ${dir}:`, error); } processedImages.set(dir, null); return null; } function readFilesRecursively(dir: string, batch = 100): string[] { let results: string[] = []; let stack = [dir]; let count = 0; while (stack.length > 0 && count < batch) { const currentDir = stack.pop(); try { const items = fs.readdirSync(currentDir); for (const item of items) { const itemPath = path.join(currentDir, item); try { const stat = fs.statSync(itemPath); if (stat.isDirectory()) { stack.push(itemPath); } else if (isAudioFile(itemPath)) { results.push(itemPath); count++; if (count >= batch) break; } } catch (err) { console.error(`Error accessing ${itemPath}:`, err); } } } catch (err) { console.error(`Error reading directory ${currentDir}:`, err); } } return results; } function scanEntireLibrary(dir: string): string[] { let results: string[] = []; try { const items = fs.readdirSync(dir); const chunkSize = 50; for (let i = 0; i < items.length; i += chunkSize) { const chunk = items.slice(i, i + chunkSize); for (const item of chunk) { const itemPath = path.join(dir, item); try { const stat = fs.statSync(itemPath); if (stat.isDirectory()) { results.push(...scanEntireLibrary(itemPath)); } else if (isAudioFile(itemPath)) { results.push(itemPath); } } catch (err) { console.error(`Error accessing ${itemPath}:`, err); } } } } catch (err) { console.error(`Error reading directory ${dir}:`, err); } return results; } export const getLibraryStats = async () => { const songCount = await db.select({ count: sql`count(*)` }).from(songs); const albumCount = await db.select({ count: sql`count(*)` }).from(albums); const playlistCount = await db .select({ count: sql`count(*)` }) .from(playlists); return { songs: songCount[0].count, albums: albumCount[0].count, playlists: playlistCount[0].count, }; }; export const getSettings = async () => { const settings = await db.select().from(schema.settings).limit(1); return settings[0]; }; export const updateSettings = async (data: any) => { const currentSettings = await db.select().from(settings); if (currentSettings[0].profilePicture) { try { fs.unlinkSync(currentSettings[0].profilePicture); } catch (error) { console.error("Error deleting old profile picture:", error); } } await db.update(settings).set({ name: data.name, profilePicture: data.profilePicture, }); return true; }; export const getSongs = async (page: number = 1, limit: number = 30) => { return await db.query.songs.findMany({ with: { album: true }, limit: limit, offset: (page - 1) * limit, orderBy: (songs, { asc }) => [asc(songs.name)], }); }; export const getAlbums = async (page: number, limit: number = 15) => { // Get albums with pagination const albumsResult = await db .select() .from(albums) .orderBy(albums.name) .limit(limit) .offset((page - 1) * limit); // Get durations for these albums const albumsWithDuration = await Promise.all( albumsResult.map(async (album) => { // Get total duration from songs in this album const durationResult = await db .select({ totalDuration: sql`SUM(${songs.duration})` }) .from(songs) .where(eq(songs.albumId, album.id)); return { ...album, duration: durationResult[0]?.totalDuration || 0, }; }), ); return albumsWithDuration; }; export const getPlaylists = async () => { return await db.select().from(playlists); }; export const createPlaylist = async (data: any) => { let description: string; let cover: string; if (data.description) { description = data.description; } else { description = "An epic playlist created by you."; } if (data.cover) { cover = data.cover; } else { cover = null; } const playlist = await db.insert(playlists).values({ name: data.name, description: description, cover: cover, }); return playlist; }; export const deletePlaylist = async (data: { id: number }) => { await db.transaction(async (tx) => { // Remove all links in playlistSongs await tx.delete(playlistSongs).where(eq(playlistSongs.playlistId, data.id)); // Now delete the playlist const result = await tx.delete(playlists).where(eq(playlists.id, data.id)); if ("changes" in result && result.changes === 0) { throw new Error(`Playlist ${data.id} not found`); } }); return { message: `Playlist ${data.id} deleted successfully` }; }; export const updatePlaylist = async (data: any) => { let description: string; let cover: string; if (data.data.description) { description = data.data.description; } else { description = "An epic playlist created by you."; } if (data.cover) { cover = data.data.cover; } const playlist = await db .update(playlists) .set({ name: data.data.name, description: description, cover: cover, }) .where(eq(playlists.id, data.id)); return playlist; }; export const getAlbumWithSongs = async (id: number) => { const albumWithSongs = await db.query.albums.findFirst({ where: eq(albums.id, id), with: { songs: { with: { album: true }, }, }, }); if (albumWithSongs) { // Calculate total duration from all songs in this album const totalDuration = albumWithSongs.songs.reduce( (total, song) => total + (song.duration || 0), 0, ); return { ...albumWithSongs, duration: totalDuration, }; } return albumWithSongs; }; export const getPlaylistWithSongs = async (id: number) => { const playlistWithSongs = await db.query.playlists.findFirst({ where: eq(playlists.id, id), with: { songs: { with: { song: { with: { album: true }, }, }, }, }, }); return { ...playlistWithSongs, songs: playlistWithSongs.songs.map((playlistSong) => ({ ...playlistSong.song, album: playlistSong.song.album, })), }; }; export const isSongFavorite = async (file: string) => { const song = await db.query.songs.findFirst({ where: eq(songs.filePath, file), }); if (!song) return false; const isFavourite = await db.query.playlistSongs.findFirst({ where: and( eq(playlistSongs.playlistId, 1), eq(playlistSongs.songId, song.id), ), }); return !!isFavourite; }; export const addToFavourites = async (songId: number) => { const existingEntry = await db .select() .from(playlistSongs) .where( and(eq(playlistSongs.playlistId, 1), eq(playlistSongs.songId, songId)), ); if (!existingEntry[0]) { await db.insert(playlistSongs).values({ playlistId: 1, songId, }); } else { await db .delete(playlistSongs) .where( and(eq(playlistSongs.playlistId, 1), eq(playlistSongs.songId, songId)), ); } }; export const searchDB = async (query: string) => { const lowerSearch = query.toLowerCase(); const searchAlbums = await db.query.albums.findMany({ where: like(albums.name, `%${lowerSearch}%`), limit: 5, }); const searchPlaylists = await db.query.playlists.findMany({ where: like(playlists.name, `%${lowerSearch}%`), limit: 5, }); const searchSongs = await db.query.songs.findMany({ where: like(songs.name, `%${lowerSearch}%`), with: { album: { columns: { id: true, cover: true, }, }, }, limit: 5, }); // Search for artists by querying unique artist names from the albums table const searchArtists = await db.query.albums.findMany({ where: like(albums.artist, `%${lowerSearch}%`), columns: { artist: true, }, limit: 5, }); // Remove duplicate artists by name const uniqueArtists = Array.from( new Set(searchArtists.map((a) => a.artist)), ).map((name) => ({ name, })); return { searchAlbums, searchPlaylists, searchSongs, searchArtists: uniqueArtists, }; }; export const addSongToPlaylist = async (playlistId: number, songId: number) => { const checkIfExists = await db.query.playlistSongs.findFirst({ where: and( eq(playlistSongs.playlistId, playlistId), eq(playlistSongs.songId, songId), ), }); if (checkIfExists) return false; await db.insert(playlistSongs).values({ playlistId, songId, }); return true; }; export const removeSongFromPlaylist = async ( playlistId: number, songId: number, ) => { await db .delete(playlistSongs) .where( and( eq(playlistSongs.playlistId, playlistId), eq(playlistSongs.songId, songId), ), ); return true; }; export const getRandomLibraryItems = async () => { const randomAlbums = await db .select() .from(albums) .orderBy(sql`RANDOM()`) .limit(10); // Add duration calculation for albums const albumsWithDuration = await Promise.all( randomAlbums.map(async (album) => { // Get total duration from songs in this album const durationResult = await db .select({ totalDuration: sql`SUM(${songs.duration})` }) .from(songs) .where(eq(songs.albumId, album.id)); return { ...album, duration: durationResult[0]?.totalDuration || 0, }; }), ); const randomSongs = await db.query.songs.findMany({ with: { album: true }, limit: 10, orderBy: sql`RANDOM()`, }); return { albums: albumsWithDuration, songs: randomSongs, }; }; // Added incremental loading support export const initializeData = async ( musicFolder: string, incremental = false, ) => { if (!fs.existsSync(musicFolder)) { console.error("Music folder does not exist:", musicFolder); return false; } try { // Add default playlist if it doesn't exist const defaultPlaylist = await db .select() .from(playlists) .where(eq(playlists.id, 1)); if (!defaultPlaylist[0]) { await db.insert(playlists).values({ name: "Favourites", cover: null, description: "Songs liked by you.", }); } // Update settings const existingSettings = await db .select() .from(settings) .where(eq(settings.id, 1)); if (existingSettings[0]) { await db.update(settings).set({ musicFolder }).where(eq(settings.id, 1)); } else { await db.insert(settings).values({ musicFolder }); } // Create art directory if it doesn't exist if (!fs.existsSync(ART_DIR)) { await fs.promises.mkdir(ART_DIR, { recursive: true }); } // First pass: Just load metadata or do a full scan based on incremental flag await processLibrary(musicFolder, incremental); return true; } catch (error) { console.error("Error initializing data:", error); return false; } }; // Batch process files to reduce memory usage and improve UI responsiveness async function processLibrary(musicFolder: string, incremental = false) { const startTime = Date.now(); const dbFilePaths = await getAllFilePathsFromDb(); if (incremental) { console.log("Starting incremental library scan..."); // Scan only the immediate music folder first to reduce initial delay const initialBatch = scanImmediateDirectory(musicFolder); const batchSize = 100; // Increased from 50 for better throughput // Process the initial batch right away for quick UI updates await processBatch(initialBatch, dbFilePaths); // Process the rest of the library in the background setTimeout(async () => { // Use a more efficient scanning algorithm for the full scan const allFiles = scanEntireLibrary(musicFolder); console.log(`Found ${allFiles.length} files in music library`); // Skip files we've already processed in the initial batch for (let i = initialBatch.length; i < allFiles.length; i += batchSize) { const batch = allFiles.slice(i, i + batchSize); await processBatch(batch, dbFilePaths); // Yield to UI thread periodically but not too often (increased from 10ms) if (i % (batchSize * 5) === 0) { await new Promise((resolve) => setTimeout(resolve, 30)); } } // Final cleanup - remove orphaned records await cleanupOrphanedRecords(allFiles); console.log( `Library processing completed in ${(Date.now() - startTime) / 1000} seconds`, ); }, 1000); // Reduced from 2000ms for faster startup } else { // Do full scan immediately if not incremental const allFiles = scanEntireLibrary(musicFolder); console.log(`Found ${allFiles.length} files in music library`); // Process in larger batches since we're not concerned about UI responsiveness const batchSize = 300; // Increased from 200 for better throughput for (let i = 0; i < allFiles.length; i += batchSize) { const batch = allFiles.slice(i, i + batchSize); await processBatch(batch, dbFilePaths); // Still yield occasionally to prevent potential lockups if (i % (batchSize * 3) === 0) { await new Promise((resolve) => setTimeout(resolve, 20)); } } await cleanupOrphanedRecords(allFiles); console.log( `Library processing completed in ${(Date.now() - startTime) / 1000} seconds`, ); } } // Helper function to get all file paths from database async function getAllFilePathsFromDb(): Promise> { const dbFiles = await db.select().from(songs); return new Set(dbFiles.map((file) => file.filePath)); } // Scan only the immediate directory for quick initial loading function scanImmediateDirectory(dir: string): string[] { let results: string[] = []; try { const items = fs.readdirSync(dir); // First collect all audio files in the current directory for (const item of items) { const itemPath = path.join(dir, item); try { const stat = fs.statSync(itemPath); if (!stat.isDirectory() && isAudioFile(itemPath)) { results.push(itemPath); } } catch (err) { console.error(`Error accessing ${itemPath}:`, err); } } // Then check immediate subdirectories (but not recursively) for (const item of items) { const itemPath = path.join(dir, item); try { const stat = fs.statSync(itemPath); if (stat.isDirectory()) { const subItems = fs.readdirSync(itemPath); for (const subItem of subItems) { const subItemPath = path.join(itemPath, subItem); try { const subStat = fs.statSync(subItemPath); if (!subStat.isDirectory() && isAudioFile(subItemPath)) { results.push(subItemPath); } } catch (err) { console.error(`Error accessing ${subItemPath}:`, err); } } } } catch (err) { console.error(`Error accessing ${itemPath}:`, err); } } } catch (err) { console.error(`Error reading directory ${dir}:`, err); } return results; } async function processBatch(files: string[], dbFilePaths: Set) { const albumCache = new Map(); for (const file of files) { try { if (!dbFilePaths.has(file)) { // New file - add to database await processAudioFile(file, albumCache); } } catch (error) { console.error(`Error processing file ${file}:`, error); } } } async function processAudioFile(file: string, albumCache: Map) { try { // Use more efficient metadata parsing with stripped options const metadata = await parseFile(file, { skipPostHeaders: true, skipCovers: false, // Still need covers duration: true, includeChapters: false, }); // Skip files with insufficient metadata if (!metadata.common.title) { return; } const albumFolder = path.dirname(file); let artPath = null; // Try to find album art in efficient order: embedded first, then folder if (metadata.common.picture && metadata.common.picture.length > 0) { const cover = selectCover(metadata.common.picture); if (cover) { artPath = await processEmbeddedArt(cover); } } else { // Fall back to external images if no embedded art is found const albumImage = findFirstImageInDirectory(albumFolder); if (albumImage) { artPath = await processAlbumArt(albumImage); } } // Get or create album with better caching let album; const albumKey = `${metadata.common.album || "Unknown Album"}-${metadata.common.artist || "Unknown Artist"}`; if (albumCache.has(albumKey)) { album = albumCache.get(albumKey); } else { // Optimize the database lookup for album const albumsFound = await db .select() .from(albums) .where(eq(albums.name, metadata.common.album || "Unknown Album")); if (albumsFound.length > 0) { album = albumsFound[0]; // Update album if needed (only when data differs) const albumArtist = metadata.common.albumartist || metadata.common.artist || "Various Artists"; if ( album.artist !== albumArtist || album.year !== metadata.common.year || (artPath && album.cover !== artPath) ) { await db .update(albums) .set({ artist: albumArtist, year: metadata.common.year, cover: artPath || album.cover, }) .where(eq(albums.id, album.id)); // Update cached version album.artist = albumArtist; album.year = metadata.common.year; album.cover = artPath || album.cover; } } else { // Create new album with a single transaction const [newAlbum] = await db .insert(albums) .values({ name: metadata.common.album || "Unknown Album", artist: metadata.common.albumartist || metadata.common.artist || "Various Artists", year: metadata.common.year, cover: artPath, }) .returning(); album = newAlbum; } albumCache.set(albumKey, album); } // Add the song using pre-calculated values to avoid repeated operations await db.insert(songs).values({ filePath: file, name: metadata.common.title, artist: metadata.common.artist || "Unknown Artist", duration: Math.round(metadata.format.duration || 0), albumId: album.id, }); } catch (error) { console.error(`Error processing audio file ${file}:`, error); } } async function processAlbumArt(imagePath: string): Promise { try { // Use a shorter hash method for faster processing const crypto = require("crypto"); const imageExt = path.extname(imagePath).slice(1); // Generate hash from filename and modified time instead of reading the whole file // This is much faster for large image files const stats = fs.statSync(imagePath); const hashInput = `${imagePath}-${stats.size}-${stats.mtimeMs}`; const hash = crypto.createHash("md5").update(hashInput).digest("hex"); const artPath = path.join(ART_DIR, `${hash}.${imageExt}`); // If the processed file already exists, return its path immediately if (fs.existsSync(artPath)) { return artPath; } // Only read the file if we need to process it const imageData = fs.readFileSync(imagePath); // For common image formats that don't need processing, just copy the file if (imageExt.match(/^(jpe?g|png|webp)$/i)) { await fs.promises.writeFile(artPath, imageData); return artPath; } // For other formats, we might want to convert them (implementation depends on available modules) // For now, just save as is await fs.promises.writeFile(artPath, imageData); return artPath; } catch (error) { console.error("Error processing album art:", error); return null; } } async function processEmbeddedArt(cover: any): Promise { try { // If we don't have cover data, return early if (!cover || !cover.data) { return null; } // Generate a hash based on a small sample of the image data // Using the full data can be slow for large embedded images const sampleSize = Math.min(cover.data.length, 4096); // Sample first 4KB const sampleBuffer = cover.data.slice(0, sampleSize); const crypto = require("crypto"); const hash = crypto.createHash("md5").update(sampleBuffer).digest("hex"); const format = cover.format ? cover.format.split("/")[1] || "jpg" : "jpg"; const artPath = path.join(ART_DIR, `${hash}.${format}`); // Skip writing if it already exists if (fs.existsSync(artPath)) { return artPath; } // Write the full image data await fs.promises.writeFile(artPath, cover.data); return artPath; } catch (error) { console.error("Error processing embedded art:", error); return null; } } async function cleanupOrphanedRecords(currentFiles: string[]) { // Create a set of current file paths for faster lookups const currentFilesSet = new Set(currentFiles); // Get all songs from the database const dbFiles = await db.select().from(songs); // Find songs that no longer exist const deletedFiles = dbFiles.filter( (dbFile) => !currentFilesSet.has(dbFile.filePath), ); if (deletedFiles.length > 0) { console.log(`Removing ${deletedFiles.length} orphaned song records`); // Delete in batches to avoid locking the database for too long const batchSize = 50; for (let i = 0; i < deletedFiles.length; i += batchSize) { const batch = deletedFiles.slice(i, i + batchSize); await db.transaction(async (tx) => { for (const file of batch) { await tx .delete(playlistSongs) .where(eq(playlistSongs.songId, file.id)); await tx.delete(songs).where(eq(songs.id, file.id)); } }); } } // Clean up empty albums const allAlbums = await db.select().from(albums); for (const album of allAlbums) { const songsInAlbum = await db .select() .from(songs) .where(eq(songs.albumId, album.id)); if (songsInAlbum.length === 0) { await db.delete(albums).where(eq(albums.id, album.id)); } } } // Migrate database to add columns that might be missing export const migrateDatabase = async () => { try { console.log("Checking database schema for migrations..."); // Check if LastFM columns exist in settings table const tableInfo = sqlite .prepare("PRAGMA table_info(settings)") .all() as Array<{ name: string }>; const columnNames = tableInfo.map((col) => col.name); const missingColumns = []; // Check for lastFmUsername column if (!columnNames.includes("lastFmUsername")) { missingColumns.push("lastFmUsername TEXT"); } // Check for lastFmSessionKey column if (!columnNames.includes("lastFmSessionKey")) { missingColumns.push("lastFmSessionKey TEXT"); } // Check for enableLastFm column if (!columnNames.includes("enableLastFm")) { missingColumns.push("enableLastFm INTEGER DEFAULT 0"); } // Check for scrobbleThreshold column if (!columnNames.includes("scrobbleThreshold")) { missingColumns.push("scrobbleThreshold INTEGER DEFAULT 50"); } // Add missing columns if any if (missingColumns.length > 0) { console.log( `Adding ${missingColumns.length} missing columns to settings table...`, ); for (const columnDef of missingColumns) { const alterSql = `ALTER TABLE settings ADD COLUMN ${columnDef}`; sqlite.exec(alterSql); console.log(`Added column: ${columnDef}`); } console.log("Database migration completed successfully."); } else { console.log("Database schema is up to date, no migration needed."); } return true; } catch (error) { console.error("Error during database migration:", error); return false; } }; // Helper function to send messages to the renderer process function sendToRenderer(channel: string, data: any) { try { // Check if we have access to the webContents const { BrowserWindow } = require("electron"); const win = BrowserWindow.getAllWindows()[0]; if (win && win.webContents) { win.webContents.send(channel, data); } } catch (error) { console.error(`Failed to send message to renderer: ${error}`); } } export const getArtistWithAlbums = async (artist: string) => { try { if (!artist) { console.log("Missing artist name in getArtistWithAlbums"); return { name: "Unknown Artist", albums: [], albumsWithSongs: [], songs: [], stats: null, }; } // Get all albums by this artist const artistAlbums = await db .select() .from(albums) .where(eq(albums.artist, artist)) .orderBy(albums.year); // Get all songs by this artist (across all albums) const artistSongs = await db.query.songs.findMany({ where: eq(songs.artist, artist), with: { album: true, }, orderBy: (songs, { asc }) => [asc(songs.name)], }); // Group songs by albums for better organization const albumsWithSongs = await Promise.all( artistAlbums.map(async (album) => { const albumSongs = await db.query.songs.findMany({ where: eq(songs.albumId, album.id), with: { album: true, }, orderBy: (songs, { asc }) => [asc(songs.name)], }); return { ...album, songs: albumSongs, }; }), ); // Calculate statistics const totalDuration = artistSongs.reduce( (sum, song) => sum + (song.duration || 0), 0, ); const genres = new Set(); const formats = new Set(); // Extract genres and formats from songs artistSongs.forEach((song) => { if (song.filePath) { const ext = song.filePath.split(".").pop()?.toUpperCase(); if (ext) formats.add(ext); } }); // Get year range const years = artistAlbums.filter((a) => a.year).map((a) => a.year); const yearRange = years.length > 0 ? { start: Math.min(...years), end: Math.max(...years) } : null; // Get most played song (would need play count tracking, using random for now) const topSongs = artistSongs.slice(0, 5).map((song) => ({ id: song.id, name: song.name, duration: song.duration, album: song.album?.name || "Unknown Album", })); const stats = { totalSongs: artistSongs.length, totalAlbums: artistAlbums.length, totalDuration, genres: Array.from(genres), formats: Array.from(formats), yearRange, topSongs, }; return { name: artist, albums: artistAlbums, albumsWithSongs: albumsWithSongs, songs: artistSongs, stats, }; } catch (error) { console.error(`Error in getArtistWithAlbums for "${artist}":`, error); return { name: artist || "Unknown Artist", albums: [], albumsWithSongs: [], songs: [], stats: null, }; } }; export const getAllArtists = async () => { try { const albumArtists = await db .selectDistinct({ artist: albums.artist }) .from(albums) .where(isNotNull(albums.artist)); const songArtists = await db .selectDistinct({ artist: songs.artist }) .from(songs) .where(isNotNull(songs.artist)); const artistNames = new Set(); albumArtists.forEach((a) => { if (a.artist) artistNames.add(a.artist); }); songArtists.forEach((s) => { if (s.artist) artistNames.add(s.artist); }); const artistStats = await db .select({ artist: albums.artist, albumCount: sql`COUNT(DISTINCT ${albums.id})`, cover: sql`MAX(${albums.cover})`, }) .from(albums) .where(isNotNull(albums.artist)) .groupBy(albums.artist); const songStats = await db .select({ artist: songs.artist, songCount: sql`COUNT(*)`, }) .from(songs) .where(isNotNull(songs.artist)) .groupBy(songs.artist); const songCountMap = new Map(); songStats.forEach((s) => { if (s.artist) { songCountMap.set(s.artist, Number(s.songCount)); } }); // Combine the data const artistsWithDetails = Array.from(artistNames).map((artistName) => { const albumData = artistStats.find((a) => a.artist === artistName); return { name: artistName, albumCount: albumData ? Number(albumData.albumCount) : 0, songCount: songCountMap.get(artistName) || 0, cover: albumData?.cover || null, }; }); // Sort by artist name return artistsWithDetails.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }), ); } catch (error) { console.error("Error getting all artists:", error); return []; } }; export const searchSongs = async (query: string) => { if (!query || query.trim() === "") { return []; } // Normalize the search query const searchTerm = `%${query.toLowerCase().trim()}%`; // Efficiently search for songs matching the query across name, artist and album name const searchResults = await db.query.songs.findMany({ where: or( like(songs.name, searchTerm), like(songs.artist, searchTerm), // Join with albums to search by album name exists( db .select() .from(albums) .where( and(eq(albums.id, songs.albumId), like(albums.name, searchTerm)), ), ), ), with: { album: true, }, // Limit to a reasonable number to avoid performance issues limit: 100, orderBy: (songs, { asc }) => [asc(songs.name)], }); return searchResults; }; export const getAlbumsWithDuration = async ( page: number = 1, limit: number = 15, ) => { // Get albums with pagination, including a more efficient duration calculation const albumsResult = await db .select() .from(albums) .orderBy(albums.name) .limit(limit) .offset((page - 1) * limit); // Get durations for these albums in a single batch query for better performance const albumIds = albumsResult.map((album) => album.id); // If no albums were found, return empty array if (albumIds.length === 0) { return []; } // Query total durations for all albums in a single database call const durationResults = await db .select({ albumId: songs.albumId, totalDuration: sql`SUM(${songs.duration})`, }) .from(songs) .where(sql`${songs.albumId} IN (${albumIds.join(",")})`) .groupBy(songs.albumId); // Create a duration lookup map for efficient access const durationMap = new Map(); durationResults.forEach((result) => { durationMap.set(result.albumId, result.totalDuration || 0); }); // Map the albums with their durations const albumsWithDurations = albumsResult.map((album) => { return { ...album, duration: durationMap.get(album.id) || 0, }; }); return albumsWithDurations; }; // Add these functions at the end of the file // LastFM related functions export const updateLastFmSettings = async (data: { lastFmUsername: string; lastFmSessionKey: string; enableLastFm: boolean; scrobbleThreshold: number; }) => { try { const currentSettings = await db.select().from(settings); if (currentSettings.length === 0) { // Create new settings if none exist await db.insert(settings).values({ lastFmUsername: data.lastFmUsername, lastFmSessionKey: data.lastFmSessionKey, enableLastFm: data.enableLastFm, scrobbleThreshold: data.scrobbleThreshold || 50, }); } else { // Update existing settings await db .update(settings) .set({ lastFmUsername: data.lastFmUsername, lastFmSessionKey: data.lastFmSessionKey, enableLastFm: data.enableLastFm, scrobbleThreshold: data.scrobbleThreshold || 50, }) .where(eq(settings.id, currentSettings[0].id)); } return true; } catch (error) { console.error("Error updating LastFM settings:", error); return false; } }; export const getLastFmSettings = async () => { try { const settingsRow = await db .select({ lastFmUsername: settings.lastFmUsername, lastFmSessionKey: settings.lastFmSessionKey, enableLastFm: settings.enableLastFm, scrobbleThreshold: settings.scrobbleThreshold, }) .from(settings) .limit(1); if (settingsRow.length === 0) { return { lastFmUsername: null, lastFmSessionKey: null, enableLastFm: false, scrobbleThreshold: 50, }; } return settingsRow[0]; } catch (error) { console.error("Error getting LastFM settings:", error); return { lastFmUsername: null, lastFmSessionKey: null, enableLastFm: false, scrobbleThreshold: 50, }; } }; ================================================ FILE: main/helpers/db/createDB.ts ================================================ import Database from "better-sqlite3"; import { app } from "electron"; import path from "path"; export const sqlite = new Database( path.join(app.getPath("userData"), "wora.db"), ); export const initDatabase = async () => { sqlite.exec(` CREATE TABLE IF NOT EXISTS settings ( id INTEGER PRIMARY KEY, name TEXT, profilePicture TEXT, musicFolder TEXT ); CREATE TABLE IF NOT EXISTS albums ( id INTEGER PRIMARY KEY, name TEXT, artist TEXT, year INTEGER, cover TEXT ); CREATE TABLE IF NOT EXISTS songs ( id INTEGER PRIMARY KEY, filePath TEXT, name TEXT, artist TEXT, duration INTEGER, albumId INTEGER, FOREIGN KEY (albumId) REFERENCES albums(id) ); CREATE TABLE IF NOT EXISTS playlists ( id INTEGER PRIMARY KEY, name TEXT, description TEXT, cover TEXT ); CREATE TABLE IF NOT EXISTS playlistSongs ( playlistId INTEGER, songId INTEGER, FOREIGN KEY (playlistId) REFERENCES playlists(id), Foreign KEY (songId) REFERENCES songs(id) ); `); }; ================================================ FILE: main/helpers/db/schema.ts ================================================ import { integer, sqliteTable, text, blob } from "drizzle-orm/sqlite-core"; import { relations } from "drizzle-orm"; export const settings = sqliteTable("settings", { id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }), name: text("name"), profilePicture: text("profilePicture"), musicFolder: text("musicFolder"), lastFmUsername: text("lastFmUsername"), lastFmSessionKey: text("lastFmSessionKey"), enableLastFm: integer("enableLastFm", { mode: "boolean" }).default(false), scrobbleThreshold: integer("scrobbleThreshold").default(50), }); export const albums = sqliteTable("albums", { id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }), name: text("name"), artist: text("artist"), year: integer("year"), cover: text("cover"), }); export const songs = sqliteTable("songs", { id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }), filePath: text("filePath"), name: text("name"), artist: text("artist"), duration: integer("duration"), albumId: integer("albumId").references(() => albums.id), }); export const albumsRelations = relations(albums, ({ many }) => ({ songs: many(songs), })); export const songsRelations = relations(songs, ({ one }) => ({ album: one(albums, { fields: [songs.albumId], references: [albums.id], }), })); export const playlists = sqliteTable("playlists", { id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }), name: text("name").notNull().unique(), description: text("description").notNull(), cover: text("cover").notNull(), }); export const playlistSongs = sqliteTable("playlistSongs", { playlistId: integer("playlistId").references(() => playlists.id, { onDelete: "cascade", }), songId: integer("songId").references(() => songs.id, { onDelete: "cascade", }), }); export const playlistRelations = relations(playlists, ({ many }) => ({ songs: many(playlistSongs), })); export const playlistSongRelations = relations(playlistSongs, ({ one }) => ({ playlist: one(playlists, { fields: [playlistSongs.playlistId], references: [playlists.id], }), song: one(songs, { fields: [playlistSongs.songId], references: [songs.id] }), })); ================================================ FILE: main/helpers/index.ts ================================================ export * from "./create-window"; ================================================ FILE: main/helpers/lastfm-service.ts ================================================ import { ipcMain } from "electron"; import fetch from "node-fetch"; import * as crypto from "crypto"; import * as path from "path"; import * as fs from "fs"; import * as electronLog from "electron-log"; const lastFmLogger = electronLog.create({ logId: "lastfm" }); lastFmLogger.transports.file.fileName = "lastfm.log"; lastFmLogger.transports.file.level = "info"; const logLastFm = ( message: string, data?: any, level: "info" | "error" | "warn" = "info", ) => { const shouldLogToConsole = process.env.NODE_ENV !== "production"; switch (level) { case "error": lastFmLogger.error(message, data); if (shouldLogToConsole) console.error(`[LastFm] ${message}`, data || ""); break; case "warn": lastFmLogger.warn(message, data); if (shouldLogToConsole) console.warn(`[LastFm] ${message}`, data || ""); break; default: lastFmLogger.info(message, data); if (shouldLogToConsole) console.log(`[LastFm] ${message}`, data || ""); } }; const API_URL = "https://ws.audioscrobbler.com/2.0/"; const apiCache = new Map(); const CACHE_TTL = 15 * 60 * 1000; const loadEnvVariables = () => { try { const envPath = path.join(process.cwd(), ".env.local"); if (!fs.existsSync(envPath)) return false; logLastFm(`Loading environment variables from ${envPath}`); const envContent = fs.readFileSync(envPath, "utf-8"); envContent.split("\n").forEach((line) => { const match = line.match(/^\s*([\w.-]+)\s*=\s*(.*)?\s*$/); if (match) { const key = match[1]; let value = match[2] || ""; if ( value.length > 0 && value.charAt(0) === '"' && value.charAt(value.length - 1) === '"' ) { value = value.replace(/^"|"$/g, ""); } process.env[key] = value; } }); return true; } catch (error) { logLastFm("Error loading environment variables", error, "error"); return false; } }; if (process.env.NODE_ENV !== "production") { loadEnvVariables(); } const DEV_API_KEY = process.env.LASTFM_API_KEY || ""; const DEV_API_SECRET = process.env.LASTFM_API_SECRET || ""; if (process.env.NODE_ENV !== "production") { if (!DEV_API_KEY || !DEV_API_SECRET) { logLastFm( "WARNING: Last.fm API credentials not found in environment variables", null, "warn", ); } } const useBackend = process.env.NODE_ENV === "production" || process.env.USE_BACKEND === "true" || (!DEV_API_KEY || !DEV_API_SECRET); // Get the backend URL based on environment const getBackendUrl = (): string => { return process.env.NODE_ENV === "production" ? "https://wora-ten.vercel.app" : "http://localhost:3000"; }; /** * Forward Last.fm requests to the Vercel backend */ const forwardToBackend = async ( endpoint: string, method: string = "GET", body?: any, ) => { try { const baseUrl = getBackendUrl(); const url = `${baseUrl}/api/lastfm/${endpoint}`; const options: any = { method, headers: { "Content-Type": "application/json", }, }; if (body && method === "POST") { options.body = JSON.stringify(body); } const response = await fetch(url, options); return await response.json(); } catch (error) { logLastFm("Error forwarding request to backend", error, "error"); return { success: false, error: "Failed to communicate with the backend API", }; } }; /** * Generate a signature for Last.fm API */ const generateSignature = (params: Record): string => { // Remove format and callback parameters const filteredParams = { ...params }; delete filteredParams.format; delete filteredParams.callback; // Sort parameters alphabetically by name const sortedKeys = Object.keys(filteredParams).sort(); // Concatenate parameters let signatureStr = ""; for (const key of sortedKeys) { signatureStr += key + filteredParams[key]; } // Append secret signatureStr += DEV_API_SECRET; // Create MD5 hash return crypto.createHash("md5").update(signatureStr).digest("hex"); }; /** * Make a direct request to Last.fm API */ const makeLastFmRequest = async ( params: Record, isAuthRequest: boolean = false, ): Promise => { try { // Always add these parameters const requestParams: Record = { ...params, api_key: DEV_API_KEY, format: "json", }; // If this is an authenticated request, add signature if (isAuthRequest) { requestParams.api_sig = generateSignature(requestParams); } // Build query string const queryString = Object.entries(requestParams) .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) .join("&"); // Make the request const url = `${API_URL}?${queryString}`; const response = await fetch(url, { method: "POST", }); const data = await response.json(); // Check for errors if (data.error) { logLastFm(`API Error ${data.error}: ${data.message}`, null, "error"); return { success: false, error: data.message, code: data.error, }; } return data; } catch (error) { logLastFm("Error making Last.fm API request", error, "error"); return { success: false, error: "Error making Last.fm API request", }; } }; /** * Generate MD5 hash for password authentication */ const getMD5Auth = (username: string, password: string): string => { const authString = username.toLowerCase() + password; return crypto.createHash("md5").update(authString).digest("hex"); }; /** * Initialize Last.fm IPC handlers */ export const initializeLastFmHandlers = () => { // Handler for log messages from renderer process - simplified to avoid duplicate logging ipcMain.on("lastfm:log", (_, data) => { const { level, message } = data; if (!level || !message) return; // Just pass to the right logger method switch (level) { case "error": lastFmLogger.error(message); break; case "warn": lastFmLogger.warn(message); break; default: lastFmLogger.info(message); } }); // Handle authentication requests ipcMain.handle( "lastfm:authenticate", async (_, username: string, password: string) => { try { // In production, use the backend API if (useBackend) { const response = await forwardToBackend("auth", "POST", { username, password, }); if (!response.success) { logLastFm("Authentication error", response.error, "error"); } return response; } // In development, call Last.fm API directly else { // Use the mobile session API for desktop auth const params = { method: "auth.getMobileSession", username: username, password: password, }; const response = await makeLastFmRequest(params, true); if (response.error) { return { success: false, error: response.error, }; } // Return success with session return { success: true, session: response.session, }; } } catch (error) { logLastFm("Error in authentication", error, "error"); return { success: false, error: "Internal error during authentication", }; } }, ); // Handle "now playing" updates ipcMain.handle("lastfm:updateNowPlaying", async (_, data) => { try { const { sessionKey, artist, track, album, duration } = data; if (!sessionKey || !artist || !track) { return { success: false, error: "Missing required parameters" }; } // Use backend or direct API based on environment if (useBackend) { const response = await forwardToBackend("now-playing", "POST", data); if (!response.success) { logLastFm("Error updating now playing", response.error, "error"); } return response; } else { // Call Last.fm API directly const params: Record = { method: "track.updateNowPlaying", artist, track, sk: sessionKey, }; // Add optional parameters if available if (album) params.album = album; if (duration) params.duration = duration; const response = await makeLastFmRequest(params, true); if (response.error) { return { success: false, error: response.message || "Failed to update now playing", }; } return { success: true, }; } } catch (error) { logLastFm("Error in updateNowPlaying", error, "error"); return { success: false, error: "Internal error updating now playing status", }; } }); // Handle track scrobbling - simplified error handling ipcMain.handle("lastfm:scrobbleTrack", async (_, data) => { try { const { sessionKey, artist, track, album, timestamp, duration } = data; if (!sessionKey || !artist || !track) { return { success: false, error: "Missing required parameters" }; } // Use backend or direct API based on environment if (useBackend) { const response = await forwardToBackend("scrobble", "POST", data); if (!response.success) { logLastFm("Error scrobbling track", response.error, "error"); } return response; } else { // Call Last.fm API directly const params: Record = { method: "track.scrobble", artist, track, timestamp: timestamp || Math.floor(Date.now() / 1000).toString(), sk: sessionKey, }; // Add optional parameters if available if (album) params.album = album; if (duration) params.duration = duration; const response = await makeLastFmRequest(params, true); if (response.error) { return { success: false, error: response.message || "Failed to scrobble track", }; } return { success: true, }; } } catch (error) { logLastFm("Error in scrobbleTrack", error, "error"); return { success: false, error: "Internal error scrobbling track" }; } }); // Handle get user info - simplified ipcMain.handle("lastfm:getUserInfo", async (_, username, sessionKey) => { try { if (!username) { return { success: false, error: "Username is required" }; } // Use backend or direct API based on environment if (useBackend) { const response = await forwardToBackend( `user-info?username=${encodeURIComponent(username)}&sessionKey=${encodeURIComponent(sessionKey || "")}`, ); if (!response.success) { logLastFm("Error getting user info", response.error, "error"); } return response; } else { // Call Last.fm API directly const params: Record = { method: "user.getInfo", user: username, }; // Add session key if available for private data if (sessionKey) params.sk = sessionKey; const response = await makeLastFmRequest(params, !!sessionKey); if (response.error) { return { success: false, error: response.message || "Failed to get user info", }; } return { success: true, user: response.user, }; } } catch (error) { logLastFm("Error in getUserInfo", error, "error"); return { success: false, error: "Internal error getting user info" }; } }); // Handle get track info - simplified ipcMain.handle("lastfm:getTrackInfo", async (_, artist, track, username) => { try { if (!artist || !track) { return { success: false, error: "Artist and track are required" }; } // Use backend or direct API based on environment if (useBackend) { // Create query string let query = `artist=${encodeURIComponent(artist)}&track=${encodeURIComponent(track)}`; if (username) { query += `&username=${encodeURIComponent(username)}`; } // Forward track info request to backend const response = await forwardToBackend(`track-info?${query}`); if (!response.success) { logLastFm("Error getting track info", response.error, "error"); } return response; } else { // Call Last.fm API directly const params: Record = { method: "track.getInfo", artist, track, }; // Add username if available for loved status if (username) params.username = username; const response = await makeLastFmRequest(params, false); if (response.error) { return { success: false, error: response.message || "Failed to get track info", }; } return { success: true, track: response.track, }; } } catch (error) { logLastFm("Error in getTrackInfo", error, "error"); return { success: false, error: "Internal error getting track info" }; } }); // Handle get artist info ipcMain.handle("lastfm:getArtistInfo", async (_, artist) => { try { if (!artist) { return { success: false, error: "Artist name is required" }; } // Use backend or direct API based on environment if (useBackend) { const query = `artist=${encodeURIComponent(artist)}`; const response = await forwardToBackend(`artist-info?${query}`); if (!response.success) { logLastFm("Error getting artist info", response.error, "error"); } return response; } else { const params: Record = { method: "artist.getInfo", artist, autocorrect: "1", }; const response = await makeLastFmRequest(params, false); if (response.error) { return { success: false, error: response.message || "Failed to get artist info", }; } return { success: true, artist: response.artist, }; } } catch (error) { logLastFm("Error in getArtistInfo", error, "error"); return { success: false, error: "Internal error getting artist info" }; } }); // Handle get artist top tracks ipcMain.handle("lastfm:getArtistTopTracks", async (_, artist) => { try { if (!artist) { return { success: false, error: "Artist name is required" }; } // Use backend or direct API based on environment if (useBackend) { const query = `artist=${encodeURIComponent(artist)}`; const response = await forwardToBackend(`artist-top-tracks?${query}`); if (!response.success) { logLastFm("Error getting artist top tracks", response.error, "error"); } return response; } else { const params: Record = { method: "artist.getTopTracks", artist, limit: "10", autocorrect: "1", }; const response = await makeLastFmRequest(params, false); if (response.error) { return { success: false, error: response.message || "Failed to get top tracks", }; } return { success: true, toptracks: response.toptracks, }; } } catch (error) { logLastFm("Error in getArtistTopTracks", error, "error"); return { success: false, error: "Internal error getting top tracks" }; } }); // Handle get similar artists ipcMain.handle("lastfm:getSimilarArtists", async (_, artist) => { try { if (!artist) { return { success: false, error: "Artist name is required" }; } // Use backend or direct API based on environment if (useBackend) { const query = `artist=${encodeURIComponent(artist)}`; const response = await forwardToBackend(`similar-artists?${query}`); if (!response.success) { logLastFm("Error getting similar artists", response.error, "error"); } return response; } else { const params: Record = { method: "artist.getSimilar", artist, limit: "6", autocorrect: "1", }; const response = await makeLastFmRequest(params, false); if (response.error) { return { success: false, error: response.message || "Failed to get similar artists", }; } return { success: true, similarartists: response.similarartists, }; } } catch (error) { logLastFm("Error in getSimilarArtists", error, "error"); return { success: false, error: "Internal error getting similar artists" }; } }); // Handle love/unlove track - simplified ipcMain.handle("lastfm:loveTrack", async (_, data) => { try { const { sessionKey, artist, track, love } = data; if (!sessionKey || !artist || !track || love === undefined) { return { success: false, error: "Missing required parameters" }; } const action = love ? "love" : "unlove"; // Use backend or direct API based on environment if (useBackend) { const response = await forwardToBackend("track-action", "POST", { sessionKey, artist, track, action, }); if (!response.success) { logLastFm(`Error ${action} track`, response.error, "error"); } return response; } else { // Call Last.fm API directly const params: Record = { method: `track.${action}`, artist, track, sk: sessionKey, }; const response = await makeLastFmRequest(params, true); if (response.error) { return { success: false, error: response.message || `Failed to ${action} track`, }; } return { success: true, }; } } catch (error) { logLastFm("Error in loveTrack", error, "error"); return { success: false, error: `Internal error processing track love/unlove`, }; } }); // Log initialization only in development if (process.env.NODE_ENV !== "production") { logLastFm( `Using ${useBackend ? "backend API" : "direct API calls"} for Last.fm`, ); } }; ================================================ FILE: main/preload.ts ================================================ import { contextBridge, ipcRenderer, IpcRendererEvent } from "electron"; const handler = { send(channel: string, value: unknown) { ipcRenderer.send(channel, value); }, on(channel: string, callback: (...args: unknown[]) => void) { const subscription = (_event: IpcRendererEvent, ...args: unknown[]) => callback(...args); ipcRenderer.on(channel, subscription); return () => { ipcRenderer.removeListener(channel, subscription); }; }, async invoke(channel: string, ...args: unknown[]) { try { const result = await ipcRenderer.invoke(channel, ...args); return result; } catch (error) { console.error(`Error invoking channel ${channel}:`, error); throw error; } }, }; contextBridge.exposeInMainWorld("ipc", handler); export type IpcHandler = typeof handler; ================================================ FILE: package.json ================================================ { "private": true, "name": "wora", "description": "🎧 A beautiful player for audiophiles.", "version": "0.4.0-beta2", "author": { "name": "Aaryan Kapoor", "email": "hi.aaryankapoor@gmail.com" }, "main": "app/background.js", "scripts": { "dev": "nextron", "build": "nextron build", "postinstall": "electron-builder install-app-deps", "build:mac": "nextron build --mac --universal", "build:linux": "nextron build --linux", "build:win64": "nextron build --win --x64" }, "dependencies": { "@hookform/resolvers": "^5.1.1", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-context-menu": "^2.2.15", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-slider": "^1.3.5", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tooltip": "^1.2.7", "@tabler/icons-react": "^3.34.0", "@tailwindcss/postcss": "^4.1.10", "@types/better-sqlite3": "^7.6.13", "@types/crypto-js": "^4.2.2", "@xhayper/discord-rpc": "^1.2.2", "axios": "^1.10.0", "better-sqlite3": "^12.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "drizzle-orm": "^0.44.2", "electron-log": "^5.4.1", "electron-serve": "^1.3.0", "electron-store": "^8.2.0", "embla-carousel-react": "^8.6.0", "eslint-config-next": "^15.3.4", "framer-motion": "^12.23.12", "howler": "^2.2.4", "last-fm": "^5.3.0", "music-metadata": "^7.14.0", "next-themes": "^0.4.6", "node-fetch": "2", "react-hook-form": "^7.58.1", "react-virtualized-auto-sizer": "^1.0.26", "react-window": "^1.8.11", "seamless-scroll-polyfill": "^2.3.4", "sonner": "^2.0.5", "tailwind-merge": "^3.3.1", "tailwindcss-animate": "^1.0.7", "zod": "^3.25.67" }, "devDependencies": { "@electron/rebuild": "^4.0.1", "@types/howler": "^2.2.12", "@types/node": "^24.0.3", "@types/react": "^19.1.8", "autoprefixer": "^10.4.21", "drizzle-kit": "^0.31.2", "electron": "^32.2.7", "electron-builder": "^24.13.3", "next": "^15.3.4", "nextron": "^9.5.0", "postcss": "^8.4.49", "prettier": "3.6.0", "prettier-plugin-tailwindcss": "^0.6.13", "react": "^19.1.0", "react-dom": "^19.1.0", "rebuild": "^0.1.2", "tailwindcss": "^4.1.10", "typescript": "^5.8.3" }, "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } ================================================ FILE: renderer/components/ErrorBoundary.tsx ================================================ import React, { Component, ErrorInfo, ReactNode } from 'react'; import { Button } from '@/components/ui/button'; import { IconAlertTriangle, IconRefresh } from '@tabler/icons-react'; interface Props { children: ReactNode; fallback?: ReactNode; } interface State { hasError: boolean; error: Error | null; } export default class ErrorBoundary extends Component { public state: State = { hasError: false, error: null, }; public static getDerivedStateFromError(error: Error): State { return { hasError: true, error }; } public componentDidCatch(error: Error, errorInfo: ErrorInfo) { console.error('Uncaught error:', error, errorInfo); } private handleReset = () => { this.setState({ hasError: false, error: null }); window.location.reload(); }; public render() { if (this.state.hasError) { if (this.props.fallback) { return this.props.fallback; } return (

Something went wrong

An unexpected error occurred. The application may not work correctly.

{process.env.NODE_ENV !== 'production' && this.state.error && (
Error details
                  {this.state.error.toString()}
                  {this.state.error.stack}
                
)}
); } return this.props.children; } } ================================================ FILE: renderer/components/LoadingSkeletons.tsx ================================================ import React from 'react'; import { Skeleton } from '@/components/ui/skeleton'; export function ArtistGridSkeleton({ count = 12, viewMode = 'grid-large' }: { count?: number; viewMode?: string }) { const isLarge = viewMode === 'grid-large'; const gridClass = isLarge ? "grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6" : viewMode === 'grid-small' ? "grid grid-cols-4 gap-3 sm:grid-cols-5 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10" : "space-y-1"; if (viewMode === 'list') { return (
{Array.from({ length: count }).map((_, i) => (
))}
); } return (
{Array.from({ length: count }).map((_, i) => (
))}
); } export function AlbumGridSkeleton({ count = 12 }: { count?: number }) { return (
{Array.from({ length: count }).map((_, i) => (
))}
); } export function SongListSkeleton({ count = 10 }: { count?: number }) { return (
{Array.from({ length: count }).map((_, i) => (
))}
); } export function ArtistDetailSkeleton() { return (
); } ================================================ FILE: renderer/components/PageTransition.tsx ================================================ import { motion, AnimatePresence, Transition } from 'framer-motion'; import { useRouter } from 'next/router'; import { ReactNode } from 'react'; interface PageTransitionProps { children: ReactNode; } // Option 1: Cross-fade (no wait, instant transition) const pageVariants = { initial: { opacity: 0, }, in: { opacity: 1, }, out: { opacity: 0, }, }; const pageTransition: Transition = { type: 'tween', ease: 'easeOut', duration: 0.1, // Very fast }; export default function PageTransition({ children }: PageTransitionProps) { const router = useRouter(); return ( {/* sync = crossfade, wait = sequential */} {children} ); } ================================================ FILE: renderer/components/PageTransitionMinimal.tsx ================================================ import { ReactNode } from 'react'; interface PageTransitionProps { children: ReactNode; } // Minimal approach: No transition, just instant page swap // This is actually what many modern apps do (Spotify, Apple Music) // The smooth scroll restoration gives enough visual continuity export default function PageTransitionMinimal({ children }: PageTransitionProps) { return <>{children}; } ================================================ FILE: renderer/components/main/lyrics.tsx ================================================ import { LyricLine } from "@/lib/helpers"; import React, { useEffect, useRef } from "react"; import { Badge } from "../ui/badge"; import { scrollIntoView } from "seamless-scroll-polyfill"; import { cn } from "@/lib/utils"; interface LyricsProps { lyrics: LyricLine[]; currentLyric: LyricLine | null; onLyricClick: (time: number) => void; isSyncedLyrics: boolean; } const Lyrics: React.FC = React.memo( ({ lyrics, currentLyric, onLyricClick, isSyncedLyrics }) => { const lyricsRef = useRef(null); useEffect(() => { if (currentLyric && lyricsRef.current) { const currentLine = document.getElementById( `line-${currentLyric.time}`, ); if (currentLine) { scrollIntoView( currentLine, { behavior: "smooth", block: "center", }, { duration: 500, }, ); } } }, [currentLyric]); return (
{isSyncedLyrics ? "Synced" : "Unsynced"}
{lyrics.map((line) => (

onLyricClick(line.time)} > {line.text}

))}
); }, ); export default Lyrics; ================================================ FILE: renderer/components/main/navbar.tsx ================================================ import { IconDeviceDesktop, IconFocusCentered, IconInbox, IconList, IconMoon, IconSearch, IconSun, IconVinyl, IconUser, IconArrowLeft, } from "@tabler/icons-react"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { Avatar, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { Command, CommandDialog, CommandGroup, CommandInput, CommandItem, CommandList, } from "@/components/ui/command"; import { useEffect, useState, useCallback } from "react"; import Link from "next/link"; import Image from "next/image"; import { useRouter } from "next/router"; import { usePlayer } from "@/context/playerContext"; import Spinner from "@/components/ui/spinner"; import { useTheme } from "next-themes"; type Settings = { name: string; profilePicture: string; }; type NavLink = { href: string; icon: React.ReactNode; label: string; }; const Navbar = () => { const router = useRouter(); const [open, setOpen] = useState(false); const [searchResults, setSearchResults] = useState([]); const [settings, setSettings] = useState(null); const [search, setSearch] = useState(""); const [loading, setLoading] = useState(false); const { setQueueAndPlay } = usePlayer(); const { theme, setTheme } = useTheme(); const [mounted, setMounted] = useState(false); const [canGoBack, setCanGoBack] = useState(false); const [isBackButtonVisible, setIsBackButtonVisible] = useState(false); useEffect(() => { setMounted(true); const checkBackButton = () => { const path = router.pathname; const isDetailPage = path.includes('/artists/[') || path.includes('/albums/[') || path.includes('/playlists/['); const shouldShow = isDetailPage && window.history.length > 1; if (shouldShow !== canGoBack) { setCanGoBack(shouldShow); if (shouldShow) { setTimeout(() => setIsBackButtonVisible(true), 50); } else { setIsBackButtonVisible(false); } } }; checkBackButton(); router.events.on('routeChangeComplete', checkBackButton); return () => { router.events.off('routeChangeComplete', checkBackButton); }; }, [router, canGoBack]); const navLinks: NavLink[] = [ { href: "/home", icon: , label: "Home", }, { href: "/playlists", icon: , label: "Playlists", }, { href: "/songs", icon: , label: "Songs", }, { href: "/albums", icon: , label: "Albums", }, { href: "/artists", icon: , label: "Artists", }, ]; const handleThemeToggle = () => { if (theme === "light") { setTheme("dark"); } else if (theme === "dark") { setTheme("system"); } else { setTheme("light"); } }; const renderIcon = () => { if (!mounted) { return ; } if (theme === "light") { return ; } else if (theme === "dark") { return ; } else { return ; } }; const isActive = (href: string): boolean => { if (href === "/home" && router.pathname === "/") { return true; } return ( router.pathname === href || (href !== "/home" && router.pathname.startsWith(href)) ); }; const handleNavigation = useCallback( (href: string, e: React.MouseEvent) => { if (isActive(href)) { e.preventDefault(); if (router.pathname === href) { const viewport = document.querySelector('[data-radix-scroll-area-viewport]'); if (viewport) { (viewport as HTMLElement).scrollTop = 0; } if (href === "/albums") { window.ipc.send("resetAlbumsPageState", null); } else if (href === "/songs") { window.ipc.send("resetSongsPageState", null); } else if (href === "/playlists") { window.ipc.send("resetPlaylistsPageState", null); } else if (href === "/home") { window.ipc.send("resetHomePageState", null); } else if (href === "/artists") { window.ipc.send("resetArtistsPageState", null); } } else { // If navigating to a different page, just push the route router.push(href); } } }, [router], ); useEffect(() => { const down = (e) => { if (e.key === "f" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); setOpen((open) => !open); } }; document.addEventListener("keydown", down); return () => document.removeEventListener("keydown", down); }, []); useEffect(() => { setLoading(true); if (!search) { setSearchResults([]); setLoading(false); return; } const delayDebounceFn = setTimeout(() => { window.ipc.invoke("search", search).then((response) => { const albums = response.searchAlbums; const playlists = response.searchPlaylists; const songs = response.searchSongs; const artists = response.searchArtists || []; setSearchResults([ ...artists.map((artist: any) => ({ ...artist, type: "Artist" })), ...playlists.map((playlist: any) => ({ ...playlist, type: "Playlist", })), ...albums.map((album: any) => ({ ...album, type: "Album" })), ...songs.map((song: any) => ({ ...song, type: "Song" })), ]); setLoading(false); }); }, 1000); return () => clearTimeout(delayDebounceFn); }, [search]); const openSearch = () => setOpen(true); const handleItemClick = (item: any) => { if (item.type === "Album") { router.push(`/albums/${item.id}`); } else if (item.type === "Song") { setQueueAndPlay([item], 0); } else if (item.type === "Playlist") { router.push(`/playlists/${item.id}`); } else if (item.type === "Artist") { router.push(`/artists/${encodeURIComponent(item.name)}`); } setOpen(false); }; useEffect(() => { window.ipc.invoke("getSettings").then((response) => { setSettings(response); }); window.ipc.on("confirmSettingsUpdate", () => { window.ipc.invoke("getSettings").then((response) => { setSettings(response); }); }); }, []); return ( <>

{settings && settings.name ? settings.name : "Wora User"}

{(canGoBack || isBackButtonVisible) && (

Back

)}
{navLinks.map((link) => (

{link.label}

))}

Search

Theme: {mounted ? theme : 'system'}

{loading && (
)} {search && !loading ? ( {searchResults.map((item) => ( handleItemClick(item)} className="text-black dark:text-white" >
{(item.type === "Playlist" || item.type === "Album") && (
{item.name}
)} {item.type === "Artist" && (
)}

{item.name} ({item.type})

{item.type === "Playlist" ? item.description : item.type === "Artist" ? "Artist" : item.artist}

))}
) : (
⌘ / Ctrl + F
)}
); }; export default Navbar; ================================================ FILE: renderer/components/main/player.tsx ================================================ import Image from "next/image"; import { Button } from "@/components/ui/button"; import { IconArrowsShuffle2, IconBrandLastfm, IconCheck, IconClock, IconHeart, IconInfoCircle, IconList, IconListTree, IconMessage, IconPlayerPause, IconPlayerPlay, IconPlayerSkipBack, IconPlayerSkipForward, IconPlus, IconRepeat, IconRipple, IconVinyl, IconVolume, IconVolumeOff, IconX, } from "@tabler/icons-react"; import React, { memo, useCallback, useEffect, useRef, useState } from "react"; import { Howl } from "howler"; import { FixedSizeList as List } from "react-window"; import { Slider } from "@/components/ui/slider"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import Lyrics from "@/components/main/lyrics"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { convertTime, isSyncedLyrics, parseLyrics, updateDiscordState, useAudioMetadata, } from "@/lib/helpers"; import { Song, usePlayer } from "@/context/playerContext"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import Link from "next/link"; import { toast } from "sonner"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger, } from "@/components/ui/context-menu"; import { initializeLastFMWithSession, scrobbleTrack, updateNowPlaying, isAuthenticated, } from "@/lib/lastfm"; import AutoSizer from "react-virtualized-auto-sizer"; import ErrorBoundary from "@/components/ErrorBoundary"; const NotificationToast = ({ success, message }: { success: boolean; message: string }) => (
{success ? ( ) : ( )} {message}
); function getAlbumCoverUrl(song: Song | undefined): string { const cover = song?.album?.cover; if (!cover) return "/coverArt.png"; if (cover.includes("://")) return cover; return `wora://${cover}`; } const QueuePanel = memo(({ queue, history, currentIndex, onSongSelect }: { queue: Song[]; history: Song[]; currentIndex: number; onSongSelect: (song: Song) => void; }) => { const ITEM_HEIGHT = 80; const VirtualizedSongListItem = ({ index, style, data }: { index: number; style: React.CSSProperties; data: { songs: Song[]; onSongSelect: (song: Song) => void } }) => { const song = data.songs[index]; return (
  • data.onSongSelect(song)} >
    {song.name

    {song.name}

    {song.artist}

  • ); }; const queueSongs = queue.slice(currentIndex + 1); const historySongs = [...history].reverse(); return (
    Queue History {queueSongs.length > 0 ? ( {({ height, width }) => ( {VirtualizedSongListItem} )} ) : (
    Queue is empty
    )}
    {historySongs.length > 0 ? ( {({ height, width }) => ( {VirtualizedSongListItem} )} ) : (
    No playback history
    )}
    ); }); export const Player = () => { // Player state const [seekPosition, setSeekPosition] = useState(0); const [volume, setVolume] = useState(0.5); const [previousVolume, setPreviousVolume] = useState(0.5); const [isMuted, setIsMuted] = useState(false); const [currentLyric, setCurrentLyric] = useState(null); const [showLyrics, setShowLyrics] = useState(false); const [showQueue, setShowQueue] = useState(false); const [isFavourite, setIsFavourite] = useState(false); const [playlists, setPlaylists] = useState([]); const [isClient, setIsClient] = useState(false); const [lastFmSettings, setLastFmSettings] = useState({ lastFmUsername: null, lastFmSessionKey: null, enableLastFm: false, scrobbleThreshold: 50, }); const [lastFmStatus, setLastFmStatus] = useState({ isScrobbled: false, isNowPlaying: false, scrobbleTimerStarted: false, error: null, lastFmActive: false, }); const scrobbleTimeout = useRef(null); // References const soundRef = useRef(null); const seekUpdateInterval = useRef(null); const volumeSliderRef = useRef(null); // Get player context and song metadata const { song, nextSong, previousSong, queue, history, currentIndex, repeat, shuffle, toggleShuffle, toggleRepeat, jumpToSong, isPlaying, setIsPlaying, } = usePlayer(); const { metadata, lyrics, favourite } = useAudioMetadata(song?.filePath); // Load Last.fm settings useEffect(() => { const loadLastFmSettings = async () => { try { const settings = await window.ipc.invoke("getLastFmSettings"); setLastFmSettings(settings); // Initialize Last.fm with session key if available if (settings.lastFmSessionKey && settings.enableLastFm) { initializeLastFMWithSession( settings.lastFmSessionKey, settings.lastFmUsername || "", ); setLastFmStatus((prev) => ({ ...prev, lastFmActive: true })); console.log("[Last.fm] Initialized with session key"); } else { // Clear Last.fm status if disabled or no session setLastFmStatus((prev) => ({ ...prev, lastFmActive: false, isScrobbled: false, isNowPlaying: false, })); console.log("[Last.fm] Disabled or no session key"); } } catch (error) { console.error("[Last.fm] Error loading settings:", error); } }; // Load settings initially loadLastFmSettings(); // Set up listener for Last.fm settings changes const removeListener = window.ipc.on( "lastFmSettingsChanged", loadLastFmSettings, ); return () => { removeListener(); }; }, []); // Reset scrobble status when song changes useEffect(() => { setLastFmStatus({ isScrobbled: false, isNowPlaying: false, scrobbleTimerStarted: false, error: null, lastFmActive: lastFmStatus.lastFmActive, }); if (scrobbleTimeout.current) { clearInterval(scrobbleTimeout.current); scrobbleTimeout.current = null; } }, [song]); // Last.fm scrobble handler const handleScrobble = useCallback(() => { if ( !song || !lastFmSettings.enableLastFm || lastFmStatus.isScrobbled || !isAuthenticated() ) { // Skip scrobble checks without verbose logging return; } // Clear existing timer if any if (scrobbleTimeout.current) { clearInterval(scrobbleTimeout.current); scrobbleTimeout.current = null; } const scrobbleIfThresholdReached = () => { if (!soundRef.current || lastFmStatus.isScrobbled) return; const duration = soundRef.current.duration(); const currentPosition = soundRef.current.seek(); const playedPercentage = (currentPosition / duration) * 100; // Only log in development if (process.env.NODE_ENV !== "production") { console.log( `[Last.fm] Position: ${playedPercentage.toFixed(1)}%, threshold: ${lastFmSettings.scrobbleThreshold}%`, ); } if (playedPercentage >= lastFmSettings.scrobbleThreshold) { // Clear the interval immediately to prevent multiple scrobbles if (scrobbleTimeout.current) { clearInterval(scrobbleTimeout.current); scrobbleTimeout.current = null; } // Set scrobbled status immediately to prevent race conditions setLastFmStatus((prev) => ({ ...prev, isScrobbled: true })); // Minimal logging for production, log to file only for important events try { window.ipc.send("lastfm:log", { level: "info", message: `Scrobbling track: ${song.artist} - ${song.name} (${playedPercentage.toFixed(1)}%)`, }); } catch (err) { // Silent error in production } // Scrobble the track scrobbleTrack(song) .then((success) => { if (!success) { setLastFmStatus((prev) => ({ ...prev, error: "Failed to scrobble track", isScrobbled: false, // Reset scrobbled state to allow retrying })); } }) .catch((err) => { // Log only the error message, not the entire error object try { window.ipc.send("lastfm:log", { level: "error", message: `Scrobble error: ${err?.message || "Unknown error"}`, }); } catch (logErr) { // Silent fail in production } setLastFmStatus((prev) => ({ ...prev, error: "Error scrobbling track", isScrobbled: false, // Reset scrobbled state to allow retrying })); }); } }; // Set timer to check scrobble threshold const checkInterval = 2000; // Check every 2 seconds scrobbleTimeout.current = setInterval( scrobbleIfThresholdReached, checkInterval, ); return () => { if (scrobbleTimeout.current) { clearInterval(scrobbleTimeout.current); scrobbleTimeout.current = null; } }; }, [song, lastFmSettings, lastFmStatus.isScrobbled]); // Player control functions - Define handlePlayPause earlier to avoid reference error const handlePlayPause = useCallback(() => { if (!soundRef.current) return; if (soundRef.current.playing()) { soundRef.current.pause(); } else { soundRef.current.play(); } }, []); const handleSeek = useCallback((value: number[]) => { if (!soundRef.current) return; soundRef.current.seek(value[0]); setSeekPosition(value[0]); }, []); const handleVolume = useCallback((value: number[]) => { // Store previous volume before muting (only if not currently muted) if (!isMuted && value[0] > 0.01) { setPreviousVolume(value[0]); } setIsMuted(value[0] === 0); setVolume(value[0]); }, [isMuted]); const toggleMute = useCallback(() => { if (!isMuted) { // Store current volume before muting if (volume > 0.01) { setPreviousVolume(volume); } setVolume(0); setIsMuted(true); // Directly apply mute to audio if (soundRef.current) { soundRef.current.mute(true); } } else { // Restore previous volume or default to 50% const restoreVolume = previousVolume > 0.05 ? previousVolume : 0.5; setVolume(restoreVolume); setPreviousVolume(restoreVolume); // Update previousVolume to the restored value setIsMuted(false); // Directly apply volume and unmute to audio to avoid desync if (soundRef.current) { soundRef.current.volume(restoreVolume); soundRef.current.mute(false); } } }, [isMuted, volume, previousVolume]); const handleVolumeWheel = useCallback((event: WheelEvent) => { event.preventDefault(); const delta = event.deltaY > 0 ? -0.05 : 0.05; // Scroll down decreases, scroll up increases const newVolume = Math.max(0, Math.min(1, Math.round((volume + delta) * 100) / 100)); console.log(`Volume changed: ${newVolume}`); handleVolume([newVolume]); }, [volume, handleVolume]); const toggleFavourite = useCallback((id: number) => { if (!id) return; window.ipc.send("addToFavourites", id); setIsFavourite((prev) => !prev); }, []); const handleKeyDown = useCallback((event: KeyboardEvent) => { // Only handle keyboard shortcuts if we're not focused on an input element if (event.target instanceof HTMLElement && ['INPUT', 'TEXTAREA', 'SELECT'].includes(event.target.tagName)) { return; } // Spacebar for play/pause (prevent page scroll) if (event.code === 'Space') { event.preventDefault(); handlePlayPause(); return; } // Like/Dislike Song: Alt + Shift + B if (event.altKey && event.shiftKey && event.code === 'KeyB') { // No preventDefault needed for this combo if (song?.id) { toggleFavourite(song.id); } return; } // Shuffle: Alt + S (Mac) | Ctrl/Cmd + S (Windows) if (((event.altKey && navigator.platform.includes('Mac')) || (event.ctrlKey && !navigator.platform.includes('Mac'))) && event.code === 'KeyS') { event.preventDefault(); // Prevent browser save dialog toggleShuffle(); return; } // Repeat: Alt + R (Mac) | Ctrl/Cmd + R (Windows) if (((event.altKey && navigator.platform.includes('Mac')) || (event.ctrlKey && !navigator.platform.includes('Mac'))) && event.code === 'KeyR') { event.preventDefault(); // Prevent browser refresh toggleRepeat(); return; } // Mute/Unmute: M if (event.code === 'KeyM') { // No preventDefault needed for M key toggleMute(); return; } // Go to Previous: Up Arrow if (event.code === 'ArrowUp') { event.preventDefault(); // Prevent page scroll previousSong(); return; } // Go to Next: Down Arrow if (event.code === 'ArrowDown') { event.preventDefault(); // Prevent page scroll nextSong(); return; } }, [handlePlayPause, song, toggleFavourite, toggleShuffle, toggleRepeat, toggleMute, previousSong, nextSong]); const handleLyricClick = useCallback((time: number) => { if (!soundRef.current) return; soundRef.current.seek(time); setSeekPosition(time); }, []); const toggleLyrics = useCallback(() => { setShowLyrics((prev) => !prev); }, []); const toggleQueue = useCallback(() => { setShowQueue((prev) => !prev); }, []); const addSongToPlaylist = useCallback( (playlistId: number, songId: number) => { window.ipc .invoke("addSongToPlaylist", { playlistId, songId }) .then((response) => { toast( , ); }) .catch(() => { toast( , ); }); }, [], ); const handleSongSelect = useCallback((selectedSong: Song) => { // Find the song in the current queue and jump to it const songIndex = queue.findIndex(song => song.id === selectedSong.id); if (songIndex !== -1) { // Use the jumpToSong function which preserves history jumpToSong(songIndex); } }, [queue, jumpToSong]); // Enable client-side rendering useEffect(() => { setIsClient(true); // Load playlists once on component mount window.ipc .invoke("getAllPlaylists") .then(setPlaylists) .catch((err) => console.error("Failed to load playlists:", err)); // Clean up on unmount return () => { if (seekUpdateInterval.current) { clearInterval(seekUpdateInterval.current); } if (scrobbleTimeout.current) { clearInterval(scrobbleTimeout.current); } }; }, []); // Setup volume slider wheel event useEffect(() => { const volumeSlider = volumeSliderRef.current; if (!volumeSlider) return; volumeSlider.addEventListener('wheel', handleVolumeWheel, { passive: false }); return () => { volumeSlider.removeEventListener('wheel', handleVolumeWheel); }; }, [handleVolumeWheel]); useEffect(() => { document.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('keydown', handleKeyDown); }; }, [handleKeyDown]); // Update favorite status when song changes useEffect(() => { if (song) { setIsFavourite(favourite); } }, [song, favourite]); // Reset scrobble status when song changes useEffect(() => { setLastFmStatus({ isScrobbled: false, isNowPlaying: false, scrobbleTimerStarted: false, error: null, lastFmActive: lastFmStatus.lastFmActive, }); if (scrobbleTimeout.current) { clearInterval(scrobbleTimeout.current); } }, [song]); // Start scrobble timer when playing useEffect(() => { if ( isPlaying && song && lastFmSettings.enableLastFm && !lastFmStatus.scrobbleTimerStarted && isAuthenticated() ) { // Send now playing update to Last.fm console.log("[Last.fm] Sending now playing update"); updateNowPlaying(song) .then((success) => { setLastFmStatus((prev) => ({ ...prev, isNowPlaying: success, scrobbleTimerStarted: true, error: success ? null : "Failed to update now playing", })); console.log("[Last.fm] Now playing update success:", success); }) .catch((err) => { console.error("[Last.fm] Now playing error:", err); setLastFmStatus((prev) => ({ ...prev, error: "Error updating now playing", })); }); // Start scrobble timer handleScrobble(); } }, [ isPlaying, song, lastFmSettings, lastFmStatus.scrobbleTimerStarted, handleScrobble, ]); // Initialize or update audio when song changes useEffect(() => { // Clean up previous audio and intervals if (soundRef.current) { soundRef.current.unload(); } if (seekUpdateInterval.current) { clearInterval(seekUpdateInterval.current); } // Reset seek position immediately when song changes setSeekPosition(0); // No song to play, exit early if (!song?.filePath) return; // Create new Howl instance const sound = new Howl({ src: [`wora://${encodeURIComponent(song.filePath)}`], format: [song.filePath.split(".").pop()], html5: true, autoplay: true, preload: true, volume: isMuted ? 0 : volume, onload: () => { setSeekPosition(0); setIsPlaying(true); updateDiscordState(1, song); window.ipc.send("update-window", [true, song?.artist, song?.name]); }, onloaderror: (error) => { console.error("Error loading audio:", error); setIsPlaying(false); toast( , ); }, onend: () => { setIsPlaying(false); window.ipc.send("update-window", [false, null, null]); if (!repeat) { nextSong(); } }, onplay: () => { setIsPlaying(true); window.ipc.send("update-window", [true, song?.artist, song?.name]); }, onpause: () => { setIsPlaying(false); window.ipc.send("update-window", [false, false, false]); }, }); soundRef.current = sound; // Set up seek position updater seekUpdateInterval.current = setInterval(() => { if (sound.playing()) { setSeekPosition(sound.seek()); } }, 100); // Clean up on unmount or when song changes return () => { sound.unload(); if (seekUpdateInterval.current) { clearInterval(seekUpdateInterval.current); } }; }, [song, nextSong]); // Removed volume and isMuted from dependencies // Handle lyrics updates useEffect(() => { if (!lyrics || !song || !isPlaying) return; // Only parse lyrics if they exist and are synced if (!isSyncedLyrics(lyrics)) return; const parsedLyrics = parseLyrics(lyrics); let lyricUpdateInterval: NodeJS.Timeout; const updateCurrentLyric = () => { if (!soundRef.current?.playing()) return; const currentSeek = soundRef.current.seek(); const currentLyricLine = parsedLyrics.find((line, index) => { const nextLine = parsedLyrics[index + 1]; return ( currentSeek >= line.time && (!nextLine || currentSeek < nextLine.time) ); }); setCurrentLyric(currentLyricLine || null); }; // Update lyrics less frequently than seek position (better performance) lyricUpdateInterval = setInterval(updateCurrentLyric, 500); return () => clearInterval(lyricUpdateInterval); }, [song, lyrics, isPlaying]); // Setup MediaSession API for media controls useEffect(() => { if (!song || !("mediaSession" in navigator)) return; const updateMediaSessionMetadata = async () => { if ("mediaSession" in navigator && song) { const toDataURL = ( url: string, callback: (dataUrl: string) => void, ) => { const xhr = new XMLHttpRequest(); xhr.onload = () => { const reader = new FileReader(); reader.onloadend = () => callback(reader.result as string); reader.readAsDataURL(xhr.response); }; xhr.open("GET", url); xhr.responseType = "blob"; xhr.send(); }; const coverUrl = song.album?.cover ? song.album.cover.startsWith("/") || song.album.cover.includes("://") ? song.album.cover : `wora://${song.album.cover}` : "/coverArt.png"; toDataURL(coverUrl, (dataUrl) => { navigator.mediaSession.metadata = new MediaMetadata({ title: song?.name || "Unknown Title", artist: song?.artist || "Unknown Artist", album: song?.album?.name || "Unknown Album", artwork: [{ src: dataUrl }], }); // Set application name for Windows Media Controller if ("mediaSession" in navigator) { // @ts-ignore - applicationName is not in the official type definitions but works in Windows navigator.mediaSession.metadata.applicationName = "Wora"; } navigator.mediaSession.setActionHandler("play", handlePlayPause); navigator.mediaSession.setActionHandler("pause", handlePlayPause); navigator.mediaSession.setActionHandler( "previoustrack", previousSong, ); navigator.mediaSession.setActionHandler("nexttrack", nextSong); navigator.mediaSession.setActionHandler("seekbackward", () => { if (soundRef.current) { soundRef.current.seek(Math.max(0, soundRef.current.seek() - 10)); } }); navigator.mediaSession.setActionHandler("seekforward", () => { if (soundRef.current) { soundRef.current.seek( Math.min( soundRef.current.duration(), soundRef.current.seek() + 10, ), ); } }); }); } }; updateMediaSessionMetadata(); const removeMediaControlListener = window.ipc.on( "media-control", (command) => { switch (command) { case "play-pause": handlePlayPause(); break; case "previous": previousSong(); break; case "next": nextSong(); break; default: break; } }, ); return () => { removeMediaControlListener(); }; }, [song, previousSong, nextSong]); // Apply volume and mute settings when they change useEffect(() => { if (!soundRef.current) return; // When unmuting, set volume first, then unmute if (!isMuted) { soundRef.current.volume(volume); soundRef.current.mute(false); } else { soundRef.current.mute(true); } }, [volume, isMuted]); // Apply repeat setting when it changes useEffect(() => { if (soundRef.current) { soundRef.current.loop(repeat); } }, [repeat]); // Server-side rendering placeholder if (!isClient) { return (
    {/* Empty placeholder to prevent hydration errors */}
    ); } return (
    {showLyrics && lyrics && ( )}
    {showQueue && }
    {song ? (
    Album Cover
    Go to Album Add to Playlist {playlists.map((playlist) => ( addSongToPlaylist(playlist.id, song.id) } >

    {playlist.name}

    ))}
    ) : (
    Album Cover
    )}

    {song ? song.name : "Echoes of Emptiness"}

    {song ? song.artist : "The Void Ensemble"}

    {metadata?.format?.lossless && (
    Lossless [{metadata.format.bitsPerSample}/ {(metadata.format.sampleRate / 1000).toFixed(1)}kHz]
    )} {lastFmSettings.enableLastFm && lastFmSettings.lastFmSessionKey && lastFmStatus.lastFmActive && (
    {lastFmStatus.error ? (

    Error: {lastFmStatus.error}

    ) : lastFmStatus.isScrobbled ? (

    Scrobbled to Last.fm

    ) : lastFmStatus.isNowPlaying ? (

    Now playing on Last.fm
    Will scrobble at{" "} {lastFmSettings.scrobbleThreshold}%

    ) : (

    Will scrobble at{" "} {lastFmSettings.scrobbleThreshold}%

    )}
    )}

    {!isFavourite ? "Add to Favorites" : "Remove from Favorites"}

    {convertTime(seekPosition)}

    {convertTime(soundRef.current?.duration() || 0)}

    {lyrics ? ( ) : ( )} {song && ( Track Information Details for your currently playing song
    {/* Album cover */}
    {song.name
    {/* Track details */}

    → {metadata?.common?.title} [ {metadata?.format?.codec || "Unknown"}]

    Artist:{" "} {metadata?.common?.artist || "Unknown"}

    Album:{" "} {metadata?.common?.album || "Unknown"}

    Codec:{" "} {metadata?.format?.codec || "Unknown"}

    Sample:{" "} {metadata?.format?.lossless ? `Lossless [${metadata.format.bitsPerSample}/${(metadata.format.sampleRate / 1000).toFixed(1)}kHz]` : "Lossy Audio"}

    Duration:{" "} {convertTime(soundRef.current?.duration() || 0)}

    Genre:{" "} {metadata?.common?.genre?.[0] || "Unknown"}

    {lastFmSettings.enableLastFm && lastFmStatus.lastFmActive && (

    Last.fm:{" "} {lastFmStatus.error ? ( Error: {lastFmStatus.error} ) : lastFmStatus.isScrobbled ? ( "Scrobbled" ) : lastFmStatus.isNowPlaying ? ( <> Now playing (will scrobble at{" "} {lastFmSettings.scrobbleThreshold}%) ) : ( <> Waiting to scrobble at{" "} {lastFmSettings.scrobbleThreshold}% )}

    )}
    )}
    ); }; export default Player; ================================================ FILE: renderer/components/themeProvider.tsx ================================================ "use client"; import * as React from "react"; import { ThemeProvider as NextThemesProvider } from "next-themes"; import { type ThemeProviderProps } from "next-themes"; export function ThemeProvider({ children, ...props }: ThemeProviderProps) { return {children}; } ================================================ FILE: renderer/components/ui/actions.tsx ================================================ import { IconBox, IconLine, IconLineDashed, IconSquare, IconX, } from "@tabler/icons-react"; import Image from "next/image"; import { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; type Data = { appVersion: string; isNotMac: boolean; }; function Actions() { const [data, setData] = useState(null); const [isMaximized, setIsMaximized] = useState(false); useEffect(() => { window.ipc.invoke("getActionsData").then((response) => { setData(response); }); }, []); return (
    logo logo Wora
    {data && data.isNotMac && ( <> )}
    ); } export default Actions; ================================================ FILE: renderer/components/ui/album.tsx ================================================ import Image from "next/image"; import Link from "next/link"; import React from "react"; type Album = { id: string; name: string; artist: string; cover: string; }; type AlbumCardProps = { album: Album; }; const AlbumCard: React.FC = ({ album }) => { return (
    {album

    {album.name}

    {album.artist}

    ); }; export default AlbumCard; ================================================ FILE: renderer/components/ui/avatar.tsx ================================================ "use client"; import * as React from "react"; import * as AvatarPrimitive from "@radix-ui/react-avatar"; import { cn } from "@/lib/utils"; const Avatar = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); Avatar.displayName = AvatarPrimitive.Root.displayName; const AvatarImage = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); AvatarImage.displayName = AvatarPrimitive.Image.displayName; const AvatarFallback = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; export { Avatar, AvatarImage, AvatarFallback }; ================================================ FILE: renderer/components/ui/badge.tsx ================================================ import * as React from "react"; import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; const badgeVariants = cva( "inline-flex items-center rounded-full py-1 px-2 text-[0.675rem]", { variants: { variant: { default: "bg-black/5 dark:bg-white/10", secondary: "border-transparent bg-neutral-100 text-neutral-900 hover:bg-neutral-100/80 dark:bg-neutral-800 dark:text-neutral-50 dark:hover:bg-neutral-800/80", destructive: "border-transparent bg-red-500 text-neutral-50 shadow-sm hover:bg-red-500/80 dark:bg-red-900 dark:text-neutral-50 dark:hover:bg-red-900/80", outline: "text-neutral-950 dark:text-neutral-50", }, }, defaultVariants: { variant: "default", }, }, ); export interface BadgeProps extends React.HTMLAttributes, VariantProps {} function Badge({ className, variant, ...props }: BadgeProps) { return (
    ); } export { Badge, badgeVariants }; ================================================ FILE: renderer/components/ui/button.tsx ================================================ import * as React from "react"; import { Slot } from "@radix-ui/react-slot"; import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; const buttonVariants = cva( "cursor-pointer active:scale-90 inline-flex py-2.5 px-4 items-center gap-2 rounded-xl wora-transition", { variants: { variant: { default: "bg-white/70 dark:bg-black/30 hover:scale-95 wora-border", destructive: "bg-red-500/10 hover:scale-95 border border-red-500/15", outline: "border border-neutral-200 bg-white shadow-xs hover:bg-neutral-100 hover:text-neutral-900 dark:border-neutral-800 dark:bg-neutral-950 dark:hover:bg-neutral-800 dark:hover:text-neutral-50", ghost: "wora-transition opacity-30 hover:opacity-100 p-0", }, }, defaultVariants: { variant: "default", }, }, ); export interface ButtonProps extends React.ButtonHTMLAttributes, VariantProps { asChild?: boolean; } const Button = React.forwardRef( ({ className, variant, asChild = false, ...props }, ref) => { const Comp = asChild ? Slot : "button"; return ( ); }, ); Button.displayName = "Button"; export { Button, buttonVariants }; ================================================ FILE: renderer/components/ui/carousel.tsx ================================================ "use client"; import * as React from "react"; import useEmblaCarousel, { type UseEmblaCarouselType, } from "embla-carousel-react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { IconArrowLeft, IconArrowRight, IconChevronCompactLeft, IconChevronLeft, IconChevronRight, } from "@tabler/icons-react"; type CarouselApi = UseEmblaCarouselType[1]; type UseCarouselParameters = Parameters; type CarouselOptions = UseCarouselParameters[0]; type CarouselPlugin = UseCarouselParameters[1]; type CarouselProps = { opts?: CarouselOptions; plugins?: CarouselPlugin; orientation?: "horizontal" | "vertical"; setApi?: (api: CarouselApi) => void; }; type CarouselContextProps = { carouselRef: ReturnType[0]; api: ReturnType[1]; scrollPrev: () => void; scrollNext: () => void; canScrollPrev: boolean; canScrollNext: boolean; } & CarouselProps; const CarouselContext = React.createContext(null); function useCarousel() { const context = React.useContext(CarouselContext); if (!context) { throw new Error("useCarousel must be used within a "); } return context; } const Carousel = React.forwardRef< HTMLDivElement, React.HTMLAttributes & CarouselProps >( ( { orientation = "horizontal", opts, setApi, plugins, className, children, ...props }, ref, ) => { const [carouselRef, api] = useEmblaCarousel( { ...opts, axis: orientation === "horizontal" ? "x" : "y", }, plugins, ); const [canScrollPrev, setCanScrollPrev] = React.useState(false); const [canScrollNext, setCanScrollNext] = React.useState(false); const onSelect = React.useCallback((api: CarouselApi) => { if (!api) { return; } setCanScrollPrev(api.canScrollPrev()); setCanScrollNext(api.canScrollNext()); }, []); const scrollPrev = React.useCallback(() => { api?.scrollPrev(); }, [api]); const scrollNext = React.useCallback(() => { api?.scrollNext(); }, [api]); const handleKeyDown = React.useCallback( (event: React.KeyboardEvent) => { if (event.key === "ArrowLeft") { event.preventDefault(); scrollPrev(); } else if (event.key === "ArrowRight") { event.preventDefault(); scrollNext(); } }, [scrollPrev, scrollNext], ); React.useEffect(() => { if (!api || !setApi) { return; } setApi(api); }, [api, setApi]); React.useEffect(() => { if (!api) { return; } onSelect(api); api.on("reInit", onSelect); api.on("select", onSelect); return () => { api?.off("select", onSelect); }; }, [api, onSelect]); return (
    {children}
    ); }, ); Carousel.displayName = "Carousel"; const CarouselContent = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => { const { carouselRef, orientation } = useCarousel(); return (
    ); }); CarouselContent.displayName = "CarouselContent"; const CarouselItem = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => { const { orientation } = useCarousel(); return (
    ); }); CarouselItem.displayName = "CarouselItem"; const CarouselPrevious = React.forwardRef< HTMLButtonElement, React.ComponentProps >(({ className, variant = "ghost", ...props }, ref) => { const { orientation, scrollPrev, canScrollPrev } = useCarousel(); return ( ); }); CarouselPrevious.displayName = "CarouselPrevious"; const CarouselNext = React.forwardRef< HTMLButtonElement, React.ComponentProps >(({ className, variant = "ghost", ...props }, ref) => { const { orientation, scrollNext, canScrollNext } = useCarousel(); return ( ); }); CarouselNext.displayName = "CarouselNext"; export { type CarouselApi, Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext, }; ================================================ FILE: renderer/components/ui/command.tsx ================================================ "use client"; import * as React from "react"; import { type DialogProps } from "@radix-ui/react-dialog"; import { Command as CommandPrimitive } from "cmdk"; import { cn } from "@/lib/utils"; import { Dialog, DialogContent } from "@/components/ui/dialog"; import { IconSearch } from "@tabler/icons-react"; const Command = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); Command.displayName = CommandPrimitive.displayName; interface CommandDialogProps extends DialogProps {} const CommandDialog = ({ children, ...props }: CommandDialogProps) => { return ( {children} ); }; const CommandInput = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => (
    )); CommandInput.displayName = CommandPrimitive.Input.displayName; const CommandList = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); CommandList.displayName = CommandPrimitive.List.displayName; const CommandEmpty = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >((props, ref) => ( )); CommandEmpty.displayName = CommandPrimitive.Empty.displayName; const CommandGroup = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); CommandGroup.displayName = CommandPrimitive.Group.displayName; const CommandSeparator = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); CommandSeparator.displayName = CommandPrimitive.Separator.displayName; const CommandItem = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); CommandItem.displayName = CommandPrimitive.Item.displayName; const CommandShortcut = ({ className, ...props }: React.HTMLAttributes) => { return ( ); }; CommandShortcut.displayName = "CommandShortcut"; export { Command, CommandDialog, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandShortcut, CommandSeparator, }; ================================================ FILE: renderer/components/ui/context-menu.tsx ================================================ "use client"; import * as React from "react"; import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"; import { cn } from "@/lib/utils"; import { IconCheck, IconChevronRight, IconCircleFilled, } from "@tabler/icons-react"; const ContextMenu = ContextMenuPrimitive.Root; const ContextMenuTrigger = ContextMenuPrimitive.Trigger; const ContextMenuGroup = ContextMenuPrimitive.Group; const ContextMenuPortal = ContextMenuPrimitive.Portal; const ContextMenuSub = ContextMenuPrimitive.Sub; const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup; const ContextMenuSubTrigger = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { inset?: boolean; } >(({ className, inset, children, ...props }, ref) => ( {children} )); ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName; const ContextMenuSubContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName; const ContextMenuContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName; const ContextMenuItem = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { inset?: boolean; } >(({ className, inset, ...props }, ref) => ( )); ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName; const ContextMenuCheckboxItem = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, children, checked, ...props }, ref) => ( {children} )); ContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName; const ContextMenuRadioItem = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, children, ...props }, ref) => ( {children} )); ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName; const ContextMenuLabel = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { inset?: boolean; } >(({ className, inset, ...props }, ref) => ( )); ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName; const ContextMenuSeparator = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName; const ContextMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => { return ( ); }; ContextMenuShortcut.displayName = "ContextMenuShortcut"; export { ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuItem, ContextMenuCheckboxItem, ContextMenuRadioItem, ContextMenuLabel, ContextMenuSeparator, ContextMenuShortcut, ContextMenuGroup, ContextMenuPortal, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuRadioGroup, }; ================================================ FILE: renderer/components/ui/dialog.tsx ================================================ "use client"; import * as React from "react"; import * as DialogPrimitive from "@radix-ui/react-dialog"; import { cn } from "@/lib/utils"; import { IconX } from "@tabler/icons-react"; const Dialog = DialogPrimitive.Root; const DialogTrigger = DialogPrimitive.Trigger; const DialogPortal = DialogPrimitive.Portal; const DialogClose = DialogPrimitive.Close; const DialogOverlay = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; const DialogContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, children, ...props }, ref) => ( {children} Close )); DialogContent.displayName = DialogPrimitive.Content.displayName; const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => (
    ); DialogHeader.displayName = "DialogHeader"; const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => (
    ); DialogFooter.displayName = "DialogFooter"; const DialogTitle = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); DialogTitle.displayName = DialogPrimitive.Title.displayName; const DialogDescription = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); DialogDescription.displayName = DialogPrimitive.Description.displayName; export { Dialog, DialogPortal, DialogOverlay, DialogTrigger, DialogClose, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription, }; ================================================ FILE: renderer/components/ui/form.tsx ================================================ import * as React from "react"; import * as LabelPrimitive from "@radix-ui/react-label"; import { Slot } from "@radix-ui/react-slot"; import { Controller, ControllerProps, FieldPath, FieldValues, FormProvider, useFormContext, } from "react-hook-form"; import { cn } from "@/lib/utils"; import { Label } from "@/components/ui/label"; const Form = FormProvider; type FormFieldContextValue< TFieldValues extends FieldValues = FieldValues, TName extends FieldPath = FieldPath, > = { name: TName; }; const FormFieldContext = React.createContext( {} as FormFieldContextValue, ); const FormField = < TFieldValues extends FieldValues = FieldValues, TName extends FieldPath = FieldPath, >({ ...props }: ControllerProps) => { return ( ); }; const useFormField = () => { const fieldContext = React.useContext(FormFieldContext); const itemContext = React.useContext(FormItemContext); const { getFieldState, formState } = useFormContext(); const fieldState = getFieldState(fieldContext.name, formState); if (!fieldContext) { throw new Error("useFormField should be used within "); } const { id } = itemContext; return { id, name: fieldContext.name, formItemId: `${id}-form-item`, formDescriptionId: `${id}-form-item-description`, formMessageId: `${id}-form-item-message`, ...fieldState, }; }; type FormItemContextValue = { id: string; }; const FormItemContext = React.createContext( {} as FormItemContextValue, ); const FormItem = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => { const id = React.useId(); return (
    ); }); FormItem.displayName = "FormItem"; const FormLabel = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => { const { error, formItemId } = useFormField(); return (