Showing preview only (481K chars total). Download the full file or copy to clipboard to get everything.
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
================================================
<p align="center">
<img src="https://github.com/playwora/wora/blob/main/renderer/public/github/Header.png?raw=true" alt="Wora Logo" />
</p>
<p align="center">
<a href="https://github.com/playwora/wora"><img alt="GitHub Actions Workflow Status" src="https://img.shields.io/github/actions/workflow/status/playwora/wora/release.yml"></a>
<a href="https://github.com/playwora/wora"><img src="https://img.shields.io/github/last-commit/playwora/wora/main?commit" alt="Last Commit" /></a>
<a href="LICENSE"><img src="https://img.shields.io/github/license/playwora/wora?license" alt="License" /></a>
<a href="https://discord.gg/CrAbAYMGCe"><img src="https://dcbadge.limes.pink/api/server/https://discord.gg/CrAbAYMGCe?style=flat" alt="Discord" /></a>
<a href="https://github.com/playwora/wora/stargazers"><img src="https://img.shields.io/github/stars/playwora/wora?style=flat&stars" alt="GitHub Stars" /></a>
<a href="https://github.com/playwora/wora/network"><img src="https://img.shields.io/github/forks/playwora/wora?style=flat&forks" alt="GitHub Forks" /></a>
<a href="https://github.com/playwora/wora/watchers"><img src="https://img.shields.io/github/watchers/playwora/wora?style=flat&watchers" alt="GitHub Watchers" /></a>
</p>
## 🤝 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
================================================
<p align="center">
<img src="https://github.com/playwora/wora/blob/main/renderer/public/github/Header.png?raw=true" alt="Wora Logo" />
</p>
<p align="center">
<a href="https://github.com/playwora/wora"><img alt="GitHub Actions Workflow Status" src="https://img.shields.io/github/actions/workflow/status/playwora/wora/release.yml"></a>
<a href="https://github.com/playwora/wora"><img src="https://img.shields.io/github/last-commit/playwora/wora/main?commit" alt="Last Commit" /></a>
<a href="LICENSE"><img src="https://img.shields.io/github/license/playwora/wora?license" alt="License" /></a>
<a href="https://discord.gg/CrAbAYMGCe"><img src="https://dcbadge.limes.pink/api/server/https://discord.gg/CrAbAYMGCe?style=flat" alt="Discord" /></a>
<a href="https://github.com/playwora/wora/stargazers"><img src="https://img.shields.io/github/stars/playwora/wora?style=flat&stars" alt="GitHub Stars" /></a>
<a href="https://github.com/playwora/wora/network"><img src="https://img.shields.io/github/forks/playwora/wora?style=flat&forks" alt="GitHub Forks" /></a>
<a href="https://github.com/playwora/wora/watchers"><img src="https://img.shields.io/github/watchers/playwora/wora?style=flat&watchers" alt="GitHub Watchers" /></a>
</p>
## 🤝 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 🛠️
<a href="https://discord.gg/CrAbAYMGCe"><img src="https://dcbadge.limes.pink/api/server/https://discord.gg/CrAbAYMGCe?style=flat" alt="Discord" /></a>
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 🤝
<a href="https://discord.gg/CrAbAYMGCe"><img src="https://dcbadge.limes.pink/api/server/https://discord.gg/CrAbAYMGCe?style=flat" alt="Discord"></a>
---
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.
<p align="center">
<img src="https://github.com/playwora/wora/blob/main/renderer/public/github/Header.png?raw=true" alt="Wora Logo" />
</p>
<p align="center">
<a href="https://github.com/playwora/wora"><img alt="GitHub Actions Workflow Status" src="https://img.shields.io/github/actions/workflow/status/playwora/wora/release.yml"></a>
<a href="https://github.com/playwora/wora"><img src="https://img.shields.io/github/last-commit/playwora/wora/main?commit" alt="Last Commit" /></a>
<a href="LICENSE"><img src="https://img.shields.io/github/license/playwora/wora?license" alt="License" /></a>
<a href="https://discord.gg/CrAbAYMGCe"><img src="https://dcbadge.limes.pink/api/server/https://discord.gg/CrAbAYMGCe?style=flat" alt="Discord" /></a>
<a href="https://github.com/playwora/wora/stargazers"><img src="https://img.shields.io/github/stars/playwora/wora?style=flat&stars" alt="GitHub Stars" /></a>
<a href="https://github.com/playwora/wora/network"><img src="https://img.shields.io/github/forks/playwora/wora?style=flat&forks" alt="GitHub Forks" /></a>
<a href="https://github.com/playwora/wora/releases"><img alt="GitHub Downloads" src="https://img.shields.io/github/downloads/playwora/wora/total?style=flat"></a>
</p>
## ⭐️ 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 ✨
<p align="center">
<img src="https://github.com/playwora/wora/blob/main/renderer/public/github/Home%20Page.png?raw=true" alt="Screenshot 1" />
<img src="https://github.com/playwora/wora/blob/main/renderer/public/github/Search%20Console.png?raw=true" alt="Screenshot 2" />
<img src="https://github.com/playwora/wora/blob/main/renderer/public/github/Album%20Page.png?raw=true" alt="Screenshot 3" />
<img src="https://github.com/playwora/wora/blob/main/renderer/public/github/Synced%20Lyrics.png?raw=true" alt="Screenshot 4" />
</p>
## 🚀 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 🛠️
<a href="https://discord.gg/CrAbAYMGCe"><img src="https://dcbadge.limes.pink/api/server/https://discord.gg/CrAbAYMGCe?style=flat" alt="Discord" /></a>
### 〽️ 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.
<a href="https://discord.gg/CrAbAYMGCe"><img src="https://dcbadge.limes.pink/api/server/https://discord.gg/CrAbAYMGCe?style=flat" alt="Discord"></a>
---
<br />
<a href="https://vercel.com/oss">
<img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge.svg" />
</a>
<br />
<br />
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<Rectangle>({ 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<typeof schema> = 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<Set<string>> {
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<string>) {
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<string, any>) {
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<string> {
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<string> {
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<string>();
const formats = new Set<string>();
// 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<string>();
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<number>`COUNT(DISTINCT ${albums.id})`,
cover: sql<string>`MAX(${albums.cover})`,
})
.from(albums)
.where(isNotNull(albums.artist))
.groupBy(albums.artist);
const songStats = await db
.select({
artist: songs.artist,
songCount: sql<number>`COUNT(*)`,
})
.from(songs)
.where(isNotNull(songs.artist))
.groupBy(songs.artist);
const songCountMap = new Map<string, number>();
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<string, { data: any; timestamp: number }>();
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, string>): 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<string, string>,
isAuthRequest: boolean = false,
): Promise<any> => {
try {
// Always add these parameters
const requestParams: Record<string, string> = {
...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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<Props, State> {
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 (
<div className="flex h-full w-full flex-col items-center justify-center p-8">
<div className="max-w-md text-center">
<IconAlertTriangle size={48} className="mx-auto mb-4 text-red-500" />
<h2 className="mb-2 text-xl font-semibold">Something went wrong</h2>
<p className="mb-4 text-sm opacity-70">
An unexpected error occurred. The application may not work correctly.
</p>
{process.env.NODE_ENV !== 'production' && this.state.error && (
<details className="mb-4 rounded-lg bg-gray-100 p-3 text-left dark:bg-gray-800">
<summary className="cursor-pointer text-xs font-medium">Error details</summary>
<pre className="mt-2 overflow-auto text-xs">
{this.state.error.toString()}
{this.state.error.stack}
</pre>
</details>
)}
<Button
onClick={this.handleReset}
className="flex items-center gap-2"
>
<IconRefresh size={16} />
Reload Application
</Button>
</div>
</div>
);
}
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 (
<div className={gridClass}>
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="flex items-center gap-4 p-3">
<Skeleton className="h-12 w-12 rounded-lg" />
<div className="flex-1">
<Skeleton className="h-4 w-32 mb-2" />
<Skeleton className="h-3 w-24" />
</div>
</div>
))}
</div>
);
}
return (
<div className={gridClass}>
{Array.from({ length: count }).map((_, i) => (
<div key={i}>
<Skeleton className={`aspect-square ${isLarge ? 'rounded-xl' : 'rounded-lg'}`} />
<Skeleton className={`h-4 w-3/4 ${isLarge ? 'mt-3' : 'mt-2'} mb-1`} />
<Skeleton className="h-3 w-1/2" />
</div>
))}
</div>
);
}
export function AlbumGridSkeleton({ count = 12 }: { count?: number }) {
return (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{Array.from({ length: count }).map((_, i) => (
<div key={i}>
<Skeleton className="aspect-square rounded-lg" />
<Skeleton className="h-4 w-3/4 mt-2 mb-1" />
<Skeleton className="h-3 w-1/2" />
</div>
))}
</div>
);
}
export function SongListSkeleton({ count = 10 }: { count?: number }) {
return (
<div className="space-y-1">
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="flex items-center gap-4 p-3">
<Skeleton className="h-12 w-12 rounded" />
<div className="flex-1">
<Skeleton className="h-4 w-48 mb-2" />
<Skeleton className="h-3 w-32" />
</div>
<Skeleton className="h-4 w-12" />
</div>
))}
</div>
);
}
export function ArtistDetailSkeleton() {
return (
<div>
<div className="relative h-96 w-full overflow-hidden rounded-2xl">
<Skeleton className="h-full w-full" />
<div className="absolute bottom-6 left-6">
<div className="flex items-end gap-6">
<Skeleton className="h-52 w-52 rounded-xl" />
<div>
<Skeleton className="h-12 w-64 mb-2" />
<Skeleton className="h-4 w-48" />
</div>
</div>
</div>
</div>
<div className="mt-8 space-y-4">
<Skeleton className="h-32 w-full rounded-lg" />
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<Skeleton className="h-24 rounded-lg" />
<Skeleton className="h-24 rounded-lg" />
<Skeleton className="h-24 rounded-lg" />
<Skeleton className="h-24 rounded-lg" />
</div>
</div>
</div>
);
}
================================================
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 (
<AnimatePresence mode="sync" initial={false}> {/* sync = crossfade, wait = sequential */}
<motion.div
key={router.asPath}
initial="initial"
animate="in"
exit="out"
variants={pageVariants}
transition={pageTransition}
style={{ height: '100%' }}
>
{children}
</motion.div>
</AnimatePresence>
);
}
================================================
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<LyricsProps> = React.memo(
({ lyrics, currentLyric, onLyricClick, isSyncedLyrics }) => {
const lyricsRef = useRef<HTMLDivElement>(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 (
<div className="wora-border relative h-full w-full rounded-2xl bg-white/70 backdrop-blur-xl dark:bg-black/70">
<div className="absolute right-6 bottom-5 z-50 flex items-center gap-2">
<Badge>{isSyncedLyrics ? "Synced" : "Unsynced"}</Badge>
</div>
<div className="h-utility mask flex w-full items-center overflow-y-auto mask-y-from-70% px-8 text-2xl font-medium text-balance">
<div
ref={lyricsRef}
className="no-scrollbar h-full w-full py-[33vh]"
style={{ overflowY: "auto" }}
>
{lyrics.map((line) => (
<p
key={line.time}
id={`line-${line.time}`}
className={cn(
currentLyric?.time === line.time
? "scale-125 font-semibold"
: "opacity-40",
"my-2 max-w-xl origin-left transform-gpu cursor-pointer rounded-xl p-4 lowercase transition-transform duration-700 hover:bg-black/5 dark:hover:bg-white/10",
)}
onClick={() => onLyricClick(line.time)}
>
{line.text}
</p>
))}
</div>
</div>
</div>
);
},
);
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<Settings | null>(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: <IconInbox stroke={2} className="w-5" />,
label: "Home",
},
{
href: "/playlists",
icon: <IconVinyl stroke={2} size={20} />,
label: "Playlists",
},
{
href: "/songs",
icon: <IconList stroke={2} size={20} />,
label: "Songs",
},
{
href: "/albums",
icon: <IconFocusCentered stroke={2} size={20} />,
label: "Albums",
},
{
href: "/artists",
icon: <IconUser stroke={2} size={20} />,
label: "Artists",
},
];
const handleThemeToggle = () => {
if (theme === "light") {
setTheme("dark");
} else if (theme === "dark") {
setTheme("system");
} else {
setTheme("light");
}
};
const renderIcon = () => {
if (!mounted) {
return <IconDeviceDesktop stroke={2} className="w-5" />;
}
if (theme === "light") {
return <IconSun stroke={2} className="w-5" />;
} else if (theme === "dark") {
return <IconMoon stroke={2} className="w-5" />;
} else {
return <IconDeviceDesktop stroke={2} className="w-5" />;
}
};
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 (
<>
<div className="flex h-full flex-col items-center justify-center gap-10">
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger>
<Link href="/settings">
<Avatar className="h-8 w-8">
<AvatarImage
src={`${settings && settings.profilePicture ? "wora://" + settings.profilePicture : "/userPicture.png"}`}
/>
</Avatar>
</Link>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={25}>
<p>{settings && settings.name ? settings.name : "Wora User"}</p>
</TooltipContent>
</Tooltip>
<div className="wora-border flex w-18 flex-col items-center gap-10 rounded-2xl p-8 transition-all duration-300 ease-in-out">
<div
className={`transition-all duration-300 ease-in-out ${
canGoBack ? 'max-h-12 opacity-100' : 'max-h-0 opacity-0 -mt-10 overflow-hidden'
}`}
>
{(canGoBack || isBackButtonVisible) && (
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant="ghost"
onClick={() => router.back()}
className={`transition-all duration-300 ${
isBackButtonVisible ? 'scale-100 opacity-100' : 'scale-95 opacity-0'
}`}
>
<IconArrowLeft stroke={2} className="w-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={50}>
<p>Back</p>
</TooltipContent>
</Tooltip>
)}
</div>
{navLinks.map((link) => (
<Tooltip key={link.href} delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant="ghost"
className={isActive(link.href) && "opacity-100"}
>
<Link
href={link.href}
onClick={(e) => handleNavigation(link.href, e)}
className="flex h-full w-full items-center justify-center"
>
{link.icon}
</Link>
</Button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={50}>
<p>{link.label}</p>
</TooltipContent>
</Tooltip>
))}
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant="ghost" onClick={openSearch}>
<IconSearch stroke={2} className="w-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={50}>
<p>Search</p>
</TooltipContent>
</Tooltip>
</div>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant="ghost" onClick={handleThemeToggle}>
{renderIcon()}
</Button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={25}>
<p className="capitalize">Theme: {mounted ? theme : 'system'}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<CommandDialog open={open} onOpenChange={setOpen}>
<Command>
<CommandInput
placeholder="Search for a song, album or playlist..."
value={search}
onValueChange={setSearch}
/>
<CommandList>
{loading && (
<div className="flex h-[325px] w-full items-center justify-center">
<Spinner className="h-6 w-6" />
</div>
)}
{search && !loading ? (
<CommandGroup heading="Search Results" className="pb-2">
{searchResults.map((item) => (
<CommandItem
key={`${item.type}-${item.id || item.name}`}
value={`${item.name}-${item.type}-${item.id || ""}`}
onSelect={() => handleItemClick(item)}
className="text-black dark:text-white"
>
<div className="flex h-full w-full items-center gap-2.5 mask-r-from-70%">
{(item.type === "Playlist" || item.type === "Album") && (
<div className="relative h-12 w-12 overflow-hidden rounded-lg shadow-xl transition duration-300">
<Image
className="object-cover"
src={`wora://${item.cover}`}
alt={item.name}
fill
/>
</div>
)}
{item.type === "Artist" && (
<div className="dark:bg.white/10 flex h-12 w-12 items-center justify-center rounded-lg bg-black/10">
<IconUser stroke={1.5} size={24} />
</div>
)}
<div>
<p className="w-full overflow-hidden text-xs text-nowrap">
{item.name}
<span className="ml-1 opacity-50">({item.type})</span>
</p>
<p className="w-full text-xs opacity-50">
{item.type === "Playlist"
? item.description
: item.type === "Artist"
? "Artist"
: item.artist}
</p>
</div>
</div>
</CommandItem>
))}
</CommandGroup>
) : (
<div className="flex h-[325px] w-full items-center justify-center text-xs">
<div className="dark:bg.white/10 ml-2 rounded-lg bg-black/5 px-1.5 py-1 shadow-xs">
⌘ / Ctrl + F
</div>
</div>
)}
</CommandList>
</Command>
</CommandDialog>
</>
);
};
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 }) => (
<div className="flex w-fit items-center gap-2 text-xs">
{success ? (
<IconCheck className="text-green-400" stroke={2} size={16} />
) : (
<IconX className="text-red-500" stroke={2} size={16} />
)}
{message}
</div>
);
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 (
<div style={style}>
<li
className="flex w-full items-center gap-4 overflow-hidden cursor-pointer hover:bg-black/5 dark:hover:bg-white/5 rounded-lg p-2 transition-colors"
onClick={() => data.onSongSelect(song)}
>
<div className="relative min-h-14 min-w-14 overflow-hidden rounded-lg shadow-lg">
<Image
alt={song.name || "Track"}
src={getAlbumCoverUrl(song)}
fill
priority={false}
className="object-cover"
/>
</div>
<div className="w-4/5 overflow-hidden">
<p className="truncate text-sm font-medium">{song.name}</p>
<p className="truncate opacity-50">{song.artist}</p>
</div>
</li>
</div>
);
};
const queueSongs = queue.slice(currentIndex + 1);
const historySongs = [...history].reverse();
return (
<div className="wora-border relative h-full w-full rounded-2xl bg-white/70 backdrop-blur-xl dark:bg-black/70 pointer-events-auto">
<div className="h-utility w-full max-w-3xl px-6 pt-6 pointer-events-auto">
<Tabs
defaultValue="queue"
className="flex h-full w-full flex-col gap-4 mask-b-from-70% pointer-events-auto"
>
<TabsList className="w-full pointer-events-auto">
<TabsTrigger value="queue" className="w-full gap-2 cursor-pointer pointer-events-auto">
<IconListTree stroke={2} size={15} /> Queue
</TabsTrigger>
<TabsTrigger value="history" className="w-full gap-2 cursor-pointer pointer-events-auto">
<IconClock stroke={2} size={15} /> History
</TabsTrigger>
</TabsList>
<TabsContent
value="queue"
className="flex-1 min-h-0 pointer-events-auto"
>
{queueSongs.length > 0 ? (
<ErrorBoundary>
<AutoSizer>
{({ height, width }) => (
<List
height={height}
width={width}
itemCount={queueSongs.length}
itemSize={ITEM_HEIGHT}
itemData={{ songs: queueSongs, onSongSelect }}
className="no-scrollbar pointer-events-auto"
>
{VirtualizedSongListItem}
</List>
)}
</AutoSizer>
</ErrorBoundary>
) : (
<div className="flex h-40 items-center justify-center text-sm opacity-50 pointer-events-none">
Queue is empty
</div>
)}
</TabsContent>
<TabsContent
value="history"
className="flex-1 min-h-0 pointer-events-auto"
>
{historySongs.length > 0 ? (
<ErrorBoundary>
<AutoSizer>
{({ height, width }) => (
<List
height={height}
width={width}
itemCount={historySongs.length}
overscanCount={5}
itemSize={ITEM_HEIGHT}
itemData={{ songs: historySongs, onSongSelect }}
className="no-scrollbar pointer-events-auto"
>
{VirtualizedSongListItem}
</List>
)}
</AutoSizer>
</ErrorBoundary>
) : (
<div className="flex h-40 items-center justify-center text-sm opacity-50 pointer-events-none">
No playback history
</div>
)}
</TabsContent>
</Tabs>
</div>
</div>
);
});
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<NodeJS.Timeout | null>(null);
// References
const soundRef = useRef<Howl | null>(null);
const seekUpdateInterval = useRef<NodeJS.Timeout | null>(null);
const volumeSliderRef = useRef<HTMLDivElement | null>(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(
<NotificationToast
success={response === true}
message={
response === true
? "Song added to playlist"
: "Song already exists in playlist"
}
/>,
);
})
.catch(() => {
toast(
<NotificationToast
success={false}
message="Failed to add song to playlist"
/>,
);
});
},
[],
);
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(
<NotificationToast success={false} message="Failed to load audio" />,
);
},
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 (
<div className="wora-border h-28 w-full overflow-hidden rounded-2xl p-6">
<div className="relative flex h-full w-full items-center">
{/* Empty placeholder to prevent hydration errors */}
</div>
</div>
);
}
return (
<div>
<div className="absolute top-0 right-0 w-full">
{showLyrics && lyrics && (
<Lyrics
lyrics={parseLyrics(lyrics)}
currentLyric={currentLyric}
onLyricClick={handleLyricClick}
isSyncedLyrics={isSyncedLyrics(lyrics)}
/>
)}
</div>
<div className="!absolute top-0 right-0 w-96">
{showQueue && <QueuePanel queue={queue} history={history} currentIndex={currentIndex} onSongSelect={handleSongSelect} />}
</div>
<div className="wora-border h-28 w-full overflow-hidden rounded-2xl p-6">
<div className="relative flex h-full w-full items-center">
<TooltipProvider>
<div className="absolute left-0 flex w-1/4 items-center justify-start gap-4 overflow-hidden">
{song ? (
<ContextMenu>
<ContextMenuTrigger>
<Link
href={song.album?.id ? `/albums/${song.album.id}` : "#"}
>
<div className="relative min-h-17 min-w-17 overflow-hidden rounded-lg shadow-lg transition">
<Image
alt="Album Cover"
src={`wora://${song?.album.cover}`}
fill
priority={true}
className="object-cover object-center"
/>
</div>
</Link>
</ContextMenuTrigger>
<ContextMenuContent className="w-64">
<Link href={`/albums/${song.album?.id}`}>
<ContextMenuItem className="flex items-center gap-2">
<IconVinyl stroke={2} size={14} />
Go to Album
</ContextMenuItem>
</Link>
<ContextMenuSub>
<ContextMenuSubTrigger className="flex items-center gap-2">
<IconPlus stroke={2} size={14} />
Add to Playlist
</ContextMenuSubTrigger>
<ContextMenuSubContent className="w-52">
{playlists.map((playlist) => (
<ContextMenuItem
key={playlist.id}
onClick={() =>
addSongToPlaylist(playlist.id, song.id)
}
>
<p className="w-full truncate">{playlist.name}</p>
</ContextMenuItem>
))}
</ContextMenuSubContent>
</ContextMenuSub>
</ContextMenuContent>
</ContextMenu>
) : (
<div className="relative min-h-17 min-w-17 overflow-hidden rounded-lg shadow-lg">
<Image
alt="Album Cover"
src="/coverArt.png"
fill
priority={true}
className="object-cover"
/>
</div>
)}
<div className="w-full">
<p className="truncate text-sm font-medium">
{song ? song.name : "Echoes of Emptiness"}
</p>
<Link
href={
song ? `/artists/${encodeURIComponent(song.artist)}` : "#"
}
>
<p className="cursor-pointer truncate opacity-50 hover:underline hover:opacity-80">
{song ? song.artist : "The Void Ensemble"}
</p>
</Link>
</div>
</div>
<div className="absolute right-0 left-0 mx-auto flex h-full w-2/4 flex-col items-center justify-between gap-4">
<div className="flex h-full w-full items-center justify-center gap-8">
{metadata?.format?.lossless && (
<div className="flex">
<Tooltip delayDuration={0}>
<TooltipTrigger>
<IconRipple
stroke={2}
className="w-3.5 cursor-pointer"
/>
</TooltipTrigger>
<TooltipContent side="left" sideOffset={25}>
Lossless [{metadata.format.bitsPerSample}/
{(metadata.format.sampleRate / 1000).toFixed(1)}kHz]
</TooltipContent>
</Tooltip>
</div>
)}
<Button
variant="ghost"
onClick={toggleShuffle}
className="relative opacity-100!"
>
{!shuffle ? (
<IconArrowsShuffle2
stroke={2}
size={16}
className="wora-transition opacity-30! hover:opacity-100!"
/>
) : (
<div>
<IconArrowsShuffle2 stroke={2} size={16} />
<div className="absolute -top-2 right-0 left-0 mx-auto h-[1.5px] w-2/3 rounded-full bg-black dark:bg-white"></div>
</div>
)}
</Button>
<Button variant="ghost" onClick={previousSong}>
<IconPlayerSkipBack
stroke={2}
className="fill-black dark:fill-white"
size={15}
/>
</Button>
<Button variant="ghost" onClick={handlePlayPause}>
{!isPlaying ? (
<IconPlayerPlay
stroke={2}
className="h-6 w-6 fill-black dark:fill-white"
/>
) : (
<IconPlayerPause
stroke={2}
className="h-6 w-6 fill-black dark:fill-white"
/>
)}
</Button>
<Button variant="ghost" onClick={nextSong}>
<IconPlayerSkipForward
stroke={2}
className="h-4 w-4 fill-black dark:fill-white"
/>
</Button>
<Button
variant="ghost"
onClick={toggleRepeat}
className="relative opacity-100!"
>
{!repeat ? (
<IconRepeat
stroke={2}
size={15}
className="wora-transition opacity-30! hover:opacity-100!"
/>
) : (
<div>
<IconRepeat stroke={2} size={15} />
<div className="absolute -top-2 right-0 left-0 mx-auto h-[1.5px] w-2/3 rounded-full bg-black dark:bg-white"></div>
</div>
)}
</Button>
{lastFmSettings.enableLastFm &&
lastFmSettings.lastFmSessionKey &&
lastFmStatus.lastFmActive && (
<div className="absolute left-28">
<Tooltip delayDuration={0}>
<TooltipTrigger>
<IconBrandLastfm
stroke={2}
size={14}
className={`w-3.5 text-red-500 ${lastFmStatus.isScrobbled ? "" : lastFmStatus.isNowPlaying ? "animate-pulse" : "opacity-30"}`}
/>
</TooltipTrigger>
<TooltipContent side="left" sideOffset={25}>
{lastFmStatus.error ? (
<p className="text-red-500">
Error: {lastFmStatus.error}
</p>
) : lastFmStatus.isScrobbled ? (
<p>Scrobbled to Last.fm</p>
) : lastFmStatus.isNowPlaying ? (
<p>
Now playing on Last.fm
<br />
Will scrobble at{" "}
{lastFmSettings.scrobbleThreshold}%
</p>
) : (
<p>
Will scrobble at{" "}
{lastFmSettings.scrobbleThreshold}%
</p>
)}
</TooltipContent>
</Tooltip>
</div>
)}
<div className="flex">
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant="ghost"
className="opacity-100! flex justify-center items-center"
onClick={() => toggleFavourite(song?.id)}
disabled={!song}
>
<IconHeart
stroke={2}
className={`w-3.5 text-red-500 ${isFavourite ? "fill-red-500" : "fill-none"}`}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={25}>
<p>
{!isFavourite
? "Add to Favorites"
: "Remove from Favorites"}
</p>
</TooltipContent>
</Tooltip>
</div>
</div>
<div className="relative flex h-full w-96 items-center px-4">
<p className="absolute -left-8">{convertTime(seekPosition)}</p>
<Slider
value={[seekPosition]}
onValueChange={handleSeek}
max={soundRef.current?.duration() || 0}
step={0.01}
/>
<p className="absolute -right-8">
{convertTime(soundRef.current?.duration() || 0)}
</p>
</div>
</div>
<div className="absolute right-0 flex w-1/4 items-center justify-end gap-10">
<div className="flex items-center gap-4">
<Button
variant="ghost"
onClick={toggleMute}
className="opacity-100!"
>
{!isMuted ? (
<IconVolume
stroke={2}
size={17.5}
className="wora-transition opacity-30! hover:opacity-100!"
/>
) : (
<IconVolumeOff
stroke={2}
size={17.5}
className="wora-transition opacity-30! hover:opacity-100!"
/>
)}
</Button>
<Slider
ref={volumeSliderRef}
onValueChange={handleVolume}
value={[volume]}
max={1}
step={0.01}
className="w-24"
/>
</div>
<div className="flex items-center gap-4">
{lyrics ? (
<Button variant="ghost" onClick={toggleLyrics}>
<IconMessage stroke={2} size={15} />
</Button>
) : (
<IconMessage
className="cursor-not-allowed text-red-500 opacity-75"
stroke={2}
size={15}
/>
)}
<Dialog>
<DialogTrigger
className={
song
? "opacity-30 duration-500 hover:opacity-100 cursor-pointer"
: "cursor-not-allowed text-red-500 opacity-75"
}
disabled={!song}
>
<IconInfoCircle stroke={2} size={15} />
</DialogTrigger>
{song && (
<DialogContent>
<DialogHeader>
<DialogTitle>Track Information</DialogTitle>
<DialogDescription>
Details for your currently playing song
</DialogDescription>
</DialogHeader>
<div className="flex gap-4 overflow-hidden text-xs">
{/* Album cover */}
<div className="h-full">
<div className="relative h-36 w-36 overflow-hidden rounded-xl">
<Image
alt={song.name || "Album"}
src={`wora://${song?.album.cover}`}
fill
className="object-cover"
quality={25}
/>
</div>
</div>
{/* Track details */}
<div className="flex h-full w-full flex-col gap-0.5">
<p className="mb-4 truncate">
→ {metadata?.common?.title} [
{metadata?.format?.codec || "Unknown"}]
</p>
<p className="truncate">
<span className="opacity-50">Artist:</span>{" "}
{metadata?.common?.artist || "Unknown"}
</p>
<p className="truncate">
<span className="opacity-50">Album:</span>{" "}
{metadata?.common?.album || "Unknown"}
</p>
<p className="truncate">
<span className="opacity-50">Codec:</span>{" "}
{metadata?.format?.codec || "Unknown"}
</p>
<p className="truncate">
<span className="opacity-50">Sample:</span>{" "}
{metadata?.format?.lossless
? `Lossless [${metadata.format.bitsPerSample}/${(metadata.format.sampleRate / 1000).toFixed(1)}kHz]`
: "Lossy Audio"}
</p>
<p className="truncate">
<span className="opacity-50">Duration:</span>{" "}
{convertTime(soundRef.current?.duration() || 0)}
</p>
<p className="truncate">
<span className="opacity-50">Genre:</span>{" "}
{metadata?.common?.genre?.[0] || "Unknown"}
</p>
{lastFmSettings.enableLastFm &&
lastFmStatus.lastFmActive && (
<p className="truncate">
<span className="opacity-50">Last.fm:</span>{" "}
{lastFmStatus.error ? (
<span className="text-red-500">
Error: {lastFmStatus.error}
</span>
) : lastFmStatus.isScrobbled ? (
"Scrobbled"
) : lastFmStatus.isNowPlaying ? (
<>
Now playing (will scrobble at{" "}
{lastFmSettings.scrobbleThreshold}%)
</>
) : (
<>
Waiting to scrobble at{" "}
{lastFmSettings.scrobbleThreshold}%
</>
)}
</p>
)}
</div>
</div>
</DialogContent>
)}
</Dialog>
<Button variant="ghost" onClick={toggleQueue}>
<IconList stroke={2} size={15} />
</Button>
</div>
</div>
</TooltipProvider>
</div>
</div>
</div>
);
};
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 <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}
================================================
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<Data>(null);
const [isMaximized, setIsMaximized] = useState(false);
useEffect(() => {
window.ipc.invoke("getActionsData").then((response) => {
setData(response);
});
}, []);
return (
<div className="drag absolute top-0 z-50 flex h-11 w-full items-center justify-end px-8 py-2.5">
<div className="relative flex h-full w-full items-center justify-center">
<div className="flex h-full items-center gap-2">
<Image
src={"/assets/Logo [Dark].ico"}
alt="logo"
width={16}
height={16}
className="hidden dark:block"
/>
<Image
src={"/assets/Logo.ico"}
className="block dark:hidden"
alt="logo"
width={16}
height={16}
/>
Wora
</div>
<div className="no-drag absolute -right-2 top-0 flex h-full items-center gap-2.5">
{data && data.isNotMac && (
<>
<Button
variant="ghost"
onClick={() => window.ipc.send("minimizeWindow", true)}
>
<IconLineDashed size={14} stroke={2} />
</Button>
<Button
variant="ghost"
onClick={() => {
setIsMaximized(!isMaximized);
window.ipc.send("maximizeWindow", !isMaximized);
}}
>
<IconSquare size={11} stroke={2} />
</Button>
<Button
variant="ghost"
onClick={() => window.ipc.send("quitApp", true)}
>
<IconX size={14} stroke={2} />
</Button>
</>
)}
</div>
</div>
</div>
);
}
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<AlbumCardProps> = ({ album }) => {
return (
<Link href={`/albums/${album.id}`}>
<div className="group/album wora-border wora-transition rounded-2xl p-5 hover:bg-black/5 dark:hover:bg-white/10">
<div className="relative flex flex-col justify-between">
<div className="relative w-full overflow-hidden rounded-xl pb-[100%] shadow-lg">
<Image
alt={album ? album.name : "Album Cover"}
src={`wora://${album.cover}`}
fill
loading="lazy"
className="z-10 cursor-pointer object-cover"
/>
</div>
<div className="mt-8 flex w-full flex-col overflow-clip">
<p className="cursor-pointer mask-r-from-70% text-sm font-medium text-nowrap">
{album.name}
</p>
<p className="mr-2 truncate opacity-50">{album.artist}</p>
</div>
</div>
</div>
</Link>
);
};
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<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full shadow-lg",
className,
)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-white/10",
className,
)}
{...props}
/>
));
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<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
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<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, className }))}
ref={ref}
{...props}
/>
);
},
);
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<typeof useEmblaCarousel>;
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<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & 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<HTMLDivElement>) => {
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 (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
},
);
Carousel.displayName = "Carousel";
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel();
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className,
)}
{...props}
/>
</div>
);
});
CarouselContent.displayName = "CarouselContent";
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel();
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className,
)}
{...props}
/>
);
});
CarouselItem.displayName = "CarouselItem";
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "ghost", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<IconChevronLeft stroke={2} size={28} />
</Button>
);
});
CarouselPrevious.displayName = "CarouselPrevious";
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "ghost", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<IconChevronRight stroke={2} size={28} />
</Button>
);
});
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<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"wora-transition no-scrollar max-h-96 min-h-96 overflow-hidden rounded-xl",
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0">
<Command>{children}</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div
className="flex items-center border-b border-black/5 p-3.5 text-black dark:border-white/10 dark:text-white"
cmdk-input-wrapper=""
>
<IconSearch className="mr-2 h-5 w-5 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex w-full bg-transparent text-xs outline-hidden placeholder:text-black/50 dark:placeholder:text-white/50",
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn(
"no-scrollbar max-h-[325px] overflow-y-auto overflow-x-hidden rounded-2xl",
className,
)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="flex h-full w-full items-center justify-center"
{...props}
/>
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden px-2 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-neutral-500 dark:[&_[cmdk-group-heading]]:text-neutral-400",
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-neutral-200 dark:bg-neutral-800", className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"wora-transition relative flex cursor-pointer select-none items-center text-nowrap rounded-xl p-2 text-xs outline-hidden data-[disabled=true]:pointer-events-none data-[selected=true]:bg-black/5 data-[selected=true]:text-black data-[disabled=true]:opacity-50 dark:data-[selected=true]:bg-white/10 dark:data-[selected=true]:text-white",
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-neutral-500 dark:text-neutral-400",
className,
)}
{...props}
/>
);
};
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<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"wora-transition flex cursor-pointer select-none items-center rounded-lg p-2 text-xs outline-hidden hover:bg-black/5 data-[state=open]:bg-black/5 data-[state=open]:text-black dark:hover:bg-white/10 dark:data-[state=open]:bg-white/10 dark:data-[state=open]:text-white",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<IconChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
));
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"wora-border z-50 min-w-20 overflow-hidden rounded-xl bg-white p-2 text-black shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:bg-black dark:text-white",
className,
)}
{...props}
/>
));
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"wora-border z-50 min-w-20 overflow-hidden rounded-xl bg-white p-2 text-black shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:bg-black dark:text-white",
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
));
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitiv
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
SYMBOL INDEX (176 symbols across 54 files)
FILE: main/helpers/db/connectDB.ts
constant APP_DATA (line 15) | const APP_DATA = app.getPath("userData");
constant ART_DIR (line 16) | const ART_DIR = path.join(APP_DATA, "utilities/uploads/covers");
function isAudioFile (line 40) | function isAudioFile(filePath: string): boolean {
function findFirstImageInDirectory (line 44) | function findFirstImageInDirectory(dir: string): string | null {
function readFilesRecursively (line 70) | function readFilesRecursively(dir: string, batch = 100): string[] {
function scanEntireLibrary (line 104) | function scanEntireLibrary(dir: string): string[] {
function processLibrary (line 540) | async function processLibrary(musicFolder: string, incremental = false) {
function getAllFilePathsFromDb (line 604) | async function getAllFilePathsFromDb(): Promise<Set<string>> {
function scanImmediateDirectory (line 610) | function scanImmediateDirectory(dir: string): string[] {
function processBatch (line 659) | async function processBatch(files: string[], dbFilePaths: Set<string>) {
function processAudioFile (line 674) | async function processAudioFile(file: string, albumCache: Map<string, an...
function processAlbumArt (line 780) | async function processAlbumArt(imagePath: string): Promise<string> {
function processEmbeddedArt (line 818) | async function processEmbeddedArt(cover: any): Promise<string> {
function cleanupOrphanedRecords (line 851) | async function cleanupOrphanedRecords(currentFiles: string[]) {
function sendToRenderer (line 955) | function sendToRenderer(channel: string, data: any) {
FILE: main/helpers/lastfm-service.ts
constant API_URL (line 33) | const API_URL = "https://ws.audioscrobbler.com/2.0/";
constant CACHE_TTL (line 36) | const CACHE_TTL = 15 * 60 * 1000;
constant DEV_API_KEY (line 72) | const DEV_API_KEY = process.env.LASTFM_API_KEY || "";
constant DEV_API_SECRET (line 73) | const DEV_API_SECRET = process.env.LASTFM_API_SECRET || "";
FILE: main/preload.ts
method send (line 4) | send(channel: string, value: unknown) {
method on (line 7) | on(channel: string, callback: (...args: unknown[]) => void) {
method invoke (line 16) | async invoke(channel: string, ...args: unknown[]) {
type IpcHandler (line 29) | type IpcHandler = typeof handler;
FILE: renderer/components/ErrorBoundary.tsx
type Props (line 5) | interface Props {
type State (line 10) | interface State {
class ErrorBoundary (line 15) | class ErrorBoundary extends Component<Props, State> {
method getDerivedStateFromError (line 21) | public static getDerivedStateFromError(error: Error): State {
method componentDidCatch (line 25) | public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
method render (line 34) | public render() {
FILE: renderer/components/LoadingSkeletons.tsx
function ArtistGridSkeleton (line 4) | function ArtistGridSkeleton({ count = 12, viewMode = 'grid-large' }: { c...
function AlbumGridSkeleton (line 41) | function AlbumGridSkeleton({ count = 12 }: { count?: number }) {
function SongListSkeleton (line 55) | function SongListSkeleton({ count = 10 }: { count?: number }) {
function ArtistDetailSkeleton (line 72) | function ArtistDetailSkeleton() {
FILE: renderer/components/PageTransition.tsx
type PageTransitionProps (line 5) | interface PageTransitionProps {
function PageTransition (line 28) | function PageTransition({ children }: PageTransitionProps) {
FILE: renderer/components/PageTransitionMinimal.tsx
type PageTransitionProps (line 3) | interface PageTransitionProps {
function PageTransitionMinimal (line 10) | function PageTransitionMinimal({ children }: PageTransitionProps) {
FILE: renderer/components/main/lyrics.tsx
type LyricsProps (line 7) | interface LyricsProps {
FILE: renderer/components/main/navbar.tsx
type Settings (line 37) | type Settings = {
type NavLink (line 42) | type NavLink = {
FILE: renderer/components/main/player.tsx
function getAlbumCoverUrl (line 84) | function getAlbumCoverUrl(song: Song | undefined): string {
FILE: renderer/components/themeProvider.tsx
function ThemeProvider (line 7) | function ThemeProvider({ children, ...props }: ThemeProviderProps) {
FILE: renderer/components/ui/actions.tsx
type Data (line 12) | type Data = {
function Actions (line 17) | function Actions() {
FILE: renderer/components/ui/album.tsx
type Album (line 5) | type Album = {
type AlbumCardProps (line 12) | type AlbumCardProps = {
FILE: renderer/components/ui/badge.tsx
type BadgeProps (line 25) | interface BadgeProps
function Badge (line 29) | function Badge({ className, variant, ...props }: BadgeProps) {
FILE: renderer/components/ui/button.tsx
type ButtonProps (line 25) | interface ButtonProps
FILE: renderer/components/ui/carousel.tsx
type CarouselApi (line 18) | type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters (line 19) | type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions (line 20) | type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin (line 21) | type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps (line 23) | type CarouselProps = {
type CarouselContextProps (line 30) | type CarouselContextProps = {
function useCarousel (line 41) | function useCarousel() {
FILE: renderer/components/ui/command.tsx
type CommandDialogProps (line 26) | interface CommandDialogProps extends DialogProps {}
FILE: renderer/components/ui/form.tsx
type FormFieldContextValue (line 18) | type FormFieldContextValue<
type FormItemContextValue (line 65) | type FormItemContextValue = {
FILE: renderer/components/ui/input.tsx
type InputProps (line 5) | interface InputProps
FILE: renderer/components/ui/skeleton.tsx
function Skeleton (line 3) | function Skeleton({
FILE: renderer/components/ui/songs.tsx
type Playlist (line 38) | type Playlist = {
type SongsProps (line 43) | type SongsProps = {
FILE: renderer/components/ui/sonner.tsx
type ToasterProps (line 6) | type ToasterProps = React.ComponentProps<typeof Sonner>;
FILE: renderer/components/ui/spinner.tsx
type SpinnerProps (line 4) | interface SpinnerProps extends React.HTMLAttributes<SVGElement> {}
FILE: renderer/context/playerContext.tsx
type Song (line 12) | interface Song {
type PlayerState (line 26) | interface PlayerState {
type PlayerContextType (line 37) | interface PlayerContextType extends PlayerState {
function findSongIndexById (line 85) | function findSongIndexById(songs: Song[], id: number): number {
FILE: renderer/hooks/useDebounce.ts
function useDebounce (line 3) | function useDebounce<T>(value: T, delay: number = 300): T {
FILE: renderer/hooks/useScrollAreaRestoration.ts
constant DEBUG (line 4) | const DEBUG = false;
constant MAX_RESTORE_ATTEMPTS (line 5) | const MAX_RESTORE_ATTEMPTS = 10;
constant RESTORE_ATTEMPT_DELAY (line 6) | const RESTORE_ATTEMPT_DELAY = 100;
constant MAX_RESTORE_TIME (line 7) | const MAX_RESTORE_TIME = 2000;
function useScrollAreaRestoration (line 9) | function useScrollAreaRestoration(key: string) {
FILE: renderer/lib/albumCache.ts
type Album (line 2) | interface Album {
type AlbumCacheStore (line 12) | interface AlbumCacheStore {
constant CACHE_TTL (line 44) | const CACHE_TTL = 5 * 60 * 1000;
class AlbumCache (line 46) | class AlbumCache {
method constructor (line 50) | private constructor() {
method getInstance (line 69) | public static getInstance(): AlbumCache {
method sortAlbums (line 77) | public sortAlbums(
method getAllAlbums (line 137) | public getAllAlbums(): Album[] {
method getFilteredAlbums (line 142) | public getFilteredAlbums(): Album[] {
method getSearchResults (line 147) | public getSearchResults(): Album[] {
method getPage (line 152) | public getPage(): number {
method isInitialized (line 157) | public isInitialized(): boolean {
method hasMore (line 162) | public hasMore(): boolean {
method getSortSettings (line 167) | public getSortSettings(): { sortBy: string; sortOrder: string } {
method getViewMode (line 175) | public getViewMode(): "grid" | "compact-grid" | "list" {
method getLastSearchQuery (line 180) | public getLastSearchQuery(): string {
method isStale (line 185) | public isStale(): boolean {
method setAllAlbums (line 190) | public setAllAlbums(albums: Album[]): void {
method addAlbums (line 197) | public addAlbums(newAlbums: Album[]): void {
method setFilteredAlbums (line 210) | public setFilteredAlbums(albums: Album[]): void {
method setSearchResults (line 216) | public setSearchResults(albums: Album[], query: string): void {
method updatePagination (line 223) | public updatePagination(page: number, hasMore: boolean): void {
method updateSortSettings (line 230) | public updateSortSettings(sortBy: string, sortOrder: string): void {
method updateViewMode (line 237) | public updateViewMode(viewMode: "grid" | "compact-grid" | "list"): void {
method getAlbumWithSongs (line 243) | public async getAlbumWithSongs(albumId: number): Promise<any> {
method setInitialized (line 270) | public setInitialized(): void {
method resetCache (line 276) | public resetCache(): void {
method resetState (line 284) | public resetState(resetData: Partial<AlbumCacheStore>): void {
method saveToLocalStorage (line 327) | private saveToLocalStorage(): void {
FILE: renderer/lib/apiConfig.ts
constant PROD_API_URL (line 5) | const PROD_API_URL = "https://wora-ten.vercel.app";
constant DEV_API_URL (line 6) | const DEV_API_URL = "http://localhost:3000";
constant API_BASE_URL (line 9) | const API_BASE_URL = isDev ? DEV_API_URL : PROD_API_URL;
FILE: renderer/lib/helpers.ts
type MetadataResponse (line 6) | interface MetadataResponse {
type LyricLine (line 11) | interface LyricLine {
function fetchCover (line 31) | async function fetchCover(artist: string, album: string) {
type DiscordState (line 157) | interface DiscordState {
FILE: renderer/lib/lastfm-client.ts
constant LASTFM_API_BASE (line 10) | const LASTFM_API_BASE = `${API_BASE_URL}/api/lastfm`;
FILE: renderer/lib/lastfm.ts
type Song (line 4) | interface Song {
type LastFmUserCache (line 18) | interface LastFmUserCache {
constant CACHE_EXPIRY_MS (line 29) | const CACHE_EXPIRY_MS = 24 * 60 * 60 * 1000;
FILE: renderer/lib/songCache.ts
type SongCacheStore (line 5) | interface SongCacheStore {
constant CACHE_TTL (line 33) | const CACHE_TTL = 5 * 60 * 1000;
class SongCache (line 35) | class SongCache {
method constructor (line 39) | private constructor() {
method getInstance (line 58) | public static getInstance(): SongCache {
method sortSongs (line 66) | public sortSongs(songs: Song[], sortBy: string, sortOrder: string): So...
method getAllSongs (line 92) | public getAllSongs(): Song[] {
method getFilteredSongs (line 97) | public getFilteredSongs(): Song[] {
method getSearchResults (line 102) | public getSearchResults(): Song[] {
method getPage (line 107) | public getPage(): number {
method isInitialized (line 112) | public isInitialized(): boolean {
method hasMore (line 117) | public hasMore(): boolean {
method getSortSettings (line 122) | public getSortSettings(): { sortBy: string; sortOrder: string } {
method getLastSearchQuery (line 130) | public getLastSearchQuery(): string {
method isStale (line 135) | public isStale(): boolean {
method setAllSongs (line 140) | public setAllSongs(songs: Song[]): void {
method addSongs (line 147) | public addSongs(newSongs: Song[]): void {
method setFilteredSongs (line 158) | public setFilteredSongs(songs: Song[]): void {
method setSearchResults (line 164) | public setSearchResults(songs: Song[], query: string): void {
method updatePagination (line 171) | public updatePagination(page: number, hasMore: boolean): void {
method updateSortSettings (line 178) | public updateSortSettings(sortBy: string, sortOrder: string): void {
method setInitialized (line 185) | public setInitialized(): void {
method resetCache (line 191) | public resetCache(): void {
method resetState (line 199) | public resetState(resetData: Partial<SongCacheStore>): void {
method saveToLocalStorage (line 239) | private saveToLocalStorage(): void {
FILE: renderer/lib/utils.ts
function cn (line 4) | function cn(...inputs: ClassValue[]) {
FILE: renderer/pages/_app.tsx
constant SPECIAL_LAYOUTS (line 14) | const SPECIAL_LAYOUTS = ["/setup"];
function App (line 16) | function App({ Component, pageProps }) {
FILE: renderer/pages/albums.tsx
type ViewMode (line 34) | type ViewMode = "grid-large" | "grid-small" | "list";
function Albums (line 37) | function Albums() {
FILE: renderer/pages/albums/[slug].tsx
type Album (line 16) | type Album = {
function Album (line 25) | function Album() {
FILE: renderer/pages/artists/[name].tsx
type Album (line 20) | type Album = {
type Artist (line 29) | type Artist = {
type ArtistInfo (line 50) | type ArtistInfo = {
type TopTrack (line 72) | type TopTrack = {
function ArtistView (line 81) | function ArtistView() {
FILE: renderer/pages/artists/index.tsx
type ArtistItem (line 26) | type ArtistItem = {
type ViewMode (line 33) | type ViewMode = "grid-large" | "grid-small" | "list";
type SortBy (line 34) | type SortBy = "name" | "albums" | "songs";
type SortOrder (line 35) | type SortOrder = "asc" | "desc";
function ArtistsPage (line 38) | function ArtistsPage() {
FILE: renderer/pages/home.tsx
function Home (line 16) | function Home() {
FILE: renderer/pages/playlists.tsx
function Playlists (line 38) | function Playlists() {
FILE: renderer/pages/playlists/[slug].tsx
type Playlist (line 47) | type Playlist = {
function Playlist (line 55) | function Playlist() {
FILE: renderer/pages/settings.tsx
type Settings (line 59) | type Settings = {
type LastFmSettings (line 69) | type LastFmSettings = {
function Settings (line 76) | function Settings() {
FILE: renderer/pages/setup.tsx
function Setup (line 10) | function Setup() {
FILE: renderer/pages/songs.tsx
function AllSongs (line 25) | function AllSongs() {
FILE: renderer/preload.d.ts
type Window (line 4) | interface Window {
FILE: vercel/pages/_app.tsx
function App (line 3) | function App({ Component, pageProps }: AppProps) {
FILE: vercel/pages/api/config.ts
constant LASTFM_CONFIG (line 3) | const LASTFM_CONFIG = {
FILE: vercel/pages/api/index.ts
function handler (line 3) | function handler(req: NextApiRequest, res: NextApiResponse) {
FILE: vercel/pages/api/lastfm/auth.ts
function handler (line 6) | async function handler(
FILE: vercel/pages/api/lastfm/now-playing.ts
function handler (line 5) | async function handler(
FILE: vercel/pages/api/lastfm/scrobble.ts
function handler (line 5) | async function handler(
FILE: vercel/pages/api/lastfm/track-info.ts
function handler (line 4) | async function handler(
FILE: vercel/pages/api/lastfm/user-info.ts
function handler (line 5) | async function handler(
FILE: vercel/pages/index.tsx
function Home (line 3) | function Home() {
Condensed preview — 97 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (490K chars).
[
{
"path": ".github/FUNDING.yml",
"chars": 796,
"preview": "# These are supported funding model platforms\n\ngithub: hiaaryan\npatreon: # Replace with a single Patreon username\nopen_c"
},
{
"path": ".github/ISSUE_TEMPLATE/report_issue.yml",
"chars": 2668,
"preview": "name: Bug Report 👾\ndescription: File a bug report.\ntitle: \"[Bug]: \"\nlabels: [\"bug\"]\nbody:\n - type: markdown\n attribu"
},
{
"path": ".github/ISSUE_TEMPLATE/request_feature.yml",
"chars": 2214,
"preview": "name: Feature Request 🌟\ndescription: Suggest a new feature or enhancement.\ntitle: \"[Feature]: \"\nlabels: [\"enhancement\"]\n"
},
{
"path": ".github/workflows/release.yml",
"chars": 2690,
"preview": "name: Release and Build\n\non:\n push:\n branches:\n - main\n\njobs:\n check-version-change:\n runs-on: ubuntu-lates"
},
{
"path": ".gitignore",
"chars": 130,
"preview": "node_modules\n*.log\n.next\napp\ndist\n.DS_Store\n.db\n\n# Environment variables\n.env\n.env.local\n.env.development\n.env.productio"
},
{
"path": ".prettierignore",
"chars": 30,
"preview": "build\ncoverage\napp\ndist\n.yarn\n"
},
{
"path": ".prettierrc",
"chars": 102,
"preview": "{\n \"plugins\": [\"prettier-plugin-tailwindcss\"],\n \"tailwindConfig\": \"./renderer/tailwind.config.js\"\n}\n"
},
{
"path": "CODE_OF_CONDUCT.md",
"chars": 5984,
"preview": "<p align=\"center\">\n <img src=\"https://github.com/playwora/wora/blob/main/renderer/public/github/Header.png?raw=true\" al"
},
{
"path": "CONTRIBUTING.md",
"chars": 4023,
"preview": "<p align=\"center\">\n <img src=\"https://github.com/playwora/wora/blob/main/renderer/public/github/Header.png?raw=true\" al"
},
{
"path": "LICENSE",
"chars": 1061,
"preview": "MIT License\n\nCopyright (c) 2024 Wora\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof th"
},
{
"path": "README.md",
"chars": 4330,
"preview": "> [!IMPORTANT] \n> There is a migrated version which is being built with tauri (rust 🦀). During this time contributions "
},
{
"path": "components.json",
"chars": 346,
"preview": "{\n \"$schema\": \"https://ui.shadcn.com/schema.json\",\n \"style\": \"new-york\",\n \"rsc\": true,\n \"tsx\": true,\n \"tailwind\": {"
},
{
"path": "electron-builder.yml",
"chars": 956,
"preview": "appId: com.wora.player\nproductName: Wora\ncopyright: Copyright © 2024 Aaryan Kapoor\ndirectories:\n output: dist\n buildRe"
},
{
"path": "main/background.ts",
"chars": 16441,
"preview": "import path from \"path\";\nimport { Menu, Tray, app, dialog, ipcMain, shell } from \"electron\";\nimport serve from \"electron"
},
{
"path": "main/helpers/create-window.ts",
"chars": 2127,
"preview": "import {\n screen,\n BrowserWindow,\n BrowserWindowConstructorOptions,\n Rectangle,\n} from \"electron\";\nimport Store from"
},
{
"path": "main/helpers/db/connectDB.ts",
"chars": 35428,
"preview": "import { and, eq, like, sql, or, exists, isNotNull } from \"drizzle-orm\";\nimport { albums, songs, settings, playlistSongs"
},
{
"path": "main/helpers/db/createDB.ts",
"chars": 1192,
"preview": "import Database from \"better-sqlite3\";\nimport { app } from \"electron\";\nimport path from \"path\";\n\nexport const sqlite = n"
},
{
"path": "main/helpers/db/schema.ts",
"chars": 2234,
"preview": "import { integer, sqliteTable, text, blob } from \"drizzle-orm/sqlite-core\";\nimport { relations } from \"drizzle-orm\";\n\nex"
},
{
"path": "main/helpers/index.ts",
"chars": 33,
"preview": "export * from \"./create-window\";\n"
},
{
"path": "main/helpers/lastfm-service.ts",
"chars": 19078,
"preview": "import { ipcMain } from \"electron\";\nimport fetch from \"node-fetch\";\nimport * as crypto from \"crypto\";\nimport * as path f"
},
{
"path": "main/preload.ts",
"chars": 836,
"preview": "import { contextBridge, ipcRenderer, IpcRendererEvent } from \"electron\";\n\nconst handler = {\n send(channel: string, valu"
},
{
"path": "package.json",
"chars": 2772,
"preview": "{\n \"private\": true,\n \"name\": \"wora\",\n \"description\": \"🎧 A beautiful player for audiophiles.\",\n \"version\": \"0.4.0-bet"
},
{
"path": "renderer/components/ErrorBoundary.tsx",
"chars": 2192,
"preview": "import React, { Component, ErrorInfo, ReactNode } from 'react';\nimport { Button } from '@/components/ui/button';\nimport "
},
{
"path": "renderer/components/LoadingSkeletons.tsx",
"chars": 3269,
"preview": "import React from 'react';\nimport { Skeleton } from '@/components/ui/skeleton';\n\nexport function ArtistGridSkeleton({ co"
},
{
"path": "renderer/components/PageTransition.tsx",
"chars": 989,
"preview": "import { motion, AnimatePresence, Transition } from 'framer-motion';\nimport { useRouter } from 'next/router';\nimport { R"
},
{
"path": "renderer/components/PageTransitionMinimal.tsx",
"chars": 395,
"preview": "import { ReactNode } from 'react';\n\ninterface PageTransitionProps {\n children: ReactNode;\n}\n\n// Minimal approach: No tr"
},
{
"path": "renderer/components/main/lyrics.tsx",
"chars": 2293,
"preview": "import { LyricLine } from \"@/lib/helpers\";\nimport React, { useEffect, useRef } from \"react\";\nimport { Badge } from \"../u"
},
{
"path": "renderer/components/main/navbar.tsx",
"chars": 13285,
"preview": "import {\n IconDeviceDesktop,\n IconFocusCentered,\n IconInbox,\n IconList,\n IconMoon,\n IconSearch,\n IconSun,\n IconV"
},
{
"path": "renderer/components/main/player.tsx",
"chars": 44977,
"preview": "import Image from \"next/image\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n IconArrowsShuffle2,\n IconBr"
},
{
"path": "renderer/components/themeProvider.tsx",
"chars": 321,
"preview": "\"use client\";\n\nimport * as React from \"react\";\nimport { ThemeProvider as NextThemesProvider } from \"next-themes\";\nimport"
},
{
"path": "renderer/components/ui/actions.tsx",
"chars": 2172,
"preview": "import {\n IconBox,\n IconLine,\n IconLineDashed,\n IconSquare,\n IconX,\n} from \"@tabler/icons-react\";\nimport Image from"
},
{
"path": "renderer/components/ui/album.tsx",
"chars": 1229,
"preview": "import Image from \"next/image\";\nimport Link from \"next/link\";\nimport React from \"react\";\n\ntype Album = {\n id: string;\n "
},
{
"path": "renderer/components/ui/avatar.tsx",
"chars": 1445,
"preview": "\"use client\";\n\nimport * as React from \"react\";\nimport * as AvatarPrimitive from \"@radix-ui/react-avatar\";\n\nimport { cn }"
},
{
"path": "renderer/components/ui/badge.tsx",
"chars": 1115,
"preview": "import * as React from \"react\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\n\nimport { cn } from \""
},
{
"path": "renderer/components/ui/button.tsx",
"chars": 1410,
"preview": "import * as React from \"react\";\nimport { Slot } from \"@radix-ui/react-slot\";\nimport { cva, type VariantProps } from \"cla"
},
{
"path": "renderer/components/ui/carousel.tsx",
"chars": 6228,
"preview": "\"use client\";\n\nimport * as React from \"react\";\nimport useEmblaCarousel, {\n type UseEmblaCarouselType,\n} from \"embla-car"
},
{
"path": "renderer/components/ui/command.tsx",
"chars": 4767,
"preview": "\"use client\";\n\nimport * as React from \"react\";\nimport { type DialogProps } from \"@radix-ui/react-dialog\";\nimport { Comma"
},
{
"path": "renderer/components/ui/context-menu.tsx",
"chars": 7653,
"preview": "\"use client\";\n\nimport * as React from \"react\";\nimport * as ContextMenuPrimitive from \"@radix-ui/react-context-menu\";\n\nim"
},
{
"path": "renderer/components/ui/dialog.tsx",
"chars": 3560,
"preview": "\"use client\";\n\nimport * as React from \"react\";\nimport * as DialogPrimitive from \"@radix-ui/react-dialog\";\n\nimport { cn }"
},
{
"path": "renderer/components/ui/form.tsx",
"chars": 4246,
"preview": "import * as React from \"react\";\nimport * as LabelPrimitive from \"@radix-ui/react-label\";\nimport { Slot } from \"@radix-ui"
},
{
"path": "renderer/components/ui/input.tsx",
"chars": 721,
"preview": "import * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface InputProps\n extends React.InputHTM"
},
{
"path": "renderer/components/ui/label.tsx",
"chars": 734,
"preview": "\"use client\";\n\nimport * as React from \"react\";\nimport * as LabelPrimitive from \"@radix-ui/react-label\";\nimport { cva, ty"
},
{
"path": "renderer/components/ui/scroll-area.tsx",
"chars": 1771,
"preview": "\"use client\";\n\nimport * as React from \"react\";\nimport * as ScrollAreaPrimitive from \"@radix-ui/react-scroll-area\";\nimpor"
},
{
"path": "renderer/components/ui/select.tsx",
"chars": 4577,
"preview": "import * as React from \"react\";\nimport * as SelectPrimitive from \"@radix-ui/react-select\";\nimport {\n IconCheck,\n IconC"
},
{
"path": "renderer/components/ui/skeleton.tsx",
"chars": 283,
"preview": "import { cn } from \"@/lib/utils\";\n\nfunction Skeleton({\n className,\n ...props\n}: React.HTMLAttributes<HTMLDivElement>) "
},
{
"path": "renderer/components/ui/slider.tsx",
"chars": 947,
"preview": "\"use client\";\n\nimport * as React from \"react\";\nimport * as SliderPrimitive from \"@radix-ui/react-slider\";\n\nimport { cn }"
},
{
"path": "renderer/components/ui/songs.tsx",
"chars": 16078,
"preview": "import React, {\n useEffect,\n useState,\n useCallback,\n memo,\n useMemo,\n useRef,\n forwardRef,\n useImperativeHandle"
},
{
"path": "renderer/components/ui/sonner.tsx",
"chars": 1197,
"preview": "\"use client\";\n\nimport { useTheme } from \"next-themes\";\nimport { Toaster as Sonner } from \"sonner\";\n\ntype ToasterProps = "
},
{
"path": "renderer/components/ui/spinner.tsx",
"chars": 1856,
"preview": "import * as React from \"react\";\nimport { cn } from \"@/lib/utils\"; // Ensure the path to cn utility is correct\n\nexport in"
},
{
"path": "renderer/components/ui/switch.tsx",
"chars": 1173,
"preview": "\"use client\";\n\nimport * as React from \"react\";\nimport * as SwitchPrimitives from \"@radix-ui/react-switch\";\n\nimport { cn "
},
{
"path": "renderer/components/ui/tabs.tsx",
"chars": 1972,
"preview": "\"use client\";\n\nimport * as React from \"react\";\nimport * as TabsPrimitive from \"@radix-ui/react-tabs\";\n\nimport { cn } fro"
},
{
"path": "renderer/components/ui/tooltip.tsx",
"chars": 1162,
"preview": "\"use client\";\n\nimport * as React from \"react\";\nimport * as TooltipPrimitive from \"@radix-ui/react-tooltip\";\n\nimport { cn"
},
{
"path": "renderer/context/playerContext.tsx",
"chars": 11654,
"preview": "import { shuffleArray } from \"@/lib/helpers\";\nimport React, {\n createContext,\n useState,\n useContext,\n ReactNode,\n "
},
{
"path": "renderer/hooks/useDebounce.ts",
"chars": 391,
"preview": "import { useEffect, useState } from 'react';\n\nexport function useDebounce<T>(value: T, delay: number = 300): T {\n const"
},
{
"path": "renderer/hooks/useScrollAreaRestoration.ts",
"chars": 6019,
"preview": "import { useEffect, useRef } from 'react';\nimport { useRouter } from 'next/router';\n\nconst DEBUG = false;\nconst MAX_REST"
},
{
"path": "renderer/lib/albumCache.ts",
"chars": 9522,
"preview": "// Global album cache to persist album data between page navigations\ninterface Album {\n id: number;\n name: string;\n a"
},
{
"path": "renderer/lib/apiConfig.ts",
"chars": 494,
"preview": "// Configure API URLs based on environment\nconst isDev = process.env.NODE_ENV === \"development\";\n\n// Your Vercel deploym"
},
{
"path": "renderer/lib/helpers.ts",
"chars": 5668,
"preview": "import { Song } from \"@/context/playerContext\";\nimport axios from \"axios\";\nimport { IAudioMetadata } from \"music-metadat"
},
{
"path": "renderer/lib/lastfm-client.ts",
"chars": 8018,
"preview": "import { Song } from \"@/context/playerContext\";\nimport { API_BASE_URL } from \"./apiConfig\";\n\n// Session information\nlet "
},
{
"path": "renderer/lib/lastfm.ts",
"chars": 8422,
"preview": "// Last.fm Client for Electron - uses IPC to securely communicate with the backend API\n\n// Types\ninterface Song {\n id?:"
},
{
"path": "renderer/lib/songCache.ts",
"chars": 6992,
"preview": "// Global song cache to persist song data between page navigations\nimport { Song } from \"@/context/playerContext\";\n\n// D"
},
{
"path": "renderer/lib/utils.ts",
"chars": 169,
"preview": "import { clsx, type ClassValue } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nexport function cn(...inputs: C"
},
{
"path": "renderer/next-env.d.ts",
"chars": 213,
"preview": "/// <reference types=\"next\" />\n/// <reference types=\"next/image-types/global\" />\n\n// NOTE: This file should not be edite"
},
{
"path": "renderer/next.config.js",
"chars": 429,
"preview": "/** @type {import('next').NextConfig} */\n\nmodule.exports = {\n // Use output: 'export' since we're integrating with Elec"
},
{
"path": "renderer/pages/_app.tsx",
"chars": 2889,
"preview": "import \"@/styles/globals.css\";\nimport Actions from \"@/components/ui/actions\";\nimport Navbar from \"@/components/main/navb"
},
{
"path": "renderer/pages/albums/[slug].tsx",
"chars": 4714,
"preview": "import React, { useEffect, useState, useRef } from \"react\";\nimport Image from \"next/image\";\nimport { useRouter } from \"n"
},
{
"path": "renderer/pages/albums.tsx",
"chars": 25346,
"preview": "import React, { useEffect, useState, useCallback, useRef } from \"react\";\nimport { useScrollAreaRestoration } from \"@/hoo"
},
{
"path": "renderer/pages/artists/[name].tsx",
"chars": 24055,
"preview": "import React, { useEffect, useState, useRef } from \"react\";\nimport { useRouter } from \"next/router\";\nimport Image from \""
},
{
"path": "renderer/pages/artists/index.tsx",
"chars": 17248,
"preview": "import React, { useEffect, useState, useMemo, useCallback, memo } from \"react\";\nimport { useRouter } from \"next/router\";"
},
{
"path": "renderer/pages/home.tsx",
"chars": 4229,
"preview": "import React, { useEffect, useState, useRef } from \"react\";\nimport {\n Carousel,\n CarouselContent,\n CarouselItem,\n Ca"
},
{
"path": "renderer/pages/playlists/[slug].tsx",
"chars": 12167,
"preview": "import React, { useEffect, useState } from \"react\";\nimport Image from \"next/image\";\nimport { useRouter } from \"next/rout"
},
{
"path": "renderer/pages/playlists.tsx",
"chars": 8506,
"preview": "\"use client\";\nimport React, { useEffect, useState } from \"react\";\nimport Image from \"next/image\";\nimport Link from \"next"
},
{
"path": "renderer/pages/settings.tsx",
"chars": 27266,
"preview": "import React, { useEffect, useState, useRef } from \"react\";\nimport {\n IconArrowRight,\n IconBrandLastfm,\n IconCheck,\n "
},
{
"path": "renderer/pages/setup.tsx",
"chars": 2277,
"preview": "import Actions from \"@/components/ui/actions\";\nimport { Button } from \"@/components/ui/button\";\nimport Image from \"next/"
},
{
"path": "renderer/pages/songs.tsx",
"chars": 18863,
"preview": "import React, { useEffect, useState, useCallback, useRef } from \"react\";\nimport { useScrollAreaRestoration } from \"@/hoo"
},
{
"path": "renderer/postcss.config.js",
"chars": 72,
"preview": "module.exports = {\n plugins: {\n \"@tailwindcss/postcss\": {},\n },\n};\n"
},
{
"path": "renderer/preload.d.ts",
"chars": 112,
"preview": "import { IpcHandler } from \"../main/preload\";\n\ndeclare global {\n interface Window {\n ipc: IpcHandler;\n }\n}\n"
},
{
"path": "renderer/styles/globals.css",
"chars": 2096,
"preview": "@import url(\"https://fonts.googleapis.com/css2?family=Maven+Pro:wght@400..900&display=swap\")\nlayer(base);\n\n@import \"tail"
},
{
"path": "renderer/tsconfig.json",
"chars": 235,
"preview": "{\n \"extends\": \"../tsconfig.json\",\n \"include\": [\"next-env.d.ts\", \"**/*.ts\", \"**/*.tsx\", \".next/types/**/*.ts\"],\n \"excl"
},
{
"path": "tsconfig.json",
"chars": 569,
"preview": "{\n \"compilerOptions\": {\n \"target\": \"es5\",\n \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n \"allowJs\": true,\n \"sk"
},
{
"path": "vercel/next-env.d.ts",
"chars": 201,
"preview": "/// <reference types=\"next\" />\n/// <reference types=\"next/image-types/global\" />\n\n// NOTE: This file should not be edite"
},
{
"path": "vercel/next.config.js",
"chars": 200,
"preview": "/** @type {import('next').NextConfig} */\nconst nextConfig = {\n reactStrictMode: true,\n swcMinify: true,\n images: {\n "
},
{
"path": "vercel/package.json",
"chars": 403,
"preview": "{\n \"name\": \"wora-api\",\n \"version\": \"0.4.0\",\n \"private\": true,\n \"scripts\": {\n \"dev\": \"next dev\",\n \"build\": \"nex"
},
{
"path": "vercel/pages/_app.tsx",
"chars": 150,
"preview": "import type { AppProps } from \"next/app\";\n\nexport default function App({ Component, pageProps }: AppProps) {\n return <C"
},
{
"path": "vercel/pages/api/config.ts",
"chars": 290,
"preview": "// Configuration for Last.fm API\n// These credentials are stored on the server side and not exposed to clients\nexport co"
},
{
"path": "vercel/pages/api/index.ts",
"chars": 402,
"preview": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nexport default function handler(req: NextApiRequest, res: NextA"
},
{
"path": "vercel/pages/api/lastfm/auth.ts",
"chars": 2262,
"preview": "import { NextApiRequest, NextApiResponse } from \"next\";\nimport { LASTFM_CONFIG } from \"../config\";\nimport { createFormDa"
},
{
"path": "vercel/pages/api/lastfm/now-playing.ts",
"chars": 1997,
"preview": "import { NextApiRequest, NextApiResponse } from \"next\";\nimport { LASTFM_CONFIG } from \"../config\";\nimport { createFormDa"
},
{
"path": "vercel/pages/api/lastfm/scrobble.ts",
"chars": 2062,
"preview": "import { NextApiRequest, NextApiResponse } from \"next\";\nimport { LASTFM_CONFIG } from \"../config\";\nimport { createFormDa"
},
{
"path": "vercel/pages/api/lastfm/track-info.ts",
"chars": 1708,
"preview": "import { NextApiRequest, NextApiResponse } from \"next\";\nimport { LASTFM_CONFIG } from \"../config\";\n\nexport default async"
},
{
"path": "vercel/pages/api/lastfm/user-info.ts",
"chars": 2568,
"preview": "import { NextApiRequest, NextApiResponse } from \"next\";\nimport { LASTFM_CONFIG } from \"../config\";\nimport { createFormDa"
},
{
"path": "vercel/pages/api/utils/lastfm.ts",
"chars": 1333,
"preview": "import * as crypto from \"crypto\";\nimport { LASTFM_CONFIG } from \"../config\";\n\n/**\n * Generate MD5 hash for password auth"
},
{
"path": "vercel/pages/index.tsx",
"chars": 1132,
"preview": "import React from \"react\";\n\nexport default function Home() {\n return (\n <div\n style={{\n padding: \"20px\","
},
{
"path": "vercel/tsconfig.json",
"chars": 575,
"preview": "{\n \"compilerOptions\": {\n \"target\": \"es2015\",\n \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n \"allowJs\": true,\n "
},
{
"path": "vercel/vercel.json",
"chars": 288,
"preview": "{\n \"framework\": \"nextjs\",\n \"outputDirectory\": \".next\",\n \"buildCommand\": \"npm run build\",\n \"devCommand\": \"npm run dev"
}
]
// ... and 2 more files (download for full content)
About this extraction
This page contains the full source code of the playwora/wora GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 97 files (452.8 KB), approximately 109.0k tokens, and a symbol index with 176 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.