Compare commits

...

25 Commits

Author SHA1 Message Date
srgooglo
2832b95a75
Merge pull request #147 from ragestudio/dev 2025-05-21 21:19:08 +02:00
af6c217176 Bump versions 2025-05-21 19:18:38 +00:00
srgooglo
add53cbe15
Merge pull request #146 from ragestudio/dev 2025-05-21 21:14:26 +02:00
007f376eae Update comty.js 2025-05-21 19:13:41 +00:00
bcd52cac55 Update comty.js 2025-05-21 19:13:27 +00:00
afc00c719f Update vessel 2025-05-21 19:10:28 +00:00
ea01907020 Bump version & tidy dependecies 2025-05-21 19:09:22 +00:00
904e8ce098 Update Dockerfile 2025-05-21 19:08:46 +00:00
dbdfc945ff Improve nginx default response 2025-05-21 19:08:30 +00:00
75174c34d0 Collect only public tracks 2025-05-21 19:07:33 +00:00
d38d449c28 removed unused argument 2025-05-21 19:07:22 +00:00
87f746d5b9 Refactor live stream page and add Shaka decoder
- Move files for `/tv/live/[id]` route into a dedicated directory structure.
- Introduce Shaka player decoder for HLS playback.
- Remove deprecated FLV decoder (mpegts.js).
- Add basic chat page placeholder.
- Improve cleanup logic for decoder instances.
2025-05-21 19:06:51 +00:00
68af8c6f93 Adjust modal styles 2025-05-21 19:05:56 +00:00
dad308c914 Remove unwanted logs 2025-05-21 19:05:17 +00:00
a478432d61 Implement music sync room and refine related features
- Add WebSocket-based sync room for real-time music playback sync.
- Expand music exploration search to include albums and artists.
- Adjust track and release data fetching and deletion on server.
- Enhance DASH segmentation job with codec overrides and MPD updates.
- Update music service configuration for websockets and middlewares.
- Make minor UI adjustments to the search component.
2025-05-21 19:04:59 +00:00
0eaecf6fd3 Refactor player internals and sync
Replace `TrackInstance` with direct `TrackManifest` usage in the player
core. Introduce a `SyncRoom` class and related hooks (`useSyncRoom`,
`useLyrics`, etc.) for real-time state synchronization and shared lyrics
display. Enhance player indicators to show detailed audio format metadata
(codec, sample rate, bit depth). Relocate the Indicators component and update
the Lyrics page to utilize these new features and components.
2025-05-21 19:03:08 +00:00
12e9cb30ca added track visibility switch 2025-05-21 18:56:45 +00:00
b695e170e8 Improve playlist view 2025-05-21 18:56:23 +00:00
a92de80121 export ogg icon 2025-05-21 18:56:06 +00:00
71cc3938b1 added new ogg icon 2025-05-21 18:56:01 +00:00
f969ba96a0 renaming some methods 2025-05-21 18:55:47 +00:00
d0ccdfed2b move docs 2025-05-21 18:55:23 +00:00
179496b061 Fix rtspt url media 2025-05-21 18:52:31 +00:00
srgooglo
a6e9bde6d4
Merge pull request #145 from ragestudio/dev 2025-05-15 16:17:50 +02:00
edf8b8c05f fix http upgrade 2025-05-15 14:17:16 +00:00
144 changed files with 3123 additions and 1897 deletions

@ -1 +1 @@
Subproject commit 7c11af2643de00423a6ac680bba235c3352620a0
Subproject commit cbb45df2ef42205022e38e4c7d33a001e162c383

View File

@ -1,9 +0,0 @@
---
sidebar_position: 3
---
# Authenticating
## Server Keys
## User Token

View File

@ -1,7 +0,0 @@
{
"label": "Definitions",
"position": 6,
"link": {
"type": "generated-index"
}
}

View File

@ -1,6 +0,0 @@
# Like Status Object
| Parameter | Type | Content |
| --- | --- | --- |
| post_id | string | |
| liked | Boolean | |
| count | Number | Current like count |

View File

@ -1,8 +0,0 @@
# Post Object
| Parameter | Type | Content |
| --- | --- | --- |
| _id | string | |
| user_id | string | |
| message | string | |
| created_at | string | |
| updated_at | string | |

View File

@ -1,6 +0,0 @@
# Save Status Object
| Parameter | Type | Content |
| --- | --- | --- |
| post_id | string | |
| saved | Boolean | |
| count | Number | Current global save count |

View File

