diff --git a/comty.js b/comty.js index 7c11af26..cbb45df2 160000 --- a/comty.js +++ b/comty.js @@ -1 +1 @@ -Subproject commit 7c11af2643de00423a6ac680bba235c3352620a0 +Subproject commit cbb45df2ef42205022e38e4c7d33a001e162c383 diff --git a/docs/comty-js/authentication.md b/docs/comty-js/authentication.md deleted file mode 100644 index 51167dfb..00000000 --- a/docs/comty-js/authentication.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -sidebar_position: 3 ---- - -# Authenticating - -## Server Keys - -## User Token \ No newline at end of file diff --git a/docs/comty-js/definitions/_category_.json b/docs/comty-js/definitions/_category_.json deleted file mode 100644 index 33898a33..00000000 --- a/docs/comty-js/definitions/_category_.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "label": "Definitions", - "position": 6, - "link": { - "type": "generated-index" - } -} diff --git a/docs/comty-js/definitions/like-status-object.md b/docs/comty-js/definitions/like-status-object.md deleted file mode 100644 index d6b6fa1f..00000000 --- a/docs/comty-js/definitions/like-status-object.md +++ /dev/null @@ -1,6 +0,0 @@ -# Like Status Object -| Parameter | Type | Content | -| --- | --- | --- | -| post_id | string | | -| liked | Boolean | | -| count | Number | Current like count | \ No newline at end of file diff --git a/docs/comty-js/definitions/post-object.md b/docs/comty-js/definitions/post-object.md deleted file mode 100644 index 2fa92991..00000000 --- a/docs/comty-js/definitions/post-object.md +++ /dev/null @@ -1,8 +0,0 @@ -# Post Object -| Parameter | Type | Content | -| --- | --- | --- | -| _id | string | | -| user_id | string | | -| message | string | | -| created_at | string | | -| updated_at | string | | diff --git a/docs/comty-js/definitions/save-status-object.md b/docs/comty-js/definitions/save-status-object.md deleted file mode 100644 index 41c0376a..00000000 --- a/docs/comty-js/definitions/save-status-object.md +++ /dev/null @@ -1,6 +0,0 @@ -# Save Status Object -| Parameter | Type | Content | -| --- | --- | --- | -| post_id | string | | -| saved | Boolean | | -| count | Number | Current global save count | \ No newline at end of file diff --git a/docs/comty-js/getting-started.md b/docs/comty-js/getting-started.md deleted file mode 100644 index 0d156ab7..00000000 --- a/docs/comty-js/getting-started.md +++ /dev/null @@ -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! diff --git a/docs/comty-js/index.mdx b/docs/comty-js/index.mdx new file mode 100644 index 00000000..bfb775ab --- /dev/null +++ b/docs/comty-js/index.mdx @@ -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)); +``` diff --git a/docs/comty-js/installing.md b/docs/comty-js/installing.md deleted file mode 100644 index 421008ad..00000000 --- a/docs/comty-js/installing.md +++ /dev/null @@ -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 ", - "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. \ No newline at end of file diff --git a/docs/comty-js/models/_category_.json b/docs/comty-js/models/_category_.json deleted file mode 100644 index bb3e21f0..00000000 --- a/docs/comty-js/models/_category_.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "label": "Models", - "position": 4, - "link": { - "type": "generated-index" - } -} diff --git a/docs/comty-js/models/addons.mdx b/docs/comty-js/models/addons.mdx new file mode 100644 index 00000000..2955f71f --- /dev/null +++ b/docs/comty-js/models/addons.mdx @@ -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. diff --git a/docs/comty-js/models/auth.mdx b/docs/comty-js/models/auth.mdx new file mode 100644 index 00000000..787922be --- /dev/null +++ b/docs/comty-js/models/auth.mdx @@ -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. diff --git a/docs/comty-js/models/auth/_category_.json b/docs/comty-js/models/auth/_category_.json deleted file mode 100644 index b50e4c7a..00000000 --- a/docs/comty-js/models/auth/_category_.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "label": "Auth", - "position": 1, - "link": { - "type": "generated-index" - } -} diff --git a/docs/comty-js/models/auth/availability.md b/docs/comty-js/models/auth/availability.md deleted file mode 100644 index 4059a6d8..00000000 --- a/docs/comty-js/models/auth/availability.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -sidebar_position: 5 ---- - -# Check if username or email is available \ No newline at end of file diff --git a/docs/comty-js/models/auth/change-password.md b/docs/comty-js/models/auth/change-password.md deleted file mode 100644 index 4b8ab475..00000000 --- a/docs/comty-js/models/auth/change-password.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -sidebar_position: 6 ---- - -# Update password \ No newline at end of file diff --git a/docs/comty-js/models/auth/login.md b/docs/comty-js/models/auth/login.md deleted file mode 100644 index efb4b1cd..00000000 --- a/docs/comty-js/models/auth/login.md +++ /dev/null @@ -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. -::: - -
- -### 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 | | - - -
- -### 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", - // } -}) -``` \ No newline at end of file diff --git a/docs/comty-js/models/auth/logout.md b/docs/comty-js/models/auth/logout.md deleted file mode 100644 index 01f405a4..00000000 --- a/docs/comty-js/models/auth/logout.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -sidebar_position: 2 ---- - -# Logout \ No newline at end of file diff --git a/docs/comty-js/models/auth/register.md b/docs/comty-js/models/auth/register.md deleted file mode 100644 index b07263fb..00000000 --- a/docs/comty-js/models/auth/register.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -sidebar_position: 3 ---- - -# Create a new Account \ No newline at end of file diff --git a/docs/comty-js/models/auth/user-validation.md b/docs/comty-js/models/auth/user-validation.md deleted file mode 100644 index fe9296b8..00000000 --- a/docs/comty-js/models/auth/user-validation.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -sidebar_position: 4 ---- - -# Check if exist a username \ No newline at end of file diff --git a/docs/comty-js/models/chats.md b/docs/comty-js/models/chats.md deleted file mode 100644 index f1a63201..00000000 --- a/docs/comty-js/models/chats.md +++ /dev/null @@ -1 +0,0 @@ -# Chats \ No newline at end of file diff --git a/docs/comty-js/models/chats.mdx b/docs/comty-js/models/chats.mdx new file mode 100644 index 00000000..8c025fe7 --- /dev/null +++ b/docs/comty-js/models/chats.mdx @@ -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. diff --git a/docs/comty-js/models/e2e.mdx b/docs/comty-js/models/e2e.mdx new file mode 100644 index 00000000..1a59b4b2 --- /dev/null +++ b/docs/comty-js/models/e2e.mdx @@ -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. diff --git a/docs/comty-js/models/events.mdx b/docs/comty-js/models/events.mdx new file mode 100644 index 00000000..aecf04cd --- /dev/null +++ b/docs/comty-js/models/events.mdx @@ -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. diff --git a/docs/comty-js/models/feed.md b/docs/comty-js/models/feed.md deleted file mode 100644 index c53658a2..00000000 --- a/docs/comty-js/models/feed.md +++ /dev/null @@ -1 +0,0 @@ -# Feed \ No newline at end of file diff --git a/docs/comty-js/models/feed.mdx b/docs/comty-js/models/feed.mdx new file mode 100644 index 00000000..6b4dd032 --- /dev/null +++ b/docs/comty-js/models/feed.mdx @@ -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. diff --git a/docs/comty-js/models/follows.mdx b/docs/comty-js/models/follows.mdx new file mode 100644 index 00000000..7d9c9ad5 --- /dev/null +++ b/docs/comty-js/models/follows.mdx @@ -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. diff --git a/docs/comty-js/models/follows/_category_.json b/docs/comty-js/models/follows/_category_.json deleted file mode 100644 index 8e5b2759..00000000 --- a/docs/comty-js/models/follows/_category_.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "label": "Follows", - "position": 3, - "link": { - "type": "generated-index" - } -} diff --git a/docs/comty-js/models/follows/im-following.md b/docs/comty-js/models/follows/im-following.md deleted file mode 100644 index 23f95cd2..00000000 --- a/docs/comty-js/models/follows/im-following.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -sidebar_position: 1 ---- - -# Get followers -Retrieves the list of followers for a given user. - -
-
-```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 | - -
- -### Success Response -| Parameter | Type | Content | -| --- | --- | --- | - -
- -## 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", -// }, -// ... -// ] -// } -``` \ No newline at end of file diff --git a/docs/comty-js/models/music.md b/docs/comty-js/models/music.md deleted file mode 100644 index 3689238e..00000000 --- a/docs/comty-js/models/music.md +++ /dev/null @@ -1 +0,0 @@ -# Music \ No newline at end of file diff --git a/docs/comty-js/models/music.mdx b/docs/comty-js/models/music.mdx new file mode 100644 index 00000000..3267087e --- /dev/null +++ b/docs/comty-js/models/music.mdx @@ -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]* diff --git a/docs/comty-js/models/nfc.md b/docs/comty-js/models/nfc.md deleted file mode 100644 index 5db8200c..00000000 --- a/docs/comty-js/models/nfc.md +++ /dev/null @@ -1 +0,0 @@ -# NFC \ No newline at end of file diff --git a/docs/comty-js/models/nfc.mdx b/docs/comty-js/models/nfc.mdx new file mode 100644 index 00000000..3f5d78e9 --- /dev/null +++ b/docs/comty-js/models/nfc.mdx @@ -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. diff --git a/docs/comty-js/models/payments.mdx b/docs/comty-js/models/payments.mdx new file mode 100644 index 00000000..b5ed4173 --- /dev/null +++ b/docs/comty-js/models/payments.mdx @@ -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. diff --git a/docs/comty-js/models/post.mdx b/docs/comty-js/models/post.mdx new file mode 100644 index 00000000..95b4fd87 --- /dev/null +++ b/docs/comty-js/models/post.mdx @@ -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. + * ` diff --git a/docs/comty-js/models/post/_category_.json b/docs/comty-js/models/post/_category_.json deleted file mode 100644 index a0ebfd79..00000000 --- a/docs/comty-js/models/post/_category_.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "label": "Post", - "position": 2, - "link": { - "type": "generated-index" - } -} diff --git a/docs/comty-js/models/post/create-post.md b/docs/comty-js/models/post/create-post.md deleted file mode 100644 index 924616ec..00000000 --- a/docs/comty-js/models/post/create-post.md +++ /dev/null @@ -1,91 +0,0 @@ ---- -sidebar_position: 8 ---- - -# Create a post -Creates a new post with the given payload. - -
-
-```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 | | - -
- -### Success Response -| Parameter | Type | Content | -| --- | --- | --- | -| data | Object | [post-object](/docs/comty-js/definitions/post-object) | - -
- -## 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", -// } - -``` \ No newline at end of file diff --git a/docs/comty-js/models/post/delete-post.md b/docs/comty-js/models/post/delete-post.md deleted file mode 100644 index 830cee63..00000000 --- a/docs/comty-js/models/post/delete-post.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -sidebar_position: 10 ---- - -# Delete a post -Delete a post with the given post ID. - -Only can delete your own posts. - -
-
-```js -async function PostModel.delete(payload) -``` - -### [Object] Payload -| Parameter | Type | Optional | Default | Description | -| --- | --- | --- | --- | --- | -| post_id | String | false | undefined | | - -
- -### Success Response -| Parameter | Type | Content | -| --- | --- | --- | -| post_id | String | | -| deleted | Boolean | | - -
- -## Examples -### Basic usage -```js -const post = await PostModel.delete({ - post_id: "0000", -}) - -console.log(post) - -// result: { -// post_id: "0000", -// deleted: true, -// } - -``` \ No newline at end of file diff --git a/docs/comty-js/models/post/get-my-liked-posts.md b/docs/comty-js/models/post/get-my-liked-posts.md deleted file mode 100644 index e2746902..00000000 --- a/docs/comty-js/models/post/get-my-liked-posts.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -sidebar_position: 2 ---- - -# Get my liked posts -Retrieves the liked posts of current authed user. - -
-
-```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 | - -
- -### Success Response -| Parameter | Type | Content | -| --- | --- | --- | -| data | Array | [[post_obj](/docs/comty-js/definitions/post-object), ...] | - -
- -## 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", ... }, -// ... -// ] - -``` \ No newline at end of file diff --git a/docs/comty-js/models/post/get-post-data.md b/docs/comty-js/models/post/get-post-data.md deleted file mode 100644 index 3d3e35e1..00000000 --- a/docs/comty-js/models/post/get-post-data.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -sidebar_position: 4 ---- - -# Get post data -Retrieves the data of a post. - -
-
-```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.| - -
- -### Success Response -| Parameter | Type | Content | -| --- | --- | --- | -| data | object | [post_obj](/docs/comty-js/definitions/post-object) | - -
- -## Examples -### Basic usage -```js -const post = await PostModel.post({ - post_id: "0000", -}) - -console.log(post) - -// result: { -// _id: "0000", -// user_id: "0000", -// message: "example text", -// ... -// } - -``` \ No newline at end of file diff --git a/docs/comty-js/models/post/get-post-replies.md b/docs/comty-js/models/post/get-post-replies.md deleted file mode 100644 index 342b728b..00000000 --- a/docs/comty-js/models/post/get-post-replies.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -sidebar_position: 5 ---- - -# Get post replies -Retrieves replies of a post. - -
-
-```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 | - -
- -### Success Response -| Parameter | Type | Content | -| --- | --- | --- | -| data | Array | [[post_obj](/docs/comty-js/definitions/post-object), ...] | - -
- -## 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", ... }, -// ... -// ] - -``` \ No newline at end of file diff --git a/docs/comty-js/models/post/get-saved-posts.md b/docs/comty-js/models/post/get-saved-posts.md deleted file mode 100644 index b8d4265a..00000000 --- a/docs/comty-js/models/post/get-saved-posts.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -sidebar_position: 3 ---- - -# Get my saved posts -Retrieves the saved posts of current authed user. - -
-
-```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 | - -
- -### Success Response -| Parameter | Type | Content | -| --- | --- | --- | -| data | Array | [[post_obj](/docs/comty-js/definitions/post-object), ...] | - -
- -## 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", ... }, -// ... -// ] - -``` \ No newline at end of file diff --git a/docs/comty-js/models/post/get-user-posts.md b/docs/comty-js/models/post/get-user-posts.md deleted file mode 100644 index c244bb3b..00000000 --- a/docs/comty-js/models/post/get-user-posts.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -sidebar_position: 1 ---- - -# Get user posts -Retrieves the public posts of a user. - -
-
-```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 | - -
- -### Success Response -| Parameter | Type | Content | -| --- | --- | --- | -| data | Array | [[post_obj](/docs/comty-js/definitions/post-object), ...] | - -
- -## 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", ... }, -// ... -// ] - -``` \ No newline at end of file diff --git a/docs/comty-js/models/post/toggle-like.md b/docs/comty-js/models/post/toggle-like.md deleted file mode 100644 index db9567c5..00000000 --- a/docs/comty-js/models/post/toggle-like.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -sidebar_position: 6 ---- - -# Toggle post like -Toggles the like status of a post. - -
-
-```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 | - -
- -### Success Response -| Parameter | Type | Content | -| --- | --- | --- | -| data | Object | [like-status-object](/docs/comty-js/definitions/like-status-object) | - -
- -## 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, -// } - -``` \ No newline at end of file diff --git a/docs/comty-js/models/post/toggle-save.md b/docs/comty-js/models/post/toggle-save.md deleted file mode 100644 index b9a8d1ce..00000000 --- a/docs/comty-js/models/post/toggle-save.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -sidebar_position: 7 ---- - -# Toggle post save -Toggles the save status of a post. - -
-
-```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 | - -
- -### Success Response -| Parameter | Type | Content | -| --- | --- | --- | -| data | Object | [save-status-object](/docs/comty-js/definitions/save-status-object) | - -
- -## 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, -// } - -``` \ No newline at end of file diff --git a/docs/comty-js/models/post/update-post.md b/docs/comty-js/models/post/update-post.md deleted file mode 100644 index e674de36..00000000 --- a/docs/comty-js/models/post/update-post.md +++ /dev/null @@ -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. - -
-
-```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 | - -
- -### Success Response -| Parameter | Type | Content | -| --- | --- | --- | -| data | Object | [post-object](/docs/comty-js/definitions/post-object) | - -
- -## 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", -// } -// ] -// } - -``` \ No newline at end of file diff --git a/docs/comty-js/models/radio.mdx b/docs/comty-js/models/radio.mdx new file mode 100644 index 00000000..8d9e8994 --- /dev/null +++ b/docs/comty-js/models/radio.mdx @@ -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. diff --git a/docs/comty-js/models/search.md b/docs/comty-js/models/search.md deleted file mode 100644 index 6aaea9fe..00000000 --- a/docs/comty-js/models/search.md +++ /dev/null @@ -1 +0,0 @@ -# Search \ No newline at end of file diff --git a/docs/comty-js/models/search.mdx b/docs/comty-js/models/search.mdx new file mode 100644 index 00000000..7fb6c34b --- /dev/null +++ b/docs/comty-js/models/search.mdx @@ -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. diff --git a/docs/comty-js/models/session.md b/docs/comty-js/models/session.md deleted file mode 100644 index 1abcb971..00000000 --- a/docs/comty-js/models/session.md +++ /dev/null @@ -1 +0,0 @@ -# Session \ No newline at end of file diff --git a/docs/comty-js/models/spectrum.mdx b/docs/comty-js/models/spectrum.mdx new file mode 100644 index 00000000..564fd39b --- /dev/null +++ b/docs/comty-js/models/spectrum.mdx @@ -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. diff --git a/docs/comty-js/models/spotify.mdx b/docs/comty-js/models/spotify.mdx new file mode 100644 index 00000000..6f623412 --- /dev/null +++ b/docs/comty-js/models/spotify.mdx @@ -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. diff --git a/docs/comty-js/models/tidal.mdx b/docs/comty-js/models/tidal.mdx new file mode 100644 index 00000000..56fad294 --- /dev/null +++ b/docs/comty-js/models/tidal.mdx @@ -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. diff --git a/docs/comty-js/models/user.md b/docs/comty-js/models/user.md deleted file mode 100644 index 80fd4137..00000000 --- a/docs/comty-js/models/user.md +++ /dev/null @@ -1 +0,0 @@ -# User \ No newline at end of file diff --git a/docs/comty-js/models/user.mdx b/docs/comty-js/models/user.mdx new file mode 100644 index 00000000..58caf2fb --- /dev/null +++ b/docs/comty-js/models/user.mdx @@ -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. diff --git a/docusaurus/docusaurus.config.js b/docusaurus/docusaurus.config.js index 767a3f28..d1db5f02 100644 --- a/docusaurus/docusaurus.config.js +++ b/docusaurus/docusaurus.config.js @@ -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", + }, + }, + }, + }, ], ], diff --git a/docusaurus/package.json b/docusaurus/package.json index 9869ecb8..9d675971 100644 --- a/docusaurus/package.json +++ b/docusaurus/package.json @@ -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" diff --git a/packages/app/package.json b/packages/app/package.json index 3813b2ee..71f52216 100755 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@comty/app", - "version": "1.42.1@alpha", + "version": "1.42.2@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", diff --git a/packages/app/src/classes/QueueManager/index.js b/packages/app/src/classes/QueueManager/index.js index 3ba5cd11..652bd342 100644 --- a/packages/app/src/classes/QueueManager/index.js +++ b/packages/app/src/classes/QueueManager/index.js @@ -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 - } } diff --git a/packages/app/src/components/Icons/customIcons/lossless.jsx b/packages/app/src/components/Icons/customIcons/lossless.jsx index 3d5c289f..884ca7c4 100644 --- a/packages/app/src/components/Icons/customIcons/lossless.jsx +++ b/packages/app/src/components/Icons/customIcons/lossless.jsx @@ -1 +1,18 @@ -export default (props) => \ No newline at end of file +export default (props) => ( + + + +) diff --git a/packages/app/src/components/Icons/customIcons/ogg.jsx b/packages/app/src/components/Icons/customIcons/ogg.jsx new file mode 100644 index 00000000..837ba4e8 --- /dev/null +++ b/packages/app/src/components/Icons/customIcons/ogg.jsx @@ -0,0 +1,17 @@ +export default (props) => ( + + + + + + + +) diff --git a/packages/app/src/components/Icons/index.jsx b/packages/app/src/components/Icons/index.jsx index 6628c905..ad5e239c 100755 --- a/packages/app/src/components/Icons/index.jsx +++ b/packages/app/src/components/Icons/index.jsx @@ -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) => , - verifiedBadge: (props) => , - VrChat: (props) => , - Crown: (props) => + Lossless: (props) => , + verifiedBadge: (props) => ( + + ), + VrChat: (props) => , + Crown: (props) => , + Ogg: (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 \ No newline at end of file +export default Icons diff --git a/packages/app/src/components/Music/Playlist/index.jsx b/packages/app/src/components/Music/Playlist/index.jsx index 5505ae86..e55e4a93 100755 --- a/packages/app/src/components/Music/Playlist/index.jsx +++ b/packages/app/src/components/Music/Playlist/index.jsx @@ -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) => {

