Compare commits

..
55 Commits
Author SHA1 Message Date
Arthur Jamet e7e89a4baf Allow library path to be ‘./‘ (#937) 2025-03-06 07:26:37 +00:00
Arthur Jamet 315807aca2 Add BPM (#936)
* Scanner: Parse BPM

* Scanner: Add BPM to Registration DTO

* Server: Persist BPM

* API: Sort Songs by BPM

* Front: Show BPM in Track Info Modal

* Front: Make Info Icon look like an info icon

* Front: Song Contextual Menu: add link to song info page

* Scanner: Fix tests
2025-03-06 06:49:55 +00:00
Arthur Jamet 7817a871bf Front: Fix Validation of nullable release dates (#934) 2025-03-04 18:41:15 +00:00
Arthur Jamet fb22c1cee7 Front: Fix Filtering of videos by albums (#933) 2025-03-04 08:35:21 +00:00
Arthur Jamet 961a9f0036 Front: Smooth Gradient Background Transition (#932)
* Front: Use Atom, Fix Gradient Background Flash

* Front: Smooth Background Gradient Transition
2025-03-04 06:47:26 +00:00
Arthur Jamet 81f4f3aecb Front: Use Atom, Fix Gradient Background Flash 2025-03-03 15:25:03 +00:00
Arthur Jamet f2eabb94c8 Scanner: Add filter to deinterlace thumbnails (#930) 2025-03-02 12:25:08 +00:00
Arthur Jamet 1ba71dbf15 Front: Ensure type-safe dates (#928)
Rehydrated dates are string, not proper Date objects.
We ensure on the type level that dates are either strings or Dates
2025-03-02 11:51:31 +00:00
Arthur Jamet a29cf5f7da Front: Empty States 2025-03-02 09:57:08 +00:00
Arthur Jamet 95bf08c3fb Front: Empty States 2025-03-02 09:57:08 +00:00
Arthur Jamet cde9df4d81 Front: Stop playback on logout 2025-03-02 09:57:08 +00:00
Arthur Jamet 30d3f1600f Server: 'Filter' API (#926) 2025-02-28 20:17:29 +00:00
Arthur Jamet c2d28d1dc8 CI: Use Composite Action for dockerization (#925) 2025-02-25 18:27:50 +00:00
Arthur Jamet 8387805f86 CI: Matcher: Fix 'needs' for dockerisation (#924) 2025-02-25 18:07:17 +00:00
Arthur Jamet c555b0063f CI: Composite Action for Dockerisation + use it for matcher (#923) 2025-02-25 17:52:03 +00:00
Arthur Jamet c741a6122a Front: Normalize color for CSS 2025-02-25 12:20:36 +00:00
Arthur Jamet c1c474e2e8 CI: Cache Dockerisation (#921)
* CI: Server: Cache Dockerisation

* CI: Front, Scanner, Matcher: Cache Dockerisation
2025-02-25 11:54:49 +00:00
Arthur Jamet 9da35dc7ff CI: Fix Cache hit (#920)
* CI: Fix Cache hit for Server

* CI: Fix Cache hit for Front

* CI: Setup cache for Matcher
2025-02-24 19:49:06 +00:00
Arthur Jamet 640653bed3 Front: Release Page shows month of release 2025-02-24 17:47:56 +00:00
Arthur Jamet 93662b802b Server: Bump Nestjs CLI 2025-02-24 08:32:41 +00:00
Arthur Jamet 549eff117b CI: Build docker with multiple platforms (#916) 2025-02-23 19:40:00 +00:00
Arthur Jamet 12dea95ccb Revert "CI: Docker: Build Docker Images for ARM"
This reverts commit 945188928e.
2025-02-23 17:42:47 +00:00
Arthur Jamet 945188928e CI: Docker: Build Docker Images for ARM 2025-02-23 17:25:03 +00:00
Arthur Jamet 82b169718f Front: Save Queue as Playlist 2025-02-22 14:12:41 +00:00
Arthur Jamet becfd85476 Front: Move User Atoms to State folder 2025-02-20 08:28:17 +00:00
Arthur Jamet 05cb71f3e3 Front: Player State is atom 2025-02-20 08:28:17 +00:00
Arthur Jamet 55a929dee8 Front: Remove Redux for user state (#905)
* Front: Remove Redux, use context provider for access token (WIP)

* Front: Set Access Token on SSR and CSR
2025-02-18 18:38:21 +00:00
Arthur Jamet e7d7d20997 Front: Add button to delete track from queue (#900)
* Front: Add 'New' Playlist button to 'Add to playlist' dialog

* Front: Lint

* Front: Add button to delete track from queue
2025-02-11 19:18:15 +00:00
Arthur Jamet 4d09f9e519 Front: Add 'New' Playlist button to 'Add to playlist' dialog (#899)
* Front: Add 'New' Playlist button to 'Add to playlist' dialog

* Front: Lint
2025-02-11 19:13:13 +00:00
Arthur Jamet 103725bc15 CI: Use new Sonarcloud action 2025-02-11 20:06:28 +01:00
Arthur Jamet 340a17165c Change Grid's Items size (#898)
* Front: Infinite Scroll Controlls: add button to change grid item size

* Front: Define Default Item Size

* Front: Lint

* Front: Grid Resize: Avoid rerender

* Front: Use Default Item Size constant instead of magic string

* Front: Add Add/Minus Icon
2025-02-11 14:06:25 +00:00
Arthur Jamet f58c3b1081 Server: Sorting: Define fallback behaviour when sort on same value (#891) 2025-02-09 19:22:53 +00:00
Arthur Jamet a0623fdeff Scanner: Split Embedded genres (#890) 2025-02-09 16:25:58 +00:00
Arthur Jamet ba16ff1c14 Scanner: Case-insensitive tags from ffprobe (#889) 2025-02-09 15:19:06 +00:00
Arthur Jamet 788a86331d Server: Simplify env setup for tests 2025-02-09 10:47:00 +01:00
Arthur Jamet 568e8996d4 Docker Compose: Use Docker hub + env var for tags (#883) 2025-02-08 21:01:15 +01:00
Arthur Jamet 4f232ac245 README: Add link to discussion (#880)
* README: Add link to discussion

* Update README.md
2025-02-08 17:17:55 +00:00
Arthur Jamet 8ce73e9920 Front: Add 'Compilations' page 2025-02-06 08:40:03 +00:00
Arthur Jamet a45d42bb95 Front: Alway use icon as illustration fallback + add skeleton for background 2025-02-05 14:13:23 +00:00
Arthur Jamet 1258de0571 Server: Parser Service: Add 'EPK' keyword for interview videos 2025-02-05 11:53:23 +00:00
Arthur Jamet 6f0a651b53 Front: Infinite Query: Return concatenated list of all items 2025-02-05 11:35:04 +00:00
Arthur Jamet 8c0f341632 Front: Remove expandable button for resource description 2025-02-04 19:38:34 +00:00
Arthur Jamet b122a48cba Matcher: Wikidata: Use v1 API 2025-02-04 13:14:40 +00:00
Arthur Jamet a7eb3514fc Matcher: Genius: Fix selector on Lyrics 2025-02-04 13:14:40 +00:00
Arthur Jamet 54514bb178 Front: Settings: Show Task progress (#867) 2025-02-03 15:12:50 +00:00
Arthur Jamet 693f617957 Scanner: Task Progress (#866)
* Scanner: Add progress value in worker

* Scanner: add progress in /tasks response + set progress for scan task

* Scanner: set progress for refresh task

* Scanner: set progress for cleaning task
2025-02-03 13:29:12 +00:00
Arthur Jamet a3fa306ac1 Docs: Remove mkdoc, use GitHub Wiki 2025-02-02 10:14:46 +00:00
Arthur Jamet a7162352ec Front: Make modal open on library metadata refresh 2025-02-01 19:22:13 +00:00
Arthur Jamet 51de0d1a03 Scanner: Use ID3 Compilation tag 2025-02-01 19:18:26 +00:00
Arthur Jamet 43f5d9f973 Scanner: POST embedded lyrics 2025-02-01 13:27:21 +00:00
Arthur Jamet a1e1060ae3 Front: Format 2025-02-01 10:55:09 +00:00
Arthur Jamet 3063fad0d2 Front: Add translation 2025-02-01 10:55:09 +00:00
Arthur Jamet 4ad434a71c Front: Refresh Metadata Action: add checkbox to force refresh 2025-02-01 10:55:09 +00:00
Arthur Jamet 8be291d9d2 Front: Bump type-fest 2025-02-01 10:55:09 +00:00
Arthur Jamet 9b07d2ecbc Scanner: Add force parameter to force metadata refresh 2025-02-01 10:55:09 +00:00
193 changed files with 3480 additions and 2474 deletions

No files matched your search

+8 -1
View File
@@ -1,3 +1,10 @@
#################### Version
# The version of meelo to use (edge, latest, vX.X.X)
# See available tags here: https://hub.docker.com/r/arthichaud/meelo-front/tags
# The default value is 'latest'
# Please read the changelog before changing this value
# https://github.com/Arthi-chaud/Meelo/releases
TAG=
#################### Database
# The port on the host where meelo will be accessible
PORT=
@@ -36,4 +43,4 @@ API_KEYS=
#################### Internal
# Do not change this
INTERNAL_DATA_DIR=/data
INTERNAL_CONFIG_DIR=/config
INTERNAL_CONFIG_DIR=/config
+2 -2
View File
@@ -12,5 +12,5 @@ runs:
path: |
./front/node_modules
./front/.next
key: front
restore-keys: front
key: front-${{ hashFiles('front/yarn.lock') }}
restore-keys: front-
+12 -1
View File
@@ -3,7 +3,18 @@ description: 'Cache for Matcher'
runs:
using: "composite"
steps:
- run: apk add --update --no-progress lsb-release python3 py3-pip
- run: apk add --update --no-progress lsb-release python3 py3-pip tar
shell: sh
- run: mkdir ./pip-cache
shell: sh
- name: Cache
uses: actions/cache@v3
with:
path: |
./pip-cache
key: matcher-${{ hashFiles('matcher/requirements.txt') }}
restore-keys: matcher-
- run: pip3 install -r matcher/requirements.txt
shell: sh
env:
PIP_CACHE_DIR: ./pip-cache
+2 -2
View File
@@ -12,5 +12,5 @@ runs:
path: |
./server/node_modules
./server/dist
key: server
restore-keys: server
key: server-${{ hashFiles('server/yarn.lock') }}
restore-keys: server-
+59
View File
@@ -0,0 +1,59 @@
name: 'Dockerisation'
description: 'Build using Docker, and publish if necessary'
inputs:
docker-username:
description: 'Username to publish Docker image under'
required: true
docker-token:
description: 'Token to publish Docker image'
required: true
github-token:
description: 'GitHub Token'
required: true
image-suffix:
description: "Suffix to 'meelo-'"
required: true
publish:
description: 'Publish to registry'
default: 'false'
context:
description: 'Directory of the Dockerfile'
required: true
runs:
using: "composite"
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ inputs.docker-username }}
password: ${{ inputs.docker-token }}
- name: Log in to the GitHub Container registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ inputs.github-token }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v4
with:
images: |
arthichaud/meelo-${{ inputs.image-suffix }}
ghcr.io/${{ github.repository }}-${{ inputs.image-suffix }}
- name: Build and push Docker images
uses: docker/build-push-action@v3
with:
context: ${{ inputs.context }}
platforms: linux/amd64,linux/arm64
push: ${{ inputs.publish == 'true' || inputs.publish == true }}
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || format('arthichaud/meelo-{0}:edge', inputs.image-suffix) }}
labels: ${{ steps.meta.outputs.labels }}
-40
View File
@@ -1,40 +0,0 @@
name: Documentation
on:
push:
branches: [ main ]
permissions:
contents: write
jobs:
Changes:
runs-on: ubuntu-latest
permissions:
pull-requests: read
outputs:
docs: ${{ steps.filter.outputs.docs }}
steps:
- name: Check out the repo
uses: actions/checkout@v3
- uses: dorny/paths-filter@v2
id: filter
with:
filters: |
docs:
- 'docs/**'
- '.github/workflows/documentation.yml'
- 'assets/**'
Build:
needs: changes
name: Deploy
runs-on: ubuntu-latest
if: ${{ needs.changes.outputs.docs == 'true' }}
steps:
- name: Checkout main
uses: actions/checkout@v3
- name: Deploy docs
uses: mhausenblas/mkdocs-deploy-gh-pages@master
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CONFIG_FILE: docs/mkdocs.yml
REQUIREMENTS: docs/requirements.txt
+10 -52
View File
@@ -28,6 +28,7 @@ jobs:
front:
- 'front/**'
- '.github/workflows/front.yml'
- '.github/actions/cache-front/**'
Build:
needs: changes
if: ${{ needs.changes.outputs.front == 'true' && github.event_name == 'pull_request' }}
@@ -92,70 +93,27 @@ jobs:
name: coverage-report-front
path: ./front/coverage
- name: Run SonarCloud Scan
uses: SonarSource/sonarcloud-github-action@master
uses: SonarSource/sonarqube-scan-action@master
with:
projectBaseDir: ./front
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_FRONT }}
Dockerize:
if: ${{ needs.changes.outputs.front == 'true' && github.event_name == 'pull_request' }}
needs: [ Build ]
runs-on: ubuntu-latest
permissions:
packages: read
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to the GitHub Container registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker
uses: docker/build-push-action@v3
with:
context: ./front
push: false
Publish:
needs: changes
runs-on: ubuntu-latest
if: ${{ github.event_name == 'release' || (needs.changes.outputs.front == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
if: ${{ github.event_name == 'release' || (needs.changes.outputs.front == 'true' && (github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main'))) }}
permissions:
packages: write
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to the GitHub Container registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v4
with:
images: |
arthichaud/meelo-front
ghcr.io/${{ github.repository }}-front
- name: Build and push Docker images
uses: docker/build-push-action@v3
with:
- uses: ./.github/actions/dockerize
with:
docker-username: ${{ secrets.DOCKERHUB_USERNAME }}
docker-token: ${{ secrets.DOCKERHUB_TOKEN }}
github-token: ${{ secrets.GITHUB_TOKEN }}
context: ./front
push: true
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || 'arthichaud/meelo-front:edge' }}
labels: ${{ steps.meta.outputs.labels }}
image-suffix: front
publish: ${{ github.event_name == 'release' || (needs.changes.outputs.front == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
+10 -51
View File
@@ -28,6 +28,7 @@ jobs:
matcher:
- 'matcher/**'
- '.github/workflows/matcher.yml'
- '.github/actions/cache-matcher/**'
Typecheck:
needs: changes
if: ${{ needs.changes.outputs.matcher == 'true' && github.event_name == 'pull_request' }}
@@ -69,63 +70,21 @@ jobs:
uses: ./.github/actions/cache-matcher
- run: python3 -m tests
Dockerize:
if: ${{ needs.changes.outputs.matcher == 'true' && github.event_name == 'pull_request' }}
needs: [ Typecheck ]
runs-on: ubuntu-latest
permissions:
packages: read
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to the GitHub Container registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Typecheck Docker
uses: docker/build-push-action@v3
with:
context: ./matcher
push: false
Publish:
needs: changes
runs-on: ubuntu-latest
if: ${{ github.event_name == 'release' || (needs.changes.outputs.matcher == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
if: ${{ github.event_name == 'release' || (needs.changes.outputs.matcher == 'true' && (github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main'))) }}
permissions:
packages: write
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to the GitHub Container registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v4
with:
images: |
arthichaud/meelo-matcher
ghcr.io/${{ github.repository }}-matcher
- name: Typecheck and push Docker images
uses: docker/build-push-action@v3
with:
- uses: ./.github/actions/dockerize
with:
docker-username: ${{ secrets.DOCKERHUB_USERNAME }}
docker-token: ${{ secrets.DOCKERHUB_TOKEN }}
github-token: ${{ secrets.GITHUB_TOKEN }}
context: ./matcher
push: true
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || 'arthichaud/meelo-matcher:edge' }}
labels: ${{ steps.meta.outputs.labels }}
image-suffix: matcher
publish: ${{ github.event_name == 'release' || (needs.changes.outputs.matcher == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
+10 -52
View File
@@ -86,70 +86,28 @@ jobs:
name: coverage-report-scanner
path: ./scanner
- name: Run SonarCloud Scan
uses: SonarSource/sonarcloud-github-action@master
uses: SonarSource/sonarqube-scan-action@master
with:
projectBaseDir: ./scanner
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_SCANNER }}
Dockerize:
if: ${{ needs.changes.outputs.scanner == 'true' && github.event_name == 'pull_request' }}
needs: [ Build ]
runs-on: ubuntu-latest
permissions:
packages: read
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to the GitHub Container registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker
uses: docker/build-push-action@v3
with:
context: ./scanner
push: false
Publish:
needs: changes
runs-on: ubuntu-latest
if: ${{ github.event_name == 'release' || (needs.changes.outputs.scanner == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
if: ${{ github.event_name == 'release' || (needs.changes.outputs.scanner == 'true' && (github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main'))) }}
permissions:
packages: write
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to the GitHub Container registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v4
with:
images: |
arthichaud/meelo-scanner
ghcr.io/${{ github.repository }}-scanner
- name: Build and push Docker images
uses: docker/build-push-action@v3
with:
- uses: ./.github/actions/dockerize
with:
docker-username: ${{ secrets.DOCKERHUB_USERNAME }}
docker-token: ${{ secrets.DOCKERHUB_TOKEN }}
github-token: ${{ secrets.GITHUB_TOKEN }}
context: ./scanner
push: true
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || 'arthichaud/meelo-scanner:edge' }}
labels: ${{ steps.meta.outputs.labels }}
image-suffix: scanner
publish: ${{ github.event_name == 'release' || (needs.changes.outputs.scanner == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
+10 -52
View File
@@ -28,6 +28,7 @@ jobs:
server:
- 'server/**'
- '.github/workflows/server.yml'
- '.github/actions/cache-server/**'
Build:
needs: changes
if: ${{ needs.changes.outputs.server == 'true' && github.event_name == 'pull_request' }}
@@ -121,70 +122,27 @@ jobs:
name: coverage-report-server
path: ./server/coverage
- name: Run SonarCloud Scan
uses: SonarSource/sonarcloud-github-action@master
uses: SonarSource/sonarqube-scan-action@master
with:
projectBaseDir: ./server
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_BACK }}
Dockerize:
if: ${{ needs.changes.outputs.server == 'true' && github.event_name == 'pull_request' }}
needs: [ Build ]
runs-on: ubuntu-latest
permissions:
packages: read
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to the GitHub Container registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker
uses: docker/build-push-action@v3
with:
context: ./server
push: false
Publish:
needs: changes
runs-on: ubuntu-latest
if: ${{ github.event_name == 'release' || (needs.changes.outputs.server == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
if: ${{ github.event_name == 'release' || (needs.changes.outputs.server == 'true' && (github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main'))) }}
permissions:
packages: write
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to the GitHub Container registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v4
with:
images: |
arthichaud/meelo-server
ghcr.io/${{ github.repository }}-server
- name: Build and push Docker images
uses: docker/build-push-action@v3
with:
- uses: ./.github/actions/dockerize
with:
docker-username: ${{ secrets.DOCKERHUB_USERNAME }}
docker-token: ${{ secrets.DOCKERHUB_TOKEN }}
github-token: ${{ secrets.GITHUB_TOKEN }}
context: ./server
push: true
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || 'arthichaud/meelo-server:edge' }}
labels: ${{ steps.meta.outputs.labels }}
image-suffix: server
publish: ${{ github.event_name == 'release' || (needs.changes.outputs.server == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
+5 -3
View File
@@ -46,7 +46,7 @@ Keep track of the upcoming features [here](https://github.com/Arthi-chaud/Meelo/
To use Meelo, you'll need a 'clean' collection: either with embedded metadata or standard file/folder architecture. Using iTunes or Beets is a great way to start.
Meelo is shipped though Docker images. You might need to know a bit about Regexes.
Look over [here](https://arthi-chaud.github.io/Meelo/) for setup instructions.
Look over [here](https://github.com/Arthi-chaud/Meelo/wiki) for setup instructions.
## :desktop_computer: Live Demo
@@ -72,12 +72,14 @@ More screenshots [here](./assets/examples/)
## :handshake: Contributing and Bugs reporting
:hammer: Would you like to contribute to Meelo? Feel free to [open a pull request](https://github.com/Arthi-chaud/Meelo/compare).
:thinking: Is there a feature you would like to have? [Open an issue](https://github.com/Arthi-chaud/Meelo/issues/new/choose), and we will be happy to discuss it!
:wrench: Need help with the setup process? Open a discussion [here](https://github.com/Arthi-chaud/Meelo/discussions/categories/need-help-with-setting-up).
:bug: Have you encountered a bug? Ugh we don't like 'em here! Report it by [opening an issue](https://github.com/Arthi-chaud/Meelo/issues/new/choose).
:hammer: Would you like to contribute to Meelo? Feel free to [open a pull request](https://github.com/Arthi-chaud/Meelo/compare).
---
Get ready to make the most out of you music collection!
+4 -4
View File
@@ -1,7 +1,7 @@
# This compose allows to setup the whole project in the production environment
services:
server:
image: ghcr.io/arthi-chaud/meelo-server:latest
image: arthichaud/meelo-server:${TAG:-latest}
expose:
- 4000
restart: on-failure
@@ -28,7 +28,7 @@ services:
timeout: 5s
retries: 5
scanner:
image: ghcr.io/arthi-chaud/meelo-scanner:latest
image: arthichaud/meelo-scanner:${TAG:-latest}
expose:
- 8133
depends_on:
@@ -48,7 +48,7 @@ services:
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}:ro
front:
image: ghcr.io/arthi-chaud/meelo-front:latest
image: arthichaud/meelo-front:${TAG:-latest}
expose:
- 3000
depends_on:
@@ -62,7 +62,7 @@ services:
- PUBLIC_SCANNER_URL=${PUBLIC_URL}/scanner
- SSR_SCANNER_URL=http://scanner:8133
matcher:
image: ghcr.io/arthi-chaud/meelo-matcher:latest
image: arthichaud/meelo-matcher:${TAG:-latest}
restart: on-failure
depends_on:
server:
-1
View File
@@ -1 +0,0 @@
site/
-45
View File
@@ -1,45 +0,0 @@
site_name: Meelo - User Documentation
docs_dir: './src'
repo_url: https://github.com/Arthi-chaud/Meelo
nav:
- Setup:
- TLDR: setup/tldr.md
- Requirements: setup/requirements.md
- Prepare Music Library: setup/prepare-music-library.md
- Create Configuration Folder: setup/create-conf-folder.md
- Prepare Your Settings: setup/settings.md
- Configure Providers: setup/providers.md
- Environment Variables: setup/env.md
- Docker: setup/docker.md
- Start: setup/startup.md
- Create First Library: setup/create-library.md
- Album Types: album-types.md
- Song Types: song-types.md
- Featuring Artists: featuring-artists.md
theme:
name: material
language: en
palette:
scheme: default
titles_only: true
icon:
repo: fontawesome/brands/github
font:
text: Noto Sans
logo: ./icon.png
favicon: ./favicon.ico
features:
- navigation.instant
- navigation.tracking
- navigation.tabs
- navigation.tabs.sticky
- navigation.sections
- navigation.indexes
- toc.integrate
- navigation.top
extra_css:
- stylesheets/extra.css
plugins:
- search
markdown_extensions:
- mdx_truly_sane_lists
-2
View File
@@ -1,2 +0,0 @@
mkdocs-material
mdx_truly_sane_lists
-42
View File
@@ -1,42 +0,0 @@
# Meelo's Album Types
In Music Apps, we usually call any song container an *Album*. While this is technically incorrect, many music streaming services categorizes these *Albums* in sub-categories: Albums, Singles, Live, Compilations, etc.
## Available Types
Meelo supports this categorization, and the following album types:
- Studio Recording
- What we would usually call *Albums*
- Live Recording
- Compilation
- Single
- Soundtrack
- Remix Album
- Collections that would mainly have remixes of songs by a single artist, while not being a Single
- Video Album
- Any kind of collection that would only contain video tracks
## Type recognition
Meelo tries to be as independent of user interactions as possible. Therefore, type recognition is done automatically, when a new album is registered.
For now, Type recognition is solely based on the Album's title. Keywords to recognize an album's type were inspired by the way other music streaming services would categorize an album. Therefore, if your music collection's naming convention work a similar way to Spotify, Apple Music, ..., there might not be a lot to change if you want to fully enjoy this feature. However, if you consider that one of the following ways to recognize a type is incomplete or incorrect, feel free to [open an issue](https://github.com/Arthi-chaud/Meelo/issues).
### Keywords
| Album Type | Keywords |
|---|---|
| Studio Album | See note below |
| Compilations | - `best of`<br>- `hits`<br>- `greatest hits`<br>- `singles`<br>- `collection`<br>+ See note below |
| Live Recording | - '` tour`' (Notice the leading whitespace)<br>- `live from`<br>- `live at`<br>- `unplugged`<br>- `(live)` |
| Single | - `- single`<br>- `- ep`<br>- `(remixes)` |
| Video Album | - `music videos`<br>- `the video`<br>- `dvd` |
| Remix Album | - `remix album`<br>- `mixes`<br>- `the remixes`<br>- `remixed`<br>- `remixes`<br>- `best mixes` |
| Soundtrack | - `soundtrack`<br>- `from the motion picture`<br>- `music from and inspired by` |
Notes:
- Keywords are case-insensitive.
- Any album that would not fall into the other categories will be considered to be a Compilation IF the album is a 'compilation' album (see metadata)
- Any album that would not fall into the other categories will be considered to be a Studio Recording
-1
View File
@@ -1 +0,0 @@
../../front/public/banner-black.png
-1
View File
@@ -1 +0,0 @@
../../front/public/favicon.ico
-53
View File
@@ -1,53 +0,0 @@
# Meelo's Featuring Artist Management
Meelo is now able to parse and recognise featuring artists on a song, based on:
- Its name
- Its artist field (from the metadata)
It does not leverage the album artist.
For example, a song named 'My Song (feat. Another Artist)' by 'Primary Artist & Other Artist' would be parsed to 'My Song' by artist 'Primary artist', featuring 'Other Artist' and 'Another Artist'.
Therefore, each featuring artist will be added to the database, and treated like any other artist.
Parsing works with `()`, `[]` and `-` as delimiters.
## Examples
In this section, we list examples that would be successfully parsed by Meelo
- 'My Song (feat. B)' by 'A'
- Name: 'My Song'
- Artist: 'A'
- Featuring: 'B'
- 'My Song (Remix) [feat. B]' by 'A'
- Name: 'My Song (Remix)'
- Artist: 'A'
- Featuring: 'B'
- 'My Song' by 'A & B'
- Name: 'My Song'
- Artist: 'A'
- Featuring: 'B'
- 'My Song' by 'A, B & C'
- Name: 'My Song'
- Artist: 'A'
- Featuring: 'B', 'C'
- 'My Song - With D' by 'A, B & C'
- Name: 'My Song'
- Artist: 'A'
- Featuring: 'B', 'C', 'D'
- 'My Song - Version With Drums' by 'A'
- Name: 'My Song - Version With Drums'
- Artist: 'A'
- Featuring: None
- 'My Song' by 'A Vs. B'
- Name: 'My Song'
- Artist: 'A'
- Featuring: 'B'
## Special Case: Artist is already known
By default, we use '&' to find and split a list of artist. However, this behaviour changes if an artist containing '&' already exists.
For example, if we had an *album* artist named 'A & B' (since album artist are not parsed as much as song artists), a song named 'My Song (feat. A & B)' would be parsed as 'My Song', with featuring artist 'A & B'.
-1
View File
@@ -1 +0,0 @@
../../front/public/icon.png
-3
View File
@@ -1,3 +0,0 @@
![Meelo](./banner-black.png)
# Welcome to Meelo's User Documentation
-7
View File
@@ -1,7 +0,0 @@
# Create a configuration folder
Meelo is runnable through a Docker container. But it needs a way to be parameterized and persist some data.
Therefore, it is recommended that you create a dedicated folder on your computer for Meelo.
For the sake of clarity, in this documentation, we'll refer to this folder as `Meelo`.
-8
View File
@@ -1,8 +0,0 @@
# Create your first library
After creating the admin account (from Web App),
Visit the `/settings/libraries`, and click `Create Library` to create your first library.
Once your library is created, you should run a scan to collect the related files. To do so, click the *Scan* button.
Your Meelo server is ready to be used! Visit the `/` route on your favourite browser to enjoy your favourite music :).
-5
View File
@@ -1,5 +0,0 @@
# Docker and Docker-compose
In your `Meelo` folder, copy this [docker-compose file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/docker-compose.prod.yml) as `docker-compose.yml`. It can be used as-is.
Additionally, you should copy [nginx configuration file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/nginx.conf.template) as is in your `Meelo` folder.
-3
View File
@@ -1,3 +0,0 @@
# Environment variables
In your `Meelo` directory, copy this [`.env.example`](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/.env.example) as `.env` and fill the variables
-18
View File
@@ -1,18 +0,0 @@
# Prepare your music library
The toughest part is to have an structured music library. The way files are organised are up to you! But here is a recommended architecture:
```tree
Library's Folder
│
Artist 1
│ │
│ └───Album 1
│ │ 01 Track 1.m4a
│ │ 02 Track 2.m4a
| | cover.jpg
│ │ ...
│ ...
```
Meelo provides two ways of collecting metadata: using embedded tags, or using the path of the file. If a metadata source does not provide a specific field, you can use the other as a fallback. (You will configure this behaviour in the next sections). But before going any further, you should decide which metadata collection method to use.
-42
View File
@@ -1,42 +0,0 @@
# Providers
## Enabling Providers
In the `settings.json`, ff the `provider` object does not contain a provider's settings, this provider will be disabled.
Providers can also be disabled explicitly by setting the Provider's settings' field `enabled` at `false`. For example:
```json
// In settings.json
{
//... Other properties
"providers": {
"provider1": {}, // Provider 1 is enabled
// Provider 2 was not specified, it is therefore disabled
"provider3": {
"enabled": true // Provider 3 is enabled
},
"provider4": {
"enabled": false, // Provider 4 is disabled
"apiKey": "" // Other property of provider 4
}
}
}
```
If a provider is disabled, no call to its service will be done.
## Providers' properties and features
| Provider Name | Key | Additional Properties | Artist Description | Artist Illustration | Album Description | Album Rating | Song Description | Song Lyrics | Release Identification | Album Genres |
|---------------|---------------|--------------------------------------------------------------------------------------------|--------------------|---------------------|-------------------|--------------|------------------|-------------|------------------------|--------------|
| Genius | `genius` | `apiKey`: Access Token, you can get it [here](https://genius.com/api-clients) | x | x | x | | x | x | | |
| MusicBrainz | `musicbrainz` | None | | | | | | | | x |
| AllMusic | `allmusic` | None | | | x | x | | | | |
| MetaCritic | `metacritic` | None | | | x | x | | | | |
| Wikipedia | `wikipedia` | None | x | | x | | x | | | |
| Discogs | `discogs` | `apiKey`: Access Token, you can get it [here](https://www.discogs.com/settings/developers) | x | | | | | | x | x |
**Warning**: Meelo heavily relies on Wikidata to optimize the metadata fetching. It is used if, and only if the MusicBrainz Provider is enabled. It is not possible to use one without the other.
*Note*: If MusicBrainz is enabled, we might make requests to [AcoustID](https://acoustid.org)'s API to match songs using audio fingerprints (No data will be submitted).
-8
View File
@@ -1,8 +0,0 @@
# Requirements
Meelo is easy to setup! But you'll need the following things before continuing
- Have you music library ready (see [*this step*](../prepare-music-library))
- Know how `Docker` and `docker-compose` work
- Know how to create a `.env` file
- Know how to manipulate Regular Expressions (Especially the Golang flavor)
-35
View File
@@ -1,35 +0,0 @@
# Prepare your settings
In your `Meelo` folder, download and fill [this `settings.json` file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/settings.json)
All fields are **required**
- `trackRegex`: Probably the most important setting for Meelo. This field takes an array of strings, each string being a Regular Expression string. The RegExp will be used to find the files to import (It will be matched against the **absolute path** of the file). As mentioned before, the path of the file can be used as a metadata source. Therefore, **the Regular Expression accepts groups to find metadata**:
- `AlbumArtist`: The artist of the parent album.
- Special Case: If it equals `Compilations`, related album will be considered as compilation albums (with no main artist, like soundtrack albums)
- `Artist`: The artist of the track. If it is not present, it will use the `AlbumArtist` field.
- `Release`: Usually no present in a standard file architecture; the name of the *release*. When it is not present, the `Album` field will be used.
- `Album`: The name of the album of the material. This field will then be parsed to group related album releases.
- `Year`: The Year of the release of the material
- `Disc`: The Index of the disc the track is on.
- `Index`: The index of the track in a playlist/on a disc.
- `Track`: The name of the track
- `Genre`: The genre of the material
- `DiscogsID`: The Discogs ID of the parent release. A Discogs ID should take the form of a digit-only string. This ID can be found in the URL of the release's Discogs page or in the top-right corner of that page (displayed as `[rXXXXXXXX]`)
- *Note*: **If you do not use embedded data**, most regex groups are optional. Only `Track` and (`AlbumArtist` or `Artist`) are required for the file to be accepted by the scanner.
- `metadata`: Configuration of the **metadata extraction** system:
- `source`: The **primary metadata source** to use:
- `embedded`: Using the embedded tags
- `path`: Using the Regular Expression's groups.
- `order`: Define the missing metadata fallback behaviour. *Note*: if this method is enabled, it will consider 'cover.*' file in the same directory as the media file as an illustration candidate.
- `only`: If a field is missing from the primary metadata source, will not use the other method to get it.
- `preferred`: If a field is missing from the primary metadata source, will try to use the other method to get it.
- `useExternalProviderGenres`: Set to `true` if you want genres from external metadata providers to be used. These genres will be used alongside with embedded genres (if parsing embedded metadata is enabled)
- `providers`: Please refer to [*Providers*](../providers)
- `compilations`:
- `artists`: Optional field. If an album artist in this list, their albums will be considered to be compilation albums.
- `useID3CompTag`: If true, use iTunes' non-standard ID3 tags for compilations.
*Note*: The provided Regular Expression uses the iTunes file architecture.
*Note*: Regular Expression must use the Golang Flavour.
-16
View File
@@ -1,16 +0,0 @@
# Start Meelo
Once these three files are ready, run the following command from your `Meelo` folder:
```bash
docker-compose up
```
## Troubleshooting
Two things can prevent Meelo from starting normally:
- A bad `settings.json` file
- In that case, a message should tell you what went wrong
- Connection to database failed
- In that case, you should have a red message telling you a query failed. To fix, simply restart the server
-12
View File
@@ -1,12 +0,0 @@
# TLDR
Don't feel like reading? Here's how to setup Meelo quickly
- Download and fill [this `docker-compose` file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/docker-compose.prod.yml)
- Download and fill [this `nginx` configuration file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/nginx.conf.template)
- Download and fill [this `.env` file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/.env.example)
- Download and fill [this `settings.json` file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/settings.json)
You should be ready to go! If something goes wrong, please take the time to read the `Setup` section of this documentation.
Enjoy!
-31
View File
@@ -1,31 +0,0 @@
# Meelo's Album Types
It is possible to filter songs based on their *type*.
## Available Types
Meelo supports the following song types:
- Original
- Live
- Acoustic
- Remix
- Instrumental
- A Cappella
- Edit
- Of the original version.
- If the song is an edit of a remix, it will be categorised as 'Remix'
- Clean
- Demo
- Medley
- Non-Music (e.g. Interviews)
## Type recognition
Songs' type recognition is solely based on keywords present in their name. For example:
- 'My Song (Remix)' would be typed as 'Remix'
- 'My Song (Live)' would be recognised as a Live rendition
- 'My Song (Rough Mix)' would be defined as 'Demo'
- 'My Song (Instrumental Mix)' would be an 'Instrumental'
- 'My Song (Instrumental Break Down Mix)' would be a 'Remix'
-3
View File
@@ -1,3 +0,0 @@
:root {
--md-primary-fg-color: #2F2C2B;
}
-28
View File
@@ -1,28 +0,0 @@
import userReducer, {
setAccessToken,
setUserProfile,
unsetAccessToken,
} from "../../src/state/userSlice";
describe("User Slice", () => {
let state: ReturnType<typeof userReducer> | undefined = undefined;
it("Should set the access token", () => {
state = userReducer({}, setAccessToken("1234"));
expect(state).toStrictEqual({ accessToken: "1234" });
});
it("Should set the user profile", () => {
const user = { id: 1, name: "user", admin: true, enabled: true };
state = userReducer(state, setUserProfile(user));
expect(state).toStrictEqual({ accessToken: "1234", user });
});
it("Should unset the access token, and user profile", () => {
state = userReducer(state, unsetAccessToken());
expect(state.accessToken).toBeUndefined();
});
});
+2 -4
View File
@@ -20,7 +20,6 @@
"@mui/utils": "^5.10.6",
"@mui/x-data-grid": "^5.17.14",
"@mui/x-date-pickers": "^5.0.8",
"@reduxjs/toolkit": "^1.8.5",
"accept-language-parser": "^1.5.0",
"blurhash": "^2.0.5",
"change-case": "^5.1.2",
@@ -31,6 +30,7 @@
"hls.js": "^1.5.11",
"i18next": "^23.7.16",
"iconsax-react": "^0.0.8",
"jotai": "^2.12.1",
"material-ui-confirm": "^3.0.7",
"mui-react-hook-form-plus": "^1.3.2",
"next": "14.2.23",
@@ -45,11 +45,9 @@
"react-infinite-scroller": "^1.2.6",
"react-loader-spinner": "^5.2.0",
"react-query": "^3.39.2",
"react-redux": "^8.0.2",
"react-use": "^17.5.1",
"redux-persist": "^6.0.0",
"sharp": "^0.33.4",
"type-fest": "^2.19.0",
"type-fest": "^4.33.0",
"usehooks-ts": "^2.9.5",
"uuid": "^10.0.0",
"yup": "^1.0.0"
+7 -5
View File
@@ -82,7 +82,8 @@ import {
type VideoType,
VideoWithRelations,
} from "../models/video";
import store from "../state/store";
import { store } from "../state/store";
import { accessTokenAtom } from "../state/user";
import { isSSR } from "../utils/is-ssr";
import type { SortingParameters } from "../utils/sorting";
import type { InfiniteQuery, Query } from "./use-query";
@@ -201,6 +202,7 @@ export default class API {
service: Service.Scanner,
validator: yup.object({
current_task: yup.string().required().nullable(),
progress: yup.number().required().nullable(),
pending_tasks: yup
.array(yup.string().required())
.required(),
@@ -413,12 +415,13 @@ export default class API {
static async refreshMetadata(
parentResourceType: "library" | "album" | "song" | "release" | "track",
resourceSlugOrId: number | string,
force: boolean,
): Promise<void> {
return API.fetch({
method: "POST",
route: "/refresh",
errorMessage: "Metadata Refresh request failed",
otherParameters: { [parentResourceType]: resourceSlugOrId },
otherParameters: { [parentResourceType]: resourceSlugOrId, force },
parameters: {},
service: Service.Scanner,
emptyResponse: true,
@@ -432,7 +435,6 @@ export default class API {
return API.fetch({
route: "/libraries",
data: { name: libraryName, path: libraryPath },
errorMessage: "Library Creation Failed",
parameters: {},
method: "POST",
validator: Library,
@@ -978,7 +980,7 @@ export default class API {
* @returns A query to a User object
*/
static getCurrentUserStatus(): Query<User> {
const accessToken = store.getState().user.accessToken;
const accessToken = store.get(accessTokenAtom);
return {
key: ["user", accessToken ?? ""],
@@ -1292,7 +1294,7 @@ export default class API {
emptyResponse,
service,
}: FetchParameters<Keys, ReturnType>): Promise<ReturnType> {
const accessToken = store.getState().user.accessToken;
const accessToken = store.get(accessTokenAtom);
const header = {
"Content-Type": "application/json",
};
+8 -2
View File
@@ -18,6 +18,7 @@
/* eslint-disable no-restricted-imports */
import { useEffect, useState } from "react";
import {
type QueryFunctionContext,
useInfiniteQuery as useReactInfiniteQuery,
@@ -172,8 +173,7 @@ const useInfiniteQuery = <ReturnType extends Resource, Params extends any[]>(
...queryParams: Partial<Params>
) => {
const pageSize = API.defaultPageSize;
return useReactInfiniteQuery({
const { data, ...res } = useReactInfiniteQuery({
...prepareMeeloInfiniteQuery(query, ...queryParams),
getNextPageParam: (
lastPage: Page<ReturnType>,
@@ -184,6 +184,12 @@ const useInfiniteQuery = <ReturnType extends Resource, Params extends any[]>(
return lastPage;
},
});
const [items, setItems] = useState(data?.pages.at(0)?.items);
useEffect(() => {
setItems(data?.pages.flatMap((p) => p.items));
}, [data?.pages]);
return { ...res, data, items };
};
/**
+3 -3
View File
@@ -16,8 +16,8 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import store from "../../state/store";
import { unsetAccessToken } from "../../state/userSlice";
import { store } from "../../state/store";
import { accessTokenAtom } from "../../state/user";
import { LogoutIcon } from "../icons";
import type Action from "./action";
@@ -25,5 +25,5 @@ export const LogoutAction: Action = {
label: "logout",
icon: <LogoutIcon />,
href: "/",
onClick: () => store.dispatch(unsetAccessToken()),
onClick: () => store.set(accessTokenAtom, undefined),
};
+9
View File
@@ -21,6 +21,7 @@ import toast from "react-hot-toast";
import {
AlbumIcon,
ArtistIcon,
InfoIcon,
LyricsIcon,
RelatedTracksIcon,
SearchIcon,
@@ -37,6 +38,14 @@ export const GoToSongLyricsAction = (
icon: <LyricsIcon />,
});
export const GoToSongInfoAction = (
songIdentifier: string | number,
): Action => ({
href: `/songs/${songIdentifier}/info`,
label: "songInfo",
icon: <InfoIcon />,
});
export const GoToArtistAction = (
artistIdentifier: string | number,
): Action => ({
+48 -9
View File
@@ -18,21 +18,33 @@
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
} from "@mui/material";
import { Add, Edit } from "iconsax-react";
import type { useConfirm } from "material-ui-confirm";
import { HookTextField, useHookForm } from "mui-react-hook-form-plus";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useMutation } from "react-query";
import API from "../../api/api";
import type { MeeloInfiniteQueryFn, QueryClient } from "../../api/use-query";
import type { PlayerActions, TrackState } from "../../contexts/player";
import {
type MeeloInfiniteQueryFn,
type QueryClient,
useQueryClient,
} from "../../api/use-query";
import type Playlist from "../../models/playlist";
import type { PlaylistWithRelations } from "../../models/playlist";
import {
type TrackState,
playAfterAtom,
playNextAtom,
} from "../../state/player";
import { store } from "../../state/store";
import {
AddItemToPlaylistIcon,
AddToPlaylistIcon,
@@ -48,11 +60,10 @@ import type Action from "./action";
export const PlayNextAction = (
getTrack: () => PromiseLike<TrackState>,
playNext: PlayerActions["playNext"],
): Action => ({
onClick: () =>
getTrack().then((track) => {
playNext(track);
store.set(playNextAtom, track);
toast.success(`'${track.track.name}' will play next!`);
}),
label: "playNext",
@@ -61,11 +72,10 @@ export const PlayNextAction = (
export const PlayAfterAction = (
getTrack: () => PromiseLike<TrackState>,
playAfter: PlayerActions["playAfter"],
): Action => ({
onClick: () =>
getTrack().then((track) => {
playAfter(track);
store.set(playAfterAtom, track);
toast.success(`'${track.track.name}' will play after!`);
}),
label: "playAfter",
@@ -73,7 +83,7 @@ export const PlayAfterAction = (
});
type CreateOrUpdatePlaylistFormProps = {
onSubmit: (newUrl: string) => void;
onSubmit: (playlistName: string) => void;
onClose: () => void;
defaultValue?: string;
};
@@ -126,15 +136,19 @@ const CreateOrUpdatePlaylistForm = (props: CreateOrUpdatePlaylistFormProps) => {
);
};
export const CreatePlaylistAction = (queryClient: QueryClient): Action => ({
export const CreatePlaylistAction = (
queryClient: QueryClient,
onCreated?: (playlistId: number) => void,
): Action => ({
label: "new",
icon: <Add />,
dialog: ({ close }) => {
const mutation = useMutation((playlistName: string) => {
return API.createPlaylist(playlistName)
.then(() => {
.then((playlist) => {
toast.success("Playlist created!");
queryClient.client.invalidateQueries("playlists");
onCreated?.(playlist.id);
})
.catch((error: Error) => toast.error(error.message));
});
@@ -213,11 +227,36 @@ type SelectPlaylistFormProps = {
const SelectPlaylistForm = (props: SelectPlaylistFormProps) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const createPlaylistAction = CreatePlaylistAction(queryClient);
const [openModal, setOpenModal] = useState(false);
const closeModal = () => setOpenModal(false);
return (
<>
<Dialog
open={openModal}
onClose={closeModal}
fullWidth
sx={{ zIndex: 999999 }}
>
{createPlaylistAction.dialog?.({ close: closeModal })}
</Dialog>
<DialogTitle>Select a playlist</DialogTitle>
<DialogContent>
<ListItem
title={t(createPlaylistAction.label!)}
icon={
<Illustration
illustration={null}
fallback={createPlaylistAction.icon}
quality="low"
/>
}
onClick={() => setOpenModal(true)}
secondTitle={null}
/>
<Divider sx={{ paddingTop: 1 }} />
<InfiniteList
loader={() => <WideLoadingComponent />}
query={props.playlistQuery}
@@ -16,22 +16,76 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Button, Checkbox, Grid } from "@mui/material";
import { useState } from "react";
import toast from "react-hot-toast";
import type { ArraySlice } from "type-fest";
import API from "../../api/api";
import type { Translator } from "../../i18n/i18n";
import { MetadataRefreshIcon } from "../icons";
import type Action from "./action";
type APIMethodParams = ArraySlice<
Parameters<typeof API.refreshMetadata>,
0,
-1
>;
const RefreshMetadataActionContent = ({
t,
params,
close,
}: { t: Translator; params: APIMethodParams; close: () => void }) => {
const [force, setForce] = useState(false);
return (
<Grid container spacing={2} sx={{ padding: 2 }}>
<Grid
item
xs={12}
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<Checkbox
checked={force}
onChange={(_, isChecked) => setForce(isChecked)}
/>
{t("refreshMetadataForceLabel")}
</Grid>
<Grid item xs={12}>
<Button
fullWidth
variant="contained"
onClick={() => {
API.refreshMetadata(...[...params, force])
.then(() =>
toast.success(t("refreshMetadataStarted")),
)
.catch(() =>
toast.error(t("refreshMetadataFailed")),
);
close();
}}
>
{t("refreshMetadata")}
</Button>
</Grid>
</Grid>
);
};
const RefreshMetadataAction = (
t: Translator,
...params: Parameters<typeof API.refreshMetadata>
...params: APIMethodParams
): Action => ({
label: "refreshMetadata",
icon: <MetadataRefreshIcon />,
onClick: () =>
API.refreshMetadata(...params)
.then(() => toast.success(t("refreshMetadataStarted")))
.catch(() => toast.error(t("refreshMetadataFailed"))),
dialog: ({ close }) => (
<RefreshMetadataActionContent t={t} params={params} close={close} />
),
});
export const RefreshLibraryMetadataAction = (
@@ -30,7 +30,8 @@ import type Song from "../../models/song";
import { SongType } from "../../models/song";
import type Video from "../../models/video";
import { VideoType } from "../../models/video";
import store from "../../state/store";
import { store } from "../../state/store";
import { userAtom } from "../../state/user";
import { EditIcon } from "../icons";
import type Action from "./action";
@@ -78,7 +79,7 @@ const ChangeResourceType = <
return {
label: label,
icon: <EditIcon />,
disabled: store.getState().user.user?.admin !== true,
disabled: store.get(userAtom)?.admin !== true,
onClick: () =>
confirm({
title: <br />,
@@ -28,7 +28,7 @@ export const ShowTrackFileInfoAction = (
trackId: number,
): Action => ({
icon: <InfoIcon />,
label: "moreInfo",
label: "trackInfo",
onClick: () => openTrackFileInfoModal(confirm, trackId),
});
@@ -38,7 +38,7 @@ export const ShowMasterTrackFileInfoAction = (
songId: number,
): Action => ({
icon: <InfoIcon />,
label: "moreInfo",
label: "trackInfo",
onClick: () =>
queryClient
.fetchQuery(API.getSongMasterTrack(songId))
@@ -28,7 +28,8 @@ import { useTranslation } from "react-i18next";
import { useMutation } from "react-query";
import API from "../../api/api";
import type { QueryClient } from "../../api/use-query";
import store from "../../state/store";
import { store } from "../../state/store";
import { userAtom } from "../../state/user";
import { UpdateIllustrationIcon } from "../icons";
import type Action from "./action";
@@ -107,7 +108,7 @@ const UpdateIllustrationAction = (
return {
label: "changeIllutration",
disabled: store.getState().user.user?.admin !== true,
disabled: store.get(userAtom)?.admin !== true,
icon: <UpdateIllustrationIcon />,
dialog: (controls) => (
<IllustrationUpdateForm
+2 -7
View File
@@ -36,19 +36,14 @@ const AdminGrid = <DataType extends Resource>({
}: AdminGridProps<DataType>) => {
const {
data,
items,
hasNextPage,
hasPreviousPage,
fetchNextPage,
fetchPreviousPage,
} = useInfiniteQuery(infiniteQuery);
const [currentPage, setCurrentPage] = useState(0);
const itemsCount = useMemo(
() =>
data?.pages
.map((page) => page.items.length)
.reduce((pageSize, sum) => pageSize + sum, 0) ?? 0,
[data?.pages],
);
const itemsCount = useMemo(() => items?.length, [items]);
useEffect(() => {
hasNextPage && fetchNextPage();
+2
View File
@@ -19,6 +19,7 @@
import { Avatar } from "@mui/material";
import type { ComponentProps } from "react";
import type IllustrationModel from "../models/illustration";
import { ArtistIcon } from "./icons";
import Illustration from "./illustration";
const ArtistAvatar = (props: {
@@ -39,6 +40,7 @@ const ArtistAvatar = (props: {
imgProps={{ objectFit: "cover" }}
illustration={props.illustration}
quality={props.quality}
fallback={<ArtistIcon />}
/>
</Avatar>
);
@@ -17,23 +17,23 @@
*/
import { Button, Divider, Grid } from "@mui/material";
import { useAtom } from "jotai";
import { HookTextField, useHookForm } from "mui-react-hook-form-plus";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useDispatch } from "react-redux";
import API from "../../api/api";
import { setAccessToken } from "../../state/userSlice";
import { accessTokenAtom } from "../../state/user";
/**
* Authentication form
* On successful authentication, update store with access token
*/
const AuthenticationForm = () => {
const dispatch = useDispatch();
const [formType, setFormType] = useState<"login" | "signup">("login");
const defaultValues = { username: "", password: "", confirm: "" };
const [password, setPassword] = useState(defaultValues.password);
const [_, setAccessToken] = useAtom(accessTokenAtom);
const { registerState, handleSubmit } = useHookForm({
defaultValues,
});
@@ -50,7 +50,7 @@ const AuthenticationForm = () => {
return;
}
}
dispatch(setAccessToken((await API.login(values)).access_token));
setAccessToken((await API.login(values)).access_token);
} catch (error: any) {
toast.error(error.message);
}
@@ -19,25 +19,22 @@
import { Grid } from "@mui/material";
import { useEffect, useState } from "react";
import { useAtom } from "jotai";
import { useQuery as useReactQuery } from "react-query";
import { useDispatch, useSelector } from "react-redux";
import API from "../../api/api";
import { prepareMeeloQuery } from "../../api/use-query";
import type { RootState } from "../../state/store";
import { setUserProfile } from "../../state/userSlice";
import { accessTokenAtom, userAtom } from "../../state/user";
import ModalPage from "../modal-page";
import ThemedImage from "../themed-image";
import AuthenticationForm from "./authentication-form";
const AuthenticationWall = (props: { children: any }) => {
const accessToken = useSelector(
(store: RootState) => store.user.accessToken,
);
const [accessToken] = useAtom(accessTokenAtom);
const [_, setUser] = useAtom(userAtom);
const status = useReactQuery({
...prepareMeeloQuery(API.getCurrentUserStatus),
useErrorBoundary: false,
});
const dispatch = useDispatch();
const [authentified, setAuthenticationStatus] = useState(
status.data?.id !== undefined,
);
@@ -49,15 +46,15 @@ const AuthenticationWall = (props: { children: any }) => {
if (accessToken && status.data?.id && !status.error) {
setAuthenticationStatus(true);
}
if (status.error || accessToken?.valueOf() === undefined) {
if (status.error || accessToken === undefined) {
setAuthenticationStatus(false);
}
}, [accessToken, status, authentified]);
useEffect(() => {
if (accessToken && status.data && !status.error) {
dispatch(setUserProfile(status.data));
setUser(status.data);
}
}, [accessToken, status, dispatch]);
}, [accessToken, status]);
if (!authentified || !status.data?.id) {
return (
<ModalPage open>
@@ -20,11 +20,9 @@ import { useConfirm } from "material-ui-confirm";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useMutation } from "react-query";
import { useSelector } from "react-redux";
import API from "../../api/api";
import { useQueryClient } from "../../api/use-query";
import type { ReleaseWithRelations } from "../../models/release";
import type { RootState } from "../../state/store";
import { DownloadReleaseAction } from "../actions/download";
import { GoToAlbumAction, GoToArtistAction } from "../actions/link";
import { RefreshReleaseMetadataAction } from "../actions/refresh-metadata";
@@ -34,14 +32,16 @@ import { UpdateReleaseIllustrationAction } from "../actions/update-illustration"
import { MasterIcon, UpgradeIcon } from "../icons";
import ContextualMenu from "./contextual-menu";
import { useAtom } from "jotai";
import { userAtom } from "../../state/user";
type ReleaseContextualMenuProps = {
release: ReleaseWithRelations<"album">;
};
const ReleaseContextualMenu = (props: ReleaseContextualMenuProps) => {
const userIsAdmin = useSelector(
(state: RootState) => state.user.user?.admin === true,
);
const [user] = useAtom(userAtom);
const userIsAdmin = user?.admin === true;
const queryClient = useQueryClient();
const confirm = useConfirm();
const { t } = useTranslation();
@@ -19,13 +19,13 @@
import { useConfirm } from "material-ui-confirm";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "../../api/use-query";
import { usePlayerContext } from "../../contexts/player";
import type Artist from "../../models/artist";
import type { TrackWithRelations } from "../../models/track";
import { DownloadAction } from "../actions/download";
import {
GoToArtistAction,
GoToRelatedTracksAction,
GoToSongInfoAction,
GoToSongLyricsAction,
GoToSongVersionAction,
} from "../actions/link";
@@ -52,17 +52,21 @@ const ReleaseTrackContextualMenu = (props: ReleaseTrackContextualMenuProps) => {
const confirm = useConfirm();
const queryClient = useQueryClient();
const { t } = useTranslation();
const { playNext, playAfter } = usePlayerContext();
return (
<ContextualMenu
onSelect={props.onSelect}
actions={[
[GoToArtistAction(props.artist.slug)],
songSlug ? [GoToSongLyricsAction(songSlug)] : [],
songSlug
? [
GoToSongLyricsAction(songSlug),
GoToSongInfoAction(songSlug),
]
: [],
[
PlayNextAction(async () => props, playNext),
PlayAfterAction(async () => props, playAfter),
PlayNextAction(async () => props),
PlayAfterAction(async () => props),
],
props.track.songId
? [AddToPlaylistAction(props.track.songId, queryClient)]
@@ -22,13 +22,13 @@ import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import API from "../../api/api";
import { useQueryClient } from "../../api/use-query";
import { usePlayerContext } from "../../contexts/player";
import type { SongWithRelations } from "../../models/song";
import { DownloadAsyncAction } from "../actions/download";
import {
GoToArtistAction,
GoToRelatedTracksAction,
GoToReleaseAsyncAction,
GoToSongInfoAction,
GoToSongLyricsAction,
GoToSongVersionAction,
} from "../actions/link";
@@ -55,7 +55,6 @@ const SongContextualMenu = (props: SongContextualMenuProps) => {
const songSlug = props.song.slug;
const queryClient = useQueryClient();
const { t } = useTranslation();
const { playNext, playAfter } = usePlayerContext();
const getMasterTrack = () =>
queryClient.fetchQuery(
API.getSongMasterTrack(songSlug, ["release", "illustration"]),
@@ -80,10 +79,10 @@ const SongContextualMenu = (props: SongContextualMenuProps) => {
(await getMasterTrack()).release?.slug ?? null,
),
],
[GoToSongLyricsAction(songSlug)],
[GoToSongLyricsAction(songSlug), GoToSongInfoAction(songSlug)],
[
PlayNextAction(getPlayNextProps, playNext),
PlayAfterAction(getPlayNextProps, playAfter),
PlayNextAction(getPlayNextProps),
PlayAfterAction(getPlayNextProps),
],
[AddToPlaylistAction(props.song.id, queryClient)],
[
@@ -16,19 +16,22 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useAtom } from "jotai";
import { useConfirm } from "material-ui-confirm";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useMutation } from "react-query";
import { useSelector } from "react-redux";
import API from "../../api/api";
import { useQueryClient } from "../../api/use-query";
import { usePlayerContext } from "../../contexts/player";
import type { TrackWithRelations } from "../../models/track";
import type { RootState } from "../../state/store";
import { userAtom } from "../../state/user";
import type Action from "../actions/action";
import { DownloadAction } from "../actions/download";
import { GoToReleaseAction, GoToSongLyricsAction } from "../actions/link";
import {
GoToReleaseAction,
GoToSongInfoAction,
GoToSongLyricsAction,
} from "../actions/link";
import {
AddToPlaylistAction,
PlayAfterAction,
@@ -47,9 +50,8 @@ type TrackContextualMenuProps = {
};
const TrackContextualMenu = (props: TrackContextualMenuProps) => {
const userIsAdmin = useSelector(
(state: RootState) => state.user.user?.admin === true,
);
const [user] = useAtom(userAtom);
const userIsAdmin = user?.admin === true;
const queryClient = useQueryClient();
const confirm = useConfirm();
const isMaster = props.track.song?.masterId === props.track.id;
@@ -68,7 +70,6 @@ const TrackContextualMenu = (props: TrackContextualMenuProps) => {
release,
}));
const { t } = useTranslation();
const { playNext, playAfter } = usePlayerContext();
const masterMutation = useMutation(async () => {
return API.updateSong(props.track.songId!, {
masterTrackId: props.track.id,
@@ -90,11 +91,14 @@ const TrackContextualMenu = (props: TrackContextualMenuProps) => {
: undefined,
].filter((a): a is Action => a !== undefined),
props.track.song
? [GoToSongLyricsAction(props.track.song.slug)]
? [
GoToSongLyricsAction(props.track.song.slug),
GoToSongInfoAction(props.track.song.slug),
]
: [],
[
PlayNextAction(getPlayNextProps, playNext),
PlayAfterAction(getPlayNextProps, playAfter),
PlayNextAction(getPlayNextProps),
PlayAfterAction(getPlayNextProps),
],
props.track.songId
? [AddToPlaylistAction(props.track.songId, queryClient)]
@@ -20,13 +20,13 @@ import { useConfirm } from "material-ui-confirm";
import { useTranslation } from "react-i18next";
import API from "../../api/api";
import { useQueryClient } from "../../api/use-query";
import { usePlayerContext } from "../../contexts/player";
import type { VideoWithRelations } from "../../models/video";
import type Action from "../actions/action";
import { DownloadAction } from "../actions/download";
import {
GoToArtistAction,
GoToReleaseAction,
GoToSongInfoAction,
GoToSongLyricsAction,
} from "../actions/link";
import {
@@ -64,7 +64,6 @@ const VideoContextualMenu = (props: VideoContextualMenuProps) => {
release,
}));
const { t } = useTranslation();
const { playNext, playAfter } = usePlayerContext();
return (
<ContextualMenu
@@ -77,11 +76,14 @@ const VideoContextualMenu = (props: VideoContextualMenuProps) => {
: undefined,
].filter((a): a is Action => a !== undefined),
props.video.songId
? [GoToSongLyricsAction(props.video.songId)]
? [
GoToSongLyricsAction(props.video.songId),
GoToSongInfoAction(props.video.songId),
]
: [],
[
PlayNextAction(getPlayNextProps, playNext),
PlayAfterAction(getPlayNextProps, playAfter),
PlayNextAction(getPlayNextProps),
PlayAfterAction(getPlayNextProps),
],
props.video.songId
? [AddToPlaylistAction(props.video.songId, queryClient)]
+61 -4
View File
@@ -28,18 +28,29 @@ import type { NextRouter } from "next/router";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useInfiniteQuery as useReactInfiniteQuery } from "react-query";
import API from "../../api/api";
import { prepareMeeloInfiniteQuery } from "../../api/use-query";
import type { TranslationKey } from "../../i18n/i18n";
import globalLibrary from "../../utils/global-library";
import { type LayoutOption, getLayoutParams } from "../../utils/layout";
import {
DefaultItemSize,
ItemSize,
type LayoutOption,
getLayoutParams,
} from "../../utils/layout";
import parseQueryParam from "../../utils/parse-query-param";
import { type Order, getOrderParams } from "../../utils/sorting";
import type Action from "../actions/action";
import Fade from "../fade";
import { AscIcon, DescIcon, GridIcon, ListIcon } from "../icons";
import {
AscIcon,
DescIcon,
GridIcon,
ListIcon,
MinusIcon,
PlusIcon,
} from "../icons";
import type Option from "./option";
import OptionButton from "./option-button";
@@ -54,6 +65,7 @@ export type OptionState<
AdditionalProps extends object = object,
> = {
view: LayoutOption;
itemSize: ItemSize;
order: Order;
sortBy: SortingKeys[number];
library: string | null;
@@ -107,6 +119,7 @@ const Controls = <
? props.router?.query.library?.at(0)
: props.router?.query.library;
const baseOptions: OptionState<SortingKeys> = {
itemSize: DefaultItemSize,
view:
((props.disableLayoutToggle !== true &&
getLayoutParams(props.router?.query.view)) ||
@@ -164,7 +177,12 @@ const Controls = <
name: string;
value: boolean | string | null;
}) => {
setOptionState({ ...optionsState, [name]: value });
setOptionState({
...optionsState,
[name]: value,
// Reset itemsize when changing layout
itemSize: name === "view" ? DefaultItemSize : optionsState.itemSize,
});
value = value?.toString() ?? null;
if (props.router) {
const path = props.router.asPath.split("?")[0];
@@ -314,6 +332,45 @@ const Controls = <
{t(toggle.label)}
</Button>
))}
</ButtonGroup>
</Grid>
<Grid item>
<ButtonGroup variant="contained">
{optionsState.view === "grid" &&
[
[
"xs",
(currentIndex: number) => currentIndex - 1,
MinusIcon,
] as const,
[
"xl",
(currentIndex: number) => currentIndex + 1,
PlusIcon,
] as const,
].map(([size, f, Icon]) => (
<Button
key={`size-button-${size}`}
disabled={optionsState.itemSize === size}
onClick={() =>
setOptionState(
({ itemSize, ...rest }) => ({
...rest,
itemSize:
ItemSize[
f(
ItemSize.indexOf(
itemSize,
),
)
],
}),
)
}
>
<Icon />
</Button>
))}
{props.disableLayoutToggle !== true && (
<Tooltip title={t("changeLayout")}>
<Button
+69
View File
@@ -0,0 +1,69 @@
/*
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
* Copyright (C) 2023
*
* Meelo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Meelo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Button, Grid } from "@mui/material";
import { useTranslation } from "react-i18next";
import type { TranslationKey } from "../i18n/i18n";
import type Action from "./actions/action";
export type EmptyStateProps = {
icon: React.ReactNode;
text: TranslationKey;
actions: Action[];
};
export const EmptyState = (props: EmptyStateProps) => {
const { t } = useTranslation();
return (
<Grid
container
direction="column"
rowSpacing={3}
sx={{
paddingTop: 3,
width: "100%",
height: "100%",
justifyContent: "center",
display: "flex",
alignItems: "center",
color: "text.disabled",
}}
>
<Grid item>{props.icon}</Grid>
<Grid item>{t(props.text)}</Grid>
<Grid
item
container
sx={{ display: "flex", justifyContent: "center" }}
>
{props.actions?.map((a, i) => (
<Grid item key={i}>
<Button
variant="outlined"
href={a.href}
onClick={a.onClick}
>
{t(a.label)}
</Button>
</Grid>
))}
</Grid>
</Grid>
);
};
@@ -19,6 +19,7 @@
import { Box, Button, Skeleton } from "@mui/material";
import Link from "next/link";
import type { ExternalMetadataSource } from "../models/external-metadata";
import { ProviderIcon } from "./icons";
import Illustration from "./illustration";
type ExternalMetadataBadgeProps = {
@@ -34,6 +35,7 @@ const ExternalMetadataBadge = ({ source }: ExternalMetadataBadgeProps) => {
<Illustration
url={source?.providerIcon ?? undefined}
quality="original"
fallback={<ProviderIcon />}
/>
</Box>
}
@@ -19,7 +19,7 @@
import { useTranslation } from "react-i18next";
import type { AlbumWithRelations } from "../../models/album";
import type { AlbumExternalMetadata } from "../../models/external-metadata";
import getYear from "../../utils/getYear";
import { getYear } from "../../utils/date";
import HighlightCard from "./highlight-card";
type AlbumHighlightCardProps = {
+9 -1
View File
@@ -28,6 +28,7 @@ export const AlbumIcon = Iconsax.MusicDashboard;
export const ArtistIcon = Iconsax.Profile2User;
export const SongIcon = Iconsax.Musicnote;
export const PlayerIcon = Iconsax.PlayCircle;
export const CompilationIcon = Iconsax.Category;
export const TrackIcon = Iconsax.Musicnote;
export const LyricsIcon = Iconsax.MessageText;
export const VideoIcon = Iconsax.VideoSquare;
@@ -36,6 +37,7 @@ export const CloseIcon = Iconsax.CloseCircle;
export const EditIcon = Iconsax.Edit2;
export const LogoutIcon = Iconsax.Logout;
export const DarkMode = Iconsax.Sun1;
export const ProviderIcon = Iconsax.Global;
export const LightMode = (props: IconProps) => (
<Iconsax.Sun1 variant="Bold" {...props} />
);
@@ -48,7 +50,9 @@ export const BurgerIcon = Iconsax.HambergerMenu;
export const AddIcon = Iconsax.AddCircle;
export const DeleteIcon = Iconsax.Trash;
export const ShuffleIcon = Iconsax.Shuffle;
export const InfoIcon = Iconsax.InfoCircle;
export const InfoIcon = (props: IconProps) => (
<Iconsax.InfoCircle style={{ rotate: "180deg" }} {...props} />
);
export const AddToPlaylistIcon = Iconsax.MusicSquareAdd;
export const AddItemToPlaylistIcon = Iconsax.MusicSquareAdd;
export const PlayNextIcon = Iconsax.MusicSquareAdd;
@@ -81,5 +85,9 @@ export const PauseIcon = Iconsax.Pause;
export const RewindIcon = Iconsax.Previous;
export const ForwardIcon = Iconsax.Next;
export const FullscreenIcon = Iconsax.Maximize4;
export const PlusIcon = Iconsax.Add;
export const MinusIcon = Iconsax.Minus;
export const DoneIcon = Iconsax.TickSquare;
export const EmptyStateIcon = Iconsax.SearchStatus1;
+38 -28
View File
@@ -20,14 +20,11 @@ import { Box, IconButton, Skeleton, useTheme } from "@mui/material";
import Image, { type ImageProps } from "next/image";
import { useState } from "react";
import type { RequireExactlyOne } from "type-fest";
import blackIllustrationFallback from "../../public/icon-black.png";
import whiteIllustrationFallback from "../../public/icon.png";
import API from "../api/api";
import type IllustrationModel from "../models/illustration";
import { isSSR } from "../utils/is-ssr";
import Blurhash from "./blurhash";
import Fade from "./fade";
import ThemedImage from "./themed-image";
type ImageQuality = "low" | "medium" | "high" | "original";
@@ -152,36 +149,49 @@ const Illustration = (props: IllustrationProps) => {
}}
>
{loadingState === "errored" || url === null ? (
props.fallback ? (
<Box
<Box
sx={{
width: "100%",
height: "100%",
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<Skeleton
variant="rounded"
animation={false}
sx={{
width: "100%",
height: "100%",
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<IconButton
disabled
sx={{ fontSize: "large" }}
component="div"
>
{props.fallback}
</IconButton>
</Box>
) : (
<ThemedImage
dark={whiteIllustrationFallback}
light={blackIllustrationFallback}
fill
alt="missing-illustration"
loading="eager"
style={{
padding: "15%",
aspectRatio:
props.aspectRatio?.toString() ?? "1",
}}
/>
)
{props.fallback && (
<Box
sx={{
top: 0,
left: 0,
bottom: 0,
right: 0,
position: "absolute",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<IconButton
disabled
sx={{ fontSize: "large" }}
component="div"
>
{props.fallback}
</IconButton>
</Box>
)}
</Box>
) : (
<Image
onError={() => setLoadingState("errored")}
@@ -16,17 +16,28 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Grid } from "@mui/material";
import { type Breakpoint, Grid } from "@mui/material";
import type { IllustratedResource } from "../../models/illustration";
import type Resource from "../../models/resource";
import { useGradientBackground } from "../../utils/gradient-background";
import type { ItemSize } from "../../utils/layout";
import InfiniteScroll from "./infinite-scroll";
type TypedList<T extends Resource> = typeof InfiniteScroll<T>;
type InfiniteGridProps<T extends Resource> = Omit<
Parameters<TypedList<T>>[0],
"render"
> & { render: (item: T | undefined) => JSX.Element };
> & { render: (item: T | undefined) => JSX.Element; itemSize: ItemSize };
const GridPresets: Record<ItemSize, Partial<Record<Breakpoint, number>>> = {
// Note: values of sm == values of md,
// because of navbar reducing the actual sufrace of md
xs: { xs: 12 / 5, sm: 2, md: 12 / 8, lg: 12 / 10, xl: 1 },
s: { xs: 3, sm: 12 / 5, md: 12 / 5, lg: 12 / 8, xl: 12 / 10 },
m: { xs: 4, sm: 3, md: 3, lg: 2, xl: 12 / 8 },
l: { xs: 6, sm: 4, md: 4, lg: 12 / 5, xl: 12 / 6 },
xl: { xs: 12, sm: 6, md: 6, lg: 12 / 4, xl: 12 / 5 },
};
/**
* Infinite Scrolling List
@@ -49,18 +60,16 @@ const InfiniteGrid = <T extends IllustratedResource>(
<>
<GradientBackground />
<Grid
columnSpacing={2}
columnSpacing={
["xs", "s"].includes(props.itemSize) ? 1.5 : 2
}
container
sx={{ alignItems: "stretch", display: "flex" }}
>
{items.map((item, index) => (
<Grid
item
xs={6}
sm={3}
// No MD because we get more space when sidebar disappears
lg={2}
xl={12 / 8}
{...GridPresets[props.itemSize]}
key={`item-${index}`}
>
{props.render(item)}
@@ -27,7 +27,9 @@ type TypedList<T extends Resource> = typeof InfiniteScroll<T>;
type InfiniteListProps<T extends Resource> = Omit<
Parameters<TypedList<T>>[0],
"render"
> & { render: (item: T | undefined, index: number) => JSX.Element };
> & { render: (item: T | undefined, index: number) => JSX.Element } & Partial<
Pick<Parameters<typeof InfiniteScroll>[0], "emptyState">
>;
/**
* Similar to InfiniteGrid, but rendered as a list
@@ -24,6 +24,7 @@ import {
AlbumType,
type AlbumWithRelations,
} from "../../../models/album";
import { DefaultItemSize } from "../../../utils/layout";
import Controls, { type OptionState } from "../../controls/controls";
import AlbumItem from "../../list-item/album-item";
import AlbumTile from "../../tile/album-tile";
@@ -52,6 +53,7 @@ const InfiniteAlbumView = (
order: props.initialSortingOrder ?? "asc",
sortBy: props.initialSortingField ?? "name",
view: props.defaultLayout ?? "grid",
itemSize: DefaultItemSize,
});
return (
@@ -74,12 +76,12 @@ const InfiniteAlbumView = (
defaultLayout={props.defaultLayout ?? "grid"}
/>
<InfiniteView
itemSize={options.itemSize}
view={options?.view ?? props.defaultLayout ?? "grid"}
query={() =>
props.query({
library: options?.library,
view: options?.view ?? props.defaultLayout ?? "grid",
type:
// @ts-ignore
options?.type === "All" ? undefined : options?.type,
@@ -22,6 +22,7 @@ import {
ArtistSortingKeys,
type ArtistWithRelations,
} from "../../../models/artist";
import { DefaultItemSize } from "../../../utils/layout";
import Controls, { type OptionState } from "../../controls/controls";
import ArtistItem from "../../list-item/artist-item";
import ArtistTile from "../../tile/artist-tile";
@@ -50,6 +51,7 @@ const InfiniteArtistView = (
defaultLayout={props.defaultLayout ?? "list"}
/>
<InfiniteView
itemSize={options?.itemSize ?? DefaultItemSize}
view={options?.view ?? props.defaultLayout ?? "list"}
query={() =>
props.query({
@@ -23,6 +23,7 @@ import {
PlaylistSortingKeys,
type PlaylistWithRelations,
} from "../../../models/playlist";
import { DefaultItemSize } from "../../../utils/layout";
import { CreatePlaylistAction } from "../../actions/playlist";
import Controls, { type OptionState } from "../../controls/controls";
import PlaylistItem from "../../list-item/playlist-item";
@@ -54,6 +55,8 @@ const InfinitePlaylistView = (
defaultLayout={props.defaultLayout ?? "list"}
/>
<InfiniteView
emptyState={props.emptyState}
itemSize={options?.itemSize ?? DefaultItemSize}
view={options?.view ?? props.defaultLayout ?? "list"}
query={() =>
props.query({
@@ -21,6 +21,7 @@ import type Resource from "../../../models/resource";
import type { LayoutOption } from "../../../utils/layout";
import type { Order } from "../../../utils/sorting";
import type { OptionState } from "../../controls/controls";
import type { EmptyStateProps } from "../../empty-state";
type InfiniteResourceViewProps<
ResourceType extends Resource,
@@ -28,7 +29,8 @@ type InfiniteResourceViewProps<
AdditionalQueryParams extends object = object,
> = {
query: (
options: OptionState<SortingKeys> & AdditionalQueryParams,
options: Omit<OptionState<SortingKeys>, "layout" | "itemSize"> &
AdditionalQueryParams,
) => ReturnType<MeeloInfiniteQueryFn<ResourceType>>;
light?: boolean;
onItemClick?: (item: ResourceType) => void;
@@ -36,6 +38,7 @@ type InfiniteResourceViewProps<
defaultLayout?: LayoutOption;
initialSortingOrder?: Order;
initialSortingField?: SortingKeys[number];
emptyState?: Partial<EmptyStateProps>;
};
export default InfiniteResourceViewProps;
@@ -24,7 +24,6 @@ import {
prepareMeeloInfiniteQuery,
useQueryClient,
} from "../../../api/use-query";
import { type PlayerActions, usePlayerContext } from "../../../contexts/player";
import {
SongSortingKeys,
SongType,
@@ -34,6 +33,13 @@ import {
SongGroupSortingKeys,
type SongGroupWithRelations,
} from "../../../models/song-group";
import {
emptyPlaylistAtom,
playAfterAtom,
playTrackAtom,
} from "../../../state/player";
import { store } from "../../../state/store";
import { DefaultItemSize } from "../../../utils/layout";
import Controls, { type OptionState } from "../../controls/controls";
import { PlayIcon, ShuffleIcon } from "../../icons";
import SongItem, { SongGroupItem } from "../../list-item/song-item";
@@ -48,15 +54,12 @@ type AdditionalProps = {
};
const playSongsAction = (
emptyPlaylist: PlayerActions["emptyPlaylist"],
playTrack: PlayerActions["playTrack"],
playAfter: PlayerActions["playAfter"],
queryClient: QueryClient,
query: () => InfiniteQuery<
SongWithRelations<"artist" | "featuring" | "master" | "illustration">
>,
) => {
emptyPlaylist();
store.set(emptyPlaylistAtom);
queryClient.client
.fetchInfiniteQuery(prepareMeeloInfiniteQuery(query))
.then(async (res) => {
@@ -64,7 +67,7 @@ const playSongsAction = (
let i = 0;
for (const song of songs) {
if (i === 0) {
playTrack({
store.set(playTrackAtom, {
track: {
...song.master,
illustration: song.illustration,
@@ -72,7 +75,7 @@ const playSongsAction = (
artist: song.artist,
});
} else {
playAfter({
store.set(playAfterAtom, {
track: {
...song.master,
illustration: song.illustration,
@@ -98,7 +101,11 @@ const InfiniteSongView = <
Pick<ComponentProps<typeof SongItem<T>>, "subtitles"> & {
disableShuffle?: boolean;
groupsQuery?: (
p: OptionState<typeof SongGroupSortingKeys> & AdditionalProps,
p: Omit<
OptionState<typeof SongGroupSortingKeys>,
"layout" | "itemSize"
> &
AdditionalProps,
) => InfiniteQuery<
SongGroupWithRelations<
"artist" | "featuring" | "master" | "illustration"
@@ -111,7 +118,6 @@ const InfiniteSongView = <
const [options, setOptions] =
useState<OptionState<typeof SongSortingKeys, AdditionalProps>>();
const queryClient = useQueryClient();
const { emptyPlaylist, playAfter, playTrack } = usePlayerContext();
const query = {
type:
// @ts-ignore
@@ -126,16 +132,11 @@ const InfiniteSongView = <
label: "shuffle",
icon: <ShuffleIcon />,
onClick: () => {
playSongsAction(
emptyPlaylist,
playTrack,
playAfter,
queryClient,
() =>
props.query({
...query,
random: Math.floor(Math.random() * 10000),
}),
playSongsAction(queryClient, () =>
props.query({
...query,
random: Math.floor(Math.random() * 10000),
}),
);
},
} as const;
@@ -143,13 +144,7 @@ const InfiniteSongView = <
label: "playAll",
icon: <PlayIcon />,
onClick: () => {
playSongsAction(
emptyPlaylist,
playTrack,
playAfter,
queryClient,
() => props.query(query),
);
playSongsAction(queryClient, () => props.query(query));
},
} as const;
@@ -198,6 +193,7 @@ const InfiniteSongView = <
/>
{options?.groups ? (
<InfiniteView
itemSize={options?.itemSize ?? DefaultItemSize}
view={"list"}
query={() => {
return props.groupsQuery!({
@@ -215,6 +211,7 @@ const InfiniteSongView = <
/>
) : (
<InfiniteView
itemSize={options?.itemSize ?? DefaultItemSize}
view={"list"}
query={() => {
return props.query({
@@ -22,6 +22,7 @@ import {
TrackSortingKeys,
type TrackWithRelations,
} from "../../../models/track";
import { DefaultItemSize } from "../../../utils/layout";
import Controls, { type OptionState } from "../../controls/controls";
import TrackItem from "../../list-item/track-item";
import InfiniteView from "../infinite-view";
@@ -49,6 +50,7 @@ const InfiniteTrackView = (
defaultLayout={"list"}
/>
<InfiniteView
itemSize={options?.itemSize ?? DefaultItemSize}
view={options?.view ?? "list"}
query={() =>
props.query({
@@ -24,12 +24,18 @@ import {
prepareMeeloInfiniteQuery,
useQueryClient,
} from "../../../api/use-query";
import { type PlayerActions, usePlayerContext } from "../../../contexts/player";
import {
VideoSortingKeys,
VideoType,
type VideoWithRelations,
} from "../../../models/video";
import {
emptyPlaylistAtom,
playAfterAtom,
playTrackAtom,
} from "../../../state/player";
import { store } from "../../../state/store";
import { DefaultItemSize } from "../../../utils/layout";
import Controls, { type OptionState } from "../../controls/controls";
import { PlayIcon, ShuffleIcon } from "../../icons";
import VideoItem from "../../list-item/video-item";
@@ -38,15 +44,12 @@ import InfiniteView from "../infinite-view";
import type InfiniteResourceViewProps from "./infinite-resource-view-props";
const playVideosAction = (
emptyPlaylist: PlayerActions["emptyPlaylist"],
playTrack: PlayerActions["playTrack"],
playAfter: PlayerActions["playAfter"],
queryClient: QueryClient,
query: () => InfiniteQuery<
VideoWithRelations<"artist" | "master" | "illustration">
>,
) => {
emptyPlaylist();
store.set(emptyPlaylistAtom);
queryClient.client
.fetchInfiniteQuery(prepareMeeloInfiniteQuery(query))
.then(async (res) => {
@@ -62,9 +65,9 @@ const playVideosAction = (
let i = 0;
for (const video of videos) {
if (i === 0) {
playTrack(video);
store.set(playTrackAtom, video);
} else {
playAfter(video);
store.set(playAfterAtom, video);
}
i++;
}
@@ -97,36 +100,25 @@ const InfiniteVideoView = <
sortBy: options?.sortBy ?? props.initialSortingField ?? "name",
order: options?.order ?? props.initialSortingOrder ?? "asc",
view: "grid",
itemSize: DefaultItemSize,
library: options?.library ?? null,
} as const;
const { emptyPlaylist, playAfter, playTrack } = usePlayerContext();
const playAction = {
label: "playAll",
icon: <PlayIcon />,
onClick: () => {
playVideosAction(
emptyPlaylist,
playTrack,
playAfter,
queryClient,
() => props.query(query),
);
playVideosAction(queryClient, () => props.query(query));
},
} as const;
const shuffleAction = {
label: "shuffle",
icon: <ShuffleIcon />,
onClick: () => {
playVideosAction(
emptyPlaylist,
playTrack,
playAfter,
queryClient,
() =>
props.query({
...query,
random: Math.floor(Math.random() * 10000),
}),
playVideosAction(queryClient, () =>
props.query({
...query,
random: Math.floor(Math.random() * 10000),
}),
);
},
} as const;
@@ -150,6 +142,7 @@ const InfiniteVideoView = <
actions={[playAction, shuffleAction]}
/>
<InfiniteView
itemSize={options?.itemSize ?? DefaultItemSize}
view={options?.view ?? "grid"}
query={() => {
return props.query({
@@ -25,6 +25,8 @@ import type PaginatedResponse from "../../models/pagination";
import type { PaginationParameters } from "../../models/pagination";
import type Resource from "../../models/resource";
import { generateArray } from "../../utils/gen-list";
import { EmptyState, type EmptyStateProps } from "../empty-state";
import { EmptyStateIcon } from "../icons";
export const parentScrollableDivId = "scrollableDiv" as const;
@@ -45,6 +47,8 @@ type InfiniteScrollProps<T extends Resource> = {
* Component to display on page fetching (except first)
*/
loader: () => JSX.Element;
emptyState?: EmptyStateProps;
};
/**
@@ -76,11 +80,17 @@ export type Page<T> = {
* @returns a dynamic list component
*/
const InfiniteScroll = <T extends Resource>(props: InfiniteScrollProps<T>) => {
const { data, hasNextPage, fetchNextPage, isFetchingNextPage } =
const { items, hasNextPage, fetchNextPage, isFetchingNextPage } =
useInfiniteQuery(props.query);
return (
<>
{items?.length === 0 && (
<EmptyState
icon={props.emptyState?.icon ?? <EmptyStateIcon />}
text={props.emptyState?.text ?? "emptyStateDefault"}
actions={props.emptyState?.actions ?? []}
/>
)}
<IScroll.default
pageStart={0}
loadMore={() => {
@@ -96,10 +106,10 @@ const InfiniteScroll = <T extends Resource>(props: InfiniteScrollProps<T>) => {
threshold={500}
>
{props.render(
data === undefined
items === undefined
? generateArray(3)
: [
...data.pages.flatMap((page) => page.items),
...items,
...(isFetchingNextPage ? generateArray(3) : []),
],
)}
@@ -21,6 +21,8 @@ import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type { MeeloInfiniteQueryFn } from "../../api/use-query";
import type { IllustratedResource } from "../../models/illustration";
import type { ItemSize } from "../../utils/layout";
import type { EmptyStateProps } from "../empty-state";
import Fade from "../fade";
import { GoBackTopIcon } from "../icons";
import { WideLoadingComponent } from "../loading/loading";
@@ -30,9 +32,11 @@ import { parentScrollableDivId } from "./infinite-scroll";
export type InfiniteViewProps<ItemType> = {
view: "list" | "grid";
itemSize: ItemSize;
query: MeeloInfiniteQueryFn<ItemType>;
renderListItem: (item: ItemType | undefined) => JSX.Element;
renderGridItem: (item: ItemType | undefined) => JSX.Element;
emptyState?: Partial<EmptyStateProps>;
};
const ScrollToTopButton = () => {
@@ -106,6 +110,7 @@ const InfiniteView = <ItemType extends IllustratedResource>(
) : (
<InfiniteGrid
query={props.query}
itemSize={props.itemSize}
loader={() => <WideLoadingComponent />}
render={(item) => (
<Fade in>
+2
View File
@@ -77,6 +77,8 @@ const LibraryForm = (props: LibraryFormProps) => {
textFieldProps={{
fullWidth: true,
label: t("pathOfLibrary"),
helperText:
"Path should be relative to the 'DATA_DIR' variable. Use './' if the library path is 'DATA_DIR' ",
}}
gridProps={{}}
rules={{
@@ -19,6 +19,7 @@
import { useTranslation } from "react-i18next";
import type { AlbumWithRelations } from "../../models/album";
import AlbumContextualMenu from "../contextual-menu/album-contextual-menu";
import { AlbumIcon } from "../icons";
import Illustration from "../illustration";
import ListItem from "./item";
@@ -43,6 +44,7 @@ const AlbumItem = ({ album, formatSubtitle, onClick }: AlbumItemProps) => {
<Illustration
illustration={album?.illustration}
quality="low"
fallback={<AlbumIcon />}
/>
}
onClick={onClick}
@@ -19,8 +19,9 @@
import { Grid } from "@mui/material";
import { Star } from "iconsax-react";
import type { ReleaseWithRelations } from "../../models/release";
import getYear from "../../utils/getYear";
import { getYear } from "../../utils/date";
import ReleaseContextualMenu from "../contextual-menu/release-contextual-menu";
import { AlbumIcon } from "../icons";
import Illustration from "../illustration";
import ListItem from "./item";
@@ -39,6 +40,7 @@ const ReleaseItem = ({ release, onClick }: ReleaseItemProps) => {
<Illustration
illustration={release.illustration}
quality="low"
fallback={<AlbumIcon />}
/>
}
href={`/releases/${release.slug}`}
+3 -2
View File
@@ -16,10 +16,11 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useSetAtom } from "jotai";
import { useEffect, useState } from "react";
import { usePlayerContext } from "../../contexts/player";
import type { SongWithRelations } from "../../models/song";
import type { SongGroupWithRelations } from "../../models/song-group";
import { playTrackAtom } from "../../state/player";
import formatArtists from "../../utils/formatArtists";
import SongContextualMenu from "../contextual-menu/song-contextual-menu";
import { SongIcon } from "../icons";
@@ -89,7 +90,7 @@ const SongItem = <
onClick,
}: SongItemProps<T>) => {
const artist = song?.artist;
const { playTrack } = usePlayerContext();
const playTrack = useSetAtom(playTrackAtom);
const [subtitle, setSubtitle] = useState<string | null | undefined>(
subtitles?.length
? ((<br />) as unknown as string)
@@ -17,10 +17,11 @@
*/
import { Grid } from "@mui/material";
import { useSetAtom } from "jotai";
import API from "../../api/api";
import { useQueryClient } from "../../api/use-query";
import { usePlayerContext } from "../../contexts/player";
import type { TrackWithRelations } from "../../models/track";
import { playTrackAtom } from "../../state/player";
import TrackContextualMenu from "../contextual-menu/track-contextual-menu";
import { MasterIcon, TrackIcon } from "../icons";
import Illustration from "../illustration";
@@ -40,9 +41,9 @@ type TrackItemProps = {
*/
const TrackItem = ({ track, onClick }: TrackItemProps) => {
const release = track?.release;
const { playTrack } = usePlayerContext();
const isMaster = track ? track.song?.masterId === track.id : false;
const queryClient = useQueryClient();
const playTrack = useSetAtom(playTrackAtom);
return (
<ListItem
@@ -16,9 +16,10 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useSetAtom } from "jotai";
import { useEffect, useState } from "react";
import { usePlayerContext } from "../../contexts/player";
import type { VideoWithRelations } from "../../models/video";
import { playTrackAtom } from "../../state/player";
import formatArtists from "../../utils/formatArtists";
import VideoContextualMenu from "../contextual-menu/video-contextual-menu";
import { VideoIcon } from "../icons";
@@ -46,7 +47,7 @@ const VideoItem = <T extends VideoType>({
onClick,
}: VideoItemProps<T>) => {
const artist = video?.artist;
const { playTrack } = usePlayerContext();
const playTrack = useSetAtom(playTrackAtom);
const [subtitle, setSubtitle] = useState<string | null | undefined>(
subtitles?.length
? ((<br />) as unknown as string)
+7 -6
View File
@@ -17,22 +17,23 @@
*/
import { Box, Skeleton, Typography } from "@mui/material";
import { useTranslation } from "react-i18next";
import { generateArray } from "../utils/gen-list";
import { EmptyState } from "./empty-state";
import Fade from "./fade";
import { LyricsIcon } from "./icons";
type LyricsProps = {
lyrics: string[] | null | undefined;
songName: string | undefined;
};
const LyricsBox = (props: LyricsProps) => {
const { t } = useTranslation();
if (props.lyrics === null) {
return (
<Typography sx={{ fontStyle: "italic" }}>
{t("noLyricsFound")}
</Typography>
<EmptyState
actions={[]}
text={"emptyStateLyrics"}
icon={<LyricsIcon />}
/>
);
}
return (
+15 -12
View File
@@ -20,14 +20,16 @@ import { Box, Button, Grid } from "@mui/material";
import Link from "next/link";
import { type ComponentProps, Fragment } from "react";
import { useTranslation } from "react-i18next";
import type { UseInfiniteQueryResult, UseQueryResult } from "react-query";
import type { UseQueryResult } from "react-query";
import type { useInfiniteQuery } from "../api/use-query";
import type { TranslationKey } from "../i18n/i18n";
import type { AlbumWithRelations } from "../models/album";
import type Artist from "../models/artist";
import type Resource from "../models/resource";
import type { SongWithRelations } from "../models/song";
import type { VideoWithRelations } from "../models/video";
import { getYear } from "../utils/date";
import { generateArray } from "../utils/gen-list";
import getYear from "../utils/getYear";
import Fade from "./fade";
import { MoreIcon } from "./icons";
import SectionHeader from "./section-header";
@@ -38,13 +40,16 @@ import VideoTile from "./tile/video-tile";
export const SectionPadding = 4;
export type PageSectionProps<T> = {
export type PageSectionProps<
T extends Resource,
QT extends typeof useInfiniteQuery<T, []> = typeof useInfiniteQuery<T, []>,
> = {
title: TranslationKey;
artist: UseQueryResult<Artist>;
seeMoreHref?: string;
maxItemCount: number;
child: (items: T[] | undefined) => JSX.Element;
query?: UseInfiniteQueryResult<{ items: T[] }>;
query?: ReturnType<QT>;
items?: T[] | undefined;
minimizePadding?: true;
};
@@ -84,7 +89,7 @@ export const SongGridPageSection = (
);
};
export const ListPageSection = <T,>(
export const ListPageSection = <T extends Resource>(
props: Omit<PageSectionProps<T>, "child"> & {
builder: (item: T | undefined, index: number) => JSX.Element;
},
@@ -171,13 +176,12 @@ export const VideoListPageSection = (
);
};
const PageSection = <T,>(props: PageSectionProps<T>) => {
const PageSection = <T extends Resource>(props: PageSectionProps<T>) => {
const { t } = useTranslation();
const data = props.query?.data || props.items;
const firstPage = props.query?.data?.pages.at(0)?.items ?? props.items;
const data = props.query?.items || props.items;
return (
<>
{firstPage?.length !== 0 && (
{data?.length !== 0 && (
<>
<SectionHeader
heading={
@@ -187,8 +191,7 @@ const PageSection = <T,>(props: PageSectionProps<T>) => {
props.seeMoreHref ? (
<Fade
in={
(firstPage?.length ?? 0) >
props.maxItemCount
(data?.length ?? 0) > props.maxItemCount
}
>
<Link href={props.seeMoreHref}>
@@ -208,7 +211,7 @@ const PageSection = <T,>(props: PageSectionProps<T>) => {
) : undefined
}
/>
{props.child(firstPage)}
{props.child(data)}
<Box
sx={{
paddingBottom: props.minimizePadding
+211 -102
View File
@@ -21,6 +21,7 @@ import {
Button,
ButtonBase,
Container,
Dialog,
Divider,
Grid,
IconButton,
@@ -29,7 +30,9 @@ import {
Typography,
useTheme,
} from "@mui/material";
import { useAtom, useSetAtom } from "jotai";
import Link from "next/link";
import { useRouter } from "next/router";
import {
type ComponentProps,
type LegacyRef,
@@ -37,17 +40,27 @@ import {
useState,
} from "react";
import { DragDropContext, Draggable, Droppable } from "react-beautiful-dnd";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import API from "../../api/api";
import { useQuery } from "../../api/use-query";
import { usePlayerContext } from "../../contexts/player";
import { useQuery, useQueryClient } from "../../api/use-query";
import type Artist from "../../models/artist";
import type { TrackWithRelations } from "../../models/track";
import {
cursorAtom,
playlistAtom,
removeTrackAtom,
reorderAtom,
skipTrackAtom,
} from "../../state/player";
import formatArtists from "../../utils/formatArtists";
import formatDuration from "../../utils/formatDuration";
import { CreatePlaylistAction } from "../actions/playlist";
import ReleaseTrackContextualMenu from "../contextual-menu/release-track-contextual-menu";
import {
CloseIcon,
ContextualMenuIcon,
DeleteIcon,
DragHandleIcon,
ForwardIcon,
FullscreenIcon,
@@ -244,10 +257,34 @@ const ExpandedPlayerControls = (
parentSongQuery,
props.track?.songId ?? undefined,
);
const { playlist, cursor, reorder, skipTrack } = usePlayerContext();
const queryClient = useQueryClient();
const [playlist] = useAtom(playlistAtom);
const [cursor] = useAtom(cursorAtom);
const [playlistModalIsOpen, openPlaylistModal] = useState(false);
const skipTrack = useSetAtom(skipTrackAtom);
const removeTrack = useSetAtom(removeTrackAtom);
const reorder = useSetAtom(reorderAtom);
const [selectedTab, selectTab] = useState<"player" | "lyrics" | "playlist">(
"player",
);
const { t } = useTranslation();
const router = useRouter();
const saveAsPlaylistAction = CreatePlaylistAction(
queryClient,
async (playlistId) => {
try {
for (const s of playlist) {
if (s.track.songId) {
await API.addSongToPlaylist(s.track.songId, playlistId);
}
}
router.push(`/playlists/${playlistId}`);
} catch {
toast.error(t("errorOccured"));
}
},
);
const requestFullscreen = useCallback(() => {
const el: any = document.getElementById("videoPlayer");
@@ -308,13 +345,16 @@ const ExpandedPlayerControls = (
variant="h6"
sx={{ textAlign: "center" }}
>
{props.track.name ?? <Skeleton />}
{props.track.name}
</Typography>
<Typography
variant="body1"
sx={{ textAlign: "center" }}
>
{props.artist.name ?? <Skeleton />}
{formatArtists(
props.artist,
parentSong.data?.featuring,
)}
</Typography>
</Box>
</>
@@ -573,109 +613,178 @@ const ExpandedPlayerControls = (
</Box>
)}
{selectedTab === "playlist" && (
<Box
sx={{
height: "100%",
width: "100%",
overflowY: "scroll",
alignSelf: "center",
maxWidth: theme.breakpoints.values.md,
}}
>
<DragDropContext
onDragEnd={(result) => {
if (result.destination) {
reorder({
from: result.source.index + cursor + 1,
to: result.destination.index + cursor + 1,
});
}
<>
<Box
sx={{
height: "100%",
width: "100%",
overflowY: "scroll",
alignSelf: "center",
maxWidth: theme.breakpoints.values.md,
}}
>
<Droppable droppableId="droppable">
{(provided) => (
<div
{...provided.droppableProps}
ref={provided.innerRef}
>
{playlist
.slice(cursor + 1)
.map((playlistItem, index) => (
<>
<Draggable
draggableId={index.toString()}
key={index}
index={index}
>
{(providedChild) => (
<div
ref={
providedChild.innerRef
}
{...providedChild.draggableProps}
style={
providedChild
.draggableProps
.style
}
>
<ListItem
title={
playlistItem
.track
.name
<DragDropContext
onDragEnd={(result) => {
if (result.destination) {
reorder({
from: result.source.index + cursor + 1,
to:
result.destination.index +
cursor +
1,
});
}
}}
>
<Droppable droppableId="droppable">
{(provided) => (
<div
{...provided.droppableProps}
ref={provided.innerRef}
>
{playlist
.slice(cursor + 1)
.map((playlistItem, index) => (
<>
<Draggable
draggableId={index.toString()}
key={index}
index={index}
>
{(providedChild) => (
<div
ref={
providedChild.innerRef
}
secondTitle={
playlistItem
.artist
.name
{...providedChild.draggableProps}
style={
providedChild
.draggableProps
.style
}
icon={
<Box
{...providedChild.dragHandleProps}
>
<DragHandleIcon />
</Box>
}
trailing={
<Typography color="text.disabled">
{formatDuration(
playlistItem
.track
.duration,
)}
</Typography>
}
onClick={() => {
let toSkip =
index +
1;
while (
toSkip >
0
) {
skipTrack();
toSkip--;
>
<ListItem
title={
playlistItem
.track
.name
}
}}
/>
</div>
)}
</Draggable>
<Divider
key={`divider-${index}`}
variant="middle"
/>
</>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
</Box>
secondTitle={
playlistItem
.artist
.name
}
icon={
<Box
{...providedChild.dragHandleProps}
>
<DragHandleIcon />
</Box>
}
trailing={
<Grid
container
wrap="nowrap"
columnSpacing={
1
}
>
<Grid
item
sx={{
display:
"flex",
alignItems:
"center",
}}
>
<Typography color="text.disabled">
{formatDuration(
playlistItem
.track
.duration,
)}
</Typography>
</Grid>
<Grid
item
>
<IconButton
size={
"small"
}
onClick={() =>
removeTrack(
cursor +
1 +
index,
)
}
>
<DeleteIcon />
</IconButton>
</Grid>
</Grid>
}
onClick={() => {
let toSkip =
index +
1;
while (
toSkip >
0
) {
skipTrack();
toSkip--;
}
}}
/>
</div>
)}
</Draggable>
<Divider
key={`divider-${index}`}
variant="middle"
/>
</>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
</Box>
{playlist.length > 0 && (
<Button
sx={{
width: "100%",
alignSelf: "center",
marginTop: 1,
maxWidth: theme.breakpoints.values.md,
}}
variant="outlined"
onClick={() => {
props.onExpand(false);
openPlaylistModal(true);
}}
>
Save queue as playlist
</Button>
)}
</>
)}
<Dialog
open={playlistModalIsOpen}
onClose={() => openPlaylistModal(false)}
fullWidth
>
{saveAsPlaylistAction.dialog!({
close: () => openPlaylistModal(false),
})}
</Dialog>
<Divider variant="middle" sx={{ margin: 1, marginBottom: 2 }} />
<Grid
container
+21 -10
View File
@@ -25,27 +25,34 @@ import {
useTheme,
} from "@mui/material";
import Hls from "hls.js";
import { useAtom, useSetAtom } from "jotai";
import { type LegacyRef, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { useReadLocalStorage } from "usehooks-ts";
import { v4 as uuidv4 } from "uuid";
import API from "../../api/api";
import { useKeyboardBinding } from "../../contexts/keybindings";
import { usePlayerContext } from "../../contexts/player";
import type { RootState } from "../../state/store";
import {
cursorAtom,
playPreviousTrackAtom,
playTracksAtom,
playlistAtom,
skipTrackAtom,
} from "../../state/player";
import { userAtom } from "../../state/user";
import { DrawerBreakpoint } from "../scaffold/scaffold";
import { ExpandedPlayerControls, MinimizedPlayerControls } from "./controls";
const Player = () => {
const { t } = useTranslation();
const theme = useTheme();
const userIsAuthentified = useSelector(
(state: RootState) => state.user.user !== undefined,
);
const { playPreviousTrack, playTracks, skipTrack, cursor, playlist } =
usePlayerContext();
const [user] = useAtom(userAtom);
const playPreviousTrack = useSetAtom(playPreviousTrackAtom);
const playTracks = useSetAtom(playTracksAtom);
const skipTrack = useSetAtom(skipTrackAtom);
const [cursor] = useAtom(cursorAtom);
const [playlist] = useAtom(playlistAtom);
const currentTrack = useMemo(() => playlist[cursor], [cursor, playlist]);
const player = useRef<HTMLAudioElement | HTMLVideoElement>();
const audioPlayer = useRef<HTMLAudioElement>(
@@ -219,11 +226,11 @@ const Player = () => {
}
}, [useTranscoding]);
useEffect(() => {
if (!userIsAuthentified) {
if (!user) {
pause();
playTracks({ tracks: [] });
}
}, [userIsAuthentified]);
}, [user]);
useKeyboardBinding(
{
key: "p",
@@ -330,6 +337,10 @@ const Player = () => {
setDuration(undefined);
setPlaying(false);
}
return () => {
player?.current?.pause();
navigator.mediaSession.metadata = null;
};
}, [currentTrack]);
const playerBgColor = useMemo(() => {
const themePaperColor = `rgba(${theme.vars.palette.background.defaultChannel} / 0.75)`;
+10 -2
View File
@@ -31,17 +31,23 @@ import {
Typography,
useTheme,
} from "@mui/material";
import { useAtom, useSetAtom } from "jotai";
import { Fragment, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Audio } from "react-loader-spinner";
import type { RequireAtLeastOne } from "type-fest";
import { type TrackState, usePlayerContext } from "../contexts/player";
import type Artist from "../models/artist";
import type Release from "../models/release";
import type { SongWithRelations } from "../models/song";
import type { TrackWithRelations } from "../models/track";
import type Tracklist from "../models/tracklist";
import type { VideoWithRelations } from "../models/video";
import {
type TrackState,
cursorAtom,
playTracksAtom,
playlistAtom,
} from "../state/player";
import formatArtists from "../utils/formatArtists";
import formatDuration from "../utils/formatDuration";
import { generateArray } from "../utils/gen-list";
@@ -77,7 +83,9 @@ const ReleaseTrackList = ({
ariaLabel="bars-loading"
/>
);
const { playTracks, playlist, cursor } = usePlayerContext();
const [playlist] = useAtom(playlistAtom);
const [cursor] = useAtom(cursorAtom);
const playTracks = useSetAtom(playTracksAtom);
const currentlyPlayingTrack = useMemo(
() => playlist[cursor] as TrackState | undefined,
[playlist, cursor],
@@ -16,9 +16,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Box, Link, Skeleton } from "@mui/material";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Box, Skeleton } from "@mui/material";
import type { CommonExternalMetadata } from "../models/external-metadata";
import { generateArray } from "../utils/gen-list";
@@ -26,37 +24,16 @@ type Props = {
externalMetadata: CommonExternalMetadata | undefined;
};
const ResourceDescriptionExpandable = ({ externalMetadata }: Props) => {
const [isExpanded, setIsExpanded] = useState(false);
const maxLine = 5;
const smallBoxStyle = {
overflow: "hidden",
display: "-webkit-box",
WebkitLineClamp: maxLine,
lineClamp: maxLine,
WebkitBoxOrient: "vertical",
} as const;
const bigBoxStyle = {};
const { t } = useTranslation();
const ResourceDescription = ({ externalMetadata }: Props) => {
return (
<Box id="description" sx={isExpanded ? bigBoxStyle : smallBoxStyle}>
<Box id="description">
{externalMetadata?.description ||
(externalMetadata?.description === undefined &&
generateArray(5).map((_, index) => (
<Skeleton key={index} />
)))}
{externalMetadata?.description && (
<Link
href="#description"
onClick={() => setIsExpanded(!isExpanded)}
>
{t(isExpanded ? "showLess" : "showMore")}
</Link>
)}
</Box>
);
};
export default ResourceDescriptionExpandable;
export default ResourceDescription;
+16 -3
View File
@@ -43,6 +43,7 @@ import {
AlbumIcon,
ArtistIcon,
BurgerIcon,
CompilationIcon,
PlaylistIcon,
SongIcon,
VideoIcon,
@@ -61,11 +62,12 @@ const primaryItems = [
"songs",
"videos",
"playlists",
"albums/compilations",
] as const;
const getPrimaryTypeIcon = (
type: (typeof primaryItems)[number],
props?: IconProps,
) => {
): JSX.Element => {
switch (type) {
case "albums":
return <AlbumIcon {...props} />;
@@ -77,6 +79,8 @@ const getPrimaryTypeIcon = (
return <VideoIcon {...props} />;
case "playlists":
return <PlaylistIcon {...props} />;
case "albums/compilations":
return <CompilationIcon {...props} />;
}
};
@@ -201,7 +205,12 @@ const Drawer = ({
: "normal",
}}
>
{t(item)}
{t(
item ===
"albums/compilations"
? "compilations"
: item,
)}
</Typography>
</ListItemText>
</ListItemButton>
@@ -308,7 +317,11 @@ const BottomNavigation = (props: { onDrawerOpen: () => void }) => {
icon={
<Icon variant={isSelected ? "Bold" : "Outline"} />
}
label={t(item)}
label={t(
item === "albums/compilations"
? "compilations"
: item,
)}
/>
);
})}
@@ -19,6 +19,7 @@
import {
Box,
Button,
CircularProgress,
Dialog,
Grid,
Hidden,
@@ -27,6 +28,7 @@ import {
ListItem,
ListItemText,
Skeleton,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material";
@@ -36,8 +38,13 @@ import { type ComponentProps, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useMutation } from "react-query";
import { useQuery as useTanStackQuery } from "react-query";
import API from "../../api/api";
import { useQuery, useQueryClient } from "../../api/use-query";
import {
prepareMeeloQuery,
useQuery,
useQueryClient,
} from "../../api/use-query";
import type Library from "../../models/library";
import type Action from "../actions/action";
import {
@@ -56,48 +63,102 @@ const actionButtonStyle = {
overflow: "hidden",
textOverflow: "ellipsis",
};
// Stolen from https://mui.com/material-ui/react-progress/?srsltid=AfmBOoo0dWRio9xMoE_zsN01tte0uKv3clCU6ALkzOVxnB-Ogg5u6guf#circular-with-label
const CircularProgressWithLabel = (
props: Parameters<typeof CircularProgress>[0] & { value: number },
) => {
return (
<Box sx={{ position: "relative", display: "inline-flex" }}>
<CircularProgress variant="determinate" {...props} />
<Box
sx={{
top: 0,
left: 0,
bottom: 0,
right: 0,
position: "absolute",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Typography
variant="caption"
component="div"
sx={{ color: "text.secondary", lineHeight: 1 }}
>{`${props.value}%`}</Typography>
</Box>
</Box>
);
};
const LibrariesSettings = () => {
const RunTaskButton = ({
icon,
label,
onClick,
dialog,
variant,
}: Action & Pick<ComponentProps<typeof Button>, "variant">) => {
const theme = useTheme();
const tasks = useQuery(API.getTasks);
const { t, i18n } = useTranslation();
const RunTaskButton = ({
icon,
label,
onClick,
variant,
}: Action & Pick<ComponentProps<typeof Button>, "variant">) => {
const theme = useTheme();
const viewPortIsSmall = useMediaQuery(theme.breakpoints.up("sm"));
const { t } = useTranslation();
const viewPortIsSmall = useMediaQuery(theme.breakpoints.up("sm"));
const [modalIsOpen, openModal] = useState(false);
return (
return (
<>
<Button
variant={variant}
size="small"
startIcon={viewPortIsSmall && icon}
onClick={() => {
tasks.refetch();
onClick?.();
dialog && openModal(true);
tasks.refetch();
}}
sx={actionButtonStyle}
>
<Hidden smUp>{icon}</Hidden>
<Hidden smDown>{t(label)}</Hidden>
</Button>
);
};
{dialog && (
<Dialog
open={modalIsOpen}
onClose={() => openModal(false)}
fullWidth
>
{dialog({
close: () => {
openModal(false);
tasks.refetch();
},
})}
</Dialog>
)}
</>
);
};
const LibrariesSettings = () => {
const tasks = useTanStackQuery({
...prepareMeeloQuery(() => API.getTasks()),
refetchInterval: 1000,
});
const { t, i18n } = useTranslation();
const queryClient = useQueryClient();
const scanAllLibaries = {
...ScanAllLibrariesAction,
onClick: () => {
tasks.refetch();
ScanAllLibrariesAction.onClick?.();
tasks.refetch();
},
};
const cleanAllLibaries = {
...CleanAllLibrariesAction,
onClick: () => {
tasks.refetch();
CleanAllLibrariesAction.onClick?.();
tasks.refetch();
},
};
const confirm = useConfirm();
@@ -286,7 +347,17 @@ const LibrariesSettings = () => {
}
/>
<List>
<ListItem>
<ListItem
secondaryAction={
tasks.data &&
tasks.data.progress !== null && (
<CircularProgressWithLabel
size="35px"
value={tasks.data.progress}
/>
)
}
>
<ListItemText
primary={
tasks.data ? (
@@ -294,7 +365,7 @@ const LibrariesSettings = () => {
tasks.data.current_task ?? t("none")
}`
) : (
<Skeleton />
<Skeleton width={"100px"} />
)
}
/>
@@ -307,7 +378,7 @@ const LibrariesSettings = () => {
tasks.data.pending_tasks.length || t("none")
}`
) : (
<Skeleton />
<Skeleton width={"100px"} />
)
}
/>
@@ -190,7 +190,7 @@ const UserSettings = () => {
<Book1 style={LinkIconStyle} />
<Link
style={{ textDecoration: "underline" }}
href="https://arthi-chaud.github.io/Meelo"
href="https://github.com/Arthi-chaud/Meelo/wiki"
>
{t("readTheDoc")}
</Link>
@@ -18,15 +18,15 @@
import { Box, Checkbox, IconButton, Typography } from "@mui/material";
import type { GridColDef } from "@mui/x-data-grid";
import { useAtom } from "jotai";
import { useConfirm } from "material-ui-confirm";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useMutation } from "react-query";
import { useSelector } from "react-redux";
import API from "../../api/api";
import { useQueryClient } from "../../api/use-query";
import type User from "../../models/user";
import type { RootState } from "../../state/store";
import { userAtom } from "../../state/user";
import AdminGrid from "../admin-grid";
import { DeleteIcon } from "../icons";
@@ -74,7 +74,7 @@ const DeleteButton = ({
const UsersSettings = () => {
const queryClient = useQueryClient();
const { t } = useTranslation();
const currentUser = useSelector((state: RootState) => state.user.user!);
const [currentUser] = useAtom(userAtom);
const userMutation = useMutation(
({
user,
+5 -3
View File
@@ -17,10 +17,12 @@
*/
import { Grid } from "@mui/material";
import { usePlayerContext } from "../contexts/player";
import { useSetAtom } from "jotai";
import type { SongWithRelations } from "../models/song";
import { playTrackAtom } from "../state/player";
import formatArtists from "../utils/formatArtists";
import SongContextualMenu from "./contextual-menu/song-contextual-menu";
import { SongIcon } from "./icons";
import Illustration from "./illustration";
import ListItem from "./list-item/item";
@@ -33,8 +35,7 @@ type SongGridProps = {
};
const SongGrid = ({ songs, parentArtistName }: SongGridProps) => {
const { playTrack } = usePlayerContext();
const playTrack = useSetAtom(playTrackAtom);
return (
<Grid container spacing={2} sx={{ display: "flex", flexGrow: 1 }}>
{songs.map((song, index) => (
@@ -50,6 +51,7 @@ const SongGrid = ({ songs, parentArtistName }: SongGridProps) => {
<Illustration
illustration={song?.illustration}
quality="low"
fallback={<SongIcon />}
/>
}
title={song?.name}
+2
View File
@@ -19,6 +19,7 @@
import { useTranslation } from "react-i18next";
import type { AlbumWithRelations } from "../../models/album";
import AlbumContextualMenu from "../contextual-menu/album-contextual-menu";
import { AlbumIcon } from "../icons";
import Illustration from "../illustration";
import Tile from "./tile";
@@ -57,6 +58,7 @@ const AlbumTile = (props: {
<Illustration
illustration={props.album?.illustration}
quality="medium"
fallback={<AlbumIcon />}
/>
}
/>
+3 -1
View File
@@ -17,8 +17,9 @@
*/
import type { ReleaseWithRelations } from "../../models/release";
import getYear from "../../utils/getYear";
import { getYear } from "../../utils/date";
import ReleaseContextualMenu from "../contextual-menu/release-contextual-menu";
import { AlbumIcon } from "../icons";
import Illustration from "../illustration";
import Tile from "./tile";
@@ -52,6 +53,7 @@ const ReleaseTile = (props: {
<Illustration
illustration={props.release?.illustration}
quality="medium"
fallback={<AlbumIcon />}
/>
}
/>
+10 -8
View File
@@ -29,7 +29,6 @@ import {
import { styled } from "@mui/material/styles";
import Link from "next/link";
import type { ComponentProps } from "react";
import type { RequireAllOrNone } from "type-fest";
const PREFIX = "Tile";
@@ -87,13 +86,16 @@ type TileProps = {
onClick?: () => void;
/* Additional props to customize card */
cardProps?: ComponentProps<typeof Card>;
} & RequireAllOrNone<{
subtitle: string | undefined | null;
/**
* URL to push on secondary tile tap
*/
secondaryHref?: string;
}>;
} & (
| {
subtitle: string | undefined | null;
/**
* URL to push on secondary tile tap
*/
secondaryHref?: string;
}
| Record<"subtitle" | "secondaryHref", never>
);
const Tile = (props: TileProps) => {
const _theme = useTheme();
+5 -2
View File
@@ -16,12 +16,14 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useSetAtom } from "jotai";
import API from "../../api/api";
import { useQueryClient } from "../../api/use-query";
import { usePlayerContext } from "../../contexts/player";
import type { VideoWithRelations } from "../../models/video";
import { playTrackAtom } from "../../state/player";
import formatDuration from "../../utils/formatDuration";
import VideoContextualMenu from "../contextual-menu/video-contextual-menu";
import { VideoIcon } from "../icons";
import Illustration from "../illustration";
import Tile from "./tile";
@@ -44,7 +46,7 @@ const VideoTile = ({
onClick,
}: VideoTileProps) => {
const queryClient = useQueryClient();
const { playTrack } = usePlayerContext();
const playTrack = useSetAtom(playTrackAtom);
const { subtitle, secondaryHref } = !video
? { subtitle: undefined, secondaryHref: undefined }
: {
@@ -88,6 +90,7 @@ const VideoTile = ({
aspectRatio={16 / 9}
illustration={video?.illustration}
imgProps={{ objectFit: "cover" }}
fallback={<VideoIcon />}
/>
}
/>
+10 -1
View File
@@ -47,8 +47,17 @@ const TrackFileInfo = ({ trackId }: { trackId: number }) => {
remastered: track.data
? t(track.data.isRemastered ? "yes" : "no")
: undefined,
bpm: track.data?.song
? (track.data.song.bpm ?? t("Unknown"))
: track.data?.song === undefined
? undefined
: "N/A",
duration: track.data ? formatDuration(track.data.duration) : undefined,
bitRate: track.data ? `${track.data.bitrate} kbps` : undefined,
bitRate: track.data
? track.data.bitrate
? `${track.data.bitrate} kbps`
: t("Unknown")
: undefined,
type: track.data?.type,
extension: sourceFile.data
? (sourceFile.data.path
-156
View File
@@ -1,156 +0,0 @@
/*
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
* Copyright (C) 2023
*
* Meelo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Meelo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { createContext, useContext, useState } from "react";
import type Artist from "../models/artist";
import type { TrackWithRelations } from "../models/track";
export type TrackState = {
track: TrackWithRelations<"illustration">;
artist: Artist;
};
export type PlayerState = {
/**
* The collection of track in the playlist
*/
playlist: TrackState[];
/**
* The position of the current track in the playlist
* Acts like a cursor
* If it equals -1, the playlist is finished or not playing
*/
cursor: number;
};
export type PlayerActions = {
playTrack: (track: TrackState) => void;
playNext: (track: TrackState) => void;
playAfter: (track: TrackState) => void;
playTracks: (payload: { tracks: TrackState[]; cursor?: number }) => void;
skipTrack: () => void;
playPreviousTrack: () => void;
reorder: (reordering: Record<"from" | "to", number>) => void;
emptyPlaylist: () => void;
};
const PlayerContext = createContext<PlayerState & PlayerActions>({
playlist: [],
cursor: -1,
playTrack: () => {},
playNext: () => {},
playAfter: () => {},
playTracks: () => {},
skipTrack: () => {},
playPreviousTrack: () => {},
reorder: () => {},
emptyPlaylist: () => {},
});
const PlayerContextProvider = (props: { children: JSX.Element }) => {
const [playerState, setPlayerState] = useState<PlayerState>({
playlist: [],
cursor: -1,
});
return (
<PlayerContext.Provider
value={{
playlist: playerState.playlist,
cursor: playerState.cursor,
playTrack: (track: TrackState) => {
setPlayerState({
playlist: [track],
cursor: 0,
});
},
playNext: (track: TrackState) => {
setPlayerState((state) => {
state.playlist.splice(state.cursor + 1, 0, track);
const newState = {
cursor: state.cursor,
playlist: Array.of(...state.playlist),
};
return newState;
});
},
playAfter: (track: TrackState) => {
setPlayerState((state) => ({
playlist: [...state.playlist, track],
cursor: state.cursor,
}));
},
playTracks: (payload: {
tracks: TrackState[];
cursor?: number;
}) => {
setPlayerState({
playlist: payload.tracks,
cursor: payload.cursor ?? 0,
});
},
skipTrack: () => {
setPlayerState((state) => {
let newCursor = state.cursor + 1;
if (newCursor >= state.playlist.length) {
newCursor = -1;
}
return {
cursor: newCursor,
playlist: state.playlist,
};
});
},
playPreviousTrack: () => {
setPlayerState((state) => {
let newCursor = state.cursor;
if (newCursor >= 0) {
newCursor--;
}
return {
cursor: newCursor,
playlist: state.playlist,
};
});
},
reorder: (action: Record<"from" | "to", number>) => {
setPlayerState((state) => {
const [removed] = state.playlist.splice(action.from, 1);
state.playlist.splice(action.to, 0, removed);
return state;
});
},
emptyPlaylist: () => {
setPlayerState(() => {
return {
playlist: [],
cursor: -1,
};
});
},
}}
>
{props.children}
</PlayerContext.Provider>
);
};
const usePlayerContext = () => useContext(PlayerContext);
export { usePlayerContext, PlayerContextProvider };
+12 -3
View File
@@ -28,6 +28,7 @@
"versions": "Versions",
"tracks": "Tracks",
"genres": "Genres",
"compilations": "Compilations",
"externalLinks": "External Links",
"disc": "Disc",
"otherAlbumReleases": "Other releases of the same album",
@@ -47,12 +48,12 @@
"song": "Song",
"videos": "Videos",
"seeAll": "See All",
"trackInformation": "Track Information",
"name": "Name",
"playCount": "Play Count",
"userPlayCount": "Personal Play Count",
"totalPlayCount": "Total Play Count",
"duration": "Duration",
"bpm": "BPM",
"bitRate": "Bit Rate",
"type": "Type",
"extension": "Extension",
@@ -80,6 +81,7 @@
"refresh": "Refresh",
"scan": "Scan",
"refreshMetadata": "Refresh Metadata",
"refreshMetadataForceLabel": "Force refresh if files have not changed",
"createLibrary": "Create Library",
"deletingLibrary": "Deleting library...",
"deleteLibrary": "Delete Library",
@@ -164,7 +166,9 @@
"usernameTooShort": "Username is too short",
"passwordTooShort": "Password is too short",
"accountCreated": "Congrats! Your Meelo account has been created. You now have to wait for the admin to enable your account",
"moreInfo": "More Info",
"trackInfo": "Track Info",
"songInfo": "Song Info",
"info": "Info",
"share": "Share",
"playNext": "Play Next",
"playAfter": "Play After",
@@ -248,5 +252,10 @@
"goToSettingsPage": "Go to Settings",
"openClosePlayer": "Open / Close Player",
"closePlayer": "Close Player",
"playPauseWhenExpanded": "Play / Pause (When player is open)"
"playPauseWhenExpanded": "Play / Pause (When player is open)",
"emptyStateDefault": "Nothing to be found here...",
"emptyStateLyrics": "No lyrics to sing along to :(",
"emptyStatePlaylists": "There are no playlists... yet!",
"emptyStatePlaylist": "There are no songs in this playlist... yet!",
"emptyStateHome": "Oh wow, it's quiet in here..."
}
+12 -3
View File
@@ -26,6 +26,7 @@
"versions": "Versions",
"tracks": "Pistes",
"genres": "Genres",
"compilations": "Compilations",
"externalLinks": "Liens Externes",
"disc": "Disque",
"otherAlbumReleases": "Autres disques du même album",
@@ -37,12 +38,12 @@
"topVideos": "Meilleurs Clips Vidéos",
"albums": "Albums",
"seeAll": "Voir Plus",
"trackInformation": "Données de la piste",
"name": "Nom",
"playCount": "Nombre de lectures",
"userPlayCount": "Nombre de lectures",
"totalPlayCount": "Nombre total de lectures",
"duration": "Durée",
"bpm": "BPM",
"bitRate": "Bitrate",
"type": "Type",
"extension": "Extension",
@@ -70,6 +71,7 @@
"refresh": "Rafraichir",
"scan": "Scanner",
"refreshMetadata": "Rafraichir les métadonnées",
"refreshMetadataForceLabel": "Force le scan si les fichiers n'ont pas changés",
"createLibrary": "Créer une Bibliothèque",
"deleteLibrary": "Supprimer la Bibliothèque",
"deleteLibraryAction": "Supprimer une Bibliothèque",
@@ -164,7 +166,9 @@
"passwordTooShort": "Le mot de passe est trop court",
"accountCreated": "Votre compte a été créé. Contactez l'administrateur pour qu'il l'active.",
"changeIllutration": "Changer l'illustration",
"moreInfo": "Voir les Informations",
"trackInfo": "Infos sur la piste",
"songInfo": "Infos sur le morceau",
"info": "Infos",
"share": "Partager",
"playNext": "Lire Ensuite",
"playAfter": "Lire Après",
@@ -248,5 +252,10 @@
"goToSettingsPage": "Accéder aux réglages",
"openClosePlayer": "Ouvrir / Fermer le lecteur",
"closePlayer": "Fermer le lecteur",
"playPauseWhenExpanded": "Pauser / Jouer (Quand le lecteur est ouvert)"
"playPauseWhenExpanded": "Pauser / Jouer (Quand le lecteur est ouvert)",
"emptyStateDefault": "Rien d'interessant ici...",
"emptyStateLyrics": "Pas de paroles pour chanter à tue-tête :(",
"emptyStatePlaylists": "Il n'y a aucune playlist... pour l'instant!",
"emptyStatePlaylist": "Il n'y a aucun morceau dans cette playlist... pour l'instant!",
"emptyStateHome": "Oh wow, c'est silencieux par ici.."
}
+6 -3
View File
@@ -21,8 +21,8 @@ import { type NextRequest, NextResponse } from "next/server";
import { QueryClient } from "react-query";
import API from "./api/api";
import { prepareMeeloQuery } from "./api/use-query";
import store from "./state/store";
import { setAccessToken } from "./state/userSlice";
import { store } from "./state/store";
import { accessTokenAtom } from "./state/user";
import { UserAccessTokenCookieKey } from "./utils/cookieKeys";
export async function middleware(request: NextRequest) {
@@ -31,13 +31,16 @@ export async function middleware(request: NextRequest) {
const queryClient = new QueryClient();
if (accessToken) {
store.dispatch(setAccessToken(accessToken));
store.set(accessTokenAtom, accessToken);
} else {
// Disable SSR if user is not authentified
return NextResponse.redirect(`${origin}/`);
}
const albumId = pathname.match("/albums/(?<slug>[^/]*)")?.at(1)!;
if (albumId === "compilations") {
return;
}
const master = await queryClient
.fetchQuery(prepareMeeloQuery(API.getMasterRelease, albumId))
.catch(() => null);
+3 -2
View File
@@ -17,6 +17,7 @@
*/
import * as yup from "yup";
import { yupdate } from "../utils/yup";
import Artist from "./artist";
import Genre from "./genre";
import Illustration from "./illustration";
@@ -46,9 +47,9 @@ const Album = Resource.concat(
slug: yup.string().required(),
/**
* The date of the first release of the album
* If unknown, the field is set to undefined
* If unknown, the field is set to null
*/
releaseDate: yup.date().required().nullable(),
releaseDate: yupdate.required().nullable(),
/**
* Type of the album
*/
+2 -1
View File
@@ -17,6 +17,7 @@
*/
import * as yup from "yup";
import { yupdate } from "../utils/yup";
import Resource from "./resource";
const File = Resource.concat(
@@ -28,7 +29,7 @@ const File = Resource.concat(
/**
* Date of the file registration
*/
registerDate: yup.date().required(),
registerDate: yupdate.required(),
/**
* ID of the library
*/
+11 -1
View File
@@ -31,7 +31,17 @@ const Illustration = yup
* Aspect Ratio of The Image
*/
aspectRatio: yup.number().required(),
colors: yup.array(yup.string().required()).required(),
colors: yup
.array(
yup
.string()
.required()
.transform((color: string) => {
const afterHash = color.slice(1);
return `#${afterHash.padStart(6, "0")}`;
}),
)
.required(),
})
.required();
+2 -1
View File
@@ -17,6 +17,7 @@
*/
import * as yup from "yup";
import { yupdate } from "../utils/yup";
import Illustration from "./illustration";
import Resource from "./resource";
import Song, { type SongInclude, SongWithRelations } from "./song";
@@ -56,7 +57,7 @@ export const Playlist = Resource.concat(
/**
* the date the playlist was created
*/
createdAt: yup.date().required(),
createdAt: yupdate.required(),
}),
);
Loaded 100 of 193 files, more files were not shown because too many files have changed in this diff. Show more