@ -1,37 +0,0 @@
---
sidebar_position: 1
---
# Getting Started with Comty.JS
[https://github.com/ragestudio/comty.js](https://github.com/ragestudio/comty.js)
Welcome to Comty.JS, the official JavaScript library for interacting with the Comty API! This library is designed to simplify communication with Comty services, whether you're building a server-side application or a client-side interface.
## What is Comty.JS?
Comty.JS provides a convenient wrapper around the Comty API, handling authentication, request management, real-time communication via WebSockets, and more. It aims to make your development process smoother and more efficient by abstracting away the complexities of direct API interaction.
**Key Features:**
- **Simplified API Access:** Easy-to-use models for various Comty services like Authentication, Posts, User Management, Music, Chats, and more.
- **Authentication Handling:** Built-in support for server keys and user token-based authentication, including automatic token refresh.
- **WebSocket Integration:** Seamlessly connect to Comty's real-time services.
- **Addon System:** Extend the library's functionality with custom addons.
- **Environment Aware:** Works in both Node.js (server-side) and browser (client-side) environments.
- **Request Management:** Uses `axios` for HTTP requests with interceptors for automatic token attachment and error handling.
## Who is this for?
This library is for developers who want to:
- Integrate their JavaScript or TypeScript applications with Comty.
- Build features that utilize Comty's social, music, or other platform functionalities.
- Quickly set up communication with the Comty API without dealing with raw HTTP requests and WebSocket management.
## Next Steps
1. **[Installing Comty.JS](./installing.md)**: Learn how to add the library to your project.
2. **[Client Initialization](./client-initialization.md)**: Understand how to set up and configure the Comty.JS client.
3. **[Authentication](./authentication.md)**: Dive into how authentication works with Comty.JS.
We're excited to see what you build with Comty.JS!

44
docs/comty-js/index.mdx Normal file
View File

@ -0,0 +1,44 @@
---
id: index
title: Comty.js Library
sidebar_label: Introduction
---
Welcome to the documentation for the `comty.js` library. This library provides a set of modules and classes for interacting with the Comty platform.
## Core Models
The following are the core models available in the `comty.js` library:
* [AddonsManager](models/addons): Manages addons within the library.
* [AuthModel](models/auth): Handles user authentication and session management.
* [MusicModel](models/music): Handles Music Data.
* [Post](models/post): Handles Post Data.
* [Search](models/search): Handles Search requests.
* [SessionModel](models/session): Manages user sessions and tokens.
* [UserModel](models/user): Manages User Data.
## Helpers
* [Remotes](remotes): Describes all the remotes.
* [Settings](settings): Describes all the settings.
* [Storage](storage): Describes all the storage.
* [WebsocketManager](ws): Handles websocket connections.
## Getting Started
To get started with the `comty.js` library, you can install it using npm:
```bash
npm install comty.js
```
Then, you can import the modules you need into your project:
```javascript
import { AuthModel, SessionModel } from 'comty.js';
// Use the modules
AuthModel.login({ username: 'myuser', password: 'mypassword' })
.then(data => console.log(data));
```

View File

@ -1,103 +0,0 @@
---
sidebar_position: 2
---
# Installing Comty.JS
To get started with Comty.JS, you need to add it as a dependency to your project. The library is available on npm.
## Prerequisites
* Node.js (version 12 or higher recommended, as per `jsonwebtoken` and `sucrase` dependencies)
* A package manager like npm or Yarn
## Installation
You can install Comty.JS using either npm or Yarn:
### Using npm
```bash
npm install comty.js
```
### Using Yarn
```bash
yarn add comty.js
```
This will download Comty.JS and add it to your project's `node_modules` directory. The following dependencies will also be installed:
* `@foxify/events`: ^2.1.0
* `axios`: ^1.8.4
* `js-cookie`: ^3.0.5
* `jsonwebtoken`: ^9.0.0
* `jwt-decode`: ^4.0.0
* `linebridge-client`: ^1.1.1
* `luxon`: ^3.6.0
* `socket.io-client`: ^4.8.1
For development, if you plan to contribute or build the library locally, you'll also need:
* `@ragestudio/hermes`: ^1.0.1 (used for building the project)
## Importing the library
Once installed, you can import Comty.JS into your project:
### ES Modules (JavaScript or TypeScript)
```javascript
import createClient from 'comty.js';
// or for specific models if needed (though typically client is the main entry)
// import { AuthModel, PostModel } from 'comty.js/models'; // Adjust path based on actual export structure if modular imports are supported
```
### CommonJS (Node.js)
```javascript
const createClient = require('comty.js');
// or for specific models
// const { AuthModel, PostModel } = require('comty.js/models'); // Adjust path
```
If you look at the `package.json`, the main entry point is `"./dist/index.js"`.
```json comty-project/public-repo/comty.js/package.json#L3
{
"name": "comty.js",
"version": "0.65.5",
"main": "./dist/index.js",
"description": "Official Comty API for JavaScript",
"homepage": "https://github.com/ragestudio/comty.js",
"author": "RageStudio <support@ragestudio.net>",
"scripts": {
"build": "hermes build"
},
"files": [
"dist"
],
"license": "MIT",
"dependencies": {
"@foxify/events": "^2.1.0",
"axios": "^1.8.4",
"js-cookie": "^3.0.5",
"jsonwebtoken": "^9.0.0",
"jwt-decode": "^4.0.0",
"linebridge-client": "^1.1.1",
"luxon": "^3.6.0",
"socket.io-client": "^4.8.1"
},
"devDependencies": {
"@ragestudio/hermes": "^1.0.1"
}
}
```
Now you're ready to initialize the client and start interacting with the Comty API.
## Next Steps
* **[Client Initialization](./client-initialization.md)**: Learn how to set up and configure the Comty.JS client.

View File

@ -1,7 +0,0 @@
{
"label": "Models",
"position": 4,
"link": {
"type": "generated-index"
}
}

View File

@ -0,0 +1,46 @@
---
id: addons
title: AddonsManager
sidebar_label: AddonsManager
---
## AddonsManager
The `AddonsManager` class provides a way to register, retrieve, and manage addons within the comty.js library.
**Class Overview:**
The `AddonsManager` class allows you to register addons, retrieve them by name, and find addons that implement specific operations. It uses a `Map` internally to store the registered addons.
### Properties
* `addons`: A `Map` that stores the registered addons. The keys are the addon names, and the values are the addon instances.
### Methods
* `register(name, addon)`
* Registers a new addon with the specified name.
* Parameters:
* `name`: *string* The name of the addon.
* `addon`: *object* The addon instance.
* Returns: void
* `get(name)`
* Retrieves an addon by its name.
* Parameters:
* `name`: *string* The name of the addon to retrieve.
* Returns: *object | undefined* The addon instance if found, otherwise `undefined`.
* `getByOperation(operation)`
* Searches all registered addons and returns an array of addons that have a function for the specified operation.
* Parameters:
* `operation`: *string* The name of the operation to search for.
* Returns: *Array[object]* An array of objects, where each object contains the addon's ID and the corresponding function for the specified operation. Each object has the following structure:
* `id`: *string* The ID of the addon (addon.constructor.id).
* `fn`: *function* The addon's function for the specified operation (addon[operation]).
### API Reference
* `register(name: string, addon: object)`: void - Registers a new addon.
* `get(name: string)`: object | undefined - Retrieves an addon by name.
* `getByOperation(operation: string)`: Array[object] - Gets addons by operation.

View File

@ -0,0 +1,101 @@
---
id: auth
title: AuthModel
sidebar_label: AuthModel
---
## AuthModel
The `AuthModel` class provides static methods for handling user authentication, registration, and session management.
**Class Overview:**
The `AuthModel` class provides methods for user login, logout, registration, token verification, username validation, password management, and account activation/disabling.
### Methods
* `login(payload, callback)`
* Asynchronously handles the login process.
* Parameters:
* `payload`: *object* An object containing the username, password, and MFA code if required.
* `callback`: *function, optional* A callback function to handle further actions after login.
* Returns: *Promise[object | boolean]* A Promise that resolves with the response data if login is successful. Returns `false` if MFA is required.
* `logout()`
* Asynchronously logs out the user by destroying the current session and emitting an event for successful logout.
* Returns: *Promise[void]* A Promise that resolves after the logout process is completed.
* `register(payload)`
* Registers a new user with the provided payload.
* Parameters:
* `payload`: *object* An object containing the user's information username, password, email, tos.
* Returns: *Promise[object]* A Promise that resolves with the response data if registration is successful.
* Throws: Error if the registration fails.
* `authToken(token)`
* Verifies the given token and returns the user data associated with it.
* Parameters:
* `token`: *string, optional* The token to verify. If not provided, the stored token is used.
* Returns: *Promise[object]* A Promise that resolves with the user data if the token is valid.
* Throws: Error if there was an issue with the request.
* `usernameValidation(username)`
* Validates the existence of a username.
* Parameters:
* `username`: *string* The username to validate.
* Returns: *Promise[boolean | object]* A Promise that resolves with the response data if the validation is successful, or `false` if there was an error.
* Throws: Error if the validation fails.
* `availability(payload)`
* Retrieves the availability of a username and email.
* Parameters:
* `payload`: *object* An object containing the username and email to check availability for.
* Returns: *Promise[object | boolean]* A Promise that resolves with the availability data if successful, or `false` if an error occurred.
* `changePassword(payload)`
* Changes the user's password.
* Parameters:
* `payload`: *object* An object containing the currentPassword, newPassword, and code optional.
* Returns: *Promise[object]* The data response after changing the password.
* `activateAccount(user_id, code)`
* Activates a user account using the provided activation code.
* Parameters:
* `user_id`: *string* The ID of the user to activate.
* `code`: *string* The activation code sent to the user's email.
* Returns: *Promise[object]* A Promise that resolves with the response data after activation.
* Throws: Error if the activation process fails.
* `resendActivationCode(user_id)`
* Resends the activation code to the user.
* Parameters:
* `user_id`: *string* The ID of the user to resend the activation code to.
* Returns: *Promise[object]* A Promise that resolves with the response data after sending the activation code.
* Throws: Error if the resend activation code process fails.
* `disableAccount(options)`
* Disables the user's account.
* Parameters:
* `options`: *object, optional* An object containing options for disabling the account.
* `confirm`: *boolean* Confirmation to disable the account.
* Returns: *Promise[object]* A Promise that resolves with the response data after disabling the account.
* `recoverPassword(usernameOrEmail)`
* Recovers the password for a user account.
* Parameters:
* `usernameOrEmail`: *string* The username or email associated with the account to recover.
* Returns: *Promise[object]* A Promise that resolves with the response data after initiating the password recovery process.
### API Reference
* `login(payload: object, callback: function | undefined)`: *Promise[object | boolean]* - Asynchronously handles the login process.
* `logout()`: *Promise[void]* - Asynchronously logs out the user.
* `register(payload: object)`: *Promise[object]* - Registers a new user.
* `authToken(token: string | undefined)`: *Promise[object]* - Verifies the given token.
* `usernameValidation(username: string)`: *Promise[boolean | object]* - Validates the existence of a username.
* `availability(payload: object)`: *Promise[object | boolean]* - Retrieves the availability of a username and email.
* `changePassword(payload: object)`: *Promise[object]* - Changes the user's password.
* `activateAccount(user_id: string, code: string)`: *Promise[object]* - Activates a user account.
* `resendActivationCode(user_id: string)`: *Promise[object]* - Resends the activation code.
* `disableAccount(options: object | undefined)`: *Promise[object]* - Disables the user's account.
* `recoverPassword(usernameOrEmail: string)`: *Promise[object]* - Recovers the password for a user account.

View File

@ -1,7 +0,0 @@
{
"label": "Auth",
"position": 1,
"link": {
"type": "generated-index"
}
}

View File

@ -1,5 +0,0 @@
---
sidebar_position: 5
---
# Check if username or email is available

View File

@ -1,5 +0,0 @@
---
sidebar_position: 6
---
# Update password

View File

@ -1,79 +0,0 @@
---
sidebar_position: 1
---
# Login (Credentials)
This method allows you to create a auth session with a username and password.
:::info
Use of [**server keys**](/docs/comty-js/authentication#server-keys) is recommended instead using credentials.
:::
<div class="divider"/>
### Parameters
| Parameter | Type | Optional | Default | Description |
| --- | --- | --- | --- | --- |
| [payload](#object-payload) | Object | true | | |
| [callback](#function-callback) | Function | false | | |
#### [Object] Payload
| Parameter | Type | Optional | Default | Description |
| --- | --- | --- | --- | --- |
| username | String | true | | |
| password | String | true | | |
| mfa_code | String | false | | Required if MFA is enabled for this user |
#### [Function] Callback
Executed on successful login
| Parameter | Type | Content |
| --- | --- | --- |
| data | Object | [Successful Auth](#successful-auth) |
#### [Object] Successful Auth
Contains the token and refresh token
| Parameter | Type | Content |
| --- | --- | --- |
| token | String | |
| refreshToken | String | |
| expires_in | String | |
<div class="divider"/>
### Examples
Basic usage
```js
const auth = await AuthModel.login({
username: "testuser",
password: "testpassword",
})
console.log(auth)
// returns
// {
// token: "xxxx",
// refreshToken: "xxxx",
// }
```
Using Callback
```js
AuthModel.login({
username: "testuser",
password: "testpassword",
}, (data) => {
console.log(data)
// returns
// {
// token: "xxxx",
// refreshToken: "xxxx",
// }
})
```

View File

@ -1,5 +0,0 @@
---
sidebar_position: 2
---
# Logout

View File

@ -1,5 +0,0 @@
---
sidebar_position: 3
---
# Create a new Account

View File

@ -1,5 +0,0 @@
---
sidebar_position: 4
---
# Check if exist a username

View File

@ -1 +0,0 @@
# Chats

View File

@ -0,0 +1,29 @@
---
id: chats
title: ChatsService
sidebar_label: ChatsService
---
## ChatsService
The `ChatsService` class provides static methods for interacting with chat data.
**Class Overview:**
The `ChatsService` class offers methods for retrieving chat history and recent chats for a user.
### Static Methods
* `getChatHistory(chat_id)`
Retrieves the chat history for a given chat ID.
* `chat_id`: *string* The ID of the chat.
* Returns: A Promise that resolves with the chat history data.
* Throws: Error if the chat_id is not provided.
* `getRecentChats()`
Retrieves the recent chats for the current user.
* Returns: A Promise that resolves with the chat history data.

View File

@ -0,0 +1,31 @@
---
id: e2e
title: E2EModel
sidebar_label: E2EModel
---
## E2EModel
The `E2EModel` class provides static methods for managing end-to-end encryption keys.
**Class Overview:**
The `E2EModel` class offers methods for retrieving and updating key pairs.
### Static Methods
* `getKeyPair()`
Retrieves the key pair for the current user.
* Returns: A Promise that resolves with the key pair data.
* `updateKeyPair(str, { imSure = false } = {})`
Updates the key pair for the current user.
* `str`: *string* The new key pair, encoded as a string.
* `{ imSure = false }`: *object, optional* Options for the update.
* `imSure`: *boolean, optional* Confirmation to update the keypair. Must be set to `true` to proceed.
* Returns: A Promise that resolves with the updated key pair data.
* Throws: Error if confirmation is missing.

View File

@ -0,0 +1,28 @@
---
id: events
title: EventsModel
sidebar_label: EventsModel
---
## EventsModel
The `EventsModel` class provides static methods for retrieving event data.
**Class Overview:**
The `EventsModel` class offers methods for retrieving featured events and data for a specific event.
### Static Methods
* `getFeatured()`
Retrieves featured events.
* Returns: A Promise that resolves with the featured events data.
* `data(id)`
Retrieves data for a specific event.
* `id`: *string* The ID of the event.
* Returns: A Promise that resolves with the event data.

View File

@ -1 +0,0 @@
# Feed

View File

@ -0,0 +1,51 @@
---
id: feed
title: FeedModel
sidebar_label: FeedModel
---
## FeedModel
The `FeedModel` class provides static methods for retrieving feed data.
**Class Overview:**
The `FeedModel` class offers methods for retrieving music, global music, timeline, and global timeline feeds.
### Static Methods
* `getMusicFeed({ page, limit } = {})`
Retrieves music feed.
* `{ page, limit }`: *object, optional* An object containing the page and limit.
* `page`: *number, optional* The number of items to page from the feed.
* `limit`: *number, optional* The maximum number of items to fetch from the feed.
* Returns: A Promise that resolves with the music feed data.
* `getGlobalMusicFeed({ page, limit } = {})`
Retrieves global music feed.
* `{ page, limit }`: *object, optional* An object containing the page and limit.
* `page`: *number, optional* The number of items to page from the feed.
* `limit`: *number, optional* The maximum number of items to fetch from the feed.
* Returns: A Promise that resolves with the global music feed data.
* `getTimelineFeed({ page, limit } = {})`
Retrieves timeline feed.
* `{ page, limit }`: *object, optional* An object containing the page and limit.
* `page`: *number, optional* The number of feed items to page.
* `limit`: *number, optional* The maximum number of feed items to retrieve.
* Returns: A Promise that resolves with the timeline feed data.
* `getGlobalTimelineFeed({ page, limit } = {})`
Retrieves global timeline feed.
* `{ page, limit }`: *object, optional* An object containing the page and limit.
* `page`: *number, optional* The number of items to page from the feed.
* `limit`: *number, optional* The maximum number of posts to fetch from the feed.
* Returns: A Promise that resolves with the posts feed data.

View File

@ -0,0 +1,38 @@
---
id: follows
title: FollowsModel
sidebar_label: FollowsModel
---
## FollowsModel
The `FollowsModel` class provides static methods for interacting with user follow relationships.
**Class Overview:**
The `FollowsModel` class offers methods for checking if a user is following another user, retrieving followers, and toggling the follow status.
### Static Methods
* `imFollowing(user_id)`
Checks if the current user is following the specified user.
* `user_id`: *string* The ID of the user to check if the current user is following.
* Returns: A Promise that resolves with the response data indicating if the current user is following the specified user.
* Throws: Error if the user_id parameter is not provided.
* `getFollowers(user_id, fetchData)`
Retrieves the list of followers for a given user.
* `user_id`: *string, optional* The ID of the user. If not provided, the current user ID will be used.
* `fetchData`: *boolean* Whether to fetch additional data for each follower. Defaults to false.
* Returns: A promise that resolves with the list of followers and their data.
* `toggleFollow({ user_id })`
Toggles the follow status for a user.
* `user_id`: *string* The ID of the user to toggle follow status.
* Returns: A promise that resolves with the response data after toggling follow status.

View File

@ -1,7 +0,0 @@
{
"label": "Follows",
"position": 3,
"link": {
"type": "generated-index"
}
}

View File

@ -1,62 +0,0 @@
---
sidebar_position: 1
---
# Get followers
Retrieves the list of followers for a given user.
<div class="divider"/>
<br />
```js
async function FollowsModel.getFollowers(user_id, fetchData)
```
### Arguments
| Parameter | Type | Optional | Default | Description |
| --- | --- | --- | --- | --- |
| user_id | String | false | | |
| fetchData | Boolean | true | false | If true, the response will contain an array of users data|
| limit | Number | true | 10 | Only if fetchData is true. Limit the number of followers to fetch |
| offset | Number | true | 0 | Only if fetchData is true. Offset the list of followers to fetch |
<div class="divider"/>
### Success Response
| Parameter | Type | Content |
| --- | --- | --- |
<div class="divider"/>
## Examples
### Basic usage
```js
const followers = await FollowsModel.getFollowers("0000")
console.log(followers)
// result: {
// count: 10
// }
```
### Retrieve user data
```js
const followers = await FollowsModel.getFollowers("0000", true, 50, 0)
console.log(followers)
// result: {
// count: 10
// list: [
// {
// _id_: "0000",
// username: "comty",
// },
// {
// _id_: "0001",
// username: "john",
// },
// ...
// ]
// }
```

View File

@ -1 +0,0 @@
# Music

View File

@ -0,0 +1,159 @@
---
id: music
title: MusicModel
sidebar_label: MusicModel
---
## MusicModel
The `MusicModel` class provides static methods for interacting with music-related data. It encapsulates various getters and setters for tracks, releases, library management, and search functionalities.
**Class Overview:**
The `MusicModel` class acts as a facade, providing a simplified interface to access and manipulate music data through its nested `Getters` and `Setters` classes.
### Static Properties
* `Getters`: An object containing static getter methods for retrieving music data.
* `Setters`: An object containing static setter methods for modifying music data.
### Track Related Methods
* `getAllTracks( { user_id, limit, page } )`
* Retrieves tracks for a given user.
* Parameters:
* `user_id`: *String* The ID of the user.
* `limit`: *Number* The number of tracks to retrieve per page.
* `page`: *Number* The page number to retrieve.
* Returns: *Promise[object]*
* `getTrackData(id: String, options: Object)`
* Retrieves data for a specific track.
* Parameters:
* `id`: *String* The ID of the track.
* `options`: *Object* Additional options for the request.
* Returns: *Promise[object]*
* `putTrack(track)`
* Creates/Updates a track.
* Parameters:
* `track`: *Object* The track object to create/update.
* Returns: *Promise[object]*
### Lyrics Related Methods
* `getTrackLyrics(id: String, options = { preferTranslation: false })`
* Retrieves lyrics for a specific track.
* Parameters:
* `id`: *String* The ID of the track.
* `options`: *Object*
* `preferTranslation`: *Boolean* If true, attempts to retrieve lyrics in the user's preferred language.
* Returns: *Promise[object]*
* `putTrackLyrics(track_id, data)`
* Updates lyrics for a specific track.
* Parameters:
* `track_id`: *String* The ID of the track.
* `data`: *Object* The lyrics data to update.
* Returns: *Promise[object]*
### Release Related Methods
* `getMyReleases({ limit, offset, keywords })`
* Retrieves the user's releases.
* Parameters:
* `limit`: *Number* The number of releases to retrieve.
* `offset`: *Number* The offset to start retrieving from.
* `keywords`: *String* Keywords to search for.
* Returns: *Promise[object]*
* `getAllReleases({ user_id, limit, page })`
* Retrieves releases for a given user.
* Parameters:
* `user_id`: *String* The ID of the user.
* `limit`: *Number* The number of releases to retrieve per page.
* `page`: *Number* The page number to retrieve.
* Returns: *Promise[object]*
* `getReleaseData(id: String)`
* Retrieves data for a specific release.
* Parameters:
* `id`: *String* The ID of the release.
* Returns: *Promise[object]*
* `putRelease(release)`
* Creates/Updates a release.
* Parameters:
* `release`: *Object* The release object to create/update.
* Returns: *Promise[object]*
* `deleteRelease(release_id)`
* Deletes a release.
* Parameters:
* `release_id`: *String* The ID of the release to delete.
* Returns: *Promise[object]*
### Library Related Methods
* `getMyLibrary({ limit = 100, offset = 0, order = "desc", kind })`
* Retrieves the user's music library.
* Parameters:
* `limit`: *Number* The number of items to retrieve.
* `offset`: *Number* The offset to start retrieving from.
* `order`: *String* The order to sort the items in ("asc" or "desc").
* `kind`: *String* Filter the library by kind (e.g., "track", "release").
* Returns: *Promise[object]*
* `toggleItemFavorite(type, item_id, to)`
* Toggles the favorite status of an item in the library.
* Parameters:
* `type`: *String* The type of item ("track", "release", etc.).
* `item_id`: *String* The ID of the item.
* `to`: *Boolean* Whether to add to favorites (true) or remove (false).
* Returns: *Promise[object]*
* `isItemFavorited(type, item_id)`
* Checks if an item is favorited in the library.
* Parameters:
* `type`: *String* The type of item ("track", "release", etc.).
* `item_id`: *String* The ID of the item.
* Returns: *Promise[object]*
### Other Methods
* `getRecentyPlayed(params)`
* Retrieves recently played tracks.
* Parameters:
* `params`: *Object* Additional parameters for the request.
* Returns: *Promise[object]*
* `search({ keywords, limit, offset })`
* Searches for music items.
* Parameters:
* `keywords`: *String* The search keywords.
* `limit`: *Number* The number of results to return.
* `offset`: *Number* The offset to start the search from.
* Returns: *Promise[object]*
### Aliases
* `toggleItemFavourite`: Alias for `toggleItemFavorite`.
* `isItemFavourited`: Alias for `isItemFavorited`.
### API Reference
* `getAllTracks( { user_id: String, limit: Number, page: Number } )`: *Promise[object]*
* `getTrackData(id: String, options: Object)`: *Promise[object]*
* `putTrack(track: Object)`: *Promise[object]*
* `getTrackLyrics(id: String, options: Object)`: *Promise[object]*
* `putTrackLyrics(track_id: String, data: Object)`: *Promise[object]*
* `getMyReleases({ limit: Number, offset: Number, keywords: String })`: *Promise[object]*
* `getAllReleases({ user_id: String, limit: Number, page: Number })`: *Promise[object]*
* `getReleaseData(id: String)`: *Promise[object]*
* `putRelease(release: Object)`: *Promise[object]*
* `deleteRelease(release_id: String)`: *Promise[object]*
* `getMyLibrary({ limit: Number, offset: Number, order: String, kind: String })`: *Promise[object]*
* `toggleItemFavorite(type: String, item_id: String, to: Boolean)`: *Promise[object]*
* `isItemFavorited(type: String, item_id: String)`: *Promise[object]*
* `getRecentyPlayed(params: Object)`: *Promise[object]*
* `search({ keywords: String, limit: Number, offset: Number })`: *Promise[object]*

View File

@ -1 +0,0 @@
# NFC

View File

@ -0,0 +1,54 @@
---
id: nfc
title: NFCModel
sidebar_label: NFCModel
---
## NFCModel
The `NFCModel` class provides static methods for interacting with NFC tags.
**Class Overview:**
The `NFCModel` class offers methods for retrieving, registering, and deleting NFC tags.
### Static Methods
* `getOwnTags()`
Retrieves the list of tags owned by the current user.
* Returns: A Promise that resolves with the data of the tags.
* `getTagById(id)`
Retrieves a tag by its ID.
* `id`: *string* The ID of the tag to retrieve.
* Returns: The data of the retrieved tag.
* Throws: Error if the ID is not provided.
* `getTagBySerial(serial)`
Retrieves a tag by its serial number.
* `serial`: *string* The serial number of the tag to retrieve.
* Returns: A Promise that resolves with the data of the tag.
* Throws: Error if the serial number is not provided.
* `registerTag(serial, payload)`
Registers a tag with the given serial number and payload.
* `serial`: *string* The serial number of the tag.
* `payload`: *object* The payload data for the tag.
* Returns: The data of the registered tag.
* Throws: Error if the serial or payload is not provided.
* `deleteTag(id)`
Deletes a tag.
* `id`: *string* The ID of the tag to delete.
* Returns: A Promise that resolves with the data of the deleted tag.
* Throws: Error if the ID is not provided.

View File

@ -0,0 +1,21 @@
---
id: payments
title: PaymentsModel
sidebar_label: PaymentsModel
---
## PaymentsModel
The `PaymentsModel` class provides static methods for interacting with payment information.
**Class Overview:**
The `PaymentsModel` class offers methods for retrieving payment data.
### Static Methods
* `fetchBalance()`
Fetches the current balance.
* Returns: A promise that resolves with the balance data received from the server.

View File

@ -0,0 +1,114 @@
---
id: post
title: Post
sidebar_label: Post
---
## Post
The `Post` class provides static methods for interacting with posts and related data on the Comty platform.
### API Reference
**Class Overview:**
The `Post` class offers a comprehensive set of methods for retrieving, creating, updating, deleting, liking, saving, and voting on posts. It also provides functionalities for retrieving trending hashtags and posts.
### Static Properties
* `maxPostTextLength`: *number* The maximum length allowed for the post text (3200).
* `maxCommentLength`: *number* The maximum length allowed for a comment (1200).
### Static Methods
* `getPostingPolicy()`
* Retrieves the posting policy from the server.
* Returns: *Promise[object]* The posting policy data.
* `post(options)`
* Retrieves the data of a post by its ID.
* Parameters:
* `options`: *object* An object containing the post_id.
* `post_id`: *string* The ID of the post to retrieve.
* Returns: *Promise[object]* The data of the post.
* Throws: Error if the post_id is not provided.
* `replies(options)`
* Retrieves the replies of a post by its ID.
* Parameters:
* `options`: *object* An object containing the post_id, page, and limit.
* `post_id`: *string* The ID of the post to retrieve replies for.
* `page`: *number, optional* The number of characters to page the reply content (default: 0).
* `limit`: *number, optional* The maximum number of replies to fetch (default: Settings.get("feed_max_fetch")).
* Returns: *Promise[object]* The data of the replies.
* Throws: Error if the post_id is not provided.
* `getSavedPosts(options)`
* Retrieves the saved posts with optional trimming and limiting.
* Parameters:
* `options`: *object* An object containing the page and limit.
* `page`: *number, optional* The number of posts to page from the result (default: 0).
* `limit`: *number, optional* The maximum number of posts to fetch (default: Settings.get("feed_max_fetch")).
* Returns: *Promise[object]* The data of the liked posts.
* `getUserPosts(options)`
* Retrieves the liked posts with optional trimming and limiting.
* Parameters:
* `options`: *object* An object containing the page and limit.
* `page`: *number, optional* The number of posts to page from the result (default: 0).
* `limit`: *number, optional* The maximum number of posts to fetch (default: Settings.get("feed_max_fetch")).
* Returns: *Promise[object]* The data of the liked posts.
* `getUserPosts(options)`
* Retrieves the posts of a user with optional trimming and limiting.
* Parameters:
* `options`: *object* An object containing the user_id, page, and limit.
* `user_id`: *string, optional* The ID of the user whose posts to retrieve. If not provided, the current user's ID will be used.
* `page`: *number, optional* The number of characters to page the post content (default: 0).
* `limit`: *number, optional* The maximum number of posts to fetch (default: Settings.get("feed_max_fetch")).
* Returns: *Promise[object]* The data of the user's posts.
* `toggleLike(options)`
* Toggles the like status of a post.
* Parameters:
* `options`: *object* An object containing the post_id.
* `post_id`: *string* The ID of the post to toggle the like status.
* Returns: *Promise[object]* The response data after toggling the like status.
* Throws: Error if the post_id is not provided.
* `toggleSave(options)`
* Toggles the save status of a post.
* Parameters:
* `options`: *object* An object containing the post_id.
* `post_id`: *string* The ID of the post to toggle the save status.
* Returns: *Promise[object]* The response data after toggling the save status.
* Throws: Error if the post_id is not provided.
* `create(payload)`
* Creates a new post with the given payload.
* Parameters:
* `payload`: *object* The data to create the post with.
* Returns: *Promise[object]* The response data after creating the post.
* `update(post_id, update)`
* Updates a post with the given post ID and update payload.
* Parameters:
* `post_id`: *string* The ID of the post to update.
* `update`: *object* The data to update the post with.
* Returns: *Promise[object]* The response data after updating the post.
* Throws: Error if the post_id is not provided.
* `delete(options)`
* Deletes a post with the given post ID.
* Parameters:
* `options`: *object* An object containing the post_id.
* `post_id`: *string* The ID of the post to delete.
* Returns: *Promise[object]* The response data after deleting the post.
* Throws: Error if the post_id is not provided.
* `votePoll(options)`
* Votes for a poll with the given post ID and option ID.
* Parameters:
* `options`: *object* An object containing the post_id and option_id.
* `post_id`: *string* The ID of the post to vote for.
* `

View File

@ -1,7 +0,0 @@
{
"label": "Post",
"position": 2,
"link": {
"type": "generated-index"
}
}

View File

@ -1,91 +0,0 @@
---
sidebar_position: 8
---
# Create a post
Creates a new post with the given payload.
<div class="divider"/>
<br />
```js
async function PostModel.create(payload)
```
### [Object] Payload
| Parameter | Type | Optional | Default | Description |
| --- | --- | --- | --- | --- |
| message | String | false | undefined | The message of the post |
| attachments | Array | true | [] | A list of attachments |
| timestamp | String | true | DateTime.local().toISO() | |
| reply_to | String | true | null | |
<div class="divider"/>
### Success Response
| Parameter | Type | Content |
| --- | --- | --- |
| data | Object | [post-object](/docs/comty-js/definitions/post-object) |
<div class="divider"/>
## Examples
### Basic usage
```js
const post = await PostModel.create({
message: "Testing Comty.JS",
timestamp: new Date(),
})
console.log(post)
// result: {
// _id_: "0000",
// message: "Testing Comty.JS",
// timestamp: "2024-01-01T17:00:00.000Z",
// }
```
### Add attachments
```js
const post = await PostModel.create({
message: "Look at this fox",
attachments: [
{
url: "https://upload.wikimedia.org/wikipedia/commons/3/30/Vulpes_vulpes_ssp_fulvus.jpg",
}
],
})
console.log(post)
// result: {
// _id_: "0001",
// message: "Look at this fox",
// timestamp: "2024-01-01T17:00:00.000Z",
// attachments: [
// {
// url: "https://upload.wikimedia.org/wikipedia/commons/3/30/Vulpes_vulpes_ssp_fulvus.jpg",
// }
// ]
// }
```
### Reply to a post
```js
const post = await PostModel.create({
reply_to: "0001",
message: "* pet pet *",
})
console.log(post)
// result: {
// _id_: "0002",
// reply_to: "0001",
// message: "* pat pat*",
// timestamp: "2024-01-01T17:30:00.000Z",
// }
```

View File

@ -1,45 +0,0 @@
---
sidebar_position: 10
---
# Delete a post
Delete a post with the given post ID.
Only can delete your own posts.
<div class="divider"/>
<br />
```js
async function PostModel.delete(payload)
```
### [Object] Payload
| Parameter | Type | Optional | Default | Description |
| --- | --- | --- | --- | --- |
| post_id | String | false | undefined | |
<div class="divider"/>
### Success Response
| Parameter | Type | Content |
| --- | --- | --- |
| post_id | String | |
| deleted | Boolean | |
<div class="divider"/>
## Examples
### Basic usage
```js
const post = await PostModel.delete({
post_id: "0000",
})
console.log(post)
// result: {
// post_id: "0000",
// deleted: true,
// }
```

View File

@ -1,45 +0,0 @@
---
sidebar_position: 2
---
# Get my liked posts
Retrieves the liked posts of current authed user.
<div class="divider"/>
<br />
```js
async function PostModel.getLikedPosts(payload)
```
### [Object] Payload
| Parameter | Type | Optional | Default | Description |
| --- | --- | --- | --- | --- |
| trim | number | true | 0 | Trim the post index content |
| limit | number | true | 10 | Limit the number of posts to fetch |
<div class="divider"/>
### Success Response
| Parameter | Type | Content |
| --- | --- | --- |
| data | Array | [[post_obj](/docs/comty-js/definitions/post-object), ...] |
<div class="divider"/>
## Examples
### Basic usage
```js
const posts = await PostModel.getLikedPosts({
trim: 0,
limit: 10,
})
console.log(posts)
// result: [
// { _id: "0000", user_id: "0000", message: "example text", ... },
// { _id: "0001", user_id: "0000", message: "example text", ... },
// ...
// ]
```

View File

@ -1,44 +0,0 @@
---
sidebar_position: 4
---
# Get post data
Retrieves the data of a post.
<div class="divider"/>
<br />
```js
async function PostModel.post(payload)
```
### [Object] Payload
| Parameter | Type | Optional | Default | Description |
| --- | --- | --- | --- | --- |
| post_id | string | false | | Defines the ID of the post to retrieve.|
<div class="divider"/>
### Success Response
| Parameter | Type | Content |
| --- | --- | --- |
| data | object | [post_obj](/docs/comty-js/definitions/post-object) |
<div class="divider"/>
## Examples
### Basic usage
```js
const post = await PostModel.post({
post_id: "0000",
})
console.log(post)
// result: {
// _id: "0000",
// user_id: "0000",
// message: "example text",
// ...
// }
```

View File

@ -1,47 +0,0 @@
---
sidebar_position: 5
---
# Get post replies
Retrieves replies of a post.
<div class="divider"/>
<br />
```js
async function PostModel.replies(payload)
```
### [Object] Payload
| Parameter | Type | Optional | Default | Description |
| --- | --- | --- | --- | --- |
| post_id | string | false | | |
| trim | number | true | 0 | Trim the post index content |
| limit | number | true | 10 | Limit the number of posts to fetch |
<div class="divider"/>
### Success Response
| Parameter | Type | Content |
| --- | --- | --- |
| data | Array | [[post_obj](/docs/comty-js/definitions/post-object), ...] |
<div class="divider"/>
## Examples
### Basic usage
```js
const posts = await PostModel.replies({
post_id: "0000",
trim: 0,
limit: 10,
})
console.log(posts)
// result: [
// { _id: "0000", user_id: "0000", message: "example text", ... },
// { _id: "0001", user_id: "0000", message: "example text", ... },
// ...
// ]
```

View File

@ -1,45 +0,0 @@
---
sidebar_position: 3
---
# Get my saved posts
Retrieves the saved posts of current authed user.
<div class="divider"/>
<br />
```js
async function PostModel.getSavedPosts(payload)
```
### [Object] Payload
| Parameter | Type | Optional | Default | Description |
| --- | --- | --- | --- | --- |
| trim | number | true | 0 | Trim the post index content |
| limit | number | true | 10 | Limit the number of posts to fetch |
<div class="divider"/>
### Success Response
| Parameter | Type | Content |
| --- | --- | --- |
| data | Array | [[post_obj](/docs/comty-js/definitions/post-object), ...] |
<div class="divider"/>
## Examples
### Basic usage
```js
const posts = await PostModel.getSavedPosts({
trim: 0,
limit: 10,
})
console.log(posts)
// result: [
// { _id: "0000", user_id: "0000", message: "example text", ... },
// { _id: "0001", user_id: "0000", message: "example text", ... },
// ...
// ]
```

View File

@ -1,47 +0,0 @@
---
sidebar_position: 1
---
# Get user posts
Retrieves the public posts of a user.
<div class="divider"/>
<br />
```js
async function PostModel.getUserPosts(payload)
```
### [Object] Payload
| Parameter | Type | Optional | Default | Description |
| --- | --- | --- | --- | --- |
| user_id | string | false | | |
| trim | number | true | 0 | Trim the post index content |
| limit | number | true | 10 | Limit the number of posts to fetch |
<div class="divider"/>
### Success Response
| Parameter | Type | Content |
| --- | --- | --- |
| data | Array | [[post_obj](/docs/comty-js/definitions/post-object), ...] |
<div class="divider"/>
## Examples
### Basic usage
```js
const posts = await PostModel.getUserPosts({
user_id: "0000",
trim: 0,
limit: 10,
})
console.log(posts)
// result: [
// { _id: "0000", user_id: "0000", message: "example text", ... },
// { _id: "0001", user_id: "0000", message: "example text", ... },
// ...
// ]
```

View File

@ -1,60 +0,0 @@
---
sidebar_position: 6
---
# Toggle post like
Toggles the like status of a post.
<div class="divider"/>
<br />
```js
async function PostModel.toggleLike(payload)
```
### [Object] Payload
| Parameter | Type | Optional | Default | Description |
| --- | --- | --- | --- | --- |
| post_id | string | false | | |
| to | Boolean | true | | Set like to true or false |
<div class="divider"/>
### Success Response
| Parameter | Type | Content |
| --- | --- | --- |
| data | Object | [like-status-object](/docs/comty-js/definitions/like-status-object) |
<div class="divider"/>
## Examples
### Basic usage
```js
const like = await PostModel.toggleLike({
post_id: "0000",
})
console.log(like)
// result: {
// post_id: "0000",
// liked: true,
// count: 1,
// }
```
### Specify status
```js
const like = await PostModel.toggleLike({
post_id: "0000",
to: false
})
console.log(like)
// result: {
// post_id: "0000",
// liked: false,
// count: 0,
// }
```

View File

@ -1,60 +0,0 @@
---
sidebar_position: 7
---
# Toggle post save
Toggles the save status of a post.
<div class="divider"/>
<br />
```js
async function PostModel.toggleSave(payload)
```
### [Object] Payload
| Parameter | Type | Optional | Default | Description |
| --- | --- | --- | --- | --- |
| post_id | string | false | | |
| to | Boolean | true | | Set save to true or false |
<div class="divider"/>
### Success Response
| Parameter | Type | Content |
| --- | --- | --- |
| data | Object | [save-status-object](/docs/comty-js/definitions/save-status-object) |
<div class="divider"/>
## Examples
### Basic usage
```js
const save = await PostModel.toggleSave({
post_id: "0000",
})
console.log(save)
// result: {
// post_id: "0000",
// saved: true,
// count: 1,
// }
```
### Specify status
```js
const save = await PostModel.toggleSave({
post_id: "0000",
to: false
})
console.log(save)
// result: {
// post_id: "0000",
// saved: false,
// count: 0,
// }
```

View File

@ -1,76 +0,0 @@
---
sidebar_position: 9
---
# Update a post
Updates a post with the given post ID and update payload.
Only can update your own posts.
<div class="divider"/>
<br />
```js
async function PostModel.update(post_id, payload)
```
### [String] post_id
Defines the ID of the post to update.
### [Object] Payload
| Parameter | Type | Optional | Default | Description |
| --- | --- | --- | --- | --- |
| message | String | false | undefined | The message of the post |
| attachments | Array | true | [] | A list of attachments |
<div class="divider"/>
### Success Response
| Parameter | Type | Content |
| --- | --- | --- |
| data | Object | [post-object](/docs/comty-js/definitions/post-object) |
<div class="divider"/>
## Examples
### Basic usage
```js
const post = await PostModel.update({
post_id: "0000",
message: "Updated message",
})
console.log(post)
// result: {
// _id_: "0000",
// message: "Updated message",
// timestamp: "2024-01-01T17:00:00.000Z",
// }
```
### Modify or remove attachments
```js
const post = await PostModel.update({
post_id: "0000",
attachments: [
{
url: "https://upload.wikimedia.org/wikipedia/commons/3/30/Vulpes_vulpes_ssp_fulvus.jpg",
}
],
})
console.log(post)
// result: {
// _id_: "0000",
// message: "Updated message",
// timestamp: "2024-01-01T17:00:00.000Z",
// attachments: [
// {
// url: "https://upload.wikimedia.org/wikipedia/commons/3/30/Vulpes_vulpes_ssp_fulvus.jpg",
// }
// ]
// }
```

View File

@ -0,0 +1,30 @@
---
id: radio
title: Radio
sidebar_label: Radio
---
## Radio
The `Radio` class provides static methods for retrieving radio data.
**Class Overview:**
The `Radio` class offers methods for retrieving radio lists and trending radio stations.
### Static Methods
* `getRadioList({ limit = 50, offset = 0 } = {})`
Retrieves a list of radio stations.
* `{ limit, offset }`: *object, optional* An object containing the limit and offset for pagination.
* `limit`: *number, optional* The maximum number of radio stations to retrieve (default: 50).
* `offset`: *number, optional* The offset to start retrieving from (default: 0).
* Returns: A promise that resolves with the radio list data.
* `getTrendings()`
Retrieves trending radio stations.
* Returns: A promise that resolves with the trending radio stations data.

View File

@ -1 +0,0 @@
# Search

View File

@ -0,0 +1,32 @@
---
id: search
title: Search
sidebar_label: Search
---
## Search
The `Search` class provides a static method for performing searches using the Comty API.
**Class Overview:**
The `Search` class encapsulates the search functionality, allowing you to search for content using keywords and optional parameters. It also supports external addons to extend the search functionality.
### Static Methods
* `search(keywords, params, returnFields)`
Performs a search using the provided keywords and optional parameters.
* `keywords`: *string* The keywords to search for.
* `params`: *object, optional* Optional parameters for the search.
* `limit`: *number, optional* The maximum number of results to return default: 50.
* `offset`: *number, optional* The offset to start the search from default: 0.
* `sort`: *string, optional* The sort order "asc" or "desc" default: "desc".
* `fields`: *array, optional* An array of fields to return in the results. If empty, all fields will be returned.
* `returnFields`: *array, optional* An array of fields to return in the results. If empty, all fields will be returned.
* Returns: A promise that resolves with the search results.
### API Reference
* `search(keywords: string, params: object, returnFields: array)`: Promise[object] - Performs a search using the provided keywords and optional parameters.

View File

@ -1 +0,0 @@
# Session

View File

@ -0,0 +1,97 @@
---
id: spectrum
title: Streaming
sidebar_label: Streaming
---
## Streaming
The `Streaming` class provides static methods for interacting with streaming data on the Comty platform.
**Class Overview:**
The `Streaming` class offers methods for managing streaming profiles, streams, and websocket connections.
### Static Methods
* `getStream(stream_id)`
Retrieves data for a specific stream.
* `stream_id`: *string* The ID of the stream.
* Returns: A promise that resolves with the stream data.
* `getOwnProfiles()`
Retrieves the streaming profiles owned by the current user.
* Returns: A promise that resolves with the profiles data.
* `getProfile(profile_id)`
Retrieves data for a specific streaming profile.
* `profile_id`: *string* The ID of the profile.
* Returns: A promise that resolves with the profile data.
* `createProfile(payload)`
Creates a new streaming profile.
* `payload`: *object* The data for the new profile.
* Returns: A promise that resolves with the created profile data.
* `updateProfile(profile_id, update)`
Updates a streaming profile.
* `profile_id`: *string* The ID of the profile to update.
* `update`: *object* The data to update the profile with.
* Returns: A promise that resolves with the updated profile data.
* `deleteProfile(profile_id)`
Deletes a streaming profile.
* `profile_id`: *string* The ID of the profile to delete.
* Returns: A promise that resolves with the response data after deleting the profile.
* `addRestreamToProfile(profileId, restreamData)`
Adds a restream to a profile.
* `profileId`: *string* The ID of the profile to add the restream to.
* `restreamData`: *object* The data for the restream.
* Returns: A promise that resolves with the response data.
* `deleteRestreamFromProfile(profileId, restreamIndexData)`
Deletes a restream from a profile.
* `profileId`: *string* The ID of the profile to delete the restream from.
* `restreamIndexData`: *object* The index data for the restream to delete.
* Returns: A promise that resolves with the response data.
* `list({ limit, offset } = {})`
Lists streaming entries.
* `{ limit, offset }`: *object, optional* An object containing pagination parameters.
* `limit`: *number, optional* The maximum number of items to retrieve.
* `offset`: *number, optional* The offset to start retrieving from.
* Returns: A promise that resolves with the streaming entries data.
* `createWebsocket(params = {})`
Creates a websocket connection.
* `params`: *object, optional* Additional parameters for the websocket connection.
* Returns: A `RTEngineClient` websocket client.
* `createStreamWebsocket(stream_id, params = {})`
Creates a stream-specific websocket connection.
* `stream_id`: *string* The ID of the stream.
* `params`: *object, optional* Additional parameters for the websocket connection.
* Returns: A `RTEngineClient` websocket client.

View File

@ -0,0 +1,63 @@
---
id: spotify
title: SpotifySyncModel
sidebar_label: SpotifySyncModel
---
## SpotifySyncModel
The `SpotifySyncModel` class provides static methods for linking and interacting with the Spotify service.
**Class Overview:**
The `SpotifySyncModel` class offers methods for authorizing, linking, unlinking, and retrieving data from the Spotify service.
### Static Properties
* `spotify_redirect_uri`: *string* The redirect URI for Spotify authorization.
* `spotify_authorize_endpoint`: *string* The Spotify authorization endpoint.
### Static Methods
* `authorizeAccount()`
Opens a new tab to authorize the user's Spotify account.
* Returns: void
* `get_client_id()`
Retrieves the Spotify client ID.
* Returns: A Promise that resolves with the client ID data.
* `syncAuthCode(code)`
Syncs the Spotify authorization code.
* `code`: *string* The Spotify authorization code.
* Returns: A Promise that resolves with the sync data.
* `unlinkAccount()`
Unlinks the user's Spotify account.
* Returns: A Promise that resolves with the unlink data.
* `isAuthorized()`
Checks if the user is authorized with Spotify.
* Returns: A Promise that resolves with a boolean indicating whether the user is authorized.
* `getData()`
Retrieves Spotify data.
* Returns: A Promise that resolves with the Spotify data.
* `getCurrentPlaying()`
Retrieves the currently playing track from Spotify.
* Returns: A Promise that resolves with the currently playing track data.

View File

@ -0,0 +1,108 @@
---
id: tidal
title: TidalService
sidebar_label: TidalService
---
## TidalService
The `TidalService` class provides static methods for linking and interacting with the Tidal service.
**Class Overview:**
The `TidalService` class offers methods for linking and unlinking accounts, checking the connection status, and retrieving data from the Tidal service.
### Static Properties
* `api_instance`: Returns the API instance for the Tidal service.
### Static Methods
* `linkAccount()`
Opens a new tab to link the user's Tidal account.
* Returns: A Promise that resolves with the link data.
* Throws: Error if not running in a browser environment
* `unlinkAccount()`
Unlinks the user's Tidal account.
* Returns: A Promise that resolves with the unlink data.
* Throws: Error if not running in a browser environment
* `isActive()`
Checks if the user's Tidal account is linked.
* Returns: A Promise that resolves with a boolean indicating whether the account is linked.
* Throws: Error if not running in a browser environment
* `getCurrentUser()`
Retrieves the current Tidal user.
* Returns: A Promise that resolves with the current user data.
* `getPlaybackUrl(track_id)`
Retrieves the playback URL for a given Tidal track ID.
* `track_id`: *string* The ID of the Tidal track.
* Returns: A Promise that resolves with the playback URL data.
* `getTrackManifest(track_id)`
Retrieves the track manifest for a given Tidal track ID.
* `track_id`: *string* The ID of the Tidal track.
* Returns: A Promise that resolves with the track manifest data.
* `getMyFavoriteTracks({ limit = 50, offset = 0 } = {})`
Retrieves the user's favorite Tidal tracks.
* `{ limit, offset }`: *object, optional* An object containing pagination parameters.
* `limit`: *number* The maximum number of tracks to retrieve.
* `offset`: *number* The offset to start retrieving from.
* Returns: A Promise that resolves with the favorite tracks data.
* `getMyFavoritePlaylists({ limit = 50, offset = 0 } = {})`
Retrieves the user's favorite Tidal playlists.
* `{ limit, offset }`: *object, optional* An object containing pagination parameters.
* `limit`: *number* The maximum number of playlists to retrieve.
* `offset`: *number* The offset to start retrieving from.
* Returns: A Promise that resolves with the favorite playlists data.
* `getPlaylistData({ playlist_id, resolve_items = false, limit = 50, offset = 0 })`
Retrieves Tidal playlist data.
* `playlist_id`: *string* The ID of the Tidal playlist.
* `{ resolve_items, limit, offset }`: *object, optional* An object containing playlist options.
* `resolve_items`: *boolean* Whether to resolve playlist items.
* `limit`: *number* The maximum number of items to retrieve.
* `offset`: *number* The offset to start retrieving from.
* Returns: A Promise that resolves with the playlist data.
* `getPlaylistItems({ playlist_id, resolve_items = false, limit = 50, offset = 0 })`
Retrieves Tidal playlist items.
* `playlist_id`: *string* The ID of the Tidal playlist.
* `{ resolve_items, limit, offset }`: *object, optional* An object containing playlist options.
* `resolve_items`: *boolean* Whether to resolve playlist items.
* `limit`: *number* The maximum number of items to retrieve.
* `offset`: *number* The offset to start retrieving from.
* Returns: A Promise that resolves with the playlist items data.
* `toggleTrackLike({ track_id, to })`
Toggles a Tidal track like.
* `track_id`: *string* The ID of the Tidal track.
* `to`: *boolean* Whether to like or unlike the track.
* Returns: A Promise that resolves with the response data.

View File

@ -1 +0,0 @@
# User

View File

@ -0,0 +1,80 @@
---
id: user
title: UserModel
sidebar_label: UserModel
---
## UserModel
The `UserModel` class provides static methods for interacting with user data on the Comty platform.
**Class Overview:**
The `UserModel` class offers a set of methods for retrieving and updating user data, including profile information, roles, badges, and configuration settings.
### Static Methods
* `data(payload)`
Retrieves the data of a user.
* `payload`: *object, optional* An object containing the username and user_id.
* `username`: *string, optional* The username of the user.
* `user_id`: *string, optional* The ID of the user.
* `basic`: *boolean, optional* Whether to fetch only basic user information default: false.
* Returns: A promise that resolves with the data of the user.
* `updateData(payload)`
Updates the user data with the given payload.
* `payload`: *object* The data to update the user with.
* Returns: A promise that resolves with the updated user data.
* `unsetPublicName()`
Update the public name to null in the user data.
* Returns: A Promise that resolves with the response data after updating the public name
* `getRoles(user_id)`
Retrieves the roles of a user.
* `user_id`: *string, optional* The ID of the user. If not provided, the current user ID will be used.
* Returns: A promise that resolves with an array of roles for the user.
* `getBadges(user_id)`
Retrieves the badges for a given user.
* `user_id`: *string, optional* The ID of the user. If not provided, the current session user ID will be used.
* Returns: A promise that resolves with an array of badges for the user.
* `getConfig(key)`
Retrive user config from server
* `key`: *string* A key of config
* Returns: A Promise that resolves with a config object
* `updateConfig(update)`
Update the configuration with the given update.
* `update`: *Object* The object containing the updated configuration data
* Returns: A Promise that resolves with the response data after the configuration is updated
* `getPublicKey(user_id)`
Retrieves the public key for a given user.
* `user_id`: *string, optional* The ID of the user. If not provided, the current session user ID will be used.
* Returns: A promise that resolves with the public key for the user.
* `updatePublicKey(public_key)`
Updates the public key for the current user.
* `public_key`: *string* The new public key to set.
* Returns: A promise that resolves with the response data after updating the public key.

View File

@ -37,17 +37,34 @@ const config = {
presets: [
[
"classic",
/** @type {import("@docusaurus/preset-classic").Options} */
({
{
docs: {
path: "../docs",
sidebarPath: "./sidebars.js",
//editUrl: "https://github.com/facebook/docusaurus/tree/main/packages/create-docusaurus/templates/shared/",
},
theme: {
customCss: "./src/css/custom.css",
},
}),
},
],
],
plugins: [
[
"docusaurus-plugin-openapi-docs",
{
id: "api", // plugin id
docsPluginId: "classic", // configured for preset-classic
config: {
petstore: {
specPath: "examples/petstore.yaml",
outputDir: "docs/petstore",
sidebarOptions: {
groupPathsBy: "tag",
},
},
},
},
],
],

View File

@ -18,6 +18,7 @@
"@docusaurus/preset-classic": "3.5.2",
"@mdx-js/react": "^3.0.0",
"clsx": "^2.0.0",
"docusaurus-plugin-openapi-docs": "^4.4.0",
"prism-react-renderer": "^2.3.0",
"react": "^18.0.0",
"react-dom": "^18.0.0"

View File

@ -1,6 +1,6 @@
{
"name": "@comty/app",
"version": "1.42.1@alpha",
"version": "1.43.0@alpha",
"license": "ComtyLicense",
"main": "electron/main",
"type": "module",
@ -34,9 +34,8 @@
"axios": "^1.7.7",
"bear-react-carousel": "^4.0.10-alpha.0",
"classnames": "2.3.1",
"comty.js": "^0.66.1",
"comty.js": "^0.67.0",
"d3": "^7.9.0",
"dashjs": "^5.0.0",
"dompurify": "^3.0.0",
"fast-average-color": "^9.2.0",
"fuse.js": "6.5.3",
@ -50,7 +49,6 @@
"mime": "^3.0.0",
"moment": "2.29.4",
"motion": "^12.4.2",
"mpegts.js": "^1.6.10",
"music-metadata": "^11.2.1",
"plyr": "^3.7.8",
"prop-types": "^15.8.1",
@ -73,6 +71,7 @@
"react-useanimations": "^2.10.0",
"remark-gfm": "^3.0.1",
"rxjs": "^7.5.5",
"shaka-player": "^4.14.12",
"store": "^2.0.12",
"swapy": "^1.0.5",
"ua-parser-js": "^1.0.36",

View File

@ -10,39 +10,17 @@ export default class QueueManager {
currentItem = null
next = ({ random = false } = {}) => {
if (this.nextItems.length === 0) {
return null
}
if (this.currentItem) {
this.prevItems.push(this.currentItem)
}
if (random) {
const randomIndex = Math.floor(
Math.random() * this.nextItems.length,
)
this.currentItem = this.nextItems.splice(randomIndex, 1)[0]
} else {
this.currentItem = this.nextItems.shift()
}
return this.currentItem
}
set = (item) => {
setCurrent = (item) => {
if (typeof item === "number") {
item = this.nextItems[item]
}
if (this.currentItem && this.currentItem.id === item.id) {
if (this.currentItem && this.currentItem._id === item._id) {
return this.currentItem
}
const itemInNext = this.nextItems.findIndex((i) => i.id === item.id)
const itemInPrev = this.prevItems.findIndex((i) => i.id === item.id)
const itemInNext = this.nextItems.findIndex((i) => i._id === item._id)
const itemInPrev = this.prevItems.findIndex((i) => i._id === item._id)
if (itemInNext === -1 && itemInPrev === -1) {
throw new Error("Item not found in the queue")
@ -71,6 +49,28 @@ export default class QueueManager {
return this.currentItem
}
next = ({ random = false } = {}) => {
if (this.nextItems.length === 0) {
return null
}
if (this.currentItem) {
this.prevItems.push(this.currentItem)
}
if (random) {
const randomIndex = Math.floor(
Math.random() * this.nextItems.length,
)
this.currentItem = this.nextItems.splice(randomIndex, 1)[0]
} else {
this.currentItem = this.nextItems.shift()
}
return this.currentItem
}
previous = () => {
if (this.prevItems.length === 0) {
return this.currentItem
@ -117,12 +117,4 @@ export default class QueueManager {
this.prevItems = []
this.currentItem = null
}
async load(item) {
if (typeof this.params.loadFunction === "function") {
return await this.params.loadFunction(item)
}
return item
}
}

View File

@ -1 +1,18 @@
export default (props) => <svg {...props} xmlns="http://www.w3.org/2000/svg" width="14.0" height="9.0" viewBox="0 0 14.0 9.0"><path fill="#ffffffff" d="M4.5522,0C4.8969,0 5.205,0.1229 5.4844,0.3361C5.5808,0.4095 5.6939,0.5149 5.7751,0.6012C7.2178,2.136 7.8307,6.1801 8.804,7.8187C8.8851,7.9552 8.9397,8.0258 8.9618,8.0559C9.1839,8.3565 9.4261,8.535 9.7017,8.535C9.7951,8.535 9.8926,8.513 9.9941,8.4706C10.091,8.5776 10.1918,8.6744 10.2966,8.7588C10.0427,8.9239 9.7355,9 9.4478,9C9.1031,9 8.795,8.8771 8.5156,8.664C8.4374,8.6043 8.3179,8.4978 8.2249,8.3989C6.7822,6.864 6.1693,2.8199 5.1959,1.1813C4.9026,0.6879 4.5245,0.3133 4.0059,0.5294C3.9089,0.4222 3.8082,0.3258 3.7033,0.2411C3.9572,0.0761 4.2643,0 4.5522,0ZM2.3869,0.0214C5.5605,-0.4229 5.8126,8.535 7.7911,8.535C7.884,8.535 7.981,8.5132 8.0818,8.4714C8.1792,8.5788 8.2807,8.6743 8.386,8.7589C8.132,8.9238 7.8249,9 7.537,9C5.8045,9 4.9435,5.8448 4.2734,3.724C4.229,3.5835 4.1847,3.4448 4.1405,3.3079C3.8674,2.4631 3.5948,1.7027 3.2851,1.1814C3.2223,1.0754 3.1578,0.9796 3.0916,0.8947C2.7131,0.4102 2.305,0.329 1.8286,0.6833C0.8287,1.4214 0.3651,3.8953 0.2183,5.1524L0.2183,5.1524C0.2107,5.2178 0.1637,5.2595 0.1092,5.2595C0.1048,5.2595 0.1005,5.2593 0.0961,5.2587C0.0362,5.2514 -0.0064,5.1959 0.0008,5.1346L0.0008,5.1346C0.2175,3.7154 0.5767,1.2827 1.6123,0.3783C1.8147,0.1972 2.0896,0.0629 2.3869,0.0214ZM6.4629,0C8.1955,0 9.0565,3.1564 9.7265,5.276C9.771,5.4165 9.8153,5.5553 9.8595,5.692C10.1326,6.537 10.4052,7.2972 10.7148,7.8186C10.7777,7.9245 10.8422,8.0205 10.9085,8.1053C11.1207,8.3769 11.3516,8.535 11.6124,8.535C11.7848,8.535 11.9702,8.4663 12.1713,8.3167C13.1712,7.5786 13.6348,5.1048 13.7816,3.8477L13.7816,3.8477C13.7893,3.7822 13.8364,3.7406 13.8908,3.7406C13.8952,3.7406 13.8996,3.7408 13.9039,3.7414C13.9637,3.7486 14.0063,3.8042 13.9992,3.8653L13.9992,3.8653C13.7825,5.2845 13.4234,7.7172 12.3876,8.6217C12.1016,8.8779 11.7247,9 11.3584,9C8.4607,9 8.1211,0.465 6.2089,0.465C6.116,0.465 6.0191,0.4868 5.9182,0.5286C5.8209,0.4213 5.7194,0.3257 5.614,0.2412C5.868,0.0761 6.1751,0 6.4629,0ZM11.9867,3.7407C12.0531,3.7487 12.0957,3.8041 12.0885,3.8654L12.0885,3.8654C11.9128,5.0167 11.6434,6.8324 10.9962,7.9608C10.9326,7.8744 10.8704,7.7766 10.8094,7.6686C11.437,6.6147 11.7543,4.8471 11.8711,3.8477L11.8711,3.8477C11.8787,3.7822 11.9257,3.7405 11.9801,3.7405C11.9845,3.7405 11.9889,3.7408 11.9932,3.7413ZM9.1606,7.1514C9.2138,7.2632 9.2681,7.3719 9.3239,7.4773C9.2504,7.6486 9.1707,7.8106 9.0847,7.9608C9.0215,7.8746 8.9594,7.7769 8.8985,7.6691C8.9927,7.5108 9.0801,7.3368 9.1606,7.1514ZM10.076,3.7407C10.1424,3.7487 10.1851,3.8041 10.1779,3.8654L10.1779,3.8654C10.1111,4.303 10.0313,4.8324 9.9229,5.3838C9.8782,5.2436 9.8336,5.1029 9.7893,4.9621C9.8681,4.5458 9.9239,4.1593 9.9604,3.8477L9.9604,3.8477C9.968,3.7822 10.015,3.7405 10.0695,3.7405C10.0739,3.7405 10.0782,3.7408 10.0826,3.7413ZM3.0038,1.0393C3.0674,1.1256 3.1296,1.2234 3.1906,1.3314C2.563,2.3853 2.2457,4.153 2.1289,5.1523L2.1289,5.1523C2.1213,5.2178 2.0743,5.2595 2.0199,5.2595C2.0155,5.2595 2.0111,5.2592 2.0068,5.2587C1.9469,5.2513 1.9042,5.1958 1.9114,5.1346L1.9114,5.1346C2.0872,3.9834 2.3565,2.1676 3.0038,1.0393ZM4.0771,3.6161C4.1218,3.7564 4.1663,3.8971 4.2107,4.038C4.1319,4.4542 4.076,4.8406 4.0396,5.1523L4.0396,5.1523C4.032,5.2178 3.985,5.2595 3.9305,5.2595C3.9261,5.2595 3.9218,5.2592 3.9174,5.2587C3.8575,5.2513 3.8149,5.1958 3.8221,5.1346L3.8221,5.1346C3.8889,4.697 3.9687,4.1676 4.0771,3.6161ZM4.9153,1.0392C4.9787,1.1253 5.0406,1.2231 5.1014,1.3309C5.0072,1.489 4.92,1.6632 4.8394,1.8486C4.7862,1.7368 4.7319,1.6281 4.6762,1.5227C4.7495,1.3514 4.8293,1.1895 4.9153,1.0392Z" stroke="#00000010" stroke-width="1.0" fill-rule="evenodd" id="path_0" /></svg>
export default (props) => (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
width="14.0"
height="9.0"
viewBox="0 0 14.0 9.0"
>
<path
fill="#ffffffff"
d="M4.5522,0C4.8969,0 5.205,0.1229 5.4844,0.3361C5.5808,0.4095 5.6939,0.5149 5.7751,0.6012C7.2178,2.136 7.8307,6.1801 8.804,7.8187C8.8851,7.9552 8.9397,8.0258 8.9618,8.0559C9.1839,8.3565 9.4261,8.535 9.7017,8.535C9.7951,8.535 9.8926,8.513 9.9941,8.4706C10.091,8.5776 10.1918,8.6744 10.2966,8.7588C10.0427,8.9239 9.7355,9 9.4478,9C9.1031,9 8.795,8.8771 8.5156,8.664C8.4374,8.6043 8.3179,8.4978 8.2249,8.3989C6.7822,6.864 6.1693,2.8199 5.1959,1.1813C4.9026,0.6879 4.5245,0.3133 4.0059,0.5294C3.9089,0.4222 3.8082,0.3258 3.7033,0.2411C3.9572,0.0761 4.2643,0 4.5522,0ZM2.3869,0.0214C5.5605,-0.4229 5.8126,8.535 7.7911,8.535C7.884,8.535 7.981,8.5132 8.0818,8.4714C8.1792,8.5788 8.2807,8.6743 8.386,8.7589C8.132,8.9238 7.8249,9 7.537,9C5.8045,9 4.9435,5.8448 4.2734,3.724C4.229,3.5835 4.1847,3.4448 4.1405,3.3079C3.8674,2.4631 3.5948,1.7027 3.2851,1.1814C3.2223,1.0754 3.1578,0.9796 3.0916,0.8947C2.7131,0.4102 2.305,0.329 1.8286,0.6833C0.8287,1.4214 0.3651,3.8953 0.2183,5.1524L0.2183,5.1524C0.2107,5.2178 0.1637,5.2595 0.1092,5.2595C0.1048,5.2595 0.1005,5.2593 0.0961,5.2587C0.0362,5.2514 -0.0064,5.1959 0.0008,5.1346L0.0008,5.1346C0.2175,3.7154 0.5767,1.2827 1.6123,0.3783C1.8147,0.1972 2.0896,0.0629 2.3869,0.0214ZM6.4629,0C8.1955,0 9.0565,3.1564 9.7265,5.276C9.771,5.4165 9.8153,5.5553 9.8595,5.692C10.1326,6.537 10.4052,7.2972 10.7148,7.8186C10.7777,7.9245 10.8422,8.0205 10.9085,8.1053C11.1207,8.3769 11.3516,8.535 11.6124,8.535C11.7848,8.535 11.9702,8.4663 12.1713,8.3167C13.1712,7.5786 13.6348,5.1048 13.7816,3.8477L13.7816,3.8477C13.7893,3.7822 13.8364,3.7406 13.8908,3.7406C13.8952,3.7406 13.8996,3.7408 13.9039,3.7414C13.9637,3.7486 14.0063,3.8042 13.9992,3.8653L13.9992,3.8653C13.7825,5.2845 13.4234,7.7172 12.3876,8.6217C12.1016,8.8779 11.7247,9 11.3584,9C8.4607,9 8.1211,0.465 6.2089,0.465C6.116,0.465 6.0191,0.4868 5.9182,0.5286C5.8209,0.4213 5.7194,0.3257 5.614,0.2412C5.868,0.0761 6.1751,0 6.4629,0ZM11.9867,3.7407C12.0531,3.7487 12.0957,3.8041 12.0885,3.8654L12.0885,3.8654C11.9128,5.0167 11.6434,6.8324 10.9962,7.9608C10.9326,7.8744 10.8704,7.7766 10.8094,7.6686C11.437,6.6147 11.7543,4.8471 11.8711,3.8477L11.8711,3.8477C11.8787,3.7822 11.9257,3.7405 11.9801,3.7405C11.9845,3.7405 11.9889,3.7408 11.9932,3.7413ZM9.1606,7.1514C9.2138,7.2632 9.2681,7.3719 9.3239,7.4773C9.2504,7.6486 9.1707,7.8106 9.0847,7.9608C9.0215,7.8746 8.9594,7.7769 8.8985,7.6691C8.9927,7.5108 9.0801,7.3368 9.1606,7.1514ZM10.076,3.7407C10.1424,3.7487 10.1851,3.8041 10.1779,3.8654L10.1779,3.8654C10.1111,4.303 10.0313,4.8324 9.9229,5.3838C9.8782,5.2436 9.8336,5.1029 9.7893,4.9621C9.8681,4.5458 9.9239,4.1593 9.9604,3.8477L9.9604,3.8477C9.968,3.7822 10.015,3.7405 10.0695,3.7405C10.0739,3.7405 10.0782,3.7408 10.0826,3.7413ZM3.0038,1.0393C3.0674,1.1256 3.1296,1.2234 3.1906,1.3314C2.563,2.3853 2.2457,4.153 2.1289,5.1523L2.1289,5.1523C2.1213,5.2178 2.0743,5.2595 2.0199,5.2595C2.0155,5.2595 2.0111,5.2592 2.0068,5.2587C1.9469,5.2513 1.9042,5.1958 1.9114,5.1346L1.9114,5.1346C2.0872,3.9834 2.3565,2.1676 3.0038,1.0393ZM4.0771,3.6161C4.1218,3.7564 4.1663,3.8971 4.2107,4.038C4.1319,4.4542 4.076,4.8406 4.0396,5.1523L4.0396,5.1523C4.032,5.2178 3.985,5.2595 3.9305,5.2595C3.9261,5.2595 3.9218,5.2592 3.9174,5.2587C3.8575,5.2513 3.8149,5.1958 3.8221,5.1346L3.8221,5.1346C3.8889,4.697 3.9687,4.1676 4.0771,3.6161ZM4.9153,1.0392C4.9787,1.1253 5.0406,1.2231 5.1014,1.3309C5.0072,1.489 4.92,1.6632 4.8394,1.8486C4.7862,1.7368 4.7319,1.6281 4.6762,1.5227C4.7495,1.3514 4.8293,1.1895 4.9153,1.0392Z"
stroke="#00000010"
strokeWidth="1.0"
fillRule="evenodd"
id="path_0"
/>
</svg>
)

View File

@ -0,0 +1,17 @@
export default (props) => (
<svg
{...props}
width="1rem"
height="1rem"
viewBox="540.525 331.625 397.476 228.02"
>
<g>
<g class="fills">
<path
d="M566.514,558.789C557.237,556.959,552.372,545.298,557.454,537.075C561.388,530.710,562.690,530.500,598.152,530.504C627.957,530.508,630.206,530.633,633.197,532.456C642.490,538.123,642.729,551.085,633.644,556.734C630.081,558.950,629.333,559.003,599.501,559.140C582.726,559.217,567.882,559.059,566.514,558.789ZL566.514,558.789ZM696.501,545.129L696.501,530.630L727.251,530.315C755.020,530.031,758.261,529.824,760.681,528.181C766.279,524.381,766.966,522.177,767.277,507.000C767.560,493.247,767.534,493.049,765.783,495.781C764.803,497.311,762.075,499.495,759.721,500.635C755.730,502.568,754.107,502.679,735.721,502.290C716.779,501.888,715.728,501.759,709.082,499.012C699.817,495.182,690.705,486.371,686.639,477.308C674.978,451.317,691.052,422.089,719.501,417.552C726.702,416.403,749.805,416.159,755.907,417.167C758.573,417.608,761.034,419.033,763.657,421.656L767.501,425.500L767.501,416.500L796.574,416.500L796.288,477.250L796.001,538.000L793.340,543.000C789.950,549.370,785.327,553.787,779.001,556.699C774.098,558.955,773.245,559.006,735.251,559.314L696.501,559.627L696.501,545.129ZM760.784,471.236C764.920,468.523,766.886,464.782,766.883,459.632C766.880,454.197,764.574,450.143,760.102,447.712C756.132,445.555,729.230,444.688,722.499,446.501C712.023,449.322,708.272,462.239,715.859,469.367C719.642,472.920,722.937,473.422,741.048,473.205C755.422,473.032,758.518,472.723,760.784,471.236ZL760.784,471.236ZM838.501,545.127L838.501,530.608L868.751,530.304C896.301,530.027,899.279,529.832,902.115,528.114C907.978,524.565,908.964,521.547,909.339,506.000L909.677,492.000L907.319,495.500C902.827,502.167,900.617,502.666,877.501,502.233C857.909,501.866,856.745,501.732,851.225,499.218C839.447,493.853,831.849,486.244,827.304,475.264C825.585,471.108,825.113,467.894,825.063,460.000C824.978,446.311,827.507,439.785,836.522,430.439C847.667,418.882,858.037,415.956,885.320,416.669C901.497,417.092,903.545,417.756,907.441,423.837L909.467,427.000L909.484,421.723L909.501,416.445L923.751,416.723L938.001,417.000L938.001,537.000L935.141,542.500C931.304,549.878,929.086,552.131,922.001,555.850L916.001,559.000L838.501,559.645L838.501,545.127ZL838.501,545.127ZM902.308,471.500C910.768,465.999,910.662,453.060,902.115,447.885C899.370,446.223,896.869,446.000,881.001,446.000C863.755,446.000,862.864,446.098,859.736,448.329C855.133,451.612,853.169,455.866,853.775,461.242C854.350,466.341,858.178,471.021,862.963,472.477C864.634,472.985,873.651,473.311,883.001,473.200C896.159,473.045,900.522,472.661,902.308,471.500ZL902.308,471.500ZM575.501,501.549C558.549,498.445,544.412,483.434,541.552,465.500C540.174,456.858,540.187,377.030,541.567,368.519C544.660,349.456,558.992,335.470,578.564,332.415C583.180,331.695,592.449,331.445,603.001,331.758C618.530,332.217,620.650,332.505,627.501,335.082C633.871,337.477,636.055,338.975,642.001,345.024C647.378,350.494,649.581,353.636,651.501,358.573L654.001,365.000L654.001,470.000L651.548,475.500C646.428,486.978,639.529,493.863,627.777,499.221C622.199,501.764,621.280,501.863,601.001,502.114C589.451,502.256,577.976,502.002,575.501,501.549ZL575.501,501.549ZM615.558,472.501C617.514,471.957,620.439,470.131,622.058,468.441L625.001,465.370L625.279,418.441C625.587,366.622,625.731,367.940,619.266,363.329C616.138,361.098,615.247,361.000,598.001,361.000C577.994,361.000,575.687,361.608,571.886,367.886C570.128,370.789,569.981,373.956,569.702,414.712C569.495,445.077,569.750,459.713,570.539,462.643C571.767,467.204,576.236,472.111,579.787,472.799C584.348,473.682,612.139,473.450,615.558,472.501ZL615.558,472.501Z"
fill="currentColor"
></path>
</g>
</g>
</svg>
)

View File

@ -4,6 +4,7 @@ import VrChatIcon from "./customIcons/vrchat"
import VerifiedBadge from "./customIcons/verifiedBadge"
import Crown from "./customIcons/crown"
import Lossless from "./customIcons/lossless"
import Ogg from "./customIcons/ogg"
// import icons lib
import * as lib1 from "react-icons/fi"
@ -15,52 +16,43 @@ import * as lib6 from "react-icons/fa"
import * as lib7 from "react-icons/tb"
const marginedStyle = {
width: "1em",
height: "1em",
marginRight: "10px",
verticalAlign: "-0.125em"
width: "1em",
height: "1em",
marginRight: "10px",
verticalAlign: "-0.125em",
}
const customs = {
Lossless: (props) => <Lossless
style={marginedStyle}
{...props}
/>,
verifiedBadge: (props) => <VerifiedBadge
style={marginedStyle}
{...props}
/>,
VrChat: (props) => <VrChatIcon
style={marginedStyle}
{...props}
/>,
Crown: (props) => <Crown
style={marginedStyle}
{...props}
/>
Lossless: (props) => <Lossless style={marginedStyle} {...props} />,
verifiedBadge: (props) => (
<VerifiedBadge style={marginedStyle} {...props} />
),
VrChat: (props) => <VrChatIcon style={marginedStyle} {...props} />,
Crown: (props) => <Crown style={marginedStyle} {...props} />,
Ogg: (props) => <Ogg style={marginedStyle} {...props} />,
}
export const Icons = {
...customs,
...lib1,
...lib2,
...lib3,
...lib4,
...lib5,
...lib6,
...lib7,
...customs,
...lib1,
...lib2,
...lib3,
...lib4,
...lib5,
...lib6,
...lib7,
}
export function createIconRender(icon, props) {
if (React.isValidElement(icon)) {
return icon
}
if (React.isValidElement(icon)) {
return icon
}
if (typeof Icons[icon] !== "undefined") {
return React.createElement(Icons[icon], props)
}
if (typeof Icons[icon] !== "undefined") {
return React.createElement(Icons[icon], props)
}
return null
return null
}
export default Icons
export default Icons

View File

@ -6,14 +6,6 @@ import { Icons } from "@components/Icons"
import "./index.less"
const typeToNavigationType = {
playlist: "playlist",
album: "album",
track: "track",
single: "track",
ep: "album",
}
const Playlist = (props) => {
const [coverHover, setCoverHover] = React.useState(false)
@ -28,13 +20,27 @@ const Playlist = (props) => {
return props.onClick(playlist)
}
return app.location.push(`/music/list/${playlist._id}`)
const params = new URLSearchParams()
if (playlist.type) {
params.set("type", playlist.type)
}
if (playlist.service) {
params.set("service", playlist.service)
}
return app.location.push(
`/music/list/${playlist._id}?${params.toString()}`,
)
}
const onClickPlay = (e) => {
e.stopPropagation()
app.cores.player.start(playlist.items)
if (playlist.items) {
app.cores.player.start(playlist.items)
}
}
return (
@ -68,8 +74,16 @@ const Playlist = (props) => {
<div className="playlist_info_title" onClick={onClick}>
<h1>{playlist.title}</h1>
</div>
{props.row && (
<div className="playlist_details">
{playlist.service === "tidal" && (
<p>
<Icons.SiTidal />
Tidal
</p>
)}
<p>
<Icons.MdAlbum />
{playlist.type ?? "playlist"}

View File

@ -45,6 +45,13 @@
justify-content: center;
.playlist_info_title {
display: flex;
flex-direction: row;
align-items: center;
gap: 7px;
h1 {
word-break: break-all;
white-space: wrap;

View File

@ -16,6 +16,7 @@ const typeToKind = {
ep: "releases",
compilation: "releases",
playlist: "playlists",
single: "tracks",
}
const PlaylistHeader = ({
@ -77,7 +78,6 @@ const PlaylistHeader = ({
<div className="play_info_details">
<div className="play_info_title">
{playlist.service === "tidal" && <Icons.SiTidal />}{" "}
{typeof playlist.title === "function" ? (
playlist.title()
) : (
@ -86,19 +86,24 @@ const PlaylistHeader = ({
</div>
<div className="play_info_statistics">
{playlist.service === "tidal" && (
<div className="play_info_statistics_item">
<p>
<Icons.SiTidal /> From Tidal
</p>
</div>
)}
{PlaylistTypeDecorators[playlistType] && (
<div className="play_info_statistics_item">
{PlaylistTypeDecorators[playlistType]()}
</div>
)}
<div className="play_info_statistics_item">
<p>
<Icons.MdLibraryMusic /> {playlist.total_items}{" "}
Items
</p>
</div>
{playlist.total_duration > 0 && (
<div className="play_info_statistics_item">
<p>
@ -107,7 +112,6 @@ const PlaylistHeader = ({
</p>
</div>
)}
{playlist.publisher && (
<div className="play_info_statistics_item">
<p onClick={handlePublisherClick}>

View File

@ -143,6 +143,18 @@ const TrackEditor = (props) => {
/>
</div>
<div className="track-editor-field">
<div className="track-editor-field-header">
<Icons.FiEye />
<span>Public</span>
</div>
<antd.Switch
checked={track.public}
onChange={(value) => handleChange("public", value)}
/>
</div>
<div className="track-editor-field">
<div className="track-editor-field-header">
<Icons.MdLyrics />

View File

@ -9,28 +9,25 @@ import { usePlayerStateContext } from "@contexts/WithPlayerContext"
import "./index.less"
const ExtraActions = (props) => {
const [trackInstance, setTrackInstance] = React.useState({})
const [track, setTrack] = React.useState({})
const onPlayerStateChange = React.useCallback((state) => {
const instance = app.cores.player.track()
const track = app.cores.player.track()
if (instance) {
setTrackInstance(instance)
if (track) {
setTrack(track)
}
}, [])
const [playerState] = usePlayerStateContext(onPlayerStateChange)
usePlayerStateContext(onPlayerStateChange)
const handleClickLike = async () => {
if (!trackInstance) {
if (!track) {
console.error("Cannot like a track if nothing is playing")
return false
}
await trackInstance.manifest.serviceOperations.toggleItemFavorite(
"tracks",
trackInstance.manifest._id,
)
await track.serviceOperations.toggleItemFavorite("tracks", track._id)
}
return (
@ -39,18 +36,15 @@ const ExtraActions = (props) => {
<Button
type="ghost"
icon={<Icons.MdAbc />}
disabled={!trackInstance?.manifest?.lyrics_enabled}
disabled={!track?.lyrics_enabled}
/>
)}
{!app.isMobile && (
<LikeButton
liked={
trackInstance?.manifest?.serviceOperations
?.isItemFavorited
}
liked={track?.serviceOperations?.isItemFavorited}
onClick={handleClickLike}
disabled={!trackInstance?.manifest?._id}
disabled={!track?._id}
/>
)}

View File

@ -5,7 +5,7 @@
align-items: center;
justify-content: space-between;
width: 70%;
width: 50%;
margin: auto;
padding: 2px 25px;

View File

@ -39,21 +39,21 @@ const EventsHandlers = {
const track = app.cores.player.track()
return await track.manifest.serviceOperations.toggleItemFavorite(
return await track.serviceOperations.toggleItemFavorite(
"track",
ctx.track_manifest._id,
track._id,
)
},
}
const Controls = (props) => {
const [trackInstance, setTrackInstance] = React.useState({})
const [trackManifest, setTrackManifest] = React.useState({})
const onPlayerStateChange = React.useCallback((state) => {
const instance = app.cores.player.track()
const track = app.cores.player.track()
if (instance) {
setTrackInstance(instance)
if (track) {
setTrackManifest(track)
}
}, [])
@ -131,12 +131,9 @@ const Controls = (props) => {
{app.isMobile && (
<LikeButton
liked={
trackInstance?.manifest?.serviceOperations
?.isItemFavorited
}
liked={trackManifest?.serviceOperations?.isItemFavorited}
onClick={() => handleAction("like")}
disabled={!trackInstance?.manifest?._id}
disabled={!trackManifest?._id}
/>
)}
</div>

View File

@ -0,0 +1,84 @@
import React from "react"
import { Tooltip } from "antd"
import { Icons } from "@components/Icons"
function getIndicators(track, playerState) {
const indicators = []
if (playerState.live) {
indicators.push({
icon: <Icons.FiRadio style={{ color: "var(--colorPrimary)" }} />,
})
}
if (playerState.format_metadata && playerState.format_metadata?.trackInfo) {
const dmuxData = playerState.format_metadata
// this commonly used my mpd's
const trackInfo = dmuxData.trackInfo[0]
const trackAudio = trackInfo?.audio
const codec = trackInfo?.codecName ?? dmuxData.codec
const sampleRate = trackAudio?.samplingFrequency ?? dmuxData.sampleRate
const bitDepth = trackAudio?.bitDepth ?? dmuxData.bitsPerSample
const bitrate = trackAudio?.bitrate ?? dmuxData.bitrate
if (codec) {
if (codec.toLowerCase().includes("flac")) {
indicators.push({
icon: <Icons.Lossless />,
tooltip: `${sampleRate / 1000} kHz / ${bitDepth ?? 16} Bits`,
})
}
if (codec.toLowerCase().includes("vorbis")) {
indicators.push({
icon: <Icons.Ogg />,
tooltip: `Vorbis ${sampleRate / 1000} kHz / ${bitrate / 1000} kbps`,
})
}
}
}
return indicators
}
const Indicators = ({ track, playerState }) => {
if (!track) {
return null
}
const indicators = React.useMemo(
() => getIndicators(track, playerState),
[track, playerState],
)
if (indicators.length === 0) {
return null
}
return (
<div className="toolbar_player_indicators_wrapper">
<div className="toolbar_player_indicators">
{indicators.map((indicator, index) => {
if (indicator.tooltip) {
return (
<Tooltip
key={indicators.length}
title={indicator.tooltip}
>
{indicator.icon}
</Tooltip>
)
}
return React.cloneElement(indicator.icon, {
key: index,
})
})}
</div>
</div>
)
}
export default Indicators

View File

@ -9,6 +9,7 @@ import LiveInfo from "@components/Player/LiveInfo"
import SeekBar from "@components/Player/SeekBar"
import Controls from "@components/Player/Controls"
import Actions from "@components/Player/Actions"
import Indicators from "@components/Player/Indicators"
import RGBStringToValues from "@utils/rgbToValues"
@ -25,40 +26,6 @@ function isOverflown(parent, element) {
return elementRect.width > parentRect.width
}
const Indicators = ({ track, playerState }) => {
if (!track) {
return null
}
const indicators = []
if (track.metadata) {
if (track.metadata.lossless) {
indicators.push(
<antd.Tooltip title="Lossless Audio">
<Icons.Lossless />
</antd.Tooltip>,
)
}
}
if (playerState.live) {
indicators.push(
<Icons.FiRadio style={{ color: "var(--colorPrimary)" }} />,
)
}
if (indicators.length === 0) {
return null
}
return (
<div className="toolbar_player_indicators_wrapper">
<div className="toolbar_player_indicators">{indicators}</div>
</div>
)
}
const ServiceIndicator = (props) => {
if (!props.service) {
return null
@ -96,14 +63,12 @@ const Player = (props) => {
}
}
const { title, artist, service, cover_analysis, cover } =
playerState.track_manifest ?? {}
const { title, artist, service, cover } = playerState.track_manifest ?? {}
const playing = playerState.playback_status === "playing"
const stopped = playerState.playback_status === "stopped"
const titleText = !playing && stopped ? "Stopped" : (title ?? "Untitled")
const subtitleText = ""
React.useEffect(() => {
const titleIsOverflown = isOverflown(
@ -115,13 +80,11 @@ const Player = (props) => {
}, [title])
React.useEffect(() => {
const trackInstance = app.cores.player.track()
const track = app.cores.player.track()
if (playerState.track_manifest && trackInstance) {
if (
typeof trackInstance.manifest.analyzeCoverColor === "function"
) {
trackInstance.manifest
if (playerState.track_manifest && track) {
if (typeof track.analyzeCoverColor === "function") {
track
.analyzeCoverColor()
.then((analysis) => {
setCoverAnalysis(analysis)
@ -203,9 +166,11 @@ const Player = (props) => {
</Marquee>
)}
<p className="toolbar_player_info_subtitle">
{artist ?? ""}
</p>
{!playerState.radioId && (
<p className="toolbar_player_info_subtitle">
{artist ?? ""}
</p>
)}
</div>
{playerState.radioId && (

View File

@ -159,6 +159,13 @@
}
.toolbar_player_info_subtitle {
display: inline-block;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: 0.8rem;
font-weight: 400;
@ -246,6 +253,8 @@
border-radius: 12px;
gap: 10px;
background-color: rgba(var(--layoutBackgroundColor), 0.7);
-webkit-backdrop-filter: blur(5px);
backdrop-filter: blur(5px);

View File

@ -184,12 +184,12 @@ const Searcher = (props) => {
if (typeof props.model === "function") {
result = await props.model(value, {
...props.modelParams,
limit_per_section: app.isMobile ? 3 : 5,
limit: app.isMobile ? 3 : 5,
})
} else {
result = await SearchModel.search(value, {
...props.modelParams,
limit_per_section: app.isMobile ? 3 : 5,
limit: app.isMobile ? 3 : 5,
})
}

View File

@ -90,7 +90,6 @@ html {
align-items: center;
border: 0;
padding: 0;
margin: 0;
@ -101,6 +100,7 @@ html {
padding: 0 10px;
background-color: var(--background-color-primary);
border: 3px solid var(--border-color) !important;
.ant-input-prefix {
font-size: 2rem;
@ -127,6 +127,9 @@ html {
flex-wrap: wrap;
width: 100%;
height: fit-content;
max-height: 70vh;
gap: 10px;
padding: 20px;
@ -137,6 +140,7 @@ html {
color: var(--text-color);
background-color: var(--background-color-primary);
border: 3px solid var(--border-color);
.ant-result,
.ant-result-title,

View File

@ -1,10 +1,17 @@
import { MediaPlayer, Debug } from "dashjs"
import shaka from "shaka-player/dist/shaka-player.compiled.js"
import PlayerProcessors from "./PlayerProcessors"
import AudioPlayerStorage from "../player.storage"
import TrackManifest from "../classes/TrackManifest"
import findInitializationChunk from "../helpers/findInitializationChunk"
import parseSourceFormatMetadata from "../helpers/parseSourceFormatMetadata"
import handleInlineDashManifest from "../helpers/handleInlineDashManifest"
export default class AudioBase {
constructor(player) {
this.player = player
this.console = player.console
}
audio = new Audio()
@ -16,6 +23,7 @@ export default class AudioBase {
processors = {}
waitUpdateTimeout = null
_firstSegmentReceived = false
initialize = async () => {
// create a audio context
@ -26,73 +34,289 @@ export default class AudioBase {
latencyHint: "playback",
})
// configure some settings for audio
// configure some settings for audio with optimized settings
this.audio.crossOrigin = "anonymous"
this.audio.preload = "metadata"
this.audio.preload = "auto"
this.audio.loop = this.player.state.playback_mode === "repeat"
this.audio.volume = 1
// listen all events
for (const [key, value] of Object.entries(this.audioEvents)) {
this.audio.addEventListener(key, value)
}
// setup demuxer for mpd
// setup shaka player for mpd
this.createDemuxer()
// create element source
// create element source with low latency buffer
this.elementSource = this.context.createMediaElementSource(this.audio)
// initialize audio processors
await this.processorsManager.initialize()
await this.processorsManager.attachAllNodes()
await this.processorsManager.initialize(),
await this.processorsManager.attachAllNodes()
}
createDemuxer() {
this.demuxer = MediaPlayer().create()
itemInit = async (manifest) => {
if (!manifest) {
return null
}
this.demuxer.updateSettings({
streaming: {
buffer: {
resetSourceBuffersForTrackSwitch: true,
if (
typeof manifest === "string" ||
(!manifest.source && !manifest.dash_manifest)
) {
this.console.time("resolve")
manifest = await this.player.serviceProviders.resolve(manifest)
this.console.timeEnd("resolve")
}
if (!(manifest instanceof TrackManifest)) {
this.console.time("init manifest")
manifest = new TrackManifest(manifest, this.player)
this.console.timeEnd("init manifest")
}
if (manifest.mpd_mode === true && !manifest.dash_manifest) {
this.console.time("fetch dash manifest")
manifest.dash_manifest = await fetch(manifest.source).then((r) =>
r.text(),
)
this.console.timeEnd("fetch dash manifest")
}
return manifest
}
play = async (manifest, params = {}) => {
// Pre-initialize audio context if needed
if (this.context.state === "suspended") {
await this.context.resume()
}
manifest = await this.itemInit(manifest)
this.console.time("load source")
await this.loadSource(manifest)
this.console.timeEnd("load source")
this.player.queue.currentItem = manifest
this.player.state.track_manifest = manifest.toSeriableObject()
this.player.nativeControls.update(manifest.toSeriableObject())
// reset audio properties
this.audio.currentTime = params.time ?? 0
this.audio.volume = 1
if (this.processors && this.processors.gain) {
this.processors.gain.set(this.player.state.volume)
}
if (this.audio.paused) {
try {
this.console.time("play")
await this.audio.play()
this.console.timeEnd("play")
} catch (error) {
this.console.error(
"Error during audio.play():",
error,
"State:",
this.audio.readyState,
)
}
}
let initChunk = manifest.source
if (this.demuxer && manifest.dash_manifest) {
initChunk = findInitializationChunk(
manifest.source,
manifest.dash_manifest,
)
}
try {
this.player.state.format_metadata =
await parseSourceFormatMetadata(initChunk)
} catch (e) {
this.player.state.format_metadata = null
console.warn("Could not parse audio metadata from source:", e)
}
}
pause = async () => {
this.audio.pause()
}
resume = async () => {
this.audio.play()
}
async loadSource(manifest) {
if (!manifest || !(manifest instanceof TrackManifest)) {
return null
}
// reset some state
this._firstSegmentReceived = false
this.player.state.format_metadata = null
const isMpd = manifest.mpd_mode
if (isMpd) {
const audioSrcAtt = this.audio.getAttribute("src")
if (audioSrcAtt && !audioSrcAtt.startsWith("blob:")) {
this.audio.removeAttribute("src")
this.audio.load()
}
if (!this.demuxer) {
this.console.log("Creating demuxer cause not initialized")
this.createDemuxer()
}
if (manifest._preloaded) {
this.console.log(
`using preloaded source >`,
manifest._preloaded,
)
return await this.demuxer.load(manifest._preloaded)
}
const inlineManifest =
"inline://" + manifest.source + "::" + manifest.dash_manifest
return await this.demuxer
.load(inlineManifest, 0, "application/dash+xml")
.catch((err) => {
this.console.error("Error loading inline manifest", err)
})
}
// if not using demuxer, destroy previous instance
if (this.demuxer) {
await this.demuxer.unload()
await this.demuxer.destroy()
this.demuxer = null
}
// load source
this.audio.src = manifest.source
return this.audio.load()
}
async createDemuxer() {
// Destroy previous instance if exists
if (this.demuxer) {
await this.demuxer.unload()
await this.demuxer.detach()
await this.demuxer.destroy()
}
this.demuxer = new shaka.Player()
this.demuxer.attach(this.audio)
this.demuxer.configure({
manifest: {
//updatePeriod: 5,
disableVideo: true,
disableText: true,
dash: {
ignoreMinBufferTime: true,
ignoreMaxSegmentDuration: true,
autoCorrectDrift: false,
enableFastSwitching: true,
useStreamOnceInPeriodFlattening: false,
},
},
// debug: {
// logLevel: Debug.LOG_LEVEL_DEBUG,
// },
streaming: {
bufferingGoal: 15,
rebufferingGoal: 1,
bufferBehind: 30,
stallThreshold: 0.5,
},
})
this.demuxer.initialize(this.audio, null, false)
shaka.net.NetworkingEngine.registerScheme(
"inline",
handleInlineDashManifest,
)
this.demuxer.addEventListener("error", (event) => {
console.error("Demuxer error", event)
})
}
timeTick = async () => {
if (
!this.audio ||
!this.audio.duration ||
this.audio.duration === Infinity
) {
return false
}
const remainingTime = this.audio.duration - this.audio.currentTime
// if remaining time is less than 3s, try to init next item
if (parseInt(remainingTime) <= 10) {
// check if queue has next item
if (this.player.queue.nextItems[0]) {
this.player.queue.nextItems[0] = await this.itemInit(
this.player.queue.nextItems[0],
)
if (
this.demuxer &&
this.player.queue.nextItems[0].source &&
this.player.queue.nextItems[0].mpd_mode &&
!this.player.queue.nextItems[0]._preloaded
) {
const manifest = this.player.queue.nextItems[0]
// preload next item
this.console.time("preload next item")
this.player.queue.nextItems[0]._preloaded =
await this.demuxer.preload(
"inline://" +
manifest.source +
"::" +
manifest.dash_manifest,
0,
"application/dash+xml",
)
this.console.timeEnd("preload next item")
}
}
}
}
flush() {
this.audio.pause()
this.audio.src = null
this.audio.currentTime = 0
if (this.demuxer) {
this.demuxer.destroy()
}
this.createDemuxer()
}
audioEvents = {
ended: () => {
this.player.next()
},
loadeddata: () => {
this.player.state.loading = false
},
loadedmetadata: () => {
if (this.audio.duration === Infinity) {
this.player.state.live = true
} else {
this.player.state.live = false
try {
this.player.next()
} catch (e) {
console.error(e)
}
},
play: () => {
this.player.state.playback_status = "playing"
},
pause: () => {
this.player.state.playback_status = "paused"
if (typeof this._timeTickInterval !== "undefined") {
clearInterval(this._timeTickInterval)
}
},
playing: () => {
this.player.state.loading = false
@ -102,15 +326,24 @@ export default class AudioBase {
clearTimeout(this.waitUpdateTimeout)
this.waitUpdateTimeout = null
}
if (typeof this._timeTickInterval !== "undefined") {
clearInterval(this._timeTickInterval)
}
this.timeTick()
this._timeTickInterval = setInterval(this.timeTick, 1000)
},
pause: () => {
this.player.state.playback_status = "paused"
loadeddata: () => {
this.player.state.loading = false
},
durationchange: () => {
this.player.eventBus.emit(
`player.durationchange`,
this.audio.duration,
)
loadedmetadata: () => {
if (this.audio.duration === Infinity) {
this.player.state.live = true
} else {
this.player.state.live = false
}
},
waiting: () => {
if (this.waitUpdateTimeout) {

View File

@ -2,36 +2,48 @@ import { Observable } from "object-observer"
import AudioPlayerStorage from "../player.storage"
export default class PlayerState {
static defaultState = {
loading: false,
playback_status: "stopped",
track_manifest: null,
static defaultState = {
loading: false,
muted: app.isMobile ? false : (AudioPlayerStorage.get("mute") ?? false),
volume: app.isMobile ? 1 : (AudioPlayerStorage.get("volume") ?? 0.3),
playback_mode: AudioPlayerStorage.get("mode") ?? "normal",
}
playback_status: "stopped",
playback_mode: AudioPlayerStorage.get("mode") ?? "normal",
constructor(player) {
this.player = player
track_manifest: null,
demuxer_metadata: null,
this.state = Observable.from(PlayerState.defaultState)
muted: app.isMobile ? false : (AudioPlayerStorage.get("mute") ?? false),
volume: app.isMobile ? 1 : (AudioPlayerStorage.get("volume") ?? 0.3),
}
Observable.observe(this.state, async (changes) => {
try {
changes.forEach((change) => {
if (change.type === "update") {
const stateKey = change.path[0]
constructor(player) {
this.player = player
this.player.eventBus.emit(`player.state.update:${stateKey}`, change.object[stateKey])
this.player.eventBus.emit("player.state.update", change.object)
}
})
} catch (error) {
this.player.console.error(`Failed to dispatch state updater >`, error)
}
})
this.state = Observable.from(PlayerState.defaultState)
return this.state
}
}
Observable.observe(this.state, async (changes) => {
try {
changes.forEach((change) => {
if (change.type === "update") {
const stateKey = change.path[0]
this.player.eventBus.emit(
`player.state.update:${stateKey}`,
change.object[stateKey],
)
this.player.eventBus.emit(
"player.state.update",
change.object,
)
}
})
} catch (error) {
this.player.console.error(
`Failed to dispatch state updater >`,
error,
)
}
})
return this.state
}
}

View File

@ -1,59 +1,71 @@
import ComtyMusicServiceInterface from "../providers/comtymusic"
export default class ServiceProviders {
providers = [
// add by default here
new ComtyMusicServiceInterface()
]
providers = [
// add by default here
new ComtyMusicServiceInterface(),
]
findProvider(providerId) {
return this.providers.find((provider) => provider.constructor.id === providerId)
}
findProvider(providerId) {
return this.providers.find(
(provider) => provider.constructor.id === providerId,
)
}
register(provider) {
this.providers.push(provider)
}
register(provider) {
this.providers.push(provider)
}
has(providerId) {
return this.providers.some((provider) => provider.constructor.id === providerId)
}
has(providerId) {
return this.providers.some(
(provider) => provider.constructor.id === providerId,
)
}
operation = async (operationName, providerId, manifest, args) => {
const provider = await this.findProvider(providerId)
operation = async (operationName, providerId, manifest, args) => {
const provider = await this.findProvider(providerId)
if (!provider) {
console.error(`Failed to resolve manifest, provider [${providerId}] not registered`)
return manifest
}
if (!provider) {
console.error(
`Failed to resolve manifest, provider [${providerId}] not registered`,
)
return manifest
}
const operationFn = provider[operationName]
const operationFn = provider[operationName]
if (typeof operationFn !== "function") {
console.error(`Failed to resolve manifest, provider [${providerId}] operation [${operationName}] not found`)
return manifest
}
if (typeof operationFn !== "function") {
console.error(
`Failed to resolve manifest, provider [${providerId}] operation [${operationName}] not found`,
)
return manifest
}
return await operationFn(manifest, args)
}
return await operationFn(manifest, args)
}
resolve = async (providerId, manifest) => {
const provider = await this.findProvider(providerId)
resolve = async (manifest) => {
let providerId = manifest.service ?? "default"
if (!provider) {
console.error(`Failed to resolve manifest, provider [${providerId}] not registered`)
return manifest
}
const provider = this.findProvider(providerId)
return await provider.resolve(manifest)
}
if (!provider) {
console.error(
`Failed to resolve manifest, provider [${providerId}] not registered`,
)
return manifest
}
resolveMany = async (manifests) => {
manifests = manifests.map(async (manifest) => {
return await this.resolve(manifest.service ?? "default", manifest)
})
return await provider.resolve(manifest)
}
manifests = await Promise.all(manifests)
resolveMany = async (manifests) => {
manifests = manifests.map(async (manifest) => {
return await this.resolve(manifest)
})
return manifests
}
}
manifests = await Promise.all(manifests)
return manifests
}
}

View File

@ -0,0 +1,202 @@
import { RTEngineClient } from "linebridge-client"
import SessionModel from "@models/session"
export default class SyncRoom {
constructor(player) {
this.player = player
}
static pushInterval = 1000
static maxTimeOffset = parseFloat(0.15)
state = {
joined_room: null,
last_track_id: null,
}
pushInterval = null
socket = null
start = async () => {
if (!this.socket) {
await this.createSocket()
}
await this.pushState()
setInterval(this.pushState, SyncRoom.pushInterval)
this.player.eventBus.on("player.state.update", this.pushState)
this.socket.on(
`sync_room:${app.userData._id}:request_lyrics`,
async () => {
let lyrics = null
if (this.player.queue.currentItem) {
lyrics =
await this.player.queue.currentItem.manifest.serviceOperations.fetchLyrics(
{
preferTranslation: false,
},
)
}
this.socket.emit(
`sync_room:${app.userData._id}:request_lyrics`,
lyrics,
)
},
)
}
stop = async () => {
if (this.pushInterval) {
clearInterval(this.pushInterval)
}
if (this.socket) {
await this.socket.destroy()
}
}
pushState = async () => {
if (!this.socket) {
return null
}
let track_manifest = null
const currentItem = this.player.queue.currentItem
if (currentItem) {
track_manifest = {
...currentItem.toSeriableObject(),
}
}
// check if has changed the track
if (
this.state.last_track_id &&
this.state.last_track_id !== track_manifest?._id
) {
// try to get lyrics
const lyrics = await currentItem.serviceOperations
.fetchLyrics()
.catch(() => null)
this.socket.emit(`sync_room:push_lyrics`, lyrics)
}
this.state.last_track_id = track_manifest?._id
await this.socket.emit(`sync_room:push`, {
...this.player.state,
track_manifest: track_manifest,
duration: this.player.duration(),
currentTime: this.player.seek(),
})
}
syncState = async (data) => {
console.log(data)
if (!data || !data.track_manifest) {
return false
}
// first check if manifest id is different
if (
!this.player.state.track_manifest ||
data.track_manifest._id !== this.player.state.track_manifest._id
) {
if (data.track_manifest && data.track_manifest.encoded_manifest) {
let mpd = new Blob(
[window.atob(data.track_manifest.encoded_manifest)],
{
type: "application/dash+xml",
},
)
data.track_manifest.dash_manifest = URL.createObjectURL(mpd)
}
// start the player
this.player.start(data.track_manifest)
}
// check if currentTime is more than maxTimeOffset
const serverTime = data.currentTime ?? 0
const currentTime = this.player.seek()
const offset = serverTime - currentTime
console.log({
serverTime: serverTime,
currentTime: currentTime,
maxTimeOffset: SyncRoom.maxTimeOffset,
offset: offset,
})
if (
typeof serverTime === "number" &&
typeof currentTime === "number" &&
Math.abs(offset) > SyncRoom.maxTimeOffset
) {
// seek to currentTime
this.player.seek(serverTime)
}
// check if playback is paused
if (
!app.cores.player.base().audio.paused &&
data.playback_status === "paused"
) {
this.player.pausePlayback()
}
if (
app.cores.player.base().audio.paused &&
data.playback_status === "playing"
) {
this.player.resumePlayback()
}
}
join = async (user_id) => {
if (!this.socket) {
await this.createSocket()
}
this.socket.emit(`sync_room:join`, user_id)
this.socket.on(`sync:receive`, this.syncState)
this.state.joined_room = {
user_id: user_id,
members: [],
}
}
leave = async () => {
await this.socket.emit(`sync_room:leave`, this.state.joined_room)
this.state.joined_room = null
if (this.socket) {
await this.socket.disconnect()
}
}
createSocket = async () => {
if (this.socket) {
await this.socket.disconnect()
}
this.socket = new RTEngineClient({
refName: "sync-room",
url: app.cores.api.client().mainOrigin + "/music",
token: SessionModel.token,
})
await this.socket.connect()
}
}

View File

@ -1,100 +0,0 @@
import TrackManifest from "./TrackManifest"
export default class TrackInstance {
constructor(manifest, player) {
if (typeof manifest === "undefined") {
throw new Error("Manifest is required")
}
if (!player) {
throw new Error("Player core is required")
}
if (!(manifest instanceof TrackManifest)) {
manifest = new TrackManifest(manifest, player)
}
if (!manifest.source) {
throw new Error("Manifest must have a source")
}
this.player = player
this.manifest = manifest
this.id = this.manifest.id ?? this.manifest._id
}
play = async (params = {}) => {
const startTime = performance.now()
const isMpd = this.manifest.source.endsWith(".mpd")
const audioEl = this.player.base.audio
if (!isMpd) {
// if a demuxer exists (from a previous MPD track), destroy it
if (this.player.base.demuxer) {
this.player.base.demuxer.destroy()
this.player.base.demuxer = null
}
// set the audio source directly
if (audioEl.src !== this.manifest.source) {
audioEl.src = this.manifest.source
audioEl.load() // important to apply the new src and stop previous playback
}
} else {
// ensure the direct 'src' attribute is removed if it was set
const currentSrc = audioEl.getAttribute("src")
if (currentSrc && !currentSrc.startsWith("blob:")) {
// blob: indicates MSE is likely already in use
audioEl.removeAttribute("src")
audioEl.load() // tell the element to update its state after src removal
}
// ensure a demuxer instance exists
if (!this.player.base.demuxer) {
this.player.base.createDemuxer()
}
// attach the mpd source to the demuxer
await this.player.base.demuxer.attachSource(this.manifest.source)
}
// reset audio properties
audioEl.currentTime = params.time ?? 0
audioEl.volume = 1
if (this.player.base.processors && this.player.base.processors.gain) {
this.player.base.processors.gain.set(this.player.state.volume)
}
if (audioEl.paused) {
try {
await audioEl.play()
} catch (error) {
console.error("[INSTANCE] Error during audio.play():", error)
}
} else {
console.log(
"[INSTANCE] Audio is already playing or will start shortly.",
)
}
this._loadMs = performance.now() - startTime
console.log(`[INSTANCE] [tooks ${this._loadMs}ms] Playing >`, this)
}
pause = async () => {
console.log("[INSTANCE] Pausing >", this)
this.player.base.audio.pause()
}
resume = async () => {
console.log("[INSTANCE] Resuming >", this)
this.player.base.audio.play()
}
}

View File

@ -27,16 +27,32 @@ export default class TrackManifest {
if (typeof params.album !== "undefined") {
this.album = params.album
if (typeof this.album === "object") {
this.album = this.album.title
}
}
if (typeof params.artist !== "undefined") {
this.artist = params.artist
if (typeof this.artist === "object") {
this.artist = this.artist.name
}
}
if (typeof params.source !== "undefined") {
this.source = params.source
}
if (typeof params.dash_manifest !== "undefined") {
this.dash_manifest = params.dash_manifest
}
if (typeof params.encoded_manifest !== "undefined") {
this.encoded_manifest = params.encoded_manifest
}
if (typeof params.metadata !== "undefined") {
this.metadata = params.metadata
}
@ -45,6 +61,15 @@ export default class TrackManifest {
this.liked = params.liked
}
if (typeof params.public !== "undefined") {
this.public = params.public
}
if (this.source) {
this.mpd_mode =
this.source.startsWith("blob:") || this.source.endsWith(".mpd")
}
return this
}
@ -60,9 +85,10 @@ export default class TrackManifest {
// set default service to default
service = "default"
mpd_mode = false
async initialize() {
if (!this.params.file) {
if (!this.params.file || !(this.params.file instanceof File)) {
return this
}
@ -93,7 +119,12 @@ export default class TrackManifest {
analyzeCoverColor = async () => {
const fac = new FastAverageColor()
return await fac.getColorAsync(this.cover)
const img = new Image()
img.src = this.cover + "?t=a"
img.crossOrigin = "anonymous"
return await fac.getColorAsync(img)
}
serviceOperations = {
@ -164,8 +195,11 @@ export default class TrackManifest {
album: this.album,
artist: this.artist,
source: this.source,
dash_manifest: this.dash_manifest,
encoded_manifest: this.encoded_manifest,
metadata: this.metadata,
liked: this.liked,
service: this.service,
}
}
}

View File

@ -0,0 +1,81 @@
export default (baseUri, mpdText, periodId = null, repId = null) => {
// parse xml
const parser = new DOMParser()
const xml = parser.parseFromString(mpdText, "application/xml")
// check parse errors
const err = xml.querySelector("parsererror")
if (err) {
console.error("Failed to parse MPD:", err.textContent)
return null
}
// select period (by ID or first)
let period = null
if (periodId) {
period = xml.querySelector(`Period[id="${periodId}"]`)
}
// if not found, select first
if (!period) {
period = xml.querySelector("Period")
}
// ultimately, return err
if (!period) {
console.error("Cannot find a <Period> on provided MPD")
return null
}
// select representation (by ID or first)
let rep = null
if (repId) {
rep = xml.querySelector(`Representation[id="${repId}"]`)
}
if (!rep) {
rep = period.querySelector("AdaptationSet Representation")
}
if (!rep) {
console.error("Cannot find a <Representation> on Period")
return null
}
// read the associated SegmentTemplate (it may be in AdaptationSet or in Representation)
let tmpl = rep.querySelector("SegmentTemplate")
if (!tmpl) {
// fallback: look in the parent AdaptationSet
const adaptation = rep.closest("AdaptationSet")
tmpl = adaptation && adaptation.querySelector("SegmentTemplate")
}
if (!tmpl) {
console.error(
"Could not find <SegmentTemplate> in either Representation or AdaptationSet.",
)
return null
}
// extract the initialization attribute
const initAttr = tmpl.getAttribute("initialization")
if (!initAttr) {
console.warn(
"The <SegmentTemplate> does not declare initialization; it may be self-initializing.",
)
return null
}
// replace $RepresentationID$ if necessary
const initPath = initAttr.replace(
/\$RepresentationID\$/g,
rep.getAttribute("id"),
)
return new URL(initPath, baseUri).toString()
}

View File

@ -0,0 +1,15 @@
export default (uri) => {
const manifest = uri.split("inline://")[1]
const [baseUri, manifestString] = manifest.split("::")
const response = {
data: new Uint8Array(new TextEncoder().encode(manifestString)).buffer,
headers: {},
uri: baseUri,
originalUri: baseUri,
timeMs: performance.now(),
fromCache: true,
}
return Promise.resolve(response)
}

View File

@ -0,0 +1,12 @@
import { parseWebStream } from "music-metadata"
export default async (source) => {
const stream = await fetch(source, {
method: "GET",
headers: {
//Range: "bytes=0-1024",
},
}).then((response) => response.body)
return (await parseWebStream(stream)).format
}

View File

@ -2,12 +2,13 @@ import { Core } from "@ragestudio/vessel"
import ActivityEvent from "@classes/ActivityEvent"
import QueueManager from "@classes/QueueManager"
import TrackInstance from "./classes/TrackInstance"
import TrackManifest from "./classes/TrackManifest"
import MediaSession from "./classes/MediaSession"
import ServiceProviders from "./classes/Services"
import PlayerState from "./classes/PlayerState"
import PlayerUI from "./classes/PlayerUI"
import AudioBase from "./classes/AudioBase"
import SyncRoom from "./classes/SyncRoom"
import setSampleRate from "./helpers/setSampleRate"
@ -23,16 +24,14 @@ export default class Player extends Core {
// player config
static defaultSampleRate = 48000
base = new AudioBase(this)
state = new PlayerState(this)
ui = new PlayerUI(this)
serviceProviders = new ServiceProviders()
nativeControls = new MediaSession(this)
syncRoom = new SyncRoom(this)
base = new AudioBase(this)
queue = new QueueManager({
loadFunction: this.createInstance,
})
queue = new QueueManager()
public = {
start: this.start,
@ -68,10 +67,16 @@ export default class Player extends Core {
base: () => {
return this.base
},
sync: () => this.syncRoom,
inOnSyncMode: this.inOnSyncMode,
state: this.state,
ui: this.ui.public,
}
inOnSyncMode() {
return !!this.syncRoom.state.joined_room
}
async afterInitialize() {
if (app.isMobile) {
this.state.volume = 1
@ -81,53 +86,20 @@ export default class Player extends Core {
await this.base.initialize()
}
//
// Instance managing methods
//
async abortPreloads() {
for await (const instance of this.queue.nextItems) {
if (instance.abortController?.abort) {
instance.abortController.abort()
}
}
}
//
// Playback methods
//
async play(instance, params = {}) {
if (!instance) {
throw new Error("Audio instance is required")
}
// resume audio context if needed
if (this.base.context.state === "suspended") {
this.base.context.resume()
}
// update manifest
this.state.track_manifest =
this.queue.currentItem.manifest.toSeriableObject()
// play
//await this.queue.currentItem.audio.play()
await this.queue.currentItem.play(params)
// update native controls
this.nativeControls.update(this.queue.currentItem.manifest)
return this.queue.currentItem
}
// TODO: Improve performance for large playlists
async start(manifest, { time, startIndex = 0, radioId } = {}) {
this.console.debug("start():", {
manifest: manifest,
time: time,
startIndex: startIndex,
radioId: radioId,
})
this.ui.attachPlayerComponent()
if (this.queue.currentItem) {
await this.queue.currentItem.pause()
await this.base.pause()
}
//await this.abortPreloads()
await this.queue.flush()
this.state.loading = true
@ -147,32 +119,31 @@ export default class Player extends Core {
return false
}
if (playlist.some((item) => typeof item === "string")) {
playlist = await this.serviceProviders.resolveMany(playlist)
// resolve only the first item if needed
if (
typeof playlist[0] === "string" ||
(!playlist[0].source && !playlist[0].dash_manifest)
) {
playlist[0] = await this.serviceProviders.resolve(playlist[0])
}
if (playlist.some((item) => !item.source)) {
playlist = await this.serviceProviders.resolveMany(playlist)
}
// create instance for the first element
playlist[0] = new TrackManifest(playlist[0], this)
for await (let [index, _manifest] of playlist.entries()) {
let instance = new TrackInstance(_manifest, this)
this.queue.add(playlist)
this.queue.add(instance)
}
const item = this.queue.setCurrent(startIndex)
const item = this.queue.set(startIndex)
this.play(item, {
this.base.play(item, {
time: time ?? 0,
})
// send the event to the server
if (item.manifest._id && item.manifest.service === "default") {
if (item._id && item.service === "default") {
new ActivityEvent("player.play", {
identifier: "unique", // this must be unique to prevent duplicate events and ensure only have unique track events
track_id: item.manifest._id,
service: item.manifest.service,
track_id: item._id,
service: item.service,
})
}
@ -182,13 +153,15 @@ export default class Player extends Core {
// similar to player.start, but add to the queue
// if next is true, it will add to the queue to the top of the queue
async addToQueue(manifest, { next = false } = {}) {
if (typeof manifest === "string") {
manifest = await this.serviceProviders.resolve(manifest)
if (this.inOnSyncMode()) {
return false
}
let instance = new TrackInstance(manifest, this)
if (this.state.playback_status === "stopped") {
return this.start(manifest)
}
this.queue.add(instance, next === true ? "start" : "end")
this.queue.add(manifest, next === true ? "start" : "end")
console.log("Added to queue", {
manifest,
@ -197,6 +170,10 @@ export default class Player extends Core {
}
next() {
if (this.inOnSyncMode()) {
return false
}
//const isRandom = this.state.playback_mode === "shuffle"
const item = this.queue.next()
@ -204,19 +181,27 @@ export default class Player extends Core {
return this.stopPlayback()
}
return this.play(item)
return this.base.play(item)
}
previous() {
if (this.inOnSyncMode()) {
return false
}
const item = this.queue.previous()
return this.play(item)
return this.base.play(item)
}
//
// Playback Control
//
async togglePlayback() {
if (this.inOnSyncMode()) {
return false
}
if (this.state.playback_status === "paused") {
await this.resumePlayback()
} else {
@ -238,7 +223,7 @@ export default class Player extends Core {
this.base.processors.gain.fade(0)
setTimeout(() => {
this.queue.currentItem.pause()
this.base.pause()
resolve()
}, Player.gradualFadeMs)
@ -258,7 +243,7 @@ export default class Player extends Core {
}
// ensure audio elemeto starts from 0 volume
this.queue.currentItem.resume().then(() => {
this.base.resume().then(() => {
resolve()
})
this.base.processors.gain.fade(this.state.volume)
@ -282,14 +267,13 @@ export default class Player extends Core {
}
stopPlayback() {
this.base.flush()
this.queue.flush()
this.state.playback_status = "stopped"
this.state.track_manifest = null
this.queue.currentItem = null
//this.abortPreloads()
this.base.flush()
this.queue.flush()
this.nativeControls.flush()
}

View File

@ -106,7 +106,6 @@ export default class SFXCore extends Core {
if (slider) {
// check if is up or down
this.console.log(slider)
}
}

View File

@ -33,8 +33,6 @@ export class ThemeProvider extends React.Component {
}
handleUpdate = (update) => {
console.log("[THEME] Update", update)
this.setState({
useAlgorigthm: variantKeyToColor(app.cores.style.currentVariantKey),
useCompactMode: update["compact-mode"],
@ -235,12 +233,8 @@ export default class StyleCore extends Core {
this.isOnTemporalVariant = false
this.console.log(`Input variant key [${variantKey}]`)
const color = variantKeyToColor(variantKey)
this.console.log(`Applying variant [${color}]`)
const values = this.public.theme.variants[color]
if (!values) {

View File

@ -1,2 +0,0 @@
export { default as useHacks } from "./useHacks"
export { default as useCenteredContainer } from "./useCenteredContainer"

View File

@ -0,0 +1,43 @@
import { useState, useEffect } from "react"
const getDominantColorStr = (analysis) => {
if (!analysis) return "0,0,0"
return analysis.value?.join(", ") || "0,0,0"
}
export default (trackManifest) => {
const [coverAnalysis, setCoverAnalysis] = useState(null)
useEffect(() => {
const getCoverAnalysis = async () => {
const track = app.cores.player.track()
if (!track?.analyzeCoverColor) {
return null
}
try {
const analysis = await track.analyzeCoverColor()
setCoverAnalysis(analysis)
} catch (error) {
console.error("Failed to get cover analysis:", error)
setCoverAnalysis(null)
}
}
if (trackManifest) {
getCoverAnalysis()
} else {
setCoverAnalysis(null)
}
}, [trackManifest])
const dominantColor = {
"--dominant-color": getDominantColorStr(coverAnalysis),
}
return {
coverAnalysis,
dominantColor,
}
}

View File

@ -0,0 +1,47 @@
import { useCallback, useEffect, useState } from "react"
const toggleFullScreen = (to) => {
const targetState = to ?? !document.fullscreenElement
try {
if (targetState) {
document.documentElement.requestFullscreen()
} else if (document.fullscreenElement) {
document.exitFullscreen()
}
} catch (error) {
console.error("Fullscreen toggle failed:", error)
}
}
export default ({ onEnter, onExit } = {}) => {
const [isFullScreen, setIsFullScreen] = useState(false)
const handleFullScreenChange = useCallback(() => {
const fullScreenState = !!document.fullscreenElement
setIsFullScreen(fullScreenState)
if (fullScreenState) {
onEnter?.()
} else {
onExit?.()
}
}, [onEnter, onExit])
useEffect(() => {
document.addEventListener("fullscreenchange", handleFullScreenChange)
return () => {
document.removeEventListener(
"fullscreenchange",
handleFullScreenChange,
)
}
}, [handleFullScreenChange])
return {
isFullScreen,
toggleFullScreen,
handleFullScreenChange,
}
}

View File

@ -0,0 +1,69 @@
import { useState, useCallback, useEffect } from "react"
import parseTimeToMs from "@utils/parseTimeToMs"
export default ({ trackManifest }) => {
const [lyrics, setLyrics] = useState(null)
const processLyrics = useCallback((rawLyrics) => {
if (!rawLyrics) return false
return rawLyrics.sync_audio_at && !rawLyrics.sync_audio_at_ms
? {
...rawLyrics,
sync_audio_at_ms: parseTimeToMs(rawLyrics.sync_audio_at),
}
: rawLyrics
}, [])
const loadCurrentTrackLyrics = useCallback(async () => {
let data = null
const track = app.cores.player.track()
if (!trackManifest || !track) {
return null
}
// if is in sync mode, fetch lyrics from sync room
if (app.cores.player.inOnSyncMode()) {
const syncRoomSocket = app.cores.player.sync().socket
if (syncRoomSocket) {
data = await syncRoomSocket
.call("sync_room:request_lyrics")
.catch(() => null)
}
} else {
data = await track.serviceOperations.fetchLyrics().catch(() => null)
}
// if no data founded, flush lyrics
if (!data) {
return setLyrics(null)
}
// process & set lyrics
data = processLyrics(data)
setLyrics(data)
console.log("Track Lyrics:", data)
}, [trackManifest, processLyrics])
// Load lyrics when track manifest changes or when translation is toggled
useEffect(() => {
if (!trackManifest) {
setLyrics(null)
return
}
if (!lyrics || lyrics.track_id !== trackManifest._id) {
loadCurrentTrackLyrics()
}
}, [trackManifest, lyrics?.track_id, loadCurrentTrackLyrics])
return {
lyrics,
setLyrics,
loadCurrentTrackLyrics,
}
}

View File

@ -0,0 +1,60 @@
import { useState, useRef, useCallback, useEffect } from "react"
export default () => {
const [syncRoom, setSyncRoom] = useState(null)
const syncSocket = useRef(null)
const subscribeLyricsUpdates = useCallback(
(callback) => {
if (!syncSocket.current) {
return null
}
syncSocket.current.on("sync:lyrics:receive", callback)
return () => syncSocket.current.off("sync:lyrics:receive", callback)
},
[syncSocket.current],
)
const unsubscribeLyricsUpdates = useCallback(
(callback) => {
if (!syncSocket.current) {
return null
}
syncSocket.current.off("sync:lyrics:receive", callback)
},
[syncSocket.current],
)
useEffect(() => {
const roomId = new URLSearchParams(window.location.search).get("sync")
if (roomId) {
app.cores.player
.sync()
.join(roomId)
.then(() => {
setSyncRoom(roomId)
syncSocket.current = app.cores.player.sync().socket
})
}
return () => {
if (syncSocket.current) {
app.cores.player.sync().leave()
setSyncRoom(null)
syncSocket.current = null
}
}
}, [])
return {
syncRoom,
subscribeLyricsUpdates,
unsubscribeLyricsUpdates,
isInSyncMode: app.cores.player.inOnSyncMode(),
}
}

View File

@ -0,0 +1,19 @@
import { useState, useEffect } from "react"
export default (playerTrackManifest) => {
const [trackManifest, setTrackManifest] = useState(null)
useEffect(() => {
if (
JSON.stringify(playerTrackManifest) !==
JSON.stringify(trackManifest)
) {
setTrackManifest(playerTrackManifest)
}
}, [playerTrackManifest, trackManifest])
return {
trackManifest,
setTrackManifest,
}
}

View File

@ -71,8 +71,8 @@ const PlayerButton = (props) => {
openPlayerView()
}
if (track.manifest?.analyzeCoverColor) {
track.manifest
if (track?.analyzeCoverColor) {
track
.analyzeCoverColor()
.then((analysis) => {
setCoverAnalyzed(analysis)

View File

@ -41,14 +41,14 @@
background-color: var(--background-color-accent);
border: 2px solid var(--border-color);
border: 3px solid var(--border-color);
border-radius: 12px;
}
}
&.active {
background-color: rgba(var(--bg_color_6), 0.1);
background-color: @modal_background_color;
backdrop-filter: blur(@modal_background_blur);
-webkit-backdrop-filter: blur(@modal_background_blur);

View File

@ -0,0 +1,22 @@
import React from 'react';
const Background = ({ trackManifest, hasVideoSource }) => {
if (!trackManifest || hasVideoSource) {
return null;
}
return (
<div className="lyrics-background-wrapper">
<div className="lyrics-background-cover">
<img
src={trackManifest.cover}
alt="Album cover"
loading="eager"
draggable={false}
/>
</div>
</div>
);
};
export default React.memo(Background);

View File

@ -3,13 +3,13 @@ import { Tag, Button } from "antd"
import classnames from "classnames"
import Marquee from "react-fast-marquee"
import useHideOnMouseStop from "@hooks/useHideOnMouseStop"
import { Icons } from "@components/Icons"
import Controls from "@components/Player/Controls"
import Indicators from "@components/Player/Indicators"
import SeekBar from "@components/Player/SeekBar"
import LiveInfo from "@components/Player/LiveInfo"
import useHideOnMouseStop from "@hooks/useHideOnMouseStop"
import { usePlayerStateContext } from "@contexts/WithPlayerContext"
function isOverflown(element) {
@ -23,7 +23,7 @@ function isOverflown(element) {
)
}
const PlayerController = React.forwardRef((props, ref) => {
const PlayerController = (props, ref) => {
const [playerState] = usePlayerStateContext()
const titleRef = React.useRef()
@ -34,51 +34,13 @@ const PlayerController = React.forwardRef((props, ref) => {
})
const [titleIsOverflown, setTitleIsOverflown] = React.useState(false)
const [currentTime, setCurrentTime] = React.useState(0)
const [trackDuration, setTrackDuration] = React.useState(0)
const [draggingTime, setDraggingTime] = React.useState(false)
const [currentDragWidth, setCurrentDragWidth] = React.useState(0)
const [syncInterval, setSyncInterval] = React.useState(null)
async function onDragEnd(seekTime) {
setDraggingTime(false)
app.cores.player.controls.seek(seekTime)
syncPlayback()
}
async function syncPlayback() {
if (!playerState.track_manifest) {
return false
}
const currentTrackTime = app.cores.player.controls.seek()
setCurrentTime(currentTrackTime)
}
//* Handle when playback status change
React.useEffect(() => {
if (playerState.playback_status === "playing") {
setSyncInterval(setInterval(syncPlayback, 1000))
} else {
if (syncInterval) {
clearInterval(syncInterval)
}
}
}, [playerState.playback_status])
React.useEffect(() => {
setTitleIsOverflown(isOverflown(titleRef.current))
setTrackDuration(app.cores.player.controls.duration())
}, [playerState.track_manifest])
React.useEffect(() => {
syncPlayback()
}, [])
const isStopped = playerState.playback_status === "stopped"
if (playerState.playback_status === "stopped") {
return null
}
return (
<div
@ -101,12 +63,7 @@ const PlayerController = React.forwardRef((props, ref) => {
},
)}
>
{playerState.playback_status === "stopped" ||
(!playerState.track_manifest?.title &&
"Nothing is playing")}
{playerState.playback_status !== "stopped" &&
playerState.track_manifest?.title}
{playerState.track_manifest?.title}
</h4>
}
@ -115,17 +72,11 @@ const PlayerController = React.forwardRef((props, ref) => {
//gradient
//gradientColor={bgColor}
//gradientWidth={20}
play={!isStopped}
play={playerState.playback_status === "playing"}
>
<h4>
{isStopped ? (
"Nothing is playing"
) : (
<>
{playerState.track_manifest
?.title ?? "Untitled"}
</>
)}
{playerState.track_manifest?.title ??
"Untitled"}
</h4>
</Marquee>
)}
@ -146,41 +97,13 @@ const PlayerController = React.forwardRef((props, ref) => {
{!playerState.live && <SeekBar />}
<div className="lyrics-player-controller-tags">
{playerState.track_manifest?.metadata?.lossless && (
<Tag
icon={
<Icons.Lossless
style={{
margin: 0,
}}
/>
}
bordered={false}
/>
)}
{playerState.track_manifest?.explicit && (
<Tag bordered={false}>Explicit</Tag>
)}
{props.lyrics?.sync_audio_at && (
<Tag bordered={false} icon={<Icons.TbMovie />}>
Video
</Tag>
)}
{props.lyrics?.available_langs?.length > 1 && (
<Button
icon={<Icons.MdTranslate />}
type={
props.translationEnabled ? "primary" : "default"
}
onClick={() => props.toggleTranslationEnabled()}
size="small"
/>
)}
</div>
<Indicators
track={playerState.track_manifest}
playerState={playerState}
/>
</div>
</div>
)
})
}
export default PlayerController

View File

@ -4,6 +4,7 @@ import { motion, AnimatePresence } from "motion/react"
import { usePlayerStateContext } from "@contexts/WithPlayerContext"
// eslint-disable-next-line
const LyricsText = React.forwardRef((props, textRef) => {
const [playerState] = usePlayerStateContext()
@ -74,20 +75,22 @@ const LyricsText = React.forwardRef((props, textRef) => {
} else {
setVisible(true)
// find line element by id
const lineElement = textRef.current.querySelector(
`#lyrics-line-${currentLineIndex}`,
)
if (textRef.current) {
// find line element by id
const lineElement = textRef.current.querySelector(
`#lyrics-line-${currentLineIndex}`,
)
// center scroll to current line
if (lineElement) {
lineElement.scrollIntoView({
behavior: "smooth",
block: "center",
})
} else {
// scroll to top
textRef.current.scrollTop = 0
// center scroll to current line
if (lineElement) {
lineElement.scrollIntoView({
behavior: "smooth",
block: "center",
})
} else {
// scroll to top
textRef.current.scrollTop = 0
}
}
}
}, [currentLineIndex])

View File

@ -1,171 +1,61 @@
import React, { useCallback, useEffect, useMemo, useRef } from "react"
import React from "react"
import classnames from "classnames"
import parseTimeToMs from "@utils/parseTimeToMs"
import useFullScreen from "@hooks/useFullScreen"
import useSyncRoom from "@hooks/useSyncRoom"
import useCoverAnalysis from "@hooks/useCoverAnalysis"
import useLyrics from "@hooks/useLyrics"
import useMaxScreen from "@hooks/useMaxScreen"
import useTrackManifest from "@hooks/useTrackManifest"
import { usePlayerStateContext } from "@contexts/WithPlayerContext"
import PlayerController from "./components/controller"
import LyricsVideo from "./components/video"
import LyricsText from "./components/text"
import Background from "./components/Background"
import "./index.less"
const getDominantColorStr = (analysis) => {
if (!analysis) return "0,0,0"
return analysis.value?.join(", ") || "0,0,0"
}
const toggleFullScreen = (to) => {
const targetState = to ?? !document.fullscreenElement
try {
if (targetState) {
document.documentElement.requestFullscreen()
} else if (document.fullscreenElement) {
document.exitFullscreen()
}
} catch (error) {
console.error("Fullscreen toggle failed:", error)
}
}
const EnhancedLyricsPage = () => {
useMaxScreen()
const [playerState] = usePlayerStateContext()
const [trackManifest, setTrackManifest] = React.useState(null)
const [lyrics, setLyrics] = React.useState(null)
const [translationEnabled, setTranslationEnabled] = React.useState(false)
const [coverAnalysis, setCoverAnalysis] = React.useState(null)
const videoRef = useRef()
const textRef = useRef()
const isMounted = useRef(true)
const currentTrackId = useRef(null)
const videoRef = React.useRef()
const textRef = React.useRef()
const dominantColor = useMemo(
() => ({ "--dominant-color": getDominantColorStr(coverAnalysis) }),
[coverAnalysis],
)
const { toggleFullScreen } = useFullScreen({
onExit: () => app?.location?.last && app.location.back(),
})
const handleFullScreenChange = useCallback(() => {
if (!document.fullscreenElement && app?.location?.last) {
app.location.back()
}
}, [])
const { trackManifest } = useTrackManifest(playerState.track_manifest)
const loadCurrentTrackLyrics = useCallback(async () => {
if (!playerState.track_manifest) return
const { dominantColor } = useCoverAnalysis(trackManifest)
const instance = app.cores.player.track()
if (!instance) return
const { syncRoom, subscribeLyricsUpdates, unsubscribeLyricsUpdates } =
useSyncRoom()
try {
const result =
await instance.manifest.serviceOperations.fetchLyrics({
preferTranslation: translationEnabled,
})
const { lyrics, setLyrics } = useLyrics({
trackManifest,
})
if (!isMounted.current) return
const processedLyrics =
result.sync_audio_at && !result.sync_audio_at_ms
? {
...result,
sync_audio_at_ms: parseTimeToMs(
result.sync_audio_at,
),
}
: result
console.log("Fetched Lyrics >", processedLyrics)
setLyrics(processedLyrics || false)
} catch (error) {
console.error("Failed to fetch lyrics", error)
setLyrics(false)
}
}, [translationEnabled, playerState.track_manifest])
// Track manifest comparison
useEffect(() => {
const newManifest = playerState.track_manifest
if (JSON.stringify(newManifest) !== JSON.stringify(trackManifest)) {
setTrackManifest(newManifest)
}
}, [playerState.track_manifest])
// Lyrics loading trigger
useEffect(() => {
if (!trackManifest) {
setLyrics(null)
return
}
if (!lyrics || lyrics.track_id !== trackManifest._id) {
loadCurrentTrackLyrics()
}
}, [trackManifest, lyrics?.track_id])
// Cover analysis
useEffect(() => {
const getCoverAnalysis = async () => {
const trackInstance = app.cores.player.track()
if (!trackInstance?.manifest.analyzeCoverColor) return
try {
const analysis =
await trackInstance.manifest.analyzeCoverColor()
if (isMounted.current) setCoverAnalysis(analysis)
} catch (error) {
console.error("Failed to get cover analysis", error)
}
}
if (playerState.track_manifest) {
getCoverAnalysis()
}
}, [playerState.track_manifest])
// Initialization and cleanup
useEffect(() => {
isMounted.current = true
// Inicialización y limpieza
React.useEffect(() => {
toggleFullScreen(true)
document.addEventListener("fullscreenchange", handleFullScreenChange)
if (syncRoom) {
subscribeLyricsUpdates(setLyrics)
}
return () => {
isMounted.current = false
toggleFullScreen(false)
document.removeEventListener(
"fullscreenchange",
handleFullScreenChange,
)
if (syncRoom) {
unsubscribeLyricsUpdates(setLyrics)
}
}
}, [])
// Translation toggler
const handleTranslationToggle = useCallback(
(to) => setTranslationEnabled((prev) => to ?? !prev),
[],
)
// Memoized background component
const renderBackground = useMemo(() => {
if (!playerState.track_manifest || lyrics?.video_source) return null
return (
<div className="lyrics-background-wrapper">
<div className="lyrics-background-cover">
<img
src={playerState.track_manifest.cover}
alt="Album cover"
/>
</div>
</div>
)
}, [playerState.track_manifest, lyrics?.video_source])
return (
<div
className={classnames("lyrics", {
@ -175,15 +65,21 @@ const EnhancedLyricsPage = () => {
>
<div className="lyrics-background-color" />
{renderBackground}
{playerState.playback_status === "stopped" && (
<div className="lyrics-stopped-decorator">
<img src="./basic_alt.svg" alt="Basic Logo" />
</div>
)}
<Background
trackManifest={trackManifest}
hasVideoSource={!!lyrics?.video_source}
/>
<LyricsVideo ref={videoRef} lyrics={lyrics} />
<LyricsText ref={textRef} lyrics={lyrics} />
<PlayerController
lyrics={lyrics}
translationEnabled={translationEnabled}
toggleTranslationEnabled={handleTranslationToggle}
/>
<PlayerController lyrics={lyrics} />
</div>
)
}

View File

@ -15,10 +15,34 @@
}
}
.lyrics-stopped-decorator {
position: absolute;
z-index: 100;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
padding: 35vh;
opacity: 0.5;
filter: grayscale(0.9);
img {
position: relative;
width: 100%;
height: 100%;
object-fit: contain;
}
}
.lyrics-background-color {
position: absolute;
z-index: 100;
z-index: 105;
width: 100%;
height: 100%;
@ -170,11 +194,6 @@
opacity: 1;
height: 30px;
}
.lyrics-player-controller-tags {
opacity: 1;
height: 10px;
}
}
.lyrics-player-controller-info {
@ -187,6 +206,8 @@
transition: all 150ms ease-in-out;
overflow: hidden;
.lyrics-player-controller-info-title {
font-size: 1.4rem;
font-weight: 600;
@ -219,6 +240,7 @@
.lyrics-player-controller-info-details {
display: flex;
flex-direction: row;
width: 100%;
align-items: center;
@ -229,7 +251,7 @@
font-weight: 400;
// do not wrap text
white-space: nowrap;
word-break: break-word;
h3 {
margin: 0;
@ -243,22 +265,24 @@
transition: all 150ms ease-in-out;
}
.lyrics-player-controller-tags {
.toolbar_player_indicators_wrapper {
position: absolute;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
width: 100%;
height: 0px;
padding: 4px;
gap: 10px;
.toolbar_player_indicators {
padding: 4px 6px;
font-size: 0.9rem;
opacity: 0;
transition: all 150ms ease-in-out;
border-radius: 8px;
}
}
}
}

View File

@ -8,11 +8,19 @@ import MusicService from "@models/music"
import "./index.less"
const ListView = (props) => {
const { type, id } = props.params
const { id } = props.params
const [loading, result, error, makeRequest] = app.cores.api.useRequest(
const query = new URLSearchParams(window.location.search)
const type = query.get("type")
const service = query.get("service")
const [loading, result, error] = app.cores.api.useRequest(
MusicService.getReleaseData,
id,
{
type: type,
service: service,
},
)
if (error) {
@ -29,6 +37,8 @@ const ListView = (props) => {
return <antd.Skeleton active />
}
console.log(result)
return (
<PlaylistView
playlist={result}

View File

@ -10,7 +10,11 @@ const MusicNavbar = React.forwardRef((props, ref) => {
useUrlQuery
renderResults={false}
model={async (keywords, params) =>
SearchModel.search(keywords, params, ["tracks"])
SearchModel.search(keywords, params, [
"tracks",
"albums",
"artists",
])
}
onSearchResult={props.setSearchResults}
onEmpty={() => props.setSearchResults(false)}

Some files were not shown because too many files have changed in this diff Show More