{playlist.title}

+ {props.row && (
+ {playlist.service === "tidal" && ( +

+ + Tidal +

+ )} +

{playlist.type ?? "playlist"} diff --git a/packages/app/src/components/Music/Playlist/index.less b/packages/app/src/components/Music/Playlist/index.less index 6ebf2a82..464c2836 100755 --- a/packages/app/src/components/Music/Playlist/index.less +++ b/packages/app/src/components/Music/Playlist/index.less @@ -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; diff --git a/packages/app/src/components/Music/PlaylistView/header.jsx b/packages/app/src/components/Music/PlaylistView/header.jsx index b24893c4..654e2848 100644 --- a/packages/app/src/components/Music/PlaylistView/header.jsx +++ b/packages/app/src/components/Music/PlaylistView/header.jsx @@ -16,6 +16,7 @@ const typeToKind = { ep: "releases", compilation: "releases", playlist: "playlists", + single: "tracks", } const PlaylistHeader = ({ @@ -77,7 +78,6 @@ const PlaylistHeader = ({

- {playlist.service === "tidal" && }{" "} {typeof playlist.title === "function" ? ( playlist.title() ) : ( @@ -86,19 +86,24 @@ const PlaylistHeader = ({
+ {playlist.service === "tidal" && ( +
+

+ From Tidal +

+
+ )} {PlaylistTypeDecorators[playlistType] && (
{PlaylistTypeDecorators[playlistType]()}
)} -

{playlist.total_items}{" "} Items

- {playlist.total_duration > 0 && (

@@ -107,7 +112,6 @@ const PlaylistHeader = ({

)} - {playlist.publisher && (

diff --git a/packages/app/src/components/MusicStudio/TrackEditor/index.jsx b/packages/app/src/components/MusicStudio/TrackEditor/index.jsx index 9c5fab25..caf1edd5 100644 --- a/packages/app/src/components/MusicStudio/TrackEditor/index.jsx +++ b/packages/app/src/components/MusicStudio/TrackEditor/index.jsx @@ -143,6 +143,18 @@ const TrackEditor = (props) => { />

+
+
+ + Public +
+ + handleChange("public", value)} + /> +
+
diff --git a/packages/app/src/components/Player/Actions/index.jsx b/packages/app/src/components/Player/Actions/index.jsx index a70b2976..b996fc4b 100755 --- a/packages/app/src/components/Player/Actions/index.jsx +++ b/packages/app/src/components/Player/Actions/index.jsx @@ -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) => {
diff --git a/packages/app/src/components/Player/Indicators/index.jsx b/packages/app/src/components/Player/Indicators/index.jsx new file mode 100644 index 00000000..b2918915 --- /dev/null +++ b/packages/app/src/components/Player/Indicators/index.jsx @@ -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: , + }) + } + + 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: , + tooltip: `${sampleRate / 1000} kHz / ${bitDepth ?? 16} Bits`, + }) + } + + if (codec.toLowerCase().includes("vorbis")) { + indicators.push({ + icon: , + 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 ( +
+
+ {indicators.map((indicator, index) => { + if (indicator.tooltip) { + return ( + + {indicator.icon} + + ) + } + + return React.cloneElement(indicator.icon, { + key: index, + }) + })} +
+
+ ) +} + +export default Indicators diff --git a/packages/app/src/components/Player/index.jsx b/packages/app/src/components/Player/index.jsx index 887635f7..804440f6 100755 --- a/packages/app/src/components/Player/index.jsx +++ b/packages/app/src/components/Player/index.jsx @@ -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( - - - , - ) - } - } - - if (playerState.live) { - indicators.push( - , - ) - } - - if (indicators.length === 0) { - return null - } - - return ( -
-
{indicators}
-
- ) -} - 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) => { )} -

- {artist ?? ""} -

+ {!playerState.radioId && ( +

+ {artist ?? ""} +

+ )}
{playerState.radioId && ( diff --git a/packages/app/src/components/Player/index.less b/packages/app/src/components/Player/index.less index 6a21b0df..479ef956 100755 --- a/packages/app/src/components/Player/index.less +++ b/packages/app/src/components/Player/index.less @@ -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); diff --git a/packages/app/src/components/Searcher/index.jsx b/packages/app/src/components/Searcher/index.jsx index 9ddbe3c0..bcfeae6d 100755 --- a/packages/app/src/components/Searcher/index.jsx +++ b/packages/app/src/components/Searcher/index.jsx @@ -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, }) } diff --git a/packages/app/src/components/Searcher/index.less b/packages/app/src/components/Searcher/index.less index d0acd1bc..4df6276f 100755 --- a/packages/app/src/components/Searcher/index.less +++ b/packages/app/src/components/Searcher/index.less @@ -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, diff --git a/packages/app/src/cores/player/classes/AudioBase.js b/packages/app/src/cores/player/classes/AudioBase.js index 6e9a2ae0..67f668b4 100644 --- a/packages/app/src/cores/player/classes/AudioBase.js +++ b/packages/app/src/cores/player/classes/AudioBase.js @@ -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) { diff --git a/packages/app/src/cores/player/classes/PlayerState.js b/packages/app/src/cores/player/classes/PlayerState.js index c2d71115..a43c1057 100644 --- a/packages/app/src/cores/player/classes/PlayerState.js +++ b/packages/app/src/cores/player/classes/PlayerState.js @@ -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 - } -} \ No newline at end of file + 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 + } +} diff --git a/packages/app/src/cores/player/classes/Services.js b/packages/app/src/cores/player/classes/Services.js index 2b2e2c66..e2c492f8 100755 --- a/packages/app/src/cores/player/classes/Services.js +++ b/packages/app/src/cores/player/classes/Services.js @@ -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 - } -} \ No newline at end of file + manifests = await Promise.all(manifests) + + return manifests + } +} diff --git a/packages/app/src/cores/player/classes/SyncRoom.js b/packages/app/src/cores/player/classes/SyncRoom.js new file mode 100644 index 00000000..6b332b4b --- /dev/null +++ b/packages/app/src/cores/player/classes/SyncRoom.js @@ -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() + } +} diff --git a/packages/app/src/cores/player/classes/TrackInstance.js b/packages/app/src/cores/player/classes/TrackInstance.js deleted file mode 100644 index d8069ba6..00000000 --- a/packages/app/src/cores/player/classes/TrackInstance.js +++ /dev/null @@ -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() - } -} diff --git a/packages/app/src/cores/player/classes/TrackManifest.js b/packages/app/src/cores/player/classes/TrackManifest.js index a2dd06b1..2bd15498 100644 --- a/packages/app/src/cores/player/classes/TrackManifest.js +++ b/packages/app/src/cores/player/classes/TrackManifest.js @@ -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, } } } diff --git a/packages/app/src/cores/player/helpers/findInitializationChunk.js b/packages/app/src/cores/player/helpers/findInitializationChunk.js new file mode 100644 index 00000000..1b50ae93 --- /dev/null +++ b/packages/app/src/cores/player/helpers/findInitializationChunk.js @@ -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 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 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 in either Representation or AdaptationSet.", + ) + return null + } + + // extract the initialization attribute + const initAttr = tmpl.getAttribute("initialization") + + if (!initAttr) { + console.warn( + "The 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() +} diff --git a/packages/app/src/cores/player/helpers/handleInlineDashManifest.js b/packages/app/src/cores/player/helpers/handleInlineDashManifest.js new file mode 100644 index 00000000..4e2e8036 --- /dev/null +++ b/packages/app/src/cores/player/helpers/handleInlineDashManifest.js @@ -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) +} diff --git a/packages/app/src/cores/player/helpers/parseSourceFormatMetadata.js b/packages/app/src/cores/player/helpers/parseSourceFormatMetadata.js new file mode 100644 index 00000000..fe258159 --- /dev/null +++ b/packages/app/src/cores/player/helpers/parseSourceFormatMetadata.js @@ -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 +} diff --git a/packages/app/src/cores/player/player.core.js b/packages/app/src/cores/player/player.core.js index 484d6c8f..7acb8227 100755 --- a/packages/app/src/cores/player/player.core.js +++ b/packages/app/src/cores/player/player.core.js @@ -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() } diff --git a/packages/app/src/cores/sfx/sfx.core.js b/packages/app/src/cores/sfx/sfx.core.js index aad78101..1205717a 100755 --- a/packages/app/src/cores/sfx/sfx.core.js +++ b/packages/app/src/cores/sfx/sfx.core.js @@ -106,7 +106,6 @@ export default class SFXCore extends Core { if (slider) { // check if is up or down - this.console.log(slider) } } diff --git a/packages/app/src/cores/style/style.core.jsx b/packages/app/src/cores/style/style.core.jsx index cc624e2a..5f3f838b 100755 --- a/packages/app/src/cores/style/style.core.jsx +++ b/packages/app/src/cores/style/style.core.jsx @@ -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) { diff --git a/packages/app/src/hooks/index.js b/packages/app/src/hooks/index.js deleted file mode 100755 index 19de4fb9..00000000 --- a/packages/app/src/hooks/index.js +++ /dev/null @@ -1,2 +0,0 @@ -export { default as useHacks } from "./useHacks" -export { default as useCenteredContainer } from "./useCenteredContainer" \ No newline at end of file diff --git a/packages/app/src/hooks/useCoverAnalysis/index.js b/packages/app/src/hooks/useCoverAnalysis/index.js new file mode 100644 index 00000000..424bcf9e --- /dev/null +++ b/packages/app/src/hooks/useCoverAnalysis/index.js @@ -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, + } +} diff --git a/packages/app/src/hooks/useFullScreen/index.js b/packages/app/src/hooks/useFullScreen/index.js new file mode 100644 index 00000000..f8e9500d --- /dev/null +++ b/packages/app/src/hooks/useFullScreen/index.js @@ -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, + } +} diff --git a/packages/app/src/hooks/useLyrics/index.js b/packages/app/src/hooks/useLyrics/index.js new file mode 100644 index 00000000..0a8df714 --- /dev/null +++ b/packages/app/src/hooks/useLyrics/index.js @@ -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, + } +} diff --git a/packages/app/src/hooks/useSyncRoom/index.js b/packages/app/src/hooks/useSyncRoom/index.js new file mode 100644 index 00000000..8b850419 --- /dev/null +++ b/packages/app/src/hooks/useSyncRoom/index.js @@ -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(), + } +} diff --git a/packages/app/src/hooks/useTrackManifest/index.js b/packages/app/src/hooks/useTrackManifest/index.js new file mode 100644 index 00000000..f94896d1 --- /dev/null +++ b/packages/app/src/hooks/useTrackManifest/index.js @@ -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, + } +} diff --git a/packages/app/src/layouts/components/@mobile/bottomBar/index.jsx b/packages/app/src/layouts/components/@mobile/bottomBar/index.jsx index 301819c1..84e4be0d 100755 --- a/packages/app/src/layouts/components/@mobile/bottomBar/index.jsx +++ b/packages/app/src/layouts/components/@mobile/bottomBar/index.jsx @@ -71,8 +71,8 @@ const PlayerButton = (props) => { openPlayerView() } - if (track.manifest?.analyzeCoverColor) { - track.manifest + if (track?.analyzeCoverColor) { + track .analyzeCoverColor() .then((analysis) => { setCoverAnalyzed(analysis) diff --git a/packages/app/src/layouts/components/modals/modal/index.less b/packages/app/src/layouts/components/modals/modal/index.less index fd4eb9fb..50023db8 100644 --- a/packages/app/src/layouts/components/modals/modal/index.less +++ b/packages/app/src/layouts/components/modals/modal/index.less @@ -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); diff --git a/packages/app/src/pages/lyrics/components/Background.jsx b/packages/app/src/pages/lyrics/components/Background.jsx new file mode 100644 index 00000000..843690e9 --- /dev/null +++ b/packages/app/src/pages/lyrics/components/Background.jsx @@ -0,0 +1,22 @@ +import React from 'react'; + +const Background = ({ trackManifest, hasVideoSource }) => { + if (!trackManifest || hasVideoSource) { + return null; + } + + return ( +
+
+ Album cover +
+
+ ); +}; + +export default React.memo(Background); \ No newline at end of file diff --git a/packages/app/src/pages/lyrics/components/controller/index.jsx b/packages/app/src/pages/lyrics/components/controller/index.jsx index 238b1ec7..b2b4b3ae 100644 --- a/packages/app/src/pages/lyrics/components/controller/index.jsx +++ b/packages/app/src/pages/lyrics/components/controller/index.jsx @@ -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 (
{ }, )} > - {playerState.playback_status === "stopped" || - (!playerState.track_manifest?.title && - "Nothing is playing")} - - {playerState.playback_status !== "stopped" && - playerState.track_manifest?.title} + {playerState.track_manifest?.title} } @@ -115,17 +72,11 @@ const PlayerController = React.forwardRef((props, ref) => { //gradient //gradientColor={bgColor} //gradientWidth={20} - play={!isStopped} + play={playerState.playback_status === "playing"} >

- {isStopped ? ( - "Nothing is playing" - ) : ( - <> - {playerState.track_manifest - ?.title ?? "Untitled"} - - )} + {playerState.track_manifest?.title ?? + "Untitled"}

)} @@ -146,41 +97,13 @@ const PlayerController = React.forwardRef((props, ref) => { {!playerState.live && } -
- {playerState.track_manifest?.metadata?.lossless && ( - - } - bordered={false} - /> - )} - {playerState.track_manifest?.explicit && ( - Explicit - )} - {props.lyrics?.sync_audio_at && ( - }> - Video - - )} - {props.lyrics?.available_langs?.length > 1 && ( -
+
) -}) +} export default PlayerController diff --git a/packages/app/src/pages/lyrics/components/text/index.jsx b/packages/app/src/pages/lyrics/components/text/index.jsx index e784df86..76d1f734 100644 --- a/packages/app/src/pages/lyrics/components/text/index.jsx +++ b/packages/app/src/pages/lyrics/components/text/index.jsx @@ -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]) diff --git a/packages/app/src/pages/lyrics/index.jsx b/packages/app/src/pages/lyrics/index.jsx index 320ca842..61e2002e 100644 --- a/packages/app/src/pages/lyrics/index.jsx +++ b/packages/app/src/pages/lyrics/index.jsx @@ -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 ( -
-
- Album cover -
-
- ) - }, [playerState.track_manifest, lyrics?.video_source]) - return (
{ >
- {renderBackground} + {playerState.playback_status === "stopped" && ( +
+ Basic Logo +
+ )} + + - + +
) } diff --git a/packages/app/src/pages/lyrics/index.less b/packages/app/src/pages/lyrics/index.less index 07bd1f0b..0f713cfc 100644 --- a/packages/app/src/pages/lyrics/index.less +++ b/packages/app/src/pages/lyrics/index.less @@ -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; + } } } } diff --git a/packages/app/src/pages/music/list/[id]/index.jsx b/packages/app/src/pages/music/list/[id]/index.jsx index 88a56eb3..19e4d5b5 100644 --- a/packages/app/src/pages/music/list/[id]/index.jsx +++ b/packages/app/src/pages/music/list/[id]/index.jsx @@ -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 } + console.log(result) + return ( { 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)} diff --git a/packages/app/src/pages/music/tabs/explore/components/SearchResults/index.jsx b/packages/app/src/pages/music/tabs/explore/components/SearchResults/index.jsx index 5abf8880..d97d81b7 100644 --- a/packages/app/src/pages/music/tabs/explore/components/SearchResults/index.jsx +++ b/packages/app/src/pages/music/tabs/explore/components/SearchResults/index.jsx @@ -8,11 +8,11 @@ import MusicTrack from "@components/Music/Track" import Playlist from "@components/Music/Playlist" const ResultGroupsDecorators = { - playlists: { - icon: "MdPlaylistPlay", - label: "Playlists", + albums: { + icon: "MdAlbum", + label: "Albums", renderItem: (props) => { - return + return }, }, tracks: { @@ -23,7 +23,6 @@ const ResultGroupsDecorators = { app.cores.player.start(props.item)} onClick={() => app.location.push(`/play/${props.item._id}`)} /> ) @@ -40,6 +39,10 @@ const SearchResults = ({ data }) => { // filter out groups with no items array property groupsKeys = groupsKeys.filter((key) => { + if (!data[key]) { + return false + } + if (!Array.isArray(data[key].items)) { return false } diff --git a/packages/app/src/pages/studio/tv/[profile_id]/tabs/MediaUrls/index.jsx b/packages/app/src/pages/studio/tv/[profile_id]/tabs/MediaUrls/index.jsx index 12b68a57..730dae6c 100644 --- a/packages/app/src/pages/studio/tv/[profile_id]/tabs/MediaUrls/index.jsx +++ b/packages/app/src/pages/studio/tv/[profile_id]/tabs/MediaUrls/index.jsx @@ -11,7 +11,35 @@ const MediaUrls = ({ profile }) => { const { hls, rtsp, html } = sources - const rtspt = rtsp ? rtsp.replace("rtsp://", "rtspt://") : null + let rtspt = null + + if (rtsp) { + try { + const url = new URL(rtsp) + + const pathParts = url.pathname.split("/") + const lastPart = pathParts.pop() + const [resource, query] = lastPart.split("?") + + let stoken = url.searchParams.get("stoken") + + let newLastPart = resource + + if (stoken) { + newLastPart = `${resource}:${stoken}` + } + + pathParts.push(newLastPart) + + url.protocol = "rtspt:" + url.pathname = pathParts.join("/") + url.search = "" + + rtspt = url.toString() + } catch (err) { + rtspt = rtsp.replace("rtsp://", "rtspt://") + } + } return (
diff --git a/packages/app/src/pages/tv/live/[id]/chat/index.jsx b/packages/app/src/pages/tv/live/[id]/chat/index.jsx new file mode 100644 index 00000000..dec7fbe0 --- /dev/null +++ b/packages/app/src/pages/tv/live/[id]/chat/index.jsx @@ -0,0 +1,7 @@ +import React from "react" + +const ChatPage = () => { + return
Chat
+} + +export default ChatPage diff --git a/packages/app/src/pages/tv/live/decoders/hls.js b/packages/app/src/pages/tv/live/[id]/decoders/hls.js similarity index 97% rename from packages/app/src/pages/tv/live/decoders/hls.js rename to packages/app/src/pages/tv/live/[id]/decoders/hls.js index 980ee06a..c614691a 100644 --- a/packages/app/src/pages/tv/live/decoders/hls.js +++ b/packages/app/src/pages/tv/live/[id]/decoders/hls.js @@ -40,7 +40,6 @@ export default (player, sources = {}, options = {}) => { source += `?token=${options.authToken}` } - console.log("[HLS] Instance options >", options) console.log(`[HLS] Loading source [${source}]`) hlsInstance.attachMedia(player) diff --git a/packages/app/src/pages/tv/live/[id]/decoders/index.js b/packages/app/src/pages/tv/live/[id]/decoders/index.js new file mode 100644 index 00000000..fd2669a3 --- /dev/null +++ b/packages/app/src/pages/tv/live/[id]/decoders/index.js @@ -0,0 +1,2 @@ +export { default as hls } from "./hls" +export { default as shaka } from "./shaka" diff --git a/packages/app/src/pages/tv/live/[id]/decoders/shaka.js b/packages/app/src/pages/tv/live/[id]/decoders/shaka.js new file mode 100644 index 00000000..9d5e79dd --- /dev/null +++ b/packages/app/src/pages/tv/live/[id]/decoders/shaka.js @@ -0,0 +1,100 @@ +import shaka from "shaka-player" + +export default async (player, sources = {}, options = {}) => { + if (!player) { + console.error("[Shaka] player is not defined") + return false + } + + if (!sources.hls) { + console.error("[Shaka] an hls source is not provided") + return false + } + + let source = sources.hls + + // Initialize shaka player + const shakaInstance = new shaka.Player(player) + + // Helper function to sync to live edge + const syncToLive = () => { + if (shakaInstance.isLive()) { + const end = shakaInstance.seekRange().end + player.currentTime = end + } + } + + // Configure for low-latency HLS + shakaInstance.configure({ + streaming: { + lowLatencyMode: true, + inaccurateManifestTolerance: 0, + rebufferingGoal: 0.01, + bufferingGoal: 0.1, + bufferBehind: 30, + startAtSegmentBoundary: false, + durationBackoff: 0.2, + }, + }) + + // Add request filter for authentication if token is provided + if (options.authToken) { + shakaInstance + .getNetworkingEngine() + .registerRequestFilter((type, request) => { + request.headers = { + ...request.headers, + Authorization: `Bearer ${options.authToken}`, + } + }) + source += `?token=${options.authToken}` + } + + console.log("[Shaka] Instance options >", options) + console.log(`[Shaka] Loading source [${source}]`) + + // Error handling + shakaInstance.addEventListener("error", (error) => { + console.error("[Shaka] Error", error) + }) + + // Buffer state monitoring + player.addEventListener("waiting", () => { + console.log("[Shaka] Buffer underrun") + }) + + // Handle stream end + player.addEventListener("ended", () => { + console.log("[Shaka] Stream ended") + if (typeof options.onSourceEnd === "function") { + options.onSourceEnd() + } + }) + + try { + await shakaInstance.load(source) + console.log("[Shaka] Stream loaded successfully") + + const tracks = shakaInstance.getVariantTracks() + console.log("[Shaka] Available qualities >", tracks) + } catch (error) { + console.error("[Shaka] Error loading stream:", error) + } + + player.addEventListener("play", () => { + console.log("[SHAKA] Syncing to last position") + syncToLive() + }) + + // Add destroy method for cleanup + shakaInstance._destroy = () => { + try { + shakaInstance.unload() + shakaInstance.destroy() + } catch (error) { + console.error("[Shaka] Error during cleanup:", error) + } + } + + return shakaInstance +} diff --git a/packages/app/src/pages/tv/live/[id].jsx b/packages/app/src/pages/tv/live/[id]/index.jsx similarity index 96% rename from packages/app/src/pages/tv/live/[id].jsx rename to packages/app/src/pages/tv/live/[id]/index.jsx index aee875c2..a4afd8df 100755 --- a/packages/app/src/pages/tv/live/[id].jsx +++ b/packages/app/src/pages/tv/live/[id]/index.jsx @@ -29,7 +29,7 @@ async function fetchStream(stream_id) { stream = stream[0] } - if (!stream.sources) { + if (!stream.sources || !stream.sources.hls) { return false } @@ -60,6 +60,8 @@ export default class StreamViewer extends React.Component { return false } + console.log(`[TV] Switching decoder to: ${decoder}`) + await this.toggleLoading(true) // check if decoder is already loaded @@ -71,8 +73,6 @@ export default class StreamViewer extends React.Component { this.setState({ decoderInstance: null }) } - console.log(`[TV] Switching decoder to: ${decoder}`) - const decoderInstance = await Decoders[decoder](...args) await this.setState({ @@ -236,8 +236,12 @@ export default class StreamViewer extends React.Component { spectators: stream.viewers, }) - // joinStreamWebsocket - await this.joinStreamWebsocket(stream) + try { + // joinStreamWebsocket + this.joinStreamWebsocket(stream) + } catch (error) { + console.error(error) + } // load decoder with provided data await this.loadDecoder( @@ -260,6 +264,10 @@ export default class StreamViewer extends React.Component { this.state.decoderInstance.destroy() } + if (typeof this.state.decoderInstance?._destroy === "function") { + this.state.decoderInstance._destroy() + } + if (this.state.websocket) { if (typeof this.state.websocket.destroy === "function") { this.state.websocket.destroy() diff --git a/packages/app/src/pages/tv/live/index.less b/packages/app/src/pages/tv/live/[id]/index.less similarity index 100% rename from packages/app/src/pages/tv/live/index.less rename to packages/app/src/pages/tv/live/[id]/index.less diff --git a/packages/app/src/pages/tv/live/decoders/flv.js b/packages/app/src/pages/tv/live/decoders/flv.js deleted file mode 100644 index 89571be0..00000000 --- a/packages/app/src/pages/tv/live/decoders/flv.js +++ /dev/null @@ -1,29 +0,0 @@ -import mpegts from "mpegts.js" - -export default async (player, source, { onSourceEnd } = {}) => { - if (!source) { - console.error("Stream source is not defined") - return false - } - - const decoderInstance = mpegts.createPlayer({ - type: "flv", - isLive: true, - enableWorker: true, - url: source, - }) - - if (typeof onSourceEnd === "function") { - decoderInstance.on(mpegts.Events.ERROR, onSourceEnd) - } - - decoderInstance.attachMediaElement(player) - - decoderInstance.load() - - await decoderInstance.play().catch((error) => { - console.error(error) - }) - - return decoderInstance -} diff --git a/packages/app/src/pages/tv/live/decoders/index.js b/packages/app/src/pages/tv/live/decoders/index.js deleted file mode 100644 index f5d88df4..00000000 --- a/packages/app/src/pages/tv/live/decoders/index.js +++ /dev/null @@ -1,2 +0,0 @@ -export { default as hls } from "./hls" -export { default as flv } from "./flv" diff --git a/packages/app/src/styles/vars.less b/packages/app/src/styles/vars.less index c1c17bc4..2d475a49 100755 --- a/packages/app/src/styles/vars.less +++ b/packages/app/src/styles/vars.less @@ -8,8 +8,12 @@ @transition-ease-inout: all 150ms ease-in-out; @card-drop-shadow: 0 0 5px var(--shadow-color); -@card-shadow: 0 0 0 1px rgba(63, 63, 68, 0.05), 0 1px 3px 0 var(--shadow-color); -@card-shadow-top: 0 -4px 3px 0 rgba(63, 63, 68, 0.05), 0 0 0 2px var(--shadow-color); +@card-shadow: + 0 0 0 1px rgba(63, 63, 68, 0.05), + 0 1px 3px 0 var(--shadow-color); +@card-shadow-top: + 0 -4px 3px 0 rgba(63, 63, 68, 0.05), + 0 0 0 2px var(--shadow-color); @default-object-padding: 10px; @default-gap: 20px; @@ -25,4 +29,6 @@ @bottomBar_iconSize: 45px; @topBar_height: 52px; -@modal_background_blur: 2px; \ No newline at end of file +// Modal +@modal_background_color: rgba(var(--bg_color_1), 0.3); +@modal_background_blur: 4px; diff --git a/packages/server/Dockerfile b/packages/server/Dockerfile index 78c8e9fa..6c001c6f 100755 --- a/packages/server/Dockerfile +++ b/packages/server/Dockerfile @@ -4,13 +4,13 @@ EXPOSE 9000 # Install dependencies RUN apt update RUN apt install -y --no-install-recommends build-essential +RUN apt install -y --no-install-recommends ca-certificates RUN apt install -y --no-install-recommends git RUN apt install -y --no-install-recommends ssh RUN apt install -y --no-install-recommends curl -RUN apt install -y --no-install-recommends nscd RUN apt install -y --no-install-recommends bash RUN apt install -y --no-install-recommends which -RUN apt install -y --no-install-recommends ca-certificates +RUN apt install -y --no-install-recommends nscd # Create workdir RUN mkdir -p /comty-server diff --git a/packages/server/classes/SegmentedAudioMPDJob/index.js b/packages/server/classes/SegmentedAudioMPDJob/index.js index d6e75bc3..bd295747 100644 --- a/packages/server/classes/SegmentedAudioMPDJob/index.js +++ b/packages/server/classes/SegmentedAudioMPDJob/index.js @@ -3,6 +3,10 @@ import path from "node:path" import { FFMPEGLib, Utils } from "../FFMPEGLib" +const codecOverrides = { + wav: "flac", +} + export default class SegmentedAudioMPDJob extends FFMPEGLib { constructor(params = {}) { super() @@ -26,11 +30,11 @@ export default class SegmentedAudioMPDJob extends FFMPEGLib { `-c:a ${this.params.audioCodec}`, `-map 0:a`, `-f dash`, - `-dash_segment_type mp4`, `-segment_time ${this.params.segmentTime}`, `-use_template 1`, `-use_timeline 1`, - `-init_seg_name "init.m4s"`, + //`-dash_segment_type mp4`, + //`-init_seg_name "init.m4s"`, ] if (this.params.includeMetadata === false) { @@ -89,25 +93,69 @@ export default class SegmentedAudioMPDJob extends FFMPEGLib { } } + _updateMpdBandwidthAndSamplingRate = async ({ + mpdPath, + bandwidth, + samplingRate, + } = {}) => { + try { + let mpdContent = await fs.promises.readFile(mpdPath, "utf-8") + + // Regex to find all tags + const representationRegex = /(]*)(>)/g + + mpdContent = mpdContent.replace( + representationRegex, + (match, startTag, endTag) => { + // Remove existing bandwidth and audioSamplingRate attributes if present + let newTag = startTag + .replace(/\sbandwidth="[^"]*"/, "") + .replace(/\saudioSamplingRate="[^"]*"/, "") + + // Add new attributes + newTag += ` bandwidth="${bandwidth}" audioSamplingRate="${samplingRate}"` + + return newTag + endTag + }, + ) + + await fs.promises.writeFile(mpdPath, mpdContent, "utf-8") + } catch (error) { + console.error( + `[SegmentedAudioMPDJob] Error updating MPD bandwidth/audioSamplingRate for ${mpdPath}:`, + error, + ) + } + } + run = async () => { - const segmentationCmd = this.buildSegmentationArgs() const outputPath = this.params.outputDir ?? `${path.dirname(this.params.input)}/dash` const outputFile = path.join(outputPath, this.params.outputMasterName) - this.emit("start", { - input: this.params.input, - output: outputPath, - params: this.params, - }) - - if (!fs.existsSync(outputPath)) { - fs.mkdirSync(outputPath, { recursive: true }) - } - try { + this.emit("start", { + input: this.params.input, + output: outputPath, + params: this.params, + }) + const inputProbe = await Utils.probe(this.params.input) + if ( + this.params.audioCodec === "copy" && + codecOverrides[inputProbe.format.format_name] + ) { + this.params.audioCodec = + codecOverrides[inputProbe.format.format_name] + } + + const segmentationCmd = this.buildSegmentationArgs() + + if (!fs.existsSync(outputPath)) { + fs.mkdirSync(outputPath, { recursive: true }) + } + const ffmpegResult = await this.ffmpeg({ args: segmentationCmd, onProcess: (process) => { @@ -135,6 +183,29 @@ export default class SegmentedAudioMPDJob extends FFMPEGLib { let outputProbe = await Utils.probe(outputFile) + let bandwidth = null + let samplingRate = null + + if ( + outputProbe && + outputProbe.streams && + outputProbe.streams.length > 0 + ) { + bandwidth = + outputProbe.format.bit_rate ?? + outputProbe.streams[0].bit_rate + + samplingRate = outputProbe.streams[0].sample_rate + } + + if (bandwidth && samplingRate) { + await this._updateMpdBandwidthAndSamplingRate({ + mpdPath: outputFile, + bandwidth: bandwidth, + samplingRate: samplingRate, + }) + } + this.emit("end", { probe: { input: inputProbe, diff --git a/packages/server/classes/SyncRoomManager/index.js b/packages/server/classes/SyncRoomManager/index.js new file mode 100644 index 00000000..0e5419d5 --- /dev/null +++ b/packages/server/classes/SyncRoomManager/index.js @@ -0,0 +1,35 @@ +export class SyncRoom { + constructor(ownerSocket) { + this.ownerSocket = ownerSocket + } + + id = global.nanoid() + + buffer = new Set() + members = new Set() + + push = async (data) => { + if (this.buffer.size > 5) { + this.buffer.delete(this.buffer.keys().next().value) + } + + this.buffer.add(data) + + for (const socket of this.members) { + socket.emit(`syncroom:push`, data) + } + } + + join = (socket) => { + this.members.add(socket) + + // send the latest buffer + socket.emit("syncroom.buffer", this.buffer[0]) + } + + leave = (socket) => { + this.members.delete(socket) + } +} + +export default class SyncRoomManager {} diff --git a/packages/server/classes/Transformation/handlers/a-dash.js b/packages/server/classes/Transformation/handlers/a-dash.js index b286bc45..bd5074d0 100644 --- a/packages/server/classes/Transformation/handlers/a-dash.js +++ b/packages/server/classes/Transformation/handlers/a-dash.js @@ -2,7 +2,7 @@ import path from "node:path" import SegmentedAudioMPDJob from "@shared-classes/SegmentedAudioMPDJob" export default async ({ filePath, workPath, onProgress }) => { - return new Promise(async (resolve, reject) => { + return new Promise((resolve) => { const outputDir = path.resolve(workPath, "a-dash") const job = new SegmentedAudioMPDJob({ @@ -10,7 +10,7 @@ export default async ({ filePath, workPath, onProgress }) => { outputDir: outputDir, // set to default as raw flac - audioCodec: "flac", + audioCodec: "copy", audioBitrate: "default", audioSampleRate: "default", }) diff --git a/packages/server/gateway/managers/nginx/index.js b/packages/server/gateway/managers/nginx/index.js index 8681c088..a4a8813f 100755 --- a/packages/server/gateway/managers/nginx/index.js +++ b/packages/server/gateway/managers/nginx/index.js @@ -184,8 +184,13 @@ http { ${this.ssl.cert_file_name ? `ssl_certificate ${this.ssl.cert_file_name};` : ""} ${this.ssl.key_file_name ? `ssl_certificate_key ${this.ssl.key_file_name};` : ""} - # Default route - location / { + + + # Include service-specific configurations + include ${normalizedConfigDir}/services.conf; + + # Default route for / + location = / { add_header Content-Type application/json; add_header 'Access-Control-Allow-Origin' '*' always; add_header 'Access-Control-Allow-Headers' '*' always; @@ -194,8 +199,15 @@ http { return 200 '${mainEndpointJSON}'; } - # Include service-specific configurations - include ${normalizedConfigDir}/services.conf; + # Catch-all for any other unmatched path + location / { + add_header Content-Type application/json; + add_header 'Access-Control-Allow-Origin' '*' always; + add_header 'Access-Control-Allow-Headers' '*' always; + add_header 'Access-Control-Allow-Methods' 'GET,HEAD,PUT,PATCH,POST,DELETE' always; + + return 404 '{"error":"Not found"}'; + } } } ` diff --git a/packages/server/services/music/classes/release/index.js b/packages/server/services/music/classes/release/index.js index 32d1f9e3..027337a9 100644 --- a/packages/server/services/music/classes/release/index.js +++ b/packages/server/services/music/classes/release/index.js @@ -130,7 +130,7 @@ export default class Release { const items = release.items ?? release.list - const items_ids = items.map((item) => item._id.toString()) + const items_ids = items.map((item) => item._id ?? item) // delete all releated tracks await Track.deleteMany({ diff --git a/packages/server/services/music/classes/track/methods/get.js b/packages/server/services/music/classes/track/methods/get.js index e3f327ec..26111c50 100644 --- a/packages/server/services/music/classes/track/methods/get.js +++ b/packages/server/services/music/classes/track/methods/get.js @@ -6,12 +6,12 @@ async function fullfillData(list, { user_id = null }) { list = [list] } - const trackIds = list.map((track) => { - return track._id - }) - // if user_id is provided, fetch likes if (user_id) { + const trackIds = list.map((track) => { + return track._id + }) + const tracksLikes = await Library.isFavorite( user_id, trackIds, @@ -32,21 +32,15 @@ async function fullfillData(list, { user_id = null }) { }) list = await Promise.all(list) + } else { + list = list.map((track) => { + delete track.source + delete track.publisher + + return track + }) } - // process some metadata - list = list.map(async (track) => { - if (track.metadata) { - if (track.metadata.bitrate && track.metadata.bitrate > 9000) { - track.metadata.lossless = true - } - } - - return track - }) - - list = await Promise.all(list) - return list } diff --git a/packages/server/services/music/music.service.js b/packages/server/services/music/music.service.js index 15f1fec2..e2df3f83 100755 --- a/packages/server/services/music/music.service.js +++ b/packages/server/services/music/music.service.js @@ -7,12 +7,20 @@ import RedisClient from "@shared-classes/RedisClient" import SharedMiddlewares from "@shared-middlewares" import LimitsClass from "@shared-classes/Limits" +import InjectedAuth from "@shared-lib/injectedAuth" + export default class API extends Server { static refName = "music" - static useEngine = "hyper-express-ng" - static enableWebsockets = true - static routesPath = `${__dirname}/routes` - static listen_port = process.env.HTTP_LISTEN_PORT ?? 3003 + static listenPort = process.env.HTTP_LISTEN_PORT ?? 3003 + + static websockets = { + enabled: true, + path: "/music", + } + + static bypassCors = true + + static useMiddlewares = ["logs"] middlewares = { ...SharedMiddlewares, @@ -24,9 +32,28 @@ export default class API extends Server { redis: RedisClient(), } + handleWsUpgrade = async (context, token, res) => { + if (!token) { + return res.upgrade(context) + } + + context = await InjectedAuth(context, token, res).catch(() => { + res.close(401, "Failed to verify auth token") + return false + }) + + if (!context || !context.user) { + res.close(401, "Unauthorized or missing auth token") + return false + } + + return res.upgrade(context) + } + async onInitialize() { global.sse = this.contexts.SSEManager global.redis = this.contexts.redis.client + global.syncRoomLyrics = new Map() await this.contexts.db.initialize() await this.contexts.redis.initialize() diff --git a/packages/server/services/music/package.json b/packages/server/services/music/package.json index 07e7f330..0bdd36bb 100755 --- a/packages/server/services/music/package.json +++ b/packages/server/services/music/package.json @@ -1,3 +1,6 @@ { - "name": "music" + "name": "music", + "dependencies": { + "linebridge": "^1.0.0-alpha.4" + } } diff --git a/packages/server/services/music/routes/music/my/library/favorite/get.js b/packages/server/services/music/routes/music/my/library/favorite/get.js index d7037ef8..7cbdfe8b 100644 --- a/packages/server/services/music/routes/music/my/library/favorite/get.js +++ b/packages/server/services/music/routes/music/my/library/favorite/get.js @@ -1,7 +1,7 @@ import Library from "@classes/library" export default { - middlewares: ["withAuthentication"], + useMiddlewares: ["withAuthentication"], fn: async (req) => { const { kind, item_id } = req.query diff --git a/packages/server/services/music/routes/music/my/library/favorite/put.js b/packages/server/services/music/routes/music/my/library/favorite/put.js index 937d84c4..b1a59ac4 100644 --- a/packages/server/services/music/routes/music/my/library/favorite/put.js +++ b/packages/server/services/music/routes/music/my/library/favorite/put.js @@ -1,7 +1,7 @@ import Library from "@classes/library" export default { - middlewares: ["withAuthentication"], + useMiddlewares: ["withAuthentication"], fn: async (req) => { const { kind, item_id, to } = req.body diff --git a/packages/server/services/music/routes/music/my/library/get.js b/packages/server/services/music/routes/music/my/library/get.js index a073ce2f..7c3cd688 100644 --- a/packages/server/services/music/routes/music/my/library/get.js +++ b/packages/server/services/music/routes/music/my/library/get.js @@ -1,7 +1,7 @@ import Library from "@classes/library" export default { - middlewares: ["withAuthentication"], + useMiddlewares: ["withAuthentication"], fn: async (req) => { const userId = req.auth.session.user_id const { limit = 50, offset = 0, kind } = req.query diff --git a/packages/server/services/music/routes/music/my/releases/get.js b/packages/server/services/music/routes/music/my/releases/get.js index 4c696b69..505e09c2 100644 --- a/packages/server/services/music/routes/music/my/releases/get.js +++ b/packages/server/services/music/routes/music/my/releases/get.js @@ -1,7 +1,7 @@ import { MusicRelease, Track } from "@db_models" export default { - middlewares: ["withAuthentication"], + useMiddlewares: ["withAuthentication"], fn: async (req) => { const { keywords, limit = 10, offset = 0 } = req.query diff --git a/packages/server/services/music/routes/music/recently/get.js b/packages/server/services/music/routes/music/recently/get.js index eb7fc0d5..e57b2fe1 100644 --- a/packages/server/services/music/routes/music/recently/get.js +++ b/packages/server/services/music/routes/music/recently/get.js @@ -3,7 +3,7 @@ import { RecentActivity } from "@db_models" import TrackClass from "@classes/track" export default { - middlewares: ["withAuthentication"], + useMiddlewares: ["withAuthentication"], fn: async (req, res) => { const user_id = req.auth.session.user_id diff --git a/packages/server/services/music/routes/music/releases/[release_id]/data/get.js b/packages/server/services/music/routes/music/releases/[release_id]/data/get.js index 4e0caca5..b352dd18 100644 --- a/packages/server/services/music/routes/music/releases/[release_id]/data/get.js +++ b/packages/server/services/music/routes/music/releases/[release_id]/data/get.js @@ -1,7 +1,7 @@ import ReleaseClass from "@classes/release" export default { - middlewares: ["withOptionalAuthentication"], + useMiddlewares: ["withOptionalAuthentication"], fn: async (req) => { const { release_id } = req.params const { limit = 50, offset = 0 } = req.query diff --git a/packages/server/services/music/routes/music/releases/[release_id]/delete.js b/packages/server/services/music/routes/music/releases/[release_id]/delete.js index 30a40b86..37ad34ba 100644 --- a/packages/server/services/music/routes/music/releases/[release_id]/delete.js +++ b/packages/server/services/music/routes/music/releases/[release_id]/delete.js @@ -1,7 +1,7 @@ import ReleaseClass from "@classes/release" export default { - middlewares: ["withAuthentication"], + useMiddlewares: ["withAuthentication"], fn: async (req) => { return await ReleaseClass.delete(req.params.release_id, { user_id: req.auth.session.user_id, diff --git a/packages/server/services/music/routes/music/releases/put.js b/packages/server/services/music/routes/music/releases/put.js index dcb91418..60103835 100644 --- a/packages/server/services/music/routes/music/releases/put.js +++ b/packages/server/services/music/routes/music/releases/put.js @@ -1,18 +1,18 @@ import ReleaseClass from "@classes/release" export default { - middlewares: ["withAuthentication"], - fn: async (req) => { - if (req.body._id) { - return await ReleaseClass.update(req.body._id, { - ...req.body, - user_id: req.auth.session.user_id, - }) - } else { - return await ReleaseClass.create({ - ...req.body, - user_id: req.auth.session.user_id, - }) - } - } -} \ No newline at end of file + useMiddlewares: ["withAuthentication"], + fn: async (req) => { + if (req.body._id) { + return await ReleaseClass.update(req.body._id, { + ...req.body, + user_id: req.auth.session.user_id, + }) + } else { + return await ReleaseClass.create({ + ...req.body, + user_id: req.auth.session.user_id, + }) + } + }, +} diff --git a/packages/server/services/music/routes/music/tracks/[track_id]/data/get.js b/packages/server/services/music/routes/music/tracks/[track_id]/data/get.js index 327a0516..b7b4f6c0 100644 --- a/packages/server/services/music/routes/music/tracks/[track_id]/data/get.js +++ b/packages/server/services/music/routes/music/tracks/[track_id]/data/get.js @@ -1,15 +1,15 @@ import TrackClass from "@classes/track" export default { - middlewares: ["withOptionalAuthentication"], - fn: async (req) => { - const { track_id } = req.params - const user_id = req.auth?.session?.user_id + useMiddlewares: ["withOptionalAuthentication"], + fn: async (req) => { + const { track_id } = req.params + const user_id = req.auth?.session?.user_id - const track = await TrackClass.get(track_id, { - user_id - }) + const track = await TrackClass.get(track_id, { + user_id, + }) - return track - } -} \ No newline at end of file + return track + }, +} diff --git a/packages/server/services/music/routes/music/tracks/[track_id]/delete.js b/packages/server/services/music/routes/music/tracks/[track_id]/delete.js index 5c14506f..9102e572 100644 --- a/packages/server/services/music/routes/music/tracks/[track_id]/delete.js +++ b/packages/server/services/music/routes/music/tracks/[track_id]/delete.js @@ -1,21 +1,21 @@ import TrackClass from "@classes/track" export default { - middlewares: ["withAuthentication"], - fn: async (req) => { - const { track_id } = req.params + useMiddlewares: ["withAuthentication"], + fn: async (req) => { + const { track_id } = req.params - const track = await TrackClass.get(track_id) + const track = await TrackClass.get(track_id) - if (track.publisher.user_id !== req.auth.session.user_id) { - throw new Error("Forbidden, you don't own this track") - } + if (track.publisher.user_id !== req.auth.session.user_id) { + throw new Error("Forbidden, you don't own this track") + } - await TrackClass.delete(track_id) + await TrackClass.delete(track_id) - return { - success: true, - track: track, - } - } -} \ No newline at end of file + return { + success: true, + track: track, + } + }, +} diff --git a/packages/server/services/music/routes/music/tracks/[track_id]/lyrics/put.js b/packages/server/services/music/routes/music/tracks/[track_id]/lyrics/put.js index 97288a21..2e917b84 100644 --- a/packages/server/services/music/routes/music/tracks/[track_id]/lyrics/put.js +++ b/packages/server/services/music/routes/music/tracks/[track_id]/lyrics/put.js @@ -1,66 +1,66 @@ import { TrackLyric, Track } from "@db_models" export default { - middlewares: ["withAuthentication"], - fn: async (req) => { - const { track_id } = req.params - const { video_source, lrc, sync_audio_at } = req.body + useMiddlewares: ["withAuthentication"], + fn: async (req) => { + const { track_id } = req.params + const { video_source, lrc, sync_audio_at } = req.body - // check if track exists - let track = await Track.findById(track_id).catch(() => null) + // check if track exists + let track = await Track.findById(track_id).catch(() => null) - if (!track) { - throw new OperationError(404, "Track not found") - } + if (!track) { + throw new OperationError(404, "Track not found") + } - if (track.publisher.user_id !== req.auth.session.user_id) { - throw new OperationError(403, "Unauthorized") - } + if (track.publisher.user_id !== req.auth.session.user_id) { + throw new OperationError(403, "Unauthorized") + } - console.log(`Setting lyrics for track ${track_id} >`, { - track_id: track_id, - video_source: video_source, - lrc: lrc, - }) + console.log(`Setting lyrics for track ${track_id} >`, { + track_id: track_id, + video_source: video_source, + lrc: lrc, + }) - // check if trackLyric exists - let trackLyric = await TrackLyric.findOne({ - track_id: track_id - }) + // check if trackLyric exists + let trackLyric = await TrackLyric.findOne({ + track_id: track_id, + }) - // if trackLyric exists, update it, else create it - if (!trackLyric) { - trackLyric = new TrackLyric({ - track_id: track_id, - video_source: video_source, - lrc: lrc, - sync_audio_at: sync_audio_at, - }) + // if trackLyric exists, update it, else create it + if (!trackLyric) { + trackLyric = new TrackLyric({ + track_id: track_id, + video_source: video_source, + lrc: lrc, + sync_audio_at: sync_audio_at, + }) - await trackLyric.save() - } else { - const update = Object() + await trackLyric.save() + } else { + const update = Object() - if (typeof video_source !== "undefined") { - update.video_source = video_source - } + if (typeof video_source !== "undefined") { + update.video_source = video_source + } - if (typeof lrc !== "undefined") { - update.lrc = lrc - } + if (typeof lrc !== "undefined") { + update.lrc = lrc + } - if (typeof sync_audio_at !== "undefined") { - update.sync_audio_at = sync_audio_at - } + if (typeof sync_audio_at !== "undefined") { + update.sync_audio_at = sync_audio_at + } - trackLyric = await TrackLyric.findOneAndUpdate( - { - track_id: track_id, - }, - update, - ) - } + trackLyric = await TrackLyric.findOneAndUpdate( + { + track_id: track_id, + }, + update, + ) + } - return trackLyric - } -} \ No newline at end of file + return trackLyric + }, +} diff --git a/packages/server/services/music/routes/music/tracks/[track_id]/override/put.js b/packages/server/services/music/routes/music/tracks/[track_id]/override/put.js index 7f22fe7b..c30e53e4 100644 --- a/packages/server/services/music/routes/music/tracks/[track_id]/override/put.js +++ b/packages/server/services/music/routes/music/tracks/[track_id]/override/put.js @@ -1,36 +1,36 @@ import { TrackOverride } from "@db_models" export default { - middlewares: ["withAuthentication", "onlyAdmin"], - fn: async (req) => { - const { track_id } = req.params - const { service, override } = req.body + useMiddlewares: ["withAuthentication", "onlyAdmin"], + fn: async (req) => { + const { track_id } = req.params + const { service, override } = req.body - let trackOverride = await TrackOverride.findOne({ - track_id: track_id, - service: service, - }).catch(() => null) + let trackOverride = await TrackOverride.findOne({ + track_id: track_id, + service: service, + }).catch(() => null) - if (!trackOverride) { - trackOverride = new TrackOverride({ - track_id: track_id, - service: service, - override: override, - }) + if (!trackOverride) { + trackOverride = new TrackOverride({ + track_id: track_id, + service: service, + override: override, + }) - await trackOverride.save() - } else { - trackOverride = await TrackOverride.findOneAndUpdate( - { - track_id: track_id, - service: service, - }, - { - override: override, - }, - ) - } + await trackOverride.save() + } else { + trackOverride = await TrackOverride.findOneAndUpdate( + { + track_id: track_id, + service: service, + }, + { + override: override, + }, + ) + } - return trackOverride.override - } -} \ No newline at end of file + return trackOverride.override + }, +} diff --git a/packages/server/services/music/routes/music/tracks/get.js b/packages/server/services/music/routes/music/tracks/get.js index 3dda149a..c51d2b82 100644 --- a/packages/server/services/music/routes/music/tracks/get.js +++ b/packages/server/services/music/routes/music/tracks/get.js @@ -19,6 +19,7 @@ export default async (req) => { const items = await Track.find(query) .limit(limit) + .select("-source -publisher -public") .skip(trim) .sort({ _id: -1 }) diff --git a/packages/server/services/music/routes/music/tracks/put.js b/packages/server/services/music/routes/music/tracks/put.js index 415bb16a..f35a5879 100644 --- a/packages/server/services/music/routes/music/tracks/put.js +++ b/packages/server/services/music/routes/music/tracks/put.js @@ -2,7 +2,7 @@ import requiredFields from "@shared-utils/requiredFields" import TrackClass from "@classes/track" export default { - middlewares: ["withAuthentication"], + useMiddlewares: ["withAuthentication"], fn: async (req) => { if (Array.isArray(req.body.items)) { let results = [] diff --git a/packages/server/services/music/ws_routes/sync_room/join.js b/packages/server/services/music/ws_routes/sync_room/join.js new file mode 100644 index 00000000..a3a09f8b --- /dev/null +++ b/packages/server/services/music/ws_routes/sync_room/join.js @@ -0,0 +1,13 @@ +import leave from "./leave" + +export default async (client, user_id) => { + console.log(`[SYNC-ROOM] Join ${client.userId} -> ${user_id}`) + + if (client.syncroom) { + await leave(client, client.syncroom) + } + + // subscribe to stream topic + await client.subscribe(`syncroom/${user_id}`) + client.syncroom = user_id +} diff --git a/packages/server/services/music/ws_routes/sync_room/leave.js b/packages/server/services/music/ws_routes/sync_room/leave.js new file mode 100644 index 00000000..fa0dbfab --- /dev/null +++ b/packages/server/services/music/ws_routes/sync_room/leave.js @@ -0,0 +1,8 @@ +export default async (client, user_id) => { + console.log(`[SYNC-ROOM] Leave ${client.userId} -> ${user_id}`) + + // unsubscribe from sync topic + await client.unsubscribe(`syncroom/${user_id}`) + + client.syncroom = null +} diff --git a/packages/server/services/music/ws_routes/sync_room/push.js b/packages/server/services/music/ws_routes/sync_room/push.js new file mode 100644 index 00000000..e9f52f08 --- /dev/null +++ b/packages/server/services/music/ws_routes/sync_room/push.js @@ -0,0 +1,7 @@ +export default async (client, payload) => { + console.log(`[SYNC-ROOM] Pushing to sync ${client.userId}`, payload) + + const roomId = `syncroom/${client.userId}` + + global.websockets.senders.toTopic(roomId, "sync:receive", payload) +} diff --git a/packages/server/services/music/ws_routes/sync_room/push_lyrics.js b/packages/server/services/music/ws_routes/sync_room/push_lyrics.js new file mode 100644 index 00000000..47bdf09c --- /dev/null +++ b/packages/server/services/music/ws_routes/sync_room/push_lyrics.js @@ -0,0 +1,14 @@ +export default async (client, payload) => { + console.log(`[SYNC-ROOM] Pushing lyrics to sync ${client.userId}`) + + const roomId = `syncroom/${client.userId}` + + if (!payload) { + // delete lyrics + global.syncRoomLyrics.delete(client.userId) + } else { + global.syncRoomLyrics.set(client.userId, payload) + } + + global.websockets.senders.toTopic(roomId, "sync:lyrics:receive", payload) +} diff --git a/packages/server/services/music/ws_routes/sync_room/request_lyrics.js b/packages/server/services/music/ws_routes/sync_room/request_lyrics.js new file mode 100644 index 00000000..1b51b7de --- /dev/null +++ b/packages/server/services/music/ws_routes/sync_room/request_lyrics.js @@ -0,0 +1,5 @@ +export default async (client) => { + console.log(`[SYNC-ROOM] Requesting lyrics of room ${client.syncroom}`) + + return global.syncRoomLyrics.get(client.syncroom) +} diff --git a/packages/server/services/posts/routes/posts/[post_id]/data/get.js b/packages/server/services/posts/routes/posts/[post_id]/data/get.js index 43e6e4bd..00c4cdda 100644 --- a/packages/server/services/posts/routes/posts/[post_id]/data/get.js +++ b/packages/server/services/posts/routes/posts/[post_id]/data/get.js @@ -2,7 +2,7 @@ import Posts from "@classes/posts" export default { useMiddlewares: ["withOptionalAuthentication"], - fn: async (req, res) => { + fn: async (req) => { const result = await Posts.data({ post_id: req.params.post_id, for_user_id: req.auth?.session?.user_id, diff --git a/packages/server/services/search/collectors/tracks.js b/packages/server/services/search/collectors/tracks.js index dd499f13..add06daa 100644 --- a/packages/server/services/search/collectors/tracks.js +++ b/packages/server/services/search/collectors/tracks.js @@ -5,6 +5,7 @@ export default { model: Track, query: (keywords) => { return { + public: true, $or: [ { title: new RegExp(keywords, "i"), diff --git a/vessel b/vessel index 8052a779..e1d44d19 160000 --- a/vessel +++ b/vessel @@ -1 +1 @@ -Subproject commit 8052a779936249cdc9f9f7379e8940f80b17cc22 +Subproject commit e1d44d19e6270c8ed9cb63a041e0fac8c49b988f