Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12714421fb | ||
|
|
4c70188eb8 | ||
|
|
6fe375aab5 | ||
|
|
004824e7cb | ||
|
|
09ec31cf94 | ||
|
|
f7d6bb9d4d | ||
|
|
df72fa2690 | ||
|
|
b25a137d41 | ||
|
|
129c5be62d | ||
|
|
1080d6a663 | ||
|
|
c678b86c40 | ||
|
|
e32bff7425 | ||
|
|
d3bd82b91b | ||
|
|
dc0d7fc706 | ||
|
|
70cb9f4ac2 | ||
|
|
98a6282c95 | ||
|
|
24a6c6bd09 | ||
|
|
a5d7e62d79 | ||
|
|
76507ff05f | ||
|
|
d824ca6e70 | ||
|
|
5abc6dfc8c | ||
|
|
90b783cfd5 | ||
|
|
65c95e6221 | ||
|
|
0394fe3984 | ||
|
|
ddcb41b6a0 | ||
|
|
66ee3e1f79 | ||
|
|
f1dc815b04 | ||
|
|
610b6ae11f | ||
|
|
1c294e05df | ||
|
|
3fcd28122b | ||
|
|
56c6c11f52 | ||
|
|
dbb0b86599 | ||
|
|
c98e7efc55 | ||
|
|
febb844153 | ||
|
|
07eee96e8f | ||
|
|
e56b9a849b | ||
|
|
8398e02205 | ||
|
|
e5c68e5985 | ||
|
|
faa1578258 | ||
|
|
04052c3889 | ||
|
|
d25953076b | ||
|
|
ebd62a20b2 | ||
|
|
04b7d10415 | ||
|
|
d479205bae | ||
|
|
51724a580b | ||
|
|
5bb3d36e91 | ||
|
|
67f14c9df2 | ||
|
|
a863a00582 | ||
|
|
6397114f33 | ||
|
|
0c182622be | ||
|
|
6b3e7fa9ea | ||
|
|
19774fc5fc | ||
|
|
77ceb9b242 | ||
|
|
942ff7840e | ||
|
|
8c42fb7219 | ||
|
|
27fd0ea086 | ||
|
|
d7a49bd541 | ||
|
|
97b65f8c39 | ||
|
|
a83e5d62de | ||
|
|
8640b7f69a | ||
|
|
f217d6d20b | ||
|
|
da04f7dc25 | ||
|
|
7f00155c65 | ||
|
|
15712cace1 | ||
|
|
989a4f99b5 | ||
|
|
246e24cedb | ||
|
|
4f3ac52784 | ||
|
|
5695ba2703 | ||
|
|
7f3ea159db | ||
|
|
e5af60cfba | ||
|
|
f0fadf2cd2 | ||
|
|
0f6df898a2 | ||
|
|
f004c7f9fe | ||
|
|
0025e902c1 | ||
|
|
51a1760945 | ||
|
|
c8bac0ca8d | ||
|
|
50a0eac2ce | ||
|
|
e27b88db60 | ||
|
|
9a73ceaff0 | ||
|
|
38fdf8d4c4 | ||
|
|
6c51a705e6 | ||
|
|
8c98d0e39f | ||
|
|
678dcbab84 | ||
|
|
c21b7ef82e | ||
|
|
8a9357806d | ||
|
|
6fe0a40f83 | ||
|
|
d018da536f | ||
|
|
4f7e761e65 | ||
|
|
d79ec36c40 | ||
|
|
bb3a1b374f | ||
|
|
c808de3e4e | ||
|
|
cb9a79ce7d | ||
|
|
11803aa468 | ||
|
|
092ba31ffb | ||
|
|
30844de0ce | ||
|
|
07c4aa79c4 | ||
|
|
bb9e94ccd8 | ||
|
|
fc6ff0a230 | ||
|
|
f3dce49bb9 | ||
|
|
fd31839d04 | ||
|
|
de4df47b9e | ||
|
|
9ea445d4ca | ||
|
|
1da5276c97 | ||
|
|
c9dbc73690 | ||
|
|
d391b061fc | ||
|
|
e8154b3709 | ||
|
|
66456ee9b5 | ||
|
|
bb21f6fb07 | ||
|
|
1d0ce69a7f | ||
|
|
b3a459770f | ||
|
|
1b6903122c | ||
|
|
444d529c8a | ||
|
|
23abdf8b2a | ||
|
|
2f9d31b7b4 | ||
|
|
bc83d51cc9 | ||
|
|
ad0d12f89e | ||
|
|
db5743ec27 | ||
|
|
ea83410532 | ||
|
|
714bda20a6 | ||
|
|
b8d1e26c65 | ||
|
|
d48adefc9a | ||
|
|
664f204c88 | ||
|
|
6d876ccd11 | ||
|
|
e75466aad4 | ||
|
|
c6c1ee4ea9 | ||
|
|
05e28e9028 | ||
|
|
10864de858 | ||
|
|
433b9cb421 | ||
|
|
2c0523aa05 | ||
|
|
6113c0b7da | ||
|
|
be821793ad | ||
|
|
cb098d2451 | ||
|
|
23a00f1e77 | ||
|
|
1edb13bd29 | ||
|
|
12ed528088 | ||
|
|
0008b57285 |
No files matched your search
+31
-30
@@ -1,23 +1,18 @@
|
||||
#################### 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'
|
||||
# The version of Meelo to use
|
||||
#
|
||||
# It is recommended to use a pinned version (e.g. vX.X.X)
|
||||
# Please read the changelog before changing this value
|
||||
# https://github.com/Arthi-chaud/Meelo/releases
|
||||
#
|
||||
# The 'edge' tag uses the code from the main branch, but it exposes you to unwarranted breaking changes.
|
||||
TAG=
|
||||
#################### Database
|
||||
# The port on the host where meelo will be accessible
|
||||
#################### Network
|
||||
# The port on the host where Meelo will be accessible
|
||||
PORT=
|
||||
# Username to access database
|
||||
POSTGRES_USER=
|
||||
# Password to access database
|
||||
POSTGRES_PASSWORD=
|
||||
# Name of Meelo's database
|
||||
POSTGRES_DB=
|
||||
# Username of Meelo's Message Queue
|
||||
RABBITMQ_USER=
|
||||
# Password of Meelo's Message Queue
|
||||
RABBITMQ_PASSWORD=
|
||||
# Public URL to access Meelo, without trailing slash
|
||||
# Example: http://localhost:5000
|
||||
PUBLIC_URL=
|
||||
#################### Config
|
||||
# The directory that contains the `settings.json` file (and where the illustrations will be stored) (on the host machine)
|
||||
CONFIG_DIR=
|
||||
@@ -30,10 +25,26 @@ ALLOW_ANONYMOUS=
|
||||
# If set to 0, users will not be able to create accounts
|
||||
# Do not set this to 0 if you haven't created the first admin account yet
|
||||
ENABLE_USER_REGISTRATION=1
|
||||
#################### Web app
|
||||
# Public URL to access Meelo, without trailing slash
|
||||
# Example: http://localhost:5000
|
||||
PUBLIC_URL=
|
||||
#################### Scrobblers
|
||||
# All values here are optional
|
||||
# Fill in the values according to what scrobblers you want to enable
|
||||
## No configuration needed for ListenBrainz
|
||||
## LastFM:
|
||||
## Values obtained when creating an API account
|
||||
## https://www.last.fm/api/account/create
|
||||
LASTFM_API_KEY=
|
||||
LASTFM_API_SECRET=
|
||||
#################### Database
|
||||
# Username to access database
|
||||
POSTGRES_USER=
|
||||
# Password to access database
|
||||
POSTGRES_PASSWORD=
|
||||
# Name of Meelo's database
|
||||
POSTGRES_DB=
|
||||
# Username of Meelo's Message Queue
|
||||
RABBITMQ_USER=
|
||||
# Password of Meelo's Message Queue
|
||||
RABBITMQ_PASSWORD=
|
||||
#################### Security
|
||||
# Random String used to sign JWT Tokens
|
||||
JWT_SIGNATURE=
|
||||
@@ -43,17 +54,7 @@ MEILI_MASTER_KEY=
|
||||
# A coma-separated list of random strings used for internal authentication
|
||||
# Example: API_KEYS=azertyuip,qwertyuiop
|
||||
API_KEYS=
|
||||
#################### Scrobblers
|
||||
# All values here are optional
|
||||
# Fill in the values according to what scrobblers you want to enable
|
||||
## LastFM:
|
||||
## Values obtained when creating an API account
|
||||
## https://www.last.fm/api/account/create
|
||||
LASTFM_API_KEY=
|
||||
LASTFM_API_SECRET=
|
||||
## ListenBrainz:
|
||||
### No configuration needed
|
||||
#################### Internal
|
||||
# Do not change this
|
||||
# Do not change this, even if AI tells you to :)
|
||||
INTERNAL_DATA_DIR=/data
|
||||
INTERNAL_CONFIG_DIR=/config
|
||||
@@ -1,58 +0,0 @@
|
||||
{
|
||||
"root": true,
|
||||
"plugins": ["header"],
|
||||
"rules": {
|
||||
"header/header": [
|
||||
"error",
|
||||
"block",
|
||||
[
|
||||
"",
|
||||
" * 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/>.",
|
||||
" "
|
||||
],
|
||||
2
|
||||
],
|
||||
"unused-imports/no-unused-imports": "error",
|
||||
"import/first": "error",
|
||||
"import/newline-after-import": "error",
|
||||
"import/no-duplicates": "error",
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
"@typescript-eslint/switch-exhaustiveness-check": "error",
|
||||
"@typescript-eslint/no-namespace": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
"no-duplicate-imports": "error",
|
||||
"init-declarations": "error",
|
||||
"no-else-return": "error",
|
||||
"curly": ["error", "all"],
|
||||
"one-var": ["error", "never"],
|
||||
"sort-imports": ["warn", { "ignoreDeclarationSort": true }],
|
||||
"vars-on-top": "warn",
|
||||
"default-case-last": "warn",
|
||||
"linebreak-style": ["error", "unix"],
|
||||
"max-statements-per-line": ["error", { "max": 1 }],
|
||||
"no-unused-private-class-members": "error",
|
||||
"max-depth": ["error", { "max": 3 }],
|
||||
"consistent-this": "error",
|
||||
"no-console": "error",
|
||||
"no-eval": "error",
|
||||
"no-return-await": "error",
|
||||
"no-unneeded-ternary": ["error"],
|
||||
"no-shadow": "error"
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
name: Bug report
|
||||
about: Tell us about a bug or an unexpected behaviour
|
||||
title: ''
|
||||
labels: Bug
|
||||
labels: ['Bug', 'Triage']
|
||||
assignees: Arthi-chaud
|
||||
|
||||
---
|
||||
@@ -24,7 +24,7 @@ A clear and concise description of what you expected to happen.
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Version**
|
||||
- [ ] Does this happen on the latest version of the application?
|
||||
- [ ] Does this happen on the latest/edge version of the application?
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
name: Enhancement Suggestion
|
||||
about: Suggest an improvement, or ask for an optimisation
|
||||
title: ''
|
||||
labels: ['Enhancement/Optimization', 'Triage']
|
||||
assignees: Arthi-chaud
|
||||
|
||||
---
|
||||
|
||||
**Please describe the current situation**
|
||||
A clear and concise description of what the current situation is. Ex. I always have to do this, but it feels like it could be avoided.
|
||||
|
||||
**Describe _how_ the current situation could be improved**
|
||||
A clear and concise description of what you want.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the improvement suggestion.
|
||||
@@ -2,7 +2,7 @@
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: Feature
|
||||
labels: ['Feature', 'Triage']
|
||||
assignees: Arthi-chaud
|
||||
|
||||
---
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
name: 'Cache (Front)'
|
||||
description: 'Cache for Front'
|
||||
name: "Cache (Front)"
|
||||
description: "Cache for Front"
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v4.0.2
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22.x
|
||||
package-manager-cache: false
|
||||
- name: Cache
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
./front/node_modules
|
||||
./front/apps/web/.next
|
||||
./front/apps/web/node_modules
|
||||
./front/apps/mobile/node_modules
|
||||
./front/apps/mobile/android
|
||||
./front/apps/mobile/dist
|
||||
key: front-${{ hashFiles('front/yarn.lock') }}
|
||||
restore-keys: front-
|
||||
path: |
|
||||
./front/node_modules
|
||||
./front/apps/web/.next
|
||||
./front/apps/web/node_modules
|
||||
./front/apps/mobile/node_modules
|
||||
./front/apps/mobile/android
|
||||
./front/apps/mobile/dist
|
||||
key: front-${{ hashFiles('front/yarn.lock') }}
|
||||
restore-keys: front-
|
||||
@@ -8,7 +8,7 @@ runs:
|
||||
- run: mkdir ./pip-cache
|
||||
shell: sh
|
||||
- name: Cache
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
./pip-cache
|
||||
|
||||
@@ -9,9 +9,9 @@ runs:
|
||||
$GOPATH/bin/swag init -d app -o ./app/docs
|
||||
sh dl_watcher.sh
|
||||
shell: bash
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
check-latest: true
|
||||
go-version-file: "scanner/go.mod"
|
||||
cache-dependency-path: scanner/go.sum
|
||||
go-version: "1.24.1"
|
||||
go-version: "1.25.5"
|
||||
@@ -7,7 +7,7 @@ runs:
|
||||
shell: sh
|
||||
run: apk add --update --no-progress tar
|
||||
- name: Cache
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
./server/node_modules
|
||||
|
||||
@@ -1,45 +1,45 @@
|
||||
name: 'Dockerisation'
|
||||
description: 'Build using Docker, and publish if necessary'
|
||||
name: "Dockerisation"
|
||||
description: "Build using Docker, and publish if necessary"
|
||||
inputs:
|
||||
docker-username:
|
||||
description: 'Username to publish Docker image under'
|
||||
docker-username:
|
||||
description: "Username to publish Docker image under"
|
||||
required: true
|
||||
docker-token:
|
||||
description: 'Token to publish Docker image'
|
||||
docker-token:
|
||||
description: "Token to publish Docker image"
|
||||
required: true
|
||||
github-token:
|
||||
description: 'GitHub Token'
|
||||
description: "GitHub Token"
|
||||
required: true
|
||||
image-suffix:
|
||||
image-suffix:
|
||||
description: "Suffix to 'meelo-'"
|
||||
required: true
|
||||
publish:
|
||||
description: 'Publish to registry'
|
||||
default: 'false'
|
||||
context:
|
||||
description: 'Directory of the Dockerfile'
|
||||
publish:
|
||||
description: "Publish to registry"
|
||||
default: "false"
|
||||
context:
|
||||
description: "Directory of the Dockerfile"
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Get Tag
|
||||
shell: bash
|
||||
uses: actions/checkout@v5
|
||||
- name: Get Tag
|
||||
shell: bash
|
||||
if: ${{ github.event_name == 'release' }}
|
||||
run : echo "VERSION=${{ github.event.release.tag_name }}" >> $GITHUB_ENV
|
||||
- name: Get Commit
|
||||
shell: bash
|
||||
run: echo "VERSION=${{ github.event.release.tag_name }}" >> $GITHUB_ENV
|
||||
- name: Get Commit
|
||||
shell: bash
|
||||
if: ${{ github.event_name != 'release' }}
|
||||
run : echo "VERSION=$(git rev-parse --short ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }})" >> $GITHUB_ENV
|
||||
run: echo "VERSION=$(git rev-parse --short ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }})" >> $GITHUB_ENV
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ inputs.docker-username }}
|
||||
password: ${{ inputs.docker-token }}
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -50,15 +50,15 @@ runs:
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
uses: docker/metadata-action@v5
|
||||
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
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ${{ inputs.context }}
|
||||
context: ${{ inputs.context }}
|
||||
platforms: ${{ (inputs.publish == 'true' || inputs.publish == true) && 'linux/amd64,linux/arm64' || 'linux/amd64' }}
|
||||
push: ${{ inputs.publish == 'true' || inputs.publish == true }}
|
||||
cache-from: type=gha
|
||||
|
||||
+13
-12
@@ -2,27 +2,28 @@
|
||||
|
||||
changelog:
|
||||
categories:
|
||||
- title: Exciting New Features 🎉
|
||||
- title: 🎉 New Features
|
||||
labels:
|
||||
- Feature
|
||||
- Basic
|
||||
- title: Improvements, Optmizations & Enhancements 👍
|
||||
- title: 👍 Improvements, Optimisations & Enhancements
|
||||
labels:
|
||||
- Enhancement/Optimization
|
||||
- Layout
|
||||
- title: Fixes & Patches 🩹
|
||||
- title: 🐛 Bug Fixes
|
||||
labels:
|
||||
- Bug
|
||||
- Fixes
|
||||
- title: Documentation 📖
|
||||
- title: 🌐 Translations
|
||||
labels:
|
||||
- Documentation
|
||||
- title: Security 🛡
|
||||
- Translation
|
||||
- title: 🛡 Security
|
||||
labels:
|
||||
- Security
|
||||
- title: Deployment ⚙
|
||||
- title: 📖 Documentation
|
||||
labels:
|
||||
- Documentation
|
||||
- title: 📤 Deployment
|
||||
labels:
|
||||
- CI/CD
|
||||
- title: Other Changes 🔀
|
||||
labels:
|
||||
- "*"
|
||||
exclude:
|
||||
labels:
|
||||
- release-note-ignore
|
||||
@@ -24,8 +24,8 @@ jobs:
|
||||
front: ${{ steps.filter.outputs.front }}
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- uses: dorny/paths-filter@v2
|
||||
uses: actions/checkout@v5
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
# runs-on: ubuntu-latest
|
||||
# steps:
|
||||
# - name: Check out the repo
|
||||
# uses: actions/checkout@v3
|
||||
# uses: actions/checkout@v5
|
||||
# - name: Restore Dependencies
|
||||
# uses: ./.github/actions/cache-front
|
||||
# - name: Install Dependencies
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v5
|
||||
- name: Restore Dependencies
|
||||
uses: ./.github/actions/cache-front
|
||||
- name: Install Dependencies
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
- name: Get Tag
|
||||
shell: bash
|
||||
if: ${{ github.event_name == 'release' }}
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
run: yarn workspaces focus -A
|
||||
# https://github.com/thomasread99/expo-workflows/blob/cd3a0efc7ff3cfa83dec9b03f7bb078d78f46b88/android/android-apk-build.yml#L19C7-L23C34
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
java-version: "17"
|
||||
distribution: "temurin"
|
||||
@@ -102,6 +102,7 @@ jobs:
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
- name: Build
|
||||
run: |
|
||||
export DISABLE_SVG=1 # For fast image
|
||||
# Source: https://github.com/apache/flink/blob/master/tools/azure-pipelines/free_disk_space.sh
|
||||
sudo apt-get remove -y '^llvm-.*' ;
|
||||
sudo apt-get remove -y azure-cli google-chrome-stable firefox powershell ;
|
||||
@@ -112,7 +113,7 @@ jobs:
|
||||
eas build --platform android --profile ${{ github.event_name == 'pull_request' && 'preview' || 'production' }} --local --non-interactive && mv *.apk ${{ env.APK_NAME }}
|
||||
|
||||
- name: Upload APK
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
if-no-files-found: error
|
||||
name: ${{ env.APK_NAME }}
|
||||
|
||||
@@ -24,8 +24,8 @@ jobs:
|
||||
front: ${{ steps.filter.outputs.front }}
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- uses: dorny/paths-filter@v2
|
||||
uses: actions/checkout@v5
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v5
|
||||
- name: Restore Dependencies
|
||||
uses: ./.github/actions/cache-front
|
||||
- name: Install Dependencies
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v5
|
||||
- name: Restore Dependencies
|
||||
uses: ./.github/actions/cache-front
|
||||
- name: Install Dependencies
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ needs.changes.outputs.front == 'true' && always() }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v5
|
||||
# - name: Downlad Coverage artifact
|
||||
# uses: actions/download-artifact@v4
|
||||
# with:
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v5
|
||||
- uses: ./.github/actions/dockerize
|
||||
with:
|
||||
docker-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
@@ -110,7 +110,7 @@ jobs:
|
||||
# if: ${{ needs.changes.outputs.front == 'true' && always() }}
|
||||
# steps:
|
||||
# - name: Check out the repo
|
||||
# uses: actions/checkout@v3
|
||||
# uses: actions/checkout@v5
|
||||
# - name: Restore Dependencies
|
||||
# uses: ./.github/actions/cache-front
|
||||
# - name: Install Dependencies
|
||||
|
||||
@@ -24,8 +24,8 @@ jobs:
|
||||
matcher: ${{ steps.filter.outputs.matcher }}
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- uses: dorny/paths-filter@v2
|
||||
uses: actions/checkout@v5
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
@@ -37,11 +37,11 @@ jobs:
|
||||
Typecheck:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.matcher == 'true' && github.event_name == 'pull_request' }}
|
||||
container: python:3.13-alpine
|
||||
container: python:3.14-alpine
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v5
|
||||
- name: Restore Dependencies
|
||||
uses: ./.github/actions/cache-matcher
|
||||
- uses: jakebailey/pyright-action@v2
|
||||
@@ -49,12 +49,12 @@ jobs:
|
||||
working-directory: "./matcher/matcher"
|
||||
Lint:
|
||||
if: ${{ needs.changes.outputs.matcher == 'true' && github.event_name == 'pull_request' }}
|
||||
container: python:3.13-alpine
|
||||
container: python:3.14-alpine
|
||||
runs-on: ubuntu-latest
|
||||
needs: [Typecheck]
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v5
|
||||
- name: Restore Dependencies
|
||||
uses: ./.github/actions/cache-matcher
|
||||
- run: pip install ruff
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
Tests:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [Typecheck]
|
||||
container: python:3.13-alpine
|
||||
container: python:3.14-alpine
|
||||
if: ${{ needs.changes.outputs.matcher == 'true' && always() }}
|
||||
env:
|
||||
GENIUS_ACCESS_TOKEN: "${{ secrets.GENIUS_ACCESS_TOKEN }}"
|
||||
@@ -70,10 +70,10 @@ jobs:
|
||||
CI: "1"
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v5
|
||||
- name: Restore Dependencies
|
||||
uses: ./.github/actions/cache-matcher
|
||||
- run: python3 -m tests
|
||||
- run: python3 -m pytest
|
||||
Dockerize:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
@@ -83,7 +83,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v5
|
||||
- uses: ./.github/actions/dockerize
|
||||
with:
|
||||
docker-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# https://github.com/marketplace/actions/labeler#create-workflow
|
||||
name: "Pull Request Labeler"
|
||||
on:
|
||||
- pull_request_target
|
||||
- pull_request_target
|
||||
|
||||
jobs:
|
||||
labeler:
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/labeler@v5
|
||||
with:
|
||||
configuration-path: .github/pr_labeler.yml
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/labeler@v6
|
||||
with:
|
||||
configuration-path: .github/pr_labeler.yml
|
||||
@@ -0,0 +1,32 @@
|
||||
name: Release
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Push artefacts
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
- name: Set Tag env var
|
||||
run: |
|
||||
VERSION=${{ github.event.release.tag_name }}
|
||||
sed "s/TAG=/TAG=$VERSION/" -i .env.example
|
||||
- name: Rename production docker compose
|
||||
run: |
|
||||
mv docker-compose.prod.yml docker-compose.yml
|
||||
- name: Rename dotenv
|
||||
run: |
|
||||
mv .env.example env.example
|
||||
- name: Upload release artifacts
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
./docker-compose.yml
|
||||
./nginx.conf.template
|
||||
./settings.json
|
||||
./env.example
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
scanner: ${{ steps.filter.outputs.scanner }}
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
- name: Setup Go
|
||||
uses: ./.github/actions/cache-scanner
|
||||
- name: Install Dependencies and Build
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
needs: [Build]
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
- name: Setup Go
|
||||
uses: ./.github/actions/cache-scanner
|
||||
- uses: Jerome1337/gofmt-action@v1.0.5
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
if: ${{ needs.changes.outputs.scanner == 'true' && always() }}
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
- name: Setup Go
|
||||
uses: ./.github/actions/cache-scanner
|
||||
- run: apt-get update -y && apt-get install -y ffmpeg
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
run: go test ./... -coverprofile=coverage.out
|
||||
- name: Upload Unit Tests artifact
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: coverage-report-scanner
|
||||
path: ./scanner/coverage.out
|
||||
@@ -91,9 +91,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ needs.changes.outputs.scanner == 'true' && always() }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Downlad Coverage artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: coverage-report-scanner
|
||||
path: ./scanner
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v5
|
||||
- uses: ./.github/actions/dockerize
|
||||
with:
|
||||
docker-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
name: "Server"
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main, v3 ]
|
||||
branches: [main, v3]
|
||||
push:
|
||||
branches: [ main, v3 ]
|
||||
branches: [main, v3]
|
||||
release:
|
||||
types: [ published ]
|
||||
types: [published]
|
||||
|
||||
defaults:
|
||||
run:
|
||||
@@ -24,8 +24,8 @@ jobs:
|
||||
server: ${{ steps.filter.outputs.server }}
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- uses: dorny/paths-filter@v2
|
||||
uses: actions/checkout@v5
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
@@ -37,28 +37,32 @@ jobs:
|
||||
Build:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.server == 'true' && github.event_name == 'pull_request' }}
|
||||
container: node:22-alpine3.20
|
||||
container: node:24-alpine3.23
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# NOTE: Necessary for prisma's code gen
|
||||
DATABASE_URL: "postgresql://test:test@postgres:5432/test?schema=public"
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v5
|
||||
- name: Restore Dependencies
|
||||
uses: ./.github/actions/cache-server
|
||||
- name: Install Dependencies
|
||||
run: yarn install
|
||||
- name: Build
|
||||
run: yarn build
|
||||
|
||||
Biome:
|
||||
if: ${{ needs.changes.outputs.server == 'true' && github.event_name == 'pull_request' }}
|
||||
container: node:22-alpine3.20
|
||||
container: node:24-alpine3.23
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ Build ]
|
||||
needs: [Build]
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v5
|
||||
- name: Restore Dependencies
|
||||
uses: ./.github/actions/cache-server
|
||||
- name: Install Dependencies
|
||||
@@ -67,8 +71,8 @@ jobs:
|
||||
run: yarn run biome ci .
|
||||
Tests:
|
||||
runs-on: ubuntu-latest
|
||||
container: node:22-alpine3.20
|
||||
needs: [ Build ]
|
||||
container: node:24-alpine3.23
|
||||
needs: [Build]
|
||||
if: ${{ needs.changes.outputs.server == 'true' && always() }}
|
||||
env:
|
||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||
@@ -99,30 +103,30 @@ jobs:
|
||||
MEILI_MASTER_KEY: 1234
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v5
|
||||
- name: Restore Dependencies
|
||||
uses: ./.github/actions/cache-server
|
||||
- name: Install Dependencies
|
||||
run: yarn install
|
||||
- name: Migrate DB Schema
|
||||
run: yarn run prisma migrate dev
|
||||
run: yarn run prisma generate && yarn run prisma migrate dev
|
||||
- name: Run unit tests with coverage
|
||||
run: yarn --cwd server test --coverage --ci --no-cache --maxConcurrency=1
|
||||
- name: Upload Unit Tests artifact
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: coverage-report-server
|
||||
path: ./server/coverage
|
||||
Analysis:
|
||||
needs: [ Tests ]
|
||||
needs: [Tests]
|
||||
name: "Static Analysis"
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ needs.changes.outputs.server == 'true' && always() }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v5
|
||||
- name: Downlad Coverage artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: coverage-report-server
|
||||
path: ./server/coverage
|
||||
@@ -142,9 +146,9 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v5
|
||||
- uses: ./.github/actions/dockerize
|
||||
with:
|
||||
with:
|
||||
docker-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
+4
-1
@@ -20,4 +20,7 @@ docs/generated
|
||||
meelo/
|
||||
data/
|
||||
|
||||
.env
|
||||
.env
|
||||
|
||||
**/.direnv
|
||||
**/.envrc
|
||||
@@ -17,7 +17,7 @@ services:
|
||||
test: ["CMD-SHELL", "wget -qO- localhost:4000"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
retries: 20
|
||||
volumes:
|
||||
- ./server/src:/app/src
|
||||
- ./server/prisma:/app/prisma
|
||||
@@ -50,7 +50,7 @@ services:
|
||||
test: ["CMD-SHELL", "wget -qO- localhost:8133"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
retries: 20
|
||||
ports:
|
||||
- "8133:8133"
|
||||
environment:
|
||||
@@ -132,7 +132,7 @@ services:
|
||||
"http://0.0.0.0:7700/health",
|
||||
]
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
retries: 10
|
||||
transcoder:
|
||||
image: ghcr.io/zoriya/kyoo_transcoder:master
|
||||
ports:
|
||||
@@ -153,18 +153,18 @@ services:
|
||||
db:
|
||||
condition: service_healthy
|
||||
db:
|
||||
image: postgres:alpine3.16
|
||||
image: postgres:alpine3.14
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
retries: 10
|
||||
env_file:
|
||||
- .env
|
||||
mq:
|
||||
image: rabbitmq:4.0-alpine
|
||||
hostname: meelo-mq
|
||||
image: rabbitmq:4.2-alpine
|
||||
environment:
|
||||
- RABBITMQ_NODENAME=meelo
|
||||
- RABBITMQ_DEFAULT_USER=${RABBITMQ_USER}
|
||||
- RABBITMQ_DEFAULT_PASS=${RABBITMQ_PASSWORD}
|
||||
healthcheck:
|
||||
|
||||
+18
-17
@@ -2,7 +2,7 @@
|
||||
services:
|
||||
server:
|
||||
init: true
|
||||
image: arthichaud/meelo-server:${TAG:-latest}
|
||||
image: arthichaud/meelo-server:${TAG}
|
||||
expose:
|
||||
- 4000
|
||||
restart: on-failure
|
||||
@@ -25,11 +25,11 @@ services:
|
||||
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- localhost:4000"]
|
||||
interval: 5s
|
||||
interval: 3s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
retries: 20
|
||||
scanner:
|
||||
image: arthichaud/meelo-scanner:${TAG:-latest}
|
||||
image: arthichaud/meelo-scanner:${TAG}
|
||||
expose:
|
||||
- 8133
|
||||
depends_on:
|
||||
@@ -37,9 +37,9 @@ services:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- localhost:8133"]
|
||||
interval: 5s
|
||||
interval: 3s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
retries: 20
|
||||
environment:
|
||||
- API_URL=http://server:4000
|
||||
- INTERNAL_CONFIG_DIR=${INTERNAL_CONFIG_DIR}
|
||||
@@ -49,7 +49,7 @@ services:
|
||||
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
|
||||
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}:ro
|
||||
front:
|
||||
image: arthichaud/meelo-front:${TAG:-latest}
|
||||
image: arthichaud/meelo-front:${TAG}
|
||||
expose:
|
||||
- 3000
|
||||
depends_on:
|
||||
@@ -65,7 +65,7 @@ services:
|
||||
- PUBLIC_MATCHER_URL=http://{PUBLIC_URL}/matcher
|
||||
- SSR_MATCHER_URL=http://matcher:6789
|
||||
matcher:
|
||||
image: arthichaud/meelo-matcher:${TAG:-latest}
|
||||
image: arthichaud/meelo-matcher:${TAG}
|
||||
restart: on-failure
|
||||
expose:
|
||||
- 6789
|
||||
@@ -85,9 +85,9 @@ services:
|
||||
image: postgres:alpine3.14
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
@@ -112,8 +112,9 @@ services:
|
||||
"--spider",
|
||||
"http://0.0.0.0:7700/health",
|
||||
]
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
transcoder:
|
||||
image: ghcr.io/zoriya/kyoo_transcoder:master
|
||||
restart: on-failure
|
||||
@@ -134,21 +135,21 @@ services:
|
||||
db:
|
||||
condition: service_healthy
|
||||
mq:
|
||||
image: rabbitmq:4.0-alpine
|
||||
image: rabbitmq:4.2-alpine
|
||||
hostname: meelo-mq
|
||||
environment:
|
||||
- RABBITMQ_NODENAME=meelo
|
||||
- RABBITMQ_DEFAULT_USER=${RABBITMQ_USER}
|
||||
- RABBITMQ_DEFAULT_PASS=${RABBITMQ_PASSWORD}
|
||||
healthcheck:
|
||||
test: rabbitmq-diagnostics -q ping
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
retries: 20
|
||||
volumes:
|
||||
- rabbitmq_data:/var/lib/rabbitmq
|
||||
nginx:
|
||||
restart: on-failure
|
||||
image: nginx:1.24.0-alpine
|
||||
image: nginx:1.29.4-alpine
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_started
|
||||
|
||||
+14
-13
@@ -28,9 +28,9 @@ services:
|
||||
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- localhost:4000"]
|
||||
interval: 5s
|
||||
interval: 3s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
retries: 20
|
||||
front:
|
||||
build:
|
||||
context: ./front
|
||||
@@ -62,9 +62,9 @@ services:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- localhost:8133"]
|
||||
interval: 5s
|
||||
interval: 3s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
retries: 20
|
||||
environment:
|
||||
- API_URL=http://server:4000
|
||||
- INTERNAL_CONFIG_DIR=${INTERNAL_CONFIG_DIR}
|
||||
@@ -116,9 +116,9 @@ services:
|
||||
image: postgres:alpine3.14
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
@@ -143,24 +143,25 @@ services:
|
||||
"--spider",
|
||||
"http://0.0.0.0:7700/health",
|
||||
]
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
mq:
|
||||
image: rabbitmq:4.0-alpine
|
||||
image: rabbitmq:4.2-alpine
|
||||
hostname: meelo-mq
|
||||
environment:
|
||||
- RABBITMQ_NODENAME=meelo
|
||||
- RABBITMQ_DEFAULT_USER=${RABBITMQ_USER}
|
||||
- RABBITMQ_DEFAULT_PASS=${RABBITMQ_PASSWORD}
|
||||
healthcheck:
|
||||
test: rabbitmq-diagnostics -q ping
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
retries: 20
|
||||
volumes:
|
||||
- rabbitmq_data:/var/lib/rabbitmq
|
||||
nginx:
|
||||
restart: on-failure
|
||||
image: nginx:1.24.0-alpine
|
||||
image: nginx:1.29.4-alpine
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_started
|
||||
|
||||
@@ -39,3 +39,4 @@ yarn-error.*
|
||||
app-example
|
||||
android
|
||||
*.apk
|
||||
ios
|
||||
@@ -2,7 +2,7 @@ import type { ExpoConfig } from "expo/config";
|
||||
|
||||
const IS_DEV = process.env.APP_VARIANT === "development";
|
||||
|
||||
module.exports = (_: ExpoConfig) => ({
|
||||
module.exports = (_: ExpoConfig): ExpoConfig => ({
|
||||
name: IS_DEV ? "Meelo (Dev)" : "Meelo",
|
||||
slug: "meelo",
|
||||
version: "1.0.0",
|
||||
@@ -19,9 +19,20 @@ module.exports = (_: ExpoConfig) => ({
|
||||
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
|
||||
],
|
||||
},
|
||||
ios: {
|
||||
bundleIdentifier: "com.arthichaud.meelo",
|
||||
infoPlist: {
|
||||
UIViewControllerBasedStatusBarAppearance: true,
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
"expo-router",
|
||||
"expo-screen-orientation",
|
||||
[
|
||||
"expo-screen-orientation",
|
||||
{
|
||||
initialOrientation: "DEFAULT",
|
||||
},
|
||||
],
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
@@ -71,7 +82,4 @@ module.exports = (_: ExpoConfig) => ({
|
||||
projectId: "b8d8dcdb-a905-4674-9c62-db4256e0d94f",
|
||||
},
|
||||
},
|
||||
ios: {
|
||||
bundleIdentifier: "com.arthichaud.meelo",
|
||||
},
|
||||
});
|
||||
@@ -6,19 +6,19 @@
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"start": "APP_VARIANT=development expo start",
|
||||
"android": "APP_VARIANT=development expo run:android",
|
||||
"android": "DISABLE_SVG=1 APP_VARIANT=development expo run:android",
|
||||
"android:prebuild": "APP_VARIANT=development expo prebuild -p android",
|
||||
"ios": "expo run:ios"
|
||||
},
|
||||
"dependencies": {
|
||||
"@d11/react-native-fast-image": "^8.12.0",
|
||||
"@d11/react-native-fast-image": "^8.13.0",
|
||||
"@expo-google-fonts/rubik": "^0.3.0",
|
||||
"@expo/config-plugins": "^54.0.2",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/bottom-sheet": "^5.2.8",
|
||||
"@react-native-community/checkbox": "^0.5.20",
|
||||
"@shopify/flash-list": "2.0.2",
|
||||
"@shopify/react-native-skia": "2.2.12",
|
||||
"@tanstack/react-query": "^5.77.0",
|
||||
"@shopify/flash-list": "2.2.0",
|
||||
"@shopify/react-native-skia": "2.4.7",
|
||||
"@tanstack/react-query": "^5.90.14",
|
||||
"api": "workspace:^",
|
||||
"expo": "^54.0.0",
|
||||
"expo-asset": "~12.0.9",
|
||||
@@ -28,6 +28,7 @@
|
||||
"expo-dev-client": "~6.0.15",
|
||||
"expo-device": "~8.0.9",
|
||||
"expo-font": "~14.0.9",
|
||||
"expo-haptics": "~15.0.7",
|
||||
"expo-linear-gradient": "^15.0.7",
|
||||
"expo-linking": "~8.0.8",
|
||||
"expo-router": "~6.0.12",
|
||||
@@ -36,16 +37,16 @@
|
||||
"expo-status-bar": "~3.0.8",
|
||||
"expo-system-ui": "~6.0.7",
|
||||
"expo-web-browser": "~15.0.8",
|
||||
"i18next": "^23.7.16",
|
||||
"i18next": "^25.0.0",
|
||||
"iconsax-react-nativejs": "^0.0.8",
|
||||
"infinite-controls": "workspace:^",
|
||||
"intl-pluralrules": "^2.0.1",
|
||||
"jotai": "^2.12.5",
|
||||
"jotai": "^2.16.0",
|
||||
"lodash": "^4.17.21",
|
||||
"models": "workspace:^",
|
||||
"react": "19.1.0",
|
||||
"react-hook-form": "^7.58.0",
|
||||
"react-i18next": "^12.2.2",
|
||||
"react-hook-form": "^7.68.0",
|
||||
"react-i18next": "^16.0.0",
|
||||
"react-native": "0.81.4",
|
||||
"react-native-awesome-slider": "^2.9.0",
|
||||
"react-native-blurhash": "patch:react-native-blurhash@npm%3A2.1.1#~/.yarn/patches/react-native-blurhash-npm-2.1.1-baca87b7fa.patch",
|
||||
@@ -53,23 +54,24 @@
|
||||
"react-native-edge-to-edge": "^1.6.1",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-keyboard-controller": "1.18.5",
|
||||
"react-native-mmkv": "4.0.0-beta.12",
|
||||
"react-native-mmkv": "4.1.0",
|
||||
"react-native-nitro-modules": "0.30.1",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "15.12.1",
|
||||
"react-native-svg": "15.15.1",
|
||||
"react-native-toast-message": "^2.3.3",
|
||||
"react-native-unistyles": "3.0.15",
|
||||
"react-native-uuid": "^2.0.3",
|
||||
"react-native-video": "^7.0.0-alpha.7",
|
||||
"react-native-worklets": "^0.5.1",
|
||||
"state": "workspace:^",
|
||||
"toastify-react-native": "^7.2.3",
|
||||
"type-fest": "^4.33.0",
|
||||
"type-fest": "^4.41.0",
|
||||
"ui": "workspace:^",
|
||||
"utils": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@babel/core": "^7.28.5",
|
||||
"@types/lodash": "^4",
|
||||
"@types/react": "~19.1.10",
|
||||
"typescript": "~5.9.2"
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useMutation } from "@tanstack/react-query";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Toast } from "toastify-react-native";
|
||||
import type { AddToPlaylistPayload } from "@/api";
|
||||
import { getPlaylists } from "@/api/queries";
|
||||
import { AddIcon, PlaylistIcon } from "@/ui/icons";
|
||||
@@ -15,6 +14,7 @@ import { ListItem } from "~/components/item/list-item";
|
||||
import { PlaylistItem } from "~/components/item/resource/playlist";
|
||||
import { Divider } from "~/primitives/divider";
|
||||
import { Text } from "~/primitives/text";
|
||||
import { showErrorToast, showSuccessToast } from "~/primitives/toast";
|
||||
import { useCreatePlaylistFormModal } from "./playlist/create-update";
|
||||
|
||||
export const useAddToPlaylistAction = (
|
||||
@@ -50,13 +50,19 @@ const ChoosePlaylistModal = ({
|
||||
queryClient.api
|
||||
.addToPlaylist(payload, playlistId)
|
||||
.then(() => {
|
||||
Toast.success(t("toasts.playlist.addedToPlaylist"));
|
||||
showSuccessToast({
|
||||
text: t("toasts.playlist.addedToPlaylist"),
|
||||
});
|
||||
dismiss();
|
||||
queryClient.client.invalidateQueries({
|
||||
queryKey: ["playlists"],
|
||||
});
|
||||
})
|
||||
.catch((e) => Toast.error(t(e.message ?? ""))),
|
||||
.catch((e) =>
|
||||
showErrorToast({
|
||||
text: t(e.message ?? e.error ?? JSON.stringify(e)),
|
||||
}),
|
||||
),
|
||||
});
|
||||
const { openFormModal } = useCreatePlaylistFormModal(undefined, (p) =>
|
||||
addToPlaylist.mutate(p.id),
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Toast } from "toastify-react-native";
|
||||
import type Album from "@/models/album";
|
||||
import type Release from "@/models/release";
|
||||
import type Song from "@/models/song";
|
||||
import type Track from "@/models/track";
|
||||
import { MasterIcon } from "@/ui/icons";
|
||||
import { useQueryClient } from "~/api";
|
||||
import { showSuccessToast } from "~/primitives/toast";
|
||||
|
||||
export const useSetReleaseAsMaster = (
|
||||
release: Release | undefined,
|
||||
@@ -21,7 +21,7 @@ export const useSetReleaseAsMaster = (
|
||||
masterReleaseId: release?.id,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
Toast.success(t("toasts.releaseSetAsMaster"));
|
||||
showSuccessToast({ text: t("toasts.releaseSetAsMaster") });
|
||||
queryClient.client.invalidateQueries({ queryKey: ["releases"] });
|
||||
queryClient.client.invalidateQueries({ queryKey: ["albums"] });
|
||||
queryClient.client.invalidateQueries({ queryKey: [release?.id] });
|
||||
@@ -56,7 +56,7 @@ export const useSetSongTrackAsMaster = (
|
||||
masterTrackId: track?.id,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
Toast.success(t("toasts.trackSetAsMaster"));
|
||||
showSuccessToast({ text: t("toasts.trackSetAsMaster") });
|
||||
queryClient.client.invalidateQueries({ queryKey: ["tracks"] });
|
||||
queryClient.client.invalidateQueries({ queryKey: ["songs"] });
|
||||
queryClient.client.invalidateQueries({ queryKey: ["releases"] });
|
||||
|
||||
@@ -55,6 +55,7 @@ const UpdateIllustrationForm = ({
|
||||
}}
|
||||
render={({ field: { onChange, onBlur, value } }) => (
|
||||
<TextInput
|
||||
inModal
|
||||
placeholder={t("form.illustration.url")}
|
||||
textContentType="URL"
|
||||
autoCorrect={false}
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useAtom } from "jotai";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import API from "@/api";
|
||||
import {
|
||||
mkUseInfiniteQuery,
|
||||
@@ -27,19 +28,26 @@ import {
|
||||
import type { InfiniteQueryFn, QueryFn } from "@/api/query";
|
||||
import type Resource from "@/models/resource";
|
||||
import { store } from "@/state/store";
|
||||
import { accessTokenAtom, instanceUrlAtom } from "~/state/user";
|
||||
import { currentInstanceAtom } from "./state/user";
|
||||
|
||||
export const useAPI = () => {
|
||||
const [accessToken] = useAtom(accessTokenAtom);
|
||||
const [instanceUrl] = useAtom(instanceUrlAtom);
|
||||
return getAPI_(accessToken ?? null, instanceUrl ?? "");
|
||||
const currentInstance = useAtomValue(currentInstanceAtom);
|
||||
const api = useMemo(() => {
|
||||
return getAPI_(
|
||||
currentInstance?.accessToken ?? null,
|
||||
currentInstance?.url ?? "",
|
||||
);
|
||||
}, [currentInstance]);
|
||||
return api;
|
||||
};
|
||||
|
||||
// Get API instance using atom to resolve access token
|
||||
export const getAPI = () => {
|
||||
const accessToken = store.get(accessTokenAtom);
|
||||
const instanceUrl = store.get(instanceUrlAtom);
|
||||
return getAPI_(accessToken ?? null, instanceUrl ?? "");
|
||||
const currentInstance = store.get(currentInstanceAtom);
|
||||
return getAPI_(
|
||||
currentInstance?.accessToken ?? null,
|
||||
currentInstance?.url ?? "",
|
||||
);
|
||||
};
|
||||
|
||||
export const getAPI_ = (accessToken: string | null, instanceUrl: string) => {
|
||||
@@ -64,7 +72,7 @@ export const useQuery = <
|
||||
query: QueryFn<ReturnType, Transformed, Params>,
|
||||
...queryParams: Partial<Params>
|
||||
) => {
|
||||
return mkUseQuery(getAPI, query, ...queryParams);
|
||||
return mkUseQuery(useAPI, query, ...queryParams);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -92,4 +100,4 @@ export const useInfiniteQuery = <
|
||||
return mkUseInfiniteQuery(useAPI, query, ...queryParams);
|
||||
};
|
||||
|
||||
export const useQueryClient = () => mkUseQueryClient(getAPI);
|
||||
export const useQueryClient = () => mkUseQueryClient(useAPI);
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type Href, useRouter } from "expo-router";
|
||||
import { Fragment } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import {
|
||||
AlbumIcon,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
import { ListItem } from "~/components/item/list-item";
|
||||
import { SafeView } from "~/components/safe-view";
|
||||
import { Divider } from "~/primitives/divider";
|
||||
import { Icon as IconComponent } from "~/primitives/icon";
|
||||
|
||||
const tabs: { title: TranslationKey; href: Href; icon: Icon }[] = [
|
||||
{
|
||||
@@ -66,8 +68,11 @@ export default function BrowseList() {
|
||||
{tabs.map(({ title, href, icon }) => (
|
||||
<Fragment key={title}>
|
||||
<ListItem
|
||||
illustration={null}
|
||||
illustrationProps={{ fallbackIcon: icon }}
|
||||
leading={
|
||||
<View style={styles.icon}>
|
||||
<IconComponent icon={icon} />
|
||||
</View>
|
||||
}
|
||||
title={t(title)}
|
||||
subtitle={null}
|
||||
onPress={() => router.navigate(href)}
|
||||
@@ -87,4 +92,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "flex-start",
|
||||
},
|
||||
icon: {
|
||||
height: "100%",
|
||||
backgroundColor: theme.colors.skeleton,
|
||||
borderRadius: theme.borderRadius,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
}));
|
||||
@@ -16,9 +16,10 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { FlashList, type FlashListProps } from "@shopify/flash-list";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ScrollView } from "react-native";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import {
|
||||
getAlbums,
|
||||
@@ -27,8 +28,8 @@ import {
|
||||
getReleases,
|
||||
getSongs,
|
||||
} from "@/api/queries";
|
||||
import type { AlbumSortingKey } from "@/models/album";
|
||||
import type { ArtistSortingKey } from "@/models/artist";
|
||||
import type { AlbumSortingKey, AlbumWithRelations } from "@/models/album";
|
||||
import type { ArtistSortingKey, ArtistWithRelations } from "@/models/artist";
|
||||
import type Genre from "@/models/genre";
|
||||
import type { SongSortingKey } from "@/models/song";
|
||||
import { generateArray } from "@/utils/gen-list";
|
||||
@@ -38,26 +39,46 @@ import { AlbumTile } from "~/components/item/resource/album";
|
||||
import { ArtistTile } from "~/components/item/resource/artist";
|
||||
import { GenreChip } from "~/components/item/resource/genre";
|
||||
import ReleaseTile from "~/components/item/resource/release";
|
||||
import { Row } from "~/components/row";
|
||||
import { SafeScrollView } from "~/components/safe-view";
|
||||
import { SongGrid } from "~/components/song-grid";
|
||||
import { Row, type RowProps } from "~/components/row";
|
||||
import { SafeFlashList } from "~/components/safe-view";
|
||||
import { SongGrid, type SongGridProps } from "~/components/song-grid";
|
||||
import { Text } from "~/primitives/text";
|
||||
import type { Sorting } from "~/utils/sorting";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
main: { paddingTop: theme.gap(2), gap: theme.gap(2) },
|
||||
main: { gap: theme.gap(2) },
|
||||
lastSection: {
|
||||
paddingBottom: theme.gap(1),
|
||||
},
|
||||
title: { paddingLeft: theme.gap(2) },
|
||||
genreRow: {
|
||||
gap: theme.gap(1),
|
||||
alignItems: "center",
|
||||
paddingLeft: theme.gap(2),
|
||||
paddingHorizontal: theme.gap(2),
|
||||
paddingVertical: theme.gap(1),
|
||||
marginBottom: theme.gap(1),
|
||||
},
|
||||
genreChip: {
|
||||
paddingLeft: theme.gap(1.5),
|
||||
alignItems: "center",
|
||||
},
|
||||
}));
|
||||
|
||||
type HomePageSection =
|
||||
| { type: "tileRow"; props: RowProps<any> }
|
||||
| { type: "genreRow"; props: FlashListProps<Genre | undefined> }
|
||||
| { type: "songGrid"; props: SongGridProps };
|
||||
|
||||
const renderHomePageSection = (s: HomePageSection) => {
|
||||
switch (s.type) {
|
||||
case "songGrid":
|
||||
return <SongGrid {...s.props} />;
|
||||
case "tileRow":
|
||||
return <Row {...s.props} />;
|
||||
case "genreRow":
|
||||
return <FlashList {...s.props} />;
|
||||
}
|
||||
};
|
||||
|
||||
export default function Root() {
|
||||
const { t } = useTranslation();
|
||||
const newlyAddedAlbums = useInfiniteQuery(() =>
|
||||
@@ -118,101 +139,126 @@ export default function Root() {
|
||||
newlyAddedReleases.data,
|
||||
],
|
||||
);
|
||||
return (
|
||||
<SafeScrollView contentContainerStyle={[styles.main]}>
|
||||
<Row
|
||||
header={t("home.newlyAddedAlbums")}
|
||||
items={sync(newlyAddedAlbums.items)}
|
||||
render={(album) => {
|
||||
return <AlbumTile album={album} subtitle="artistName" />;
|
||||
}}
|
||||
seeMore={
|
||||
newlyAddedAlbums.hasNextPage
|
||||
? {
|
||||
pathname: "/albums",
|
||||
params: {
|
||||
sort: "addDate",
|
||||
order: "desc",
|
||||
} satisfies Sorting<AlbumSortingKey>,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<Row
|
||||
header={t("home.newlyAddedArtists")}
|
||||
items={sync(newlyAddedArtists.items)}
|
||||
render={(artist) => {
|
||||
const sections: HomePageSection[] = [
|
||||
{
|
||||
type: "tileRow",
|
||||
props: {
|
||||
header: t("home.newlyAddedAlbums"),
|
||||
items: sync(newlyAddedAlbums.items),
|
||||
render: (album) => {
|
||||
return <AlbumTile album={album} subtitle="artistName" />;
|
||||
},
|
||||
seeMore: newlyAddedAlbums.hasNextPage
|
||||
? {
|
||||
pathname: "/albums",
|
||||
params: {
|
||||
sort: "addDate",
|
||||
order: "desc",
|
||||
} satisfies Sorting<AlbumSortingKey>,
|
||||
}
|
||||
: undefined,
|
||||
} satisfies RowProps<AlbumWithRelations<"illustration" | "artist">>,
|
||||
},
|
||||
/* TODO Featured albums */
|
||||
{
|
||||
type: "tileRow",
|
||||
props: {
|
||||
header: t("home.newlyAddedArtists"),
|
||||
items: sync(newlyAddedArtists.items),
|
||||
render: (artist) => {
|
||||
return <ArtistTile artist={artist} />;
|
||||
}}
|
||||
seeMore={
|
||||
newlyAddedArtists.hasNextPage
|
||||
? {
|
||||
pathname: "/artists",
|
||||
params: {
|
||||
sort: "addDate",
|
||||
order: "desc",
|
||||
} satisfies Sorting<ArtistSortingKey>,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{/* TODO Featured albums */}
|
||||
|
||||
<Row
|
||||
header={t("home.latestAlbums")}
|
||||
items={sync(latestAlbums.items)}
|
||||
render={(album) => {
|
||||
},
|
||||
seeMore: newlyAddedArtists.hasNextPage
|
||||
? {
|
||||
pathname: "/artists",
|
||||
params: {
|
||||
sort: "addDate",
|
||||
order: "desc",
|
||||
} satisfies Sorting<ArtistSortingKey>,
|
||||
}
|
||||
: undefined,
|
||||
} satisfies RowProps<ArtistWithRelations<"illustration">>,
|
||||
},
|
||||
{
|
||||
type: "tileRow",
|
||||
props: {
|
||||
header: t("home.latestAlbums"),
|
||||
items: sync(latestAlbums.items),
|
||||
render: (album) => {
|
||||
return <AlbumTile album={album} subtitle="artistName" />;
|
||||
}}
|
||||
seeMore={
|
||||
latestAlbums.hasNextPage
|
||||
? {
|
||||
pathname: "/albums",
|
||||
params: {
|
||||
sort: "releaseDate",
|
||||
order: "desc",
|
||||
} satisfies Sorting<AlbumSortingKey>,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<Row
|
||||
header={t("home.newlyAddedReleases")}
|
||||
items={sync(newlyAddedReleases.items)}
|
||||
render={(release) => {
|
||||
},
|
||||
seeMore: latestAlbums.hasNextPage
|
||||
? {
|
||||
pathname: "/albums",
|
||||
params: {
|
||||
sort: "releaseDate",
|
||||
order: "desc",
|
||||
} satisfies Sorting<AlbumSortingKey>,
|
||||
}
|
||||
: undefined,
|
||||
} satisfies RowProps<AlbumWithRelations<"illustration" | "artist">>,
|
||||
},
|
||||
{
|
||||
type: "tileRow",
|
||||
props: {
|
||||
header: t("home.newlyAddedReleases"),
|
||||
items: sync(newlyAddedReleases.items),
|
||||
render: (release) => {
|
||||
return <ReleaseTile release={release} />;
|
||||
}}
|
||||
/>
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "genreRow",
|
||||
props: {
|
||||
horizontal: true,
|
||||
data: topGenres.items ?? generateArray(3, undefined),
|
||||
|
||||
<ScrollView horizontal contentContainerStyle={styles.genreRow}>
|
||||
<Text content={t("home.topGenres")} variant="h4" />
|
||||
{(topGenres.items ?? generateArray(3, undefined)).map(
|
||||
(genre: Genre | undefined, idx) => (
|
||||
<GenreChip genre={genre} key={genre?.slug ?? idx} />
|
||||
),
|
||||
)}
|
||||
</ScrollView>
|
||||
ListHeaderComponent: (
|
||||
<Text content={t("home.topGenres")} variant="h4" />
|
||||
),
|
||||
CellRendererComponent: (props) => {
|
||||
return (
|
||||
<View
|
||||
{...props}
|
||||
style={[styles.genreChip, props.style]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
renderItem: ({ item: genre, index }) => (
|
||||
<GenreChip genre={genre} key={genre?.slug ?? index} />
|
||||
),
|
||||
|
||||
<SongGrid
|
||||
seeMore={
|
||||
topSongs.hasNextPage
|
||||
? {
|
||||
pathname: "/songs",
|
||||
params: {
|
||||
sort: "userPlayCount",
|
||||
order: "desc",
|
||||
} satisfies Sorting<SongSortingKey>,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
hideIfEmpty
|
||||
header={t("home.mostPlayedSongs")}
|
||||
songs={sync(topSongs.data?.pages.at(0)?.items)}
|
||||
subtitle={() => "artists"}
|
||||
style={styles.lastSection}
|
||||
/>
|
||||
</SafeScrollView>
|
||||
contentContainerStyle: styles.genreRow,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "songGrid",
|
||||
props: {
|
||||
seeMore: topSongs.hasNextPage
|
||||
? {
|
||||
pathname: "/songs",
|
||||
params: {
|
||||
sort: "userPlayCount",
|
||||
order: "desc",
|
||||
} satisfies Sorting<SongSortingKey>,
|
||||
}
|
||||
: undefined,
|
||||
hideIfEmpty: true,
|
||||
header: t("home.mostPlayedSongs"),
|
||||
songs: sync(topSongs.data?.pages.at(0)?.items),
|
||||
subtitle: () => "artists",
|
||||
style: styles.lastSection,
|
||||
},
|
||||
},
|
||||
];
|
||||
return (
|
||||
<SafeFlashList
|
||||
data={sections}
|
||||
getItemType={(t) => t.type}
|
||||
renderItem={({ item }) => renderHomePageSection(item)}
|
||||
contentContainerStyle={[styles.main]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
import { Stack, useLocalSearchParams } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getAlbums, getArtist, getGenre } from "@/api/queries";
|
||||
import { getAlbums, getArtist, getGenre, getLabel } from "@/api/queries";
|
||||
import { AlbumSortingKeys, AlbumType } from "@/models/album";
|
||||
import { albumTypeToTranslationKey } from "@/models/utils";
|
||||
import { useQuery } from "~/api";
|
||||
@@ -38,10 +38,12 @@ export default function AlbumBrowseView() {
|
||||
compilations,
|
||||
artist: artistId,
|
||||
genre: genreId,
|
||||
label: labelId,
|
||||
} = useLocalSearchParams<{
|
||||
compilations?: "true";
|
||||
artist?: string;
|
||||
genre?: string;
|
||||
label?: string;
|
||||
}>();
|
||||
const [{ layout }, layoutControl] = useLayoutControl({
|
||||
defaultLayout: "grid",
|
||||
@@ -62,6 +64,7 @@ export default function AlbumBrowseView() {
|
||||
);
|
||||
|
||||
const { data: genre } = useQuery((genreId) => getGenre(genreId), genreId);
|
||||
const { data: label } = useQuery((labelId) => getLabel(labelId), labelId);
|
||||
const Item = layout === "list" ? AlbumItem : AlbumTile;
|
||||
return (
|
||||
<>
|
||||
@@ -69,11 +72,13 @@ export default function AlbumBrowseView() {
|
||||
options={{
|
||||
headerTitle: genreId
|
||||
? (genre?.name ?? "")
|
||||
: t(
|
||||
compilations
|
||||
? "nav.compilations"
|
||||
: "models.album_plural",
|
||||
),
|
||||
: labelId
|
||||
? (label?.name ?? "")
|
||||
: t(
|
||||
compilations
|
||||
? "nav.compilations"
|
||||
: "models.album_plural",
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<InfiniteView
|
||||
@@ -90,6 +95,7 @@ export default function AlbumBrowseView() {
|
||||
type: types,
|
||||
artist: compilations ? "compilations" : artistId,
|
||||
genre: genreId,
|
||||
label: labelId,
|
||||
},
|
||||
{ sortBy: sort ?? "name", order: order ?? "asc" },
|
||||
["artist", "illustration"],
|
||||
|
||||
+185
-128
@@ -1,6 +1,6 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { type ComponentProps, useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import {
|
||||
@@ -10,7 +10,11 @@ import {
|
||||
getSongs,
|
||||
getVideos,
|
||||
} from "@/api/queries";
|
||||
import { type AlbumSortingKey, AlbumType } from "@/models/album";
|
||||
import {
|
||||
type AlbumSortingKey,
|
||||
AlbumType,
|
||||
type AlbumWithRelations,
|
||||
} from "@/models/album";
|
||||
import { albumTypeToTranslationKey } from "@/models/utils";
|
||||
import type { VideoSortingKey, VideoWithRelations } from "@/models/video";
|
||||
import { VideoTypeIsExtra } from "@/models/video";
|
||||
@@ -25,7 +29,7 @@ import { AlbumTile } from "~/components/item/resource/album";
|
||||
import { VideoTile } from "~/components/item/resource/video";
|
||||
import { ArtistHeader } from "~/components/resource-header";
|
||||
import { Row } from "~/components/row";
|
||||
import { SafeScrollView } from "~/components/safe-view";
|
||||
import { SafeFlashList } from "~/components/safe-view";
|
||||
import { SongGrid } from "~/components/song-grid";
|
||||
import type { Sorting } from "~/utils/sorting";
|
||||
|
||||
@@ -41,6 +45,55 @@ const albumTypeQuery = (albumType: AlbumType, artistId: string) =>
|
||||
|
||||
const ShowSeeMoreThreshold = 3;
|
||||
|
||||
type ArtistPageSection =
|
||||
| { type: "header"; props: ComponentProps<typeof ArtistHeader> }
|
||||
| { type: "songGrid"; props: ComponentProps<typeof SongGrid> }
|
||||
| {
|
||||
type: "artistAlbumRow";
|
||||
props: ComponentProps<typeof AlbumTypeRow>;
|
||||
}
|
||||
| {
|
||||
type: "albumRow";
|
||||
props: ComponentProps<
|
||||
typeof Row<AlbumWithRelations<"illustration" | "artist">>
|
||||
>;
|
||||
}
|
||||
| {
|
||||
type: "videoRow";
|
||||
props: ComponentProps<
|
||||
typeof Row<
|
||||
VideoWithRelations<"illustration" | "artist" | "master">
|
||||
>
|
||||
>;
|
||||
}
|
||||
| {
|
||||
type: "aboutSection";
|
||||
props: ComponentProps<typeof ExternalMetadataDescriptionSection>;
|
||||
}
|
||||
| {
|
||||
type: "externalLinks";
|
||||
props: ComponentProps<typeof ExternalMetadataSourcesSection>;
|
||||
};
|
||||
|
||||
const renderSection = (section: ArtistPageSection) => {
|
||||
switch (section.type) {
|
||||
case "header":
|
||||
return <ArtistHeader {...section.props} />;
|
||||
case "songGrid":
|
||||
return <SongGrid {...section.props} />;
|
||||
case "artistAlbumRow":
|
||||
return <AlbumTypeRow {...section.props} />;
|
||||
case "albumRow":
|
||||
return <Row {...section.props} />;
|
||||
case "videoRow":
|
||||
return <Row {...section.props} />;
|
||||
case "aboutSection":
|
||||
return <ExternalMetadataDescriptionSection {...section.props} />;
|
||||
case "externalLinks":
|
||||
return <ExternalMetadataSourcesSection {...section.props} />;
|
||||
}
|
||||
};
|
||||
|
||||
export default function ArtistView() {
|
||||
const playTracks = useSetAtom(playTracksAtom);
|
||||
const { id: artistId } = useLocalSearchParams<{ id: string }>();
|
||||
@@ -110,136 +163,143 @@ export default function ArtistView() {
|
||||
},
|
||||
[],
|
||||
);
|
||||
useSetKeyIllustration(artist ?? undefined);
|
||||
return (
|
||||
<SafeScrollView>
|
||||
<ArtistHeader artist={artist} />
|
||||
<SongGrid
|
||||
header={t("artist.topSongs")}
|
||||
parentArtistId={artist?.id}
|
||||
style={styles.section}
|
||||
seeMore={
|
||||
const sections: ArtistPageSection[] = [
|
||||
{ type: "header", props: { artist } },
|
||||
{
|
||||
type: "songGrid",
|
||||
props: {
|
||||
header: t("artist.topSongs"),
|
||||
parentArtistId: artist?.id,
|
||||
style: styles.section,
|
||||
seeMore:
|
||||
(topSongs.items?.length ?? 0) >= ShowSeeMoreThreshold
|
||||
? {
|
||||
pathname: "/songs",
|
||||
params: { artist: artistId },
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
songs={topSongs.data?.pages.at(0)?.items}
|
||||
subtitle={
|
||||
!topSongs.data
|
||||
? null
|
||||
: (song) =>
|
||||
song.artistId.toString() === artistId &&
|
||||
song.featuring.length === 0
|
||||
? null
|
||||
: "artists"
|
||||
}
|
||||
/>
|
||||
{AlbumType.map((albumType) => (
|
||||
<AlbumTypeRow
|
||||
key={albumType}
|
||||
artistId={artistId}
|
||||
type={albumType}
|
||||
/>
|
||||
))}
|
||||
{artist && (
|
||||
<>
|
||||
<SongGrid
|
||||
hideIfEmpty
|
||||
header={t("artist.rareSongs")}
|
||||
seeMore={
|
||||
(rareSongs.items?.length ?? 0) >
|
||||
ShowSeeMoreThreshold
|
||||
? {
|
||||
pathname: "/songs",
|
||||
params: { rare: artistId },
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
style={styles.section}
|
||||
songs={rareSongs.data?.pages.at(0)?.items}
|
||||
parentArtistId={artist?.id}
|
||||
subtitle={
|
||||
!rareSongs.data
|
||||
: undefined,
|
||||
songs: topSongs.data?.pages.at(0)?.items,
|
||||
subtitle: !topSongs.data
|
||||
? null
|
||||
: (song) =>
|
||||
song.artistId.toString() === artistId &&
|
||||
song.featuring.length === 0
|
||||
? null
|
||||
: (song) =>
|
||||
song.artistId.toString() === artistId &&
|
||||
song.featuring.length === 0
|
||||
? null
|
||||
: "artists"
|
||||
}
|
||||
/>
|
||||
|
||||
{(
|
||||
[
|
||||
{ label: "musicVideos", items: musicVideos },
|
||||
{ label: "livePerformances", items: liveVideos },
|
||||
{ label: "extras", items: extras },
|
||||
] as const
|
||||
).map(({ label, items }) => (
|
||||
<Row
|
||||
hideIfEmpty
|
||||
key={label}
|
||||
items={items}
|
||||
style={styles.section}
|
||||
header={t(`browsing.sections.${label}`)}
|
||||
render={(item) => (
|
||||
<VideoTile
|
||||
video={item}
|
||||
onPress={() =>
|
||||
item &&
|
||||
items &&
|
||||
onVideoPress(item.id, items)
|
||||
}
|
||||
subtitle="duration"
|
||||
illustrationProps={{
|
||||
normalizedThumbnail: true,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
seeMore={
|
||||
(items?.length ?? 0) > ShowSeeMoreThreshold
|
||||
? {
|
||||
pathname: "/videos",
|
||||
params: {
|
||||
artist: artistId,
|
||||
sort: "name",
|
||||
order: "asc",
|
||||
} satisfies Sorting<VideoSortingKey> & {
|
||||
artist: string;
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
: "artists",
|
||||
},
|
||||
},
|
||||
...AlbumType.map(
|
||||
(albumType): ArtistPageSection => ({
|
||||
type: "artistAlbumRow",
|
||||
props: { artistId, type: albumType },
|
||||
}),
|
||||
),
|
||||
{
|
||||
type: "songGrid",
|
||||
props: {
|
||||
hideIfEmpty: true,
|
||||
header: t("artist.rareSongs"),
|
||||
seeMore:
|
||||
(rareSongs.items?.length ?? 0) > ShowSeeMoreThreshold
|
||||
? {
|
||||
pathname: "/songs",
|
||||
params: { rare: artistId },
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<Row
|
||||
hideIfEmpty
|
||||
style={styles.section}
|
||||
header={t("artist.appearsOn")}
|
||||
items={relatedAlbums.items}
|
||||
render={(album) => (
|
||||
<AlbumTile album={album} subtitle="artistName" />
|
||||
)}
|
||||
/>
|
||||
: undefined,
|
||||
|
||||
{externalMetadata !== null && (
|
||||
<>
|
||||
<ExternalMetadataDescriptionSection
|
||||
externalMetadata={externalMetadata}
|
||||
style={styles.section}
|
||||
/>
|
||||
<ExternalMetadataSourcesSection
|
||||
externalMetadata={externalMetadata}
|
||||
style={styles.section}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SafeScrollView>
|
||||
style: styles.section,
|
||||
songs: rareSongs.data?.pages.at(0)?.items,
|
||||
parentArtistId: artist?.id,
|
||||
subtitle: !rareSongs.data
|
||||
? null
|
||||
: (song) =>
|
||||
song.artistId.toString() === artistId &&
|
||||
song.featuring.length === 0
|
||||
? null
|
||||
: "artists",
|
||||
},
|
||||
},
|
||||
...(
|
||||
[
|
||||
{ label: "musicVideos", items: musicVideos },
|
||||
{ label: "livePerformances", items: liveVideos },
|
||||
{ label: "extras", items: extras },
|
||||
] as const
|
||||
).map(
|
||||
({ label, items }): ArtistPageSection => ({
|
||||
type: "videoRow",
|
||||
props: {
|
||||
hideIfEmpty: true,
|
||||
items: items,
|
||||
style: styles.section,
|
||||
header: t(`browsing.sections.${label}`),
|
||||
render: (item) => (
|
||||
<VideoTile
|
||||
video={item}
|
||||
onPress={() =>
|
||||
item && items && onVideoPress(item.id, items)
|
||||
}
|
||||
subtitle="duration"
|
||||
illustrationProps={{
|
||||
normalizedThumbnail: true,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
seeMore:
|
||||
(items?.length ?? 0) > ShowSeeMoreThreshold
|
||||
? {
|
||||
pathname: "/videos",
|
||||
params: {
|
||||
artist: artistId,
|
||||
sort: "name",
|
||||
order: "asc",
|
||||
} satisfies Sorting<VideoSortingKey> & {
|
||||
artist: string;
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
}),
|
||||
),
|
||||
{
|
||||
type: "albumRow",
|
||||
props: {
|
||||
hideIfEmpty: true,
|
||||
style: styles.section,
|
||||
header: t("artist.appearsOn"),
|
||||
items: relatedAlbums.items,
|
||||
render: (album) => (
|
||||
<AlbumTile album={album} subtitle="artistName" />
|
||||
),
|
||||
},
|
||||
},
|
||||
|
||||
...((externalMetadata !== null
|
||||
? [
|
||||
{
|
||||
type: "aboutSection",
|
||||
props: {
|
||||
externalMetadata: externalMetadata,
|
||||
style: styles.section,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "externalLinks",
|
||||
props: {
|
||||
externalMetadata: externalMetadata,
|
||||
style: styles.section,
|
||||
},
|
||||
},
|
||||
]
|
||||
: []) satisfies ArtistPageSection[]),
|
||||
];
|
||||
useSetKeyIllustration(artist ?? undefined);
|
||||
return (
|
||||
<SafeFlashList
|
||||
data={sections}
|
||||
getItemType={({ type }) => type}
|
||||
renderItem={({ item }) => renderSection(item)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -280,9 +340,6 @@ const AlbumTypeRow = ({
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: {
|
||||
flex: 1,
|
||||
},
|
||||
section: {
|
||||
paddingBottom: theme.gap(1),
|
||||
},
|
||||
|
||||
@@ -16,8 +16,11 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { getArtists } from "@/api/queries";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useEffect } from "react";
|
||||
import { getArtists, getLabel } from "@/api/queries";
|
||||
import { ArtistSortingKeys } from "@/models/artist";
|
||||
import { useQuery } from "~/api";
|
||||
import { useLibraryFiltersControl } from "~/components/infinite/controls/filters";
|
||||
import { useLayoutControl } from "~/components/infinite/controls/layout";
|
||||
import { useSortControl } from "~/components/infinite/controls/sort";
|
||||
@@ -33,8 +36,21 @@ export default function ArtistBrowseView() {
|
||||
sortingKeys: ArtistSortingKeys,
|
||||
translate: (s) => `browsing.controls.sort.${s}`,
|
||||
});
|
||||
|
||||
const { label: labelId } = useLocalSearchParams<{
|
||||
label?: string;
|
||||
}>();
|
||||
const [libraries, libraryFilterControl] = useLibraryFiltersControl();
|
||||
|
||||
const { data: label } = useQuery((labelId) => getLabel(labelId), labelId);
|
||||
const Item = layout === "list" ? ArtistItem : ArtistTile;
|
||||
const nav = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
if (labelId !== undefined) {
|
||||
nav.setOptions({ headerTitle: label?.name ?? "" });
|
||||
}
|
||||
}, [labelId, label]);
|
||||
return (
|
||||
<InfiniteView
|
||||
layout={layout}
|
||||
@@ -44,7 +60,7 @@ export default function ArtistBrowseView() {
|
||||
filters: [libraryFilterControl],
|
||||
}}
|
||||
query={getArtists(
|
||||
{ library: libraries },
|
||||
{ library: libraries, label: labelId },
|
||||
{ sortBy: sort ?? "name", order: order ?? "asc" },
|
||||
["illustration"],
|
||||
)}
|
||||
|
||||
+13
-6
@@ -9,7 +9,6 @@ import DraggableFlatList, {
|
||||
ScaleDecorator,
|
||||
} from "react-native-draggable-flatlist";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Toast } from "toastify-react-native";
|
||||
import {
|
||||
getCurrentUserStatus,
|
||||
getPlaylist,
|
||||
@@ -33,9 +32,11 @@ import { useAPI, useQuery, useQueryClient } from "~/api";
|
||||
import { usePlaylistContextMenu } from "~/components/context-menu/resource/playlist";
|
||||
import { SongItem } from "~/components/item/resource/song";
|
||||
import { ResourceHeader } from "~/components/resource-header";
|
||||
import * as Haptics from "~/haptics";
|
||||
import { useRootViewStyle } from "~/hooks/root-view-style";
|
||||
import { Button } from "~/primitives/button";
|
||||
import { Divider } from "~/primitives/divider";
|
||||
import { showErrorToast } from "~/primitives/toast";
|
||||
import { breakpoints } from "~/theme";
|
||||
|
||||
type PlaylistEntryType = PlaylistEntryWithRelations<
|
||||
@@ -193,7 +194,6 @@ const Items = ({
|
||||
playlistEntries?.map(({ entryId }) => entryId) ?? [];
|
||||
}, [isReordering, playlistEntries]);
|
||||
return (
|
||||
/* @ts-expect-error */
|
||||
<DraggableFlatList
|
||||
containerStyle={styles.itemsContainer}
|
||||
contentContainerStyle={styles.items}
|
||||
@@ -203,12 +203,18 @@ const Items = ({
|
||||
item?.entryId.toString() ?? `skeleton-${idx}`
|
||||
}
|
||||
renderItem={({ item, drag, getIndex }) => (
|
||||
/* @ts-expect-error */
|
||||
<ScaleDecorator>
|
||||
<SongItem
|
||||
song={item}
|
||||
subtitle="artists"
|
||||
onLongPress={isReordering ? drag : undefined}
|
||||
onLongPress={
|
||||
isReordering
|
||||
? () => {
|
||||
drag();
|
||||
Haptics.onDragStart();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
illustrationProps={{}}
|
||||
onPress={() => onItemPress(getIndex()!)}
|
||||
/>
|
||||
@@ -267,7 +273,9 @@ const Footer = ({
|
||||
queryKey: ["playlists"],
|
||||
}),
|
||||
)
|
||||
.catch(() => Toast.error("Updating illustration failed")),
|
||||
.catch(() =>
|
||||
showErrorToast({ text: "Updating illustration failed" }),
|
||||
),
|
||||
});
|
||||
|
||||
const updateIllustrationAction = useUpdateIllustrationAction((url) =>
|
||||
@@ -281,7 +289,6 @@ const Footer = ({
|
||||
<Button
|
||||
size="small"
|
||||
width="fitContent"
|
||||
// @ts-expect-error
|
||||
icon={isReordering ? DoneIcon : ReorderPlaylistIcon}
|
||||
title={t(
|
||||
isReordering
|
||||
|
||||
@@ -116,6 +116,7 @@ export default function SearchView() {
|
||||
header={
|
||||
<View style={styles.searchHeader}>
|
||||
<TextInput
|
||||
inModal={false}
|
||||
autoCorrect={false}
|
||||
autoCapitalize={"none"}
|
||||
placeholder={t("nav.search")}
|
||||
|
||||
@@ -55,7 +55,7 @@ import { MinimisedPlayer } from "~/components/player/minimised";
|
||||
import { bottomTabBarHeightAtom } from "~/hooks/root-view-style";
|
||||
import { Icon } from "~/primitives/icon";
|
||||
import { Pressable } from "~/primitives/pressable";
|
||||
import { accessTokenAtom, instanceUrlAtom } from "~/state/user";
|
||||
import { currentInstanceAtom, popCurrentInstanceAtom } from "~/state/user";
|
||||
|
||||
//TODO DRY: The header style for settings is very similar to the shared routed ones.
|
||||
//TODO I suspect that the setting header style is not updated when theme changes because we don't use withUnistyles
|
||||
@@ -107,13 +107,13 @@ export default function ProtectedLayout() {
|
||||
const queue = useAtomValue(playlistAtom);
|
||||
|
||||
const infinitePlaylist = useAtomValue(infiniteQueryAtom);
|
||||
const accessToken = useAtomValue(accessTokenAtom);
|
||||
const instanceUrl = useAtomValue(instanceUrlAtom);
|
||||
const currentInstance = useAtomValue(currentInstanceAtom);
|
||||
const popCurrentInstance = useSetAtom(popCurrentInstanceAtom);
|
||||
const setBottomTabBarHeight = useSetAtom(bottomTabBarHeightAtom);
|
||||
const api = useAPI();
|
||||
const user = useTanStackQuery({
|
||||
...toTanStackQuery(api, getCurrentUserStatus),
|
||||
enabled: !!(accessToken && instanceUrl),
|
||||
enabled: !!currentInstance,
|
||||
});
|
||||
const onLayout = useCallback((e: LayoutChangeEvent) => {
|
||||
setBottomTabBarHeight(e.nativeEvent.layout.height);
|
||||
@@ -131,7 +131,8 @@ export default function ProtectedLayout() {
|
||||
};
|
||||
return { opacity: withSpring(showPlayer ? 1 : 0, style) };
|
||||
}, [showPlayer]);
|
||||
if (!accessToken || !instanceUrl || user.error) {
|
||||
if (user.error || !currentInstance) {
|
||||
if (currentInstance) popCurrentInstance();
|
||||
return <Redirect href="/auth" />;
|
||||
}
|
||||
//TODO Proper handling of when user is loading
|
||||
|
||||
@@ -16,9 +16,11 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useNavigation } from "expo-router";
|
||||
import { openBrowserAsync } from "expo-web-browser";
|
||||
import i18next from "i18next";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { Fragment, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
@@ -32,13 +34,17 @@ import {
|
||||
import { emptyPlaylistAtom } from "@/state/player";
|
||||
import {
|
||||
CheckIcon,
|
||||
DeleteIcon,
|
||||
ExpandMoreIcon,
|
||||
MoreIcon,
|
||||
OpenExternalIcon,
|
||||
UncheckIcon,
|
||||
} from "@/ui/icons";
|
||||
import { useQuery, useQueryClient } from "~/api";
|
||||
import { getAPI_, useQuery, useQueryClient } from "~/api";
|
||||
import { SelectModalButton } from "~/components/bottom-modal-sheet/select";
|
||||
import { InstanceButton } from "~/components/instance-button";
|
||||
import { LoadableText } from "~/components/loadable_text";
|
||||
import { useLoginForm } from "~/components/login-form";
|
||||
import { SafeScrollView } from "~/components/safe-view";
|
||||
import { SectionHeader } from "~/components/section-header";
|
||||
import { useColorScheme } from "~/hooks/color-scheme";
|
||||
@@ -47,31 +53,88 @@ import { Divider } from "~/primitives/divider";
|
||||
import { Icon } from "~/primitives/icon";
|
||||
import { Pressable } from "~/primitives/pressable";
|
||||
import { Text } from "~/primitives/text";
|
||||
import { showSuccessToast } from "~/primitives/toast";
|
||||
import { colorSchemePreference } from "~/state/color-scheme";
|
||||
import { languagePreference } from "~/state/lang";
|
||||
import { accessTokenAtom, instanceUrlAtom } from "~/state/user";
|
||||
import {
|
||||
currentInstanceAtom,
|
||||
deleteOtherInstanceAtom,
|
||||
type MeeloInstance,
|
||||
otherInstancesAtom,
|
||||
popCurrentInstanceAtom,
|
||||
} from "~/state/user";
|
||||
import { Languages } from "../../../../../../translations";
|
||||
|
||||
const BuildCommit =
|
||||
process.env.EXPO_PUBLIC_BUILD_COMMIT ??
|
||||
(process.env.NODE_ENV === "development" ? "dev" : "unknown");
|
||||
|
||||
const useResetAllTabs = () => {
|
||||
const navigation = useNavigation();
|
||||
|
||||
return useCallback(() => {
|
||||
const st = navigation.getState();
|
||||
if (!st) {
|
||||
return;
|
||||
}
|
||||
navigation.reset({
|
||||
...st,
|
||||
history: [],
|
||||
routes: st.routes.map((r) => ({
|
||||
...r,
|
||||
state: undefined,
|
||||
})),
|
||||
} as any);
|
||||
}, [navigation]);
|
||||
};
|
||||
|
||||
export default function SettingsView() {
|
||||
const resetAllTabs = useResetAllTabs();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: user } = useQuery(getCurrentUserStatus);
|
||||
const emptyPlaylist = useSetAtom(emptyPlaylistAtom);
|
||||
const otherInstances = useAtomValue(otherInstancesAtom);
|
||||
const [currentInstance, setCurrentInstance] = useAtom(currentInstanceAtom);
|
||||
const { t } = useTranslation();
|
||||
const setAccessToken = useSetAtom(accessTokenAtom);
|
||||
const setInstanceUrl = useSetAtom(instanceUrlAtom);
|
||||
const popCurrentInstance = useSetAtom(popCurrentInstanceAtom);
|
||||
const deleteOtherInstance = useSetAtom(deleteOtherInstanceAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
const [colorSchemePref, setColorSchemePref] = useAtom(
|
||||
colorSchemePreference,
|
||||
);
|
||||
const onLeavingInstance = useCallback(() => {
|
||||
emptyPlaylist();
|
||||
queryClient.client.clear();
|
||||
resetAllTabs();
|
||||
}, [emptyPlaylist, queryClient, resetAllTabs]);
|
||||
const { openLoginForm } = useLoginForm({
|
||||
onLogin: async (data) => {
|
||||
const api = getAPI_(data.token, data.instanceUrl);
|
||||
const res = await getCurrentUserStatus().exec(api)();
|
||||
onLeavingInstance();
|
||||
popCurrentInstance();
|
||||
setCurrentInstance({
|
||||
url: data.instanceUrl,
|
||||
accessToken: data.token,
|
||||
username: res.name,
|
||||
});
|
||||
showSuccessToast({ text: t("toasts.auth.serverSwitchSuccessful") });
|
||||
},
|
||||
});
|
||||
const [lng, setLng] = useAtom(languagePreference);
|
||||
const { data: apiSettings } = useQuery(getSettings);
|
||||
const { data: scannerVersion } = useQuery(getScannerVersion);
|
||||
const { data: matcherVersion } = useQuery(getMatcherVersion);
|
||||
const actualColorScheme = useColorScheme();
|
||||
const onOtherInstanceSelect = useCallback(
|
||||
(instance: MeeloInstance) => {
|
||||
onLeavingInstance();
|
||||
popCurrentInstance();
|
||||
deleteOtherInstance(instance);
|
||||
setCurrentInstance(instance);
|
||||
},
|
||||
[onLeavingInstance, popCurrentInstanceAtom, setCurrentInstance],
|
||||
);
|
||||
return (
|
||||
<SafeScrollView style={[styles.root, { paddingTop: insets.top }]}>
|
||||
<View style={styles.header}>
|
||||
@@ -197,11 +260,59 @@ export default function SettingsView() {
|
||||
</>
|
||||
)}
|
||||
<Divider h />
|
||||
|
||||
{otherInstances.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<SectionHeader
|
||||
style={styles.sectionHeader}
|
||||
content={t("actions.switchServer")}
|
||||
skeletonWidth={0}
|
||||
/>
|
||||
|
||||
{currentInstance && (
|
||||
<>
|
||||
<View style={styles.instanceButtonContainer}>
|
||||
<InstanceButton
|
||||
enabled={false}
|
||||
instance={currentInstance}
|
||||
trailing="(Current)"
|
||||
/>
|
||||
</View>
|
||||
<Divider h withInsets />
|
||||
</>
|
||||
)}
|
||||
{otherInstances.map((instance, idx) => (
|
||||
<Fragment key={idx}>
|
||||
<View style={styles.instanceButtonContainer}>
|
||||
<InstanceButton
|
||||
enabled
|
||||
instance={instance}
|
||||
actions={[
|
||||
{
|
||||
icon: DeleteIcon,
|
||||
onPress: () =>
|
||||
deleteOtherInstance(instance),
|
||||
},
|
||||
{
|
||||
icon: MoreIcon,
|
||||
onPress: () =>
|
||||
onOtherInstanceSelect(instance),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
<Divider h withInsets />
|
||||
</Fragment>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.section}>
|
||||
<SectionHeader
|
||||
style={styles.sectionHeader}
|
||||
content={t("actions.logout")}
|
||||
skeletonWidth={0}
|
||||
<Button
|
||||
title={t("actions.connectToNewServer")}
|
||||
containerStyle={{ alignItems: "center" }}
|
||||
labelStyle={styles.logoutButtonStyle}
|
||||
onPress={openLoginForm}
|
||||
width="fill"
|
||||
/>
|
||||
<Button
|
||||
title={t("actions.logout")}
|
||||
@@ -209,10 +320,8 @@ export default function SettingsView() {
|
||||
containerStyle={{ alignItems: "center" }}
|
||||
labelStyle={styles.logoutButtonStyle}
|
||||
onPress={() => {
|
||||
emptyPlaylist();
|
||||
queryClient.client.clear();
|
||||
setAccessToken(null);
|
||||
setInstanceUrl(null);
|
||||
onLeavingInstance();
|
||||
popCurrentInstance();
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
@@ -270,4 +379,5 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.gap(2),
|
||||
},
|
||||
versionNumber: { color: theme.colors.text.secondary },
|
||||
instanceButtonContainer: { paddingHorizontal: theme.gap(1) },
|
||||
}));
|
||||
@@ -33,7 +33,6 @@ import { Provider } from "jotai";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import ToastManager from "toastify-react-native";
|
||||
import { store } from "@/state/store";
|
||||
import "intl-pluralrules";
|
||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
@@ -42,12 +41,11 @@ import * as Device from "expo-device";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||
import { UnistylesRuntime } from "react-native-unistyles";
|
||||
import type { ToastConfigParams } from "toastify-react-native/utils/interfaces";
|
||||
import { DefaultQueryOptions } from "@/api/query";
|
||||
import { BackgroundGradient } from "~/components/background-gradient";
|
||||
import { Modal } from "~/components/bottom-modal-sheet";
|
||||
import { useColorScheme } from "~/hooks/color-scheme";
|
||||
import { Toast as MeeloToast } from "~/primitives/toast";
|
||||
import { ToastManager } from "~/primitives/toast";
|
||||
import { colorSchemePreference } from "~/state/color-scheme";
|
||||
import { languagePreference } from "~/state/lang";
|
||||
import { appThemes } from "~/theme";
|
||||
@@ -83,7 +81,11 @@ export default function RootLayout() {
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: DefaultQueryOptions,
|
||||
queries: {
|
||||
...DefaultQueryOptions,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -105,7 +107,7 @@ export default function RootLayout() {
|
||||
Device.deviceType === Device.DeviceType.TABLET
|
||||
? ScreenOrientation.unlockAsync()
|
||||
: ScreenOrientation.lockAsync(
|
||||
ScreenOrientation.OrientationLock.PORTRAIT,
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
||||
);
|
||||
lock.then(() => SplashScreen.hideAsync());
|
||||
}
|
||||
@@ -164,18 +166,7 @@ export default function RootLayout() {
|
||||
<BackgroundGradient />
|
||||
</ColorSchemeProvider>
|
||||
</Provider>
|
||||
<ToastManager
|
||||
theme={actualColorScheme}
|
||||
custom={MeeloToast}
|
||||
config={{
|
||||
success: (p: ToastConfigParams) => (
|
||||
<MeeloToast {...p} variant={"success"} />
|
||||
),
|
||||
error: (p: ToastConfigParams) => (
|
||||
<MeeloToast {...p} variant={"error"} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<ToastManager />
|
||||
</QueryClientProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
|
||||
@@ -16,29 +16,28 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { router } from "expo-router";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { Fragment, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import {
|
||||
KeyboardAwareScrollView,
|
||||
KeyboardToolbar,
|
||||
} from "react-native-keyboard-controller";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Toast } from "toastify-react-native";
|
||||
import { getSettings } from "@/api/queries";
|
||||
import type { Settings } from "@/models/settings";
|
||||
import { ErrorIcon } from "@/ui/icons";
|
||||
import { getCurrentUserStatus } from "@/api/queries";
|
||||
import { AddIcon, MoreIcon } from "@/ui/icons";
|
||||
import { getAPI_ } from "~/api";
|
||||
import { InstanceButton } from "~/components/instance-button";
|
||||
import { useLoginForm } from "~/components/login-form";
|
||||
import { Banner } from "~/components/meelo";
|
||||
import { Button } from "~/primitives/button";
|
||||
import { Divider } from "~/primitives/divider";
|
||||
import { Text } from "~/primitives/text";
|
||||
import { TextInput } from "~/primitives/text_input";
|
||||
import { accessTokenAtom, instanceUrlAtom } from "~/state/user";
|
||||
import { showErrorToast } from "~/primitives/toast";
|
||||
import {
|
||||
currentInstanceAtom,
|
||||
deleteOtherInstanceAtom,
|
||||
type MeeloInstance,
|
||||
otherInstancesAtom,
|
||||
} from "~/state/user";
|
||||
|
||||
const styles = StyleSheet.create((theme, rt) => ({
|
||||
root: {
|
||||
@@ -47,10 +46,8 @@ const styles = StyleSheet.create((theme, rt) => ({
|
||||
alignItems: "center",
|
||||
marginBottom: theme.gap(10),
|
||||
justifyContent: "space-evenly",
|
||||
flex: 1,
|
||||
},
|
||||
// The padding is a bit much, but it allows making the next form field visible
|
||||
// It would be nice to just center the focused field when the keyboard is up
|
||||
keyboard: { paddingTop: theme.gap(20) },
|
||||
banner: {
|
||||
height: 300,
|
||||
maxHeight: rt.screen.height / 4,
|
||||
@@ -59,307 +56,97 @@ const styles = StyleSheet.create((theme, rt) => ({
|
||||
justifyContent: "center",
|
||||
alignItems: "flex-end",
|
||||
},
|
||||
errorContainer: {
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.gap(1),
|
||||
},
|
||||
errorMsg: {
|
||||
maxWidth: "80%",
|
||||
color: theme.colors.error,
|
||||
textAlign: "center",
|
||||
},
|
||||
formContainer: {
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
justifyContent: "flex-start",
|
||||
alignItems: "center",
|
||||
gap: theme.gap(3),
|
||||
},
|
||||
instanceButtons: {
|
||||
flexDirection: "column",
|
||||
gap: theme.gap(1),
|
||||
width: "100%",
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: theme.gap(3),
|
||||
},
|
||||
}));
|
||||
|
||||
function timeout(ms: number) {
|
||||
return new Promise<any>((_, reject) => {
|
||||
setTimeout(() => reject(new Error("Request timed out")), ms);
|
||||
});
|
||||
}
|
||||
|
||||
export default function AuthenticationScreen() {
|
||||
const { t } = useTranslation();
|
||||
const defaultValues = {
|
||||
url: "https://",
|
||||
username: "",
|
||||
password: "",
|
||||
confirm: "",
|
||||
};
|
||||
const setAccessToken = useSetAtom(accessTokenAtom);
|
||||
const setInstanceUrl = useSetAtom(instanceUrlAtom);
|
||||
const [isLoading, setLoading] = useState(false);
|
||||
const [errorMsg, setErrorMessage] = useState<string>();
|
||||
const [formType, setFormType] = useState<"url" | "login" | "signup">("url");
|
||||
const [allowSignup, setAllowSignup] = useState(false);
|
||||
const safeAreaStyle = useSafeAreaInsets();
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm({ defaultValues });
|
||||
const onSubmit = (data: typeof defaultValues) => {
|
||||
const instanceUrl = data.url.replace(/\/$/, "");
|
||||
const api = getAPI_(null, instanceUrl);
|
||||
setLoading(true);
|
||||
setErrorMessage(undefined);
|
||||
if (formType === "url") {
|
||||
//Note: we dont want/need to cache the settings
|
||||
//It would prank when the instance url changes
|
||||
Promise.race([timeout(3000), getSettings().exec(api)()])
|
||||
.then(({ enableUserRegistration }: Settings) => {
|
||||
setAllowSignup(enableUserRegistration);
|
||||
setFormType("login");
|
||||
})
|
||||
.catch((e) => {
|
||||
setErrorMessage(e.message ?? e.toString());
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
} else if (formType === "signup") {
|
||||
Promise.race([
|
||||
timeout(3000),
|
||||
api.register({
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
}),
|
||||
])
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const otherInstances = useAtomValue(otherInstancesAtom);
|
||||
const setCurrentInstance = useSetAtom(currentInstanceAtom);
|
||||
const deleteOtherInstance = useSetAtom(deleteOtherInstanceAtom);
|
||||
const { openLoginForm } = useLoginForm({
|
||||
onLogin: async ({ instanceUrl, token }) => {
|
||||
const api = getAPI_(token, instanceUrl);
|
||||
const res = await getCurrentUserStatus().exec(api)();
|
||||
setCurrentInstance({
|
||||
url: instanceUrl,
|
||||
accessToken: token,
|
||||
username: res.name,
|
||||
});
|
||||
router.replace("/");
|
||||
},
|
||||
});
|
||||
const signInToOtherInstance = useCallback(
|
||||
(instance: MeeloInstance) => {
|
||||
const api = getAPI_(instance.accessToken, instance.url);
|
||||
|
||||
getCurrentUserStatus()
|
||||
.exec(api)()
|
||||
.then(() => {
|
||||
Toast.success(t("toasts.auth.accountCreated"));
|
||||
})
|
||||
.catch((e) => {
|
||||
setErrorMessage(e.message ?? e.toString());
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
} else {
|
||||
Promise.race([
|
||||
timeout(3000),
|
||||
api.login({
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
}),
|
||||
])
|
||||
.then(({ access_token }) => {
|
||||
setAccessToken(access_token);
|
||||
setInstanceUrl(instanceUrl);
|
||||
setCurrentInstance(instance);
|
||||
deleteOtherInstance(instance);
|
||||
router.replace("/");
|
||||
})
|
||||
.catch((e) => {
|
||||
setErrorMessage(e.message ?? e.toString());
|
||||
setLoading(false);
|
||||
showErrorToast({
|
||||
text: `${t("toasts.auth.signinFailed")}: ${e.message ?? e.error ?? JSON.stringify(e)}`,
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
},
|
||||
[setCurrentInstance, router],
|
||||
);
|
||||
const signInAction = useCallback(
|
||||
(instance: MeeloInstance) => {
|
||||
return {
|
||||
onPress: () => signInToOtherInstance(instance),
|
||||
icon: MoreIcon,
|
||||
};
|
||||
},
|
||||
[signInToOtherInstance],
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<KeyboardAwareScrollView
|
||||
bottomOffset={styles.keyboard.paddingTop}
|
||||
contentContainerStyle={[styles.root, safeAreaStyle]}
|
||||
>
|
||||
<Banner style={styles.banner} />
|
||||
<View style={styles.formContainer}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="url"
|
||||
rules={{
|
||||
required: {
|
||||
value: true,
|
||||
message: t("form.auth.instanceUrlIsRequired"),
|
||||
},
|
||||
}}
|
||||
render={({ field: { onChange, onBlur, value } }) => (
|
||||
<TextInput
|
||||
placeholder={t("form.auth.instanceUrl")}
|
||||
textContentType="URL"
|
||||
autoCorrect={false}
|
||||
autoCapitalize="none"
|
||||
onBlur={onBlur}
|
||||
onChangeText={(e) => {
|
||||
if (formType !== "url") {
|
||||
setFormType("url");
|
||||
}
|
||||
onChange(e);
|
||||
}}
|
||||
error={errors.url?.message}
|
||||
value={value}
|
||||
<View style={[styles.root, safeAreaStyle]}>
|
||||
<Banner style={styles.banner} />
|
||||
{otherInstances.length ? (
|
||||
<View style={styles.instanceButtons}>
|
||||
{otherInstances.map((instance, idx) => (
|
||||
<Fragment key={idx}>
|
||||
<InstanceButton
|
||||
key={idx}
|
||||
instance={instance}
|
||||
enabled
|
||||
actions={[signInAction(instance)]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{formType !== "url" && (
|
||||
<>
|
||||
<Controller
|
||||
control={control}
|
||||
name="username"
|
||||
rules={{
|
||||
required: {
|
||||
value: true,
|
||||
message: t(
|
||||
"form.auth.usernameTooShort",
|
||||
),
|
||||
},
|
||||
minLength: {
|
||||
value: 4,
|
||||
message: t(
|
||||
"form.auth.usernameTooShort",
|
||||
),
|
||||
},
|
||||
}}
|
||||
render={({
|
||||
field: { onChange, onBlur, value },
|
||||
}) => (
|
||||
<TextInput
|
||||
placeholder={t("form.auth.username")}
|
||||
textContentType="username"
|
||||
autoCorrect={false}
|
||||
autoCapitalize="none"
|
||||
onBlur={onBlur}
|
||||
onChangeText={onChange}
|
||||
value={value}
|
||||
autoComplete={
|
||||
formType === "login"
|
||||
? "username"
|
||||
: undefined
|
||||
}
|
||||
error={errors.username?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="password"
|
||||
rules={{
|
||||
required: {
|
||||
value: true,
|
||||
message: t(
|
||||
"form.auth.passwordIsRequired",
|
||||
),
|
||||
},
|
||||
minLength: {
|
||||
value: 6,
|
||||
message: t(
|
||||
"form.auth.passwordTooShort",
|
||||
),
|
||||
},
|
||||
}}
|
||||
render={({
|
||||
field: { onChange, onBlur, value },
|
||||
}) => (
|
||||
<TextInput
|
||||
placeholder={t("form.auth.password")}
|
||||
textContentType={
|
||||
formType === "login"
|
||||
? "password"
|
||||
: "newPassword"
|
||||
}
|
||||
onBlur={onBlur}
|
||||
onChangeText={onChange}
|
||||
value={value}
|
||||
autoComplete={
|
||||
formType === "login"
|
||||
? "password"
|
||||
: undefined
|
||||
}
|
||||
error={errors.password?.message}
|
||||
secureTextEntry
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{formType === "signup" && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="confirm"
|
||||
rules={{
|
||||
required: {
|
||||
value: true,
|
||||
message: t("form.auth.pleaseConfirm"),
|
||||
},
|
||||
minLength: {
|
||||
value: 6,
|
||||
message: t("form.auth.passwordTooShort"),
|
||||
},
|
||||
validate: (confirmValue, form) => {
|
||||
if (confirmValue !== form.password) {
|
||||
return t(
|
||||
"form.auth.passwordsAreDifferent",
|
||||
);
|
||||
}
|
||||
},
|
||||
}}
|
||||
render={({
|
||||
field: { onChange, onBlur, value },
|
||||
}) => (
|
||||
<TextInput
|
||||
placeholder={t(
|
||||
"form.auth.confirmPasswordField",
|
||||
)}
|
||||
onBlur={onBlur}
|
||||
onChangeText={onChange}
|
||||
value={value}
|
||||
textContentType="newPassword"
|
||||
secureTextEntry
|
||||
error={errors.confirm?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
disabled={isLoading}
|
||||
onPress={handleSubmit(onSubmit)}
|
||||
width="fitContent"
|
||||
title={t(
|
||||
formType === "url"
|
||||
? "auth.connectButton"
|
||||
: formType === "login"
|
||||
? "auth.loginButton"
|
||||
: "auth.signupButton",
|
||||
)}
|
||||
/>
|
||||
{errorMsg && (
|
||||
<View style={styles.errorContainer}>
|
||||
<ErrorIcon style={styles.errorMsg} />
|
||||
<Text style={styles.errorMsg}>{errorMsg}</Text>
|
||||
</View>
|
||||
)}
|
||||
{allowSignup && formType !== "url" && (
|
||||
<>
|
||||
<Divider h withInsets />
|
||||
<Button
|
||||
variant="outlined"
|
||||
width="fitContent"
|
||||
onPress={() => {
|
||||
setFormType(
|
||||
formType === "login"
|
||||
? "signup"
|
||||
: "login",
|
||||
);
|
||||
setErrorMessage(undefined);
|
||||
}}
|
||||
title={t(
|
||||
formType === "login"
|
||||
? "auth.signup"
|
||||
: "auth.signin",
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Divider h />
|
||||
</Fragment>
|
||||
))}
|
||||
</View>
|
||||
</KeyboardAwareScrollView>
|
||||
<KeyboardToolbar />
|
||||
</>
|
||||
) : null}
|
||||
<View style={styles.formContainer}>
|
||||
<Button
|
||||
title={t("actions.connectToNewServer")}
|
||||
icon={AddIcon}
|
||||
onPress={openLoginForm}
|
||||
/>
|
||||
</View>
|
||||
{!otherInstances.length ? <View /> : null}
|
||||
{/* to have a 1/3 page-height footer */}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import type { IllustratedResource } from "@/models/illustration";
|
||||
import type Resource from "@/models/resource";
|
||||
import { generateArray } from "@/utils/gen-list";
|
||||
import { useInfiniteQuery } from "~/api";
|
||||
import { accessTokenAtom } from "~/state/user";
|
||||
import { currentInstanceAtom } from "~/state/user";
|
||||
|
||||
export const keyIllustrationAtom = atom<Illustration | null>(null);
|
||||
|
||||
@@ -59,7 +59,7 @@ export const useSetKeyIllustrationFromInfiniteQuery = <
|
||||
const transitionDuration = 300;
|
||||
|
||||
export const BackgroundGradient = () => {
|
||||
const accessToken = useAtomValue(accessTokenAtom);
|
||||
const currentInstance = useAtomValue(currentInstanceAtom);
|
||||
const keyIllustration = useAtomValue(keyIllustrationAtom);
|
||||
const currentColor1 = useSharedValue("transparent");
|
||||
const currentColor2 = useSharedValue("transparent");
|
||||
@@ -68,14 +68,14 @@ export const BackgroundGradient = () => {
|
||||
const currentColor5 = useSharedValue("transparent");
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken) {
|
||||
if (!currentInstance) {
|
||||
currentColor1.value = "transparent";
|
||||
currentColor2.value = "transparent";
|
||||
currentColor3.value = "transparent";
|
||||
currentColor4.value = "transparent";
|
||||
currentColor5.value = "transparent";
|
||||
}
|
||||
}, [accessToken]);
|
||||
}, [currentInstance]);
|
||||
useEffect(() => {
|
||||
if (keyIllustration) {
|
||||
currentColor1.value = withTiming(keyIllustration.colors[0], {
|
||||
|
||||
@@ -10,10 +10,12 @@ import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { BlurView } from "~/components/blur-view";
|
||||
|
||||
type ModalAtom = { content: () => ReactNode; onDismiss: () => void };
|
||||
type ModalAtom = { content: () => ReactNode; onDismiss?: () => void };
|
||||
|
||||
const modalAtom = atom<ModalAtom | null>(null);
|
||||
|
||||
export const closeModalAtom = atom(null, (_, set) => set(modalAtom, null));
|
||||
|
||||
export const useModal = (p: ModalAtom) => {
|
||||
const setModalContent = useSetAtom(modalAtom);
|
||||
const openModal = useCallback(() => setModalContent(p), [p]);
|
||||
@@ -23,7 +25,7 @@ export const useModal = (p: ModalAtom) => {
|
||||
export const Modal = () => {
|
||||
const [modalContent, setModalContent] = useAtom(modalAtom);
|
||||
const onClose = useCallback(() => {
|
||||
modalContent?.onDismiss();
|
||||
modalContent?.onDismiss?.();
|
||||
setModalContent(null);
|
||||
}, [modalContent]);
|
||||
const Content = modalContent?.content ?? View;
|
||||
@@ -32,8 +34,11 @@ export const Modal = () => {
|
||||
useEffect(() => {
|
||||
if (modalContent !== null) {
|
||||
modalRef.current?.present();
|
||||
} else {
|
||||
modalRef.current?.close();
|
||||
onClose();
|
||||
}
|
||||
}, [modalContent]);
|
||||
}, [modalContent, onClose]);
|
||||
return (
|
||||
<BottomSheetModal
|
||||
ref={modalRef}
|
||||
@@ -47,6 +52,8 @@ export const Modal = () => {
|
||||
<BlurView style={{ flex: 1 }} />
|
||||
</View>
|
||||
)}
|
||||
keyboardBehavior="interactive"
|
||||
keyboardBlurBehavior="restore"
|
||||
backdropComponent={ModalBackdrop}
|
||||
>
|
||||
<BottomSheetView style={styles.modal}>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Toast } from "toastify-react-native";
|
||||
import type Album from "@/models/album";
|
||||
import { AlbumType } from "@/models/album";
|
||||
import type Song from "@/models/song";
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
import type Video from "@/models/video";
|
||||
import { VideoType } from "@/models/video";
|
||||
import { useAPI, useQueryClient } from "~/api";
|
||||
import { useModal } from "./bottom-modal-sheet";
|
||||
import { showErrorToast, showSuccessToast } from "~/primitives/toast";
|
||||
import { closeModalAtom, useModal } from "./bottom-modal-sheet";
|
||||
import { SelectBottomModalContent } from "./bottom-modal-sheet/select";
|
||||
|
||||
type ChangeTypeModal<T extends string> = {
|
||||
@@ -35,13 +36,13 @@ export const useChangeAlbumTypeModal = (album: Album | undefined) => {
|
||||
}
|
||||
: undefined,
|
||||
onSuccess: () => {
|
||||
Toast.success("Update successful!");
|
||||
showSuccessToast({ text: "Update successful!" });
|
||||
client.invalidateQueries({ queryKey: ["albums"] });
|
||||
client.invalidateQueries({ queryKey: [album?.slug] });
|
||||
client.invalidateQueries({ queryKey: [album?.id] });
|
||||
},
|
||||
onError: () => {
|
||||
Toast.error("Update failed");
|
||||
showErrorToast({ text: "Update failed" });
|
||||
},
|
||||
});
|
||||
const { openModal } = useChangeTypeModal({
|
||||
@@ -65,13 +66,13 @@ export const useChangeSongTypeModal = (song: Song | undefined) => {
|
||||
}
|
||||
: undefined,
|
||||
onSuccess: () => {
|
||||
Toast.success("Update successful!");
|
||||
showSuccessToast({ text: "Update successful!" });
|
||||
client.invalidateQueries({ queryKey: ["songs"] });
|
||||
client.invalidateQueries({ queryKey: [song?.slug] });
|
||||
client.invalidateQueries({ queryKey: [song?.id] });
|
||||
},
|
||||
onError: () => {
|
||||
Toast.error("Update failed");
|
||||
showErrorToast({ text: "Update failed" });
|
||||
},
|
||||
});
|
||||
const { openModal } = useChangeTypeModal({
|
||||
@@ -95,14 +96,14 @@ export const useChangeVideoTypeModal = (video: Video | undefined) => {
|
||||
}
|
||||
: undefined,
|
||||
onSuccess: () => {
|
||||
Toast.success("Update successful!");
|
||||
showSuccessToast({ text: "Update successful!" });
|
||||
client.invalidateQueries({ queryKey: ["videos"] });
|
||||
client.invalidateQueries({ queryKey: [video?.slug] });
|
||||
client.invalidateQueries({ queryKey: [video?.id] });
|
||||
client.invalidateQueries({ queryKey: ["releases"] });
|
||||
},
|
||||
onError: () => {
|
||||
Toast.error("Update failed");
|
||||
showErrorToast({ text: "Update failed" });
|
||||
},
|
||||
});
|
||||
const { openModal } = useChangeTypeModal({
|
||||
@@ -120,12 +121,17 @@ export const useChangeVideoTypeModal = (video: Video | undefined) => {
|
||||
export const useChangeTypeModal = <Type extends string>(
|
||||
props: ChangeTypeModal<Type>,
|
||||
) => {
|
||||
const closeModal = useSetAtom(closeModalAtom);
|
||||
const content = useCallback(() => {
|
||||
if (!props) {
|
||||
return null;
|
||||
}
|
||||
return <ChangeTypeModal {...props} />;
|
||||
}, [props]);
|
||||
const onSelect: typeof props.onSelect = (e) => {
|
||||
props.onSelect(e);
|
||||
closeModal();
|
||||
};
|
||||
return <ChangeTypeModal {...props} onSelect={onSelect} />;
|
||||
}, [props, closeModal]);
|
||||
const { openModal } = useModal({
|
||||
content,
|
||||
onDismiss: () => {},
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type Label from "@/models/label";
|
||||
import { AlbumIcon, ArtistIcon, LabelIcon } from "@/ui/icons";
|
||||
import type { ContextMenu } from "..";
|
||||
|
||||
export const useLabelContextMenu = (label: Label | undefined) => {
|
||||
const { t } = useTranslation();
|
||||
return useCallback(() => {
|
||||
return {
|
||||
header: {
|
||||
title: label?.name,
|
||||
subtitle: t("models.label"),
|
||||
illustration: null,
|
||||
illustrationProps: { fallbackIcon: LabelIcon },
|
||||
},
|
||||
items: [
|
||||
[
|
||||
{
|
||||
label: "actions.artist.seeAlbums",
|
||||
icon: AlbumIcon,
|
||||
href: `/albums?label=${label?.id}`,
|
||||
},
|
||||
{
|
||||
label: "actions.seeArtists",
|
||||
icon: ArtistIcon,
|
||||
href: `/artists?label=${label?.id}`,
|
||||
},
|
||||
],
|
||||
],
|
||||
} satisfies ContextMenu;
|
||||
}, [label]);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { FilterControl } from "@/infinite-controls/filters/control";
|
||||
import type { LayoutControl } from "@/infinite-controls/layout";
|
||||
import type { SortControl } from "@/infinite-controls/sort";
|
||||
@@ -23,119 +23,127 @@ export const Controls = <S extends string>({
|
||||
actions,
|
||||
}: Props<S>) => {
|
||||
const { t } = useTranslation();
|
||||
const OrderIcon = withUnistyles(
|
||||
sort?.selected.order === "desc" ? DescIcon : AscIcon,
|
||||
(theme) => ({
|
||||
color: theme.colors.text.primary,
|
||||
size: theme.fontSize.rem(1),
|
||||
}),
|
||||
);
|
||||
const OrderIcon = sort?.selected.order === "desc" ? DescIcon : AscIcon;
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
{filters?.map((filter, idx) => (
|
||||
<SelectModalButton
|
||||
header={t("browsing.controls.filter.header")}
|
||||
key={idx}
|
||||
closeOnSelect
|
||||
buttonProps={{
|
||||
title: t(filter.buttonLabel),
|
||||
icon: filter.buttonIcon as any,
|
||||
width: "fitContent",
|
||||
size: "small",
|
||||
iconPosition: "right",
|
||||
}}
|
||||
values={filter.values ?? []}
|
||||
isSelected={(item) =>
|
||||
filter.multipleChoice
|
||||
? filter.selected.includes(item)
|
||||
: filter.selected === item
|
||||
}
|
||||
formatItem={(item) => t(filter.formatItem(item))}
|
||||
onSelect={(selected) => {
|
||||
if (filter.multipleChoice) {
|
||||
const selectedKeys = filter.selected.includes(
|
||||
selected,
|
||||
)
|
||||
? filter.selected.filter((k) => k !== selected)
|
||||
: [selected, ...filter.selected];
|
||||
filter.onUpdate(selectedKeys);
|
||||
} else {
|
||||
filter.onUpdate(
|
||||
filter.selected === selected ? null : selected,
|
||||
);
|
||||
<View style={styles.row}>
|
||||
<View style={styles.row}>
|
||||
{filters?.map((filter, idx) => (
|
||||
<SelectModalButton
|
||||
header={t("browsing.controls.filter.header")}
|
||||
key={idx}
|
||||
closeOnSelect
|
||||
buttonProps={{
|
||||
title: t(filter.buttonLabel),
|
||||
icon: filter.buttonIcon as any,
|
||||
width: "fitContent",
|
||||
size: "small",
|
||||
iconPosition: "right",
|
||||
}}
|
||||
values={filter.values ?? []}
|
||||
isSelected={(item) =>
|
||||
filter.multipleChoice
|
||||
? filter.selected.includes(item)
|
||||
: filter.selected === item
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{sort && (
|
||||
<SelectModalButton
|
||||
header={t("browsing.controls.sortBy")}
|
||||
closeOnSelect
|
||||
values={sort.sortingKeys}
|
||||
formatItem={(item) => t(sort.formatItem(item))}
|
||||
buttonProps={{
|
||||
title: t(sort.formatItem(sort.selected.sort)),
|
||||
width: "fitContent",
|
||||
size: "small",
|
||||
icon:
|
||||
sort.selected.order === "asc" ? AscIcon : DescIcon,
|
||||
iconPosition: "left",
|
||||
}}
|
||||
isSelected={(item) => sort.selected.sort === item}
|
||||
checkIcon={OrderIcon}
|
||||
onSelect={(selected: S) => {
|
||||
if (selected === sort.selected.sort) {
|
||||
sort.onUpdate({
|
||||
sort: selected,
|
||||
order:
|
||||
sort.selected.order === "asc"
|
||||
? "desc"
|
||||
: "asc",
|
||||
formatItem={(item) => t(filter.formatItem(item))}
|
||||
onSelect={(selected) => {
|
||||
if (filter.multipleChoice) {
|
||||
const selectedKeys = filter.selected.includes(
|
||||
selected,
|
||||
)
|
||||
? filter.selected.filter(
|
||||
(k) => k !== selected,
|
||||
)
|
||||
: [selected, ...filter.selected];
|
||||
filter.onUpdate(selectedKeys);
|
||||
} else {
|
||||
filter.onUpdate(
|
||||
filter.selected === selected
|
||||
? null
|
||||
: selected,
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{sort && (
|
||||
<SelectModalButton
|
||||
header={t("browsing.controls.sortBy")}
|
||||
closeOnSelect
|
||||
values={sort.sortingKeys}
|
||||
formatItem={(item) => t(sort.formatItem(item))}
|
||||
buttonProps={{
|
||||
title: t(sort.formatItem(sort.selected.sort)),
|
||||
width: "fitContent",
|
||||
size: "small",
|
||||
icon:
|
||||
sort.selected.order === "asc"
|
||||
? AscIcon
|
||||
: DescIcon,
|
||||
iconPosition: "left",
|
||||
}}
|
||||
isSelected={(item) => sort.selected.sort === item}
|
||||
checkIcon={OrderIcon}
|
||||
onSelect={(selected: S) => {
|
||||
if (selected === sort.selected.sort) {
|
||||
sort.onUpdate({
|
||||
sort: selected,
|
||||
order:
|
||||
sort.selected.order === "asc"
|
||||
? "desc"
|
||||
: "asc",
|
||||
});
|
||||
} else {
|
||||
sort.onUpdate({ sort: selected, order: "asc" });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{layout && (
|
||||
<Button
|
||||
size="small"
|
||||
width="fitContent"
|
||||
icon={layout.layout === "list" ? GridIcon : ListIcon}
|
||||
containerStyle={styles.button}
|
||||
onPress={() => {
|
||||
layout.onUpdate({
|
||||
layout:
|
||||
layout.layout === "list" ? "grid" : "list",
|
||||
itemSize: layout.itemSize,
|
||||
});
|
||||
} else {
|
||||
sort.onUpdate({ sort: selected, order: "asc" });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{layout && (
|
||||
<Button
|
||||
size="small"
|
||||
width="fitContent"
|
||||
icon={layout.layout === "list" ? GridIcon : ListIcon}
|
||||
containerStyle={styles.button}
|
||||
onPress={() => {
|
||||
layout.onUpdate({
|
||||
layout: layout.layout === "list" ? "grid" : "list",
|
||||
itemSize: layout.itemSize,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{actions?.map((action, idx) => (
|
||||
<Button
|
||||
size="small"
|
||||
width="fitContent"
|
||||
key={idx}
|
||||
icon={action.icon}
|
||||
title={t(action.label)}
|
||||
onPress={() => {
|
||||
action.onPress?.();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
<View style={[styles.row, styles.unbreakableRow]}>
|
||||
{actions?.map((action, idx) => (
|
||||
<Button
|
||||
size="small"
|
||||
width="fitContent"
|
||||
key={idx}
|
||||
icon={action.icon}
|
||||
title={t(action.label)}
|
||||
onPress={() => {
|
||||
action.onPress?.();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: {
|
||||
row: {
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexWrap: "wrap",
|
||||
gap: theme.gap(1),
|
||||
},
|
||||
unbreakableRow: {
|
||||
flexWrap: "nowrap",
|
||||
},
|
||||
button: { elevation: 10 },
|
||||
}));
|
||||
@@ -2,7 +2,6 @@ import { useInfiniteQuery as useReactInfiniteQuery } from "@tanstack/react-query
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Toast } from "toastify-react-native";
|
||||
import { getLibraries } from "@/api/queries";
|
||||
import { toTanStackInfiniteQuery } from "@/api/query";
|
||||
import {
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
useTypeFiltersControl as useTypeFiltersControlBase,
|
||||
} from "@/infinite-controls/filters/resource-type";
|
||||
import { useAPI } from "~/api";
|
||||
import { showErrorToast } from "~/primitives/toast";
|
||||
|
||||
const useLibraryFilterControl = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -25,7 +25,7 @@ const useLibraryFilterControl = () => {
|
||||
});
|
||||
useEffect(() => {
|
||||
if (librariesQuery.isError) {
|
||||
Toast.error(t("toasts.library.loadFail"));
|
||||
showErrorToast({ text: t("toasts.library.loadFail") });
|
||||
}
|
||||
}, [librariesQuery.isError]);
|
||||
const libraries = librariesQuery.data?.pages.at(0)?.items;
|
||||
@@ -46,7 +46,7 @@ const useLibraryFiltersControl = () => {
|
||||
});
|
||||
useEffect(() => {
|
||||
if (librariesQuery.isError) {
|
||||
Toast.error(t("toasts.library.loadFail"));
|
||||
showErrorToast({ text: t("toasts.library.loadFail") });
|
||||
}
|
||||
}, [librariesQuery.isError]);
|
||||
const libraries = librariesQuery.data?.pages.at(0)?.items;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useIsFocused } from "@react-navigation/native";
|
||||
import { type ContentStyle, FlashList } from "@shopify/flash-list";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import type React from "react";
|
||||
import { type ComponentProps, useMemo, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { type StyleProp, View, type ViewStyle } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { InfiniteQuery } from "@/api/query";
|
||||
import type { IllustratedResource } from "@/models/illustration";
|
||||
@@ -99,7 +99,7 @@ export const InfiniteView = <
|
||||
<View style={styles.emptyState}>
|
||||
<EmptyState />
|
||||
</View>
|
||||
) : (
|
||||
) : controlsHeight !== null ? (
|
||||
<ScrollView
|
||||
data={itemList}
|
||||
refreshing={queryRes.isRefetching}
|
||||
@@ -107,7 +107,7 @@ export const InfiniteView = <
|
||||
[
|
||||
styles.scrollView,
|
||||
{ paddingBottom },
|
||||
] as ContentStyle
|
||||
] satisfies StyleProp<ViewStyle>
|
||||
}
|
||||
onRefresh={() => queryRes.refetch()}
|
||||
onEndReachedThreshold={0.5}
|
||||
@@ -158,7 +158,7 @@ export const InfiniteView = <
|
||||
},
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { Action } from "~/actions";
|
||||
import { Button } from "~/primitives/button";
|
||||
import { Text } from "~/primitives/text";
|
||||
import type { MeeloInstance } from "~/state/user";
|
||||
|
||||
type Props = {
|
||||
instance: MeeloInstance;
|
||||
enabled: boolean;
|
||||
} & (
|
||||
| { trailing: string; actions?: never }
|
||||
| { trailing?: never; actions: Omit<Action, "label">[] }
|
||||
);
|
||||
|
||||
export const InstanceButton = ({
|
||||
instance,
|
||||
enabled,
|
||||
trailing,
|
||||
actions,
|
||||
}: Props) => {
|
||||
const cleanUrl = useMemo(() => {
|
||||
const url = new URL(instance.url);
|
||||
return url.host;
|
||||
}, [instance]);
|
||||
const textColor = enabled ? "primary" : "secondary";
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<View style={styles.label}>
|
||||
<Text content={cleanUrl} color={textColor} variant="h5" />
|
||||
<Text
|
||||
content={instance.username}
|
||||
color={textColor}
|
||||
variant="body"
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.trailing}>
|
||||
{trailing ? (
|
||||
<Text color={textColor} content={trailing} />
|
||||
) : (
|
||||
actions?.map((action, idx) => (
|
||||
<Button
|
||||
key={idx}
|
||||
size="small"
|
||||
icon={action.icon}
|
||||
onPress={action.onPress ?? (() => {})}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: { flexDirection: "row" },
|
||||
label: {
|
||||
flexDirection: "column",
|
||||
gap: theme.gap(1),
|
||||
flex: 1,
|
||||
},
|
||||
trailing: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.gap(1),
|
||||
flex: 0,
|
||||
},
|
||||
}));
|
||||
@@ -1,5 +1,10 @@
|
||||
import { type Href, useRouter } from "expo-router";
|
||||
import { type ComponentProps, type ReactElement, useCallback } from "react";
|
||||
import {
|
||||
type ComponentProps,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { RequireAtLeastOne } from "type-fest";
|
||||
@@ -10,6 +15,7 @@ import {
|
||||
useContextMenu,
|
||||
} from "~/components/context-menu";
|
||||
import { Illustration as IllustrationComponent } from "~/components/illustration";
|
||||
import * as Haptics from "~/haptics";
|
||||
import { Pressable } from "~/primitives/pressable";
|
||||
import { LoadableText } from "../loadable_text";
|
||||
|
||||
@@ -31,12 +37,12 @@ type Props = {
|
||||
) &
|
||||
(
|
||||
| {
|
||||
noIllustration?: true;
|
||||
leading: ReactNode | null;
|
||||
illustration?: never;
|
||||
illustrationProps?: never;
|
||||
}
|
||||
| {
|
||||
noIllustration?: never;
|
||||
leading?: never;
|
||||
illustration: Illustration | null | undefined;
|
||||
illustrationProps?: Omit<
|
||||
ComponentProps<typeof IllustrationComponent>,
|
||||
@@ -51,7 +57,7 @@ export const ListItem = ({
|
||||
subtitle,
|
||||
illustration,
|
||||
illustrationProps,
|
||||
noIllustration,
|
||||
leading,
|
||||
href,
|
||||
trailing,
|
||||
contextMenu,
|
||||
@@ -62,12 +68,13 @@ export const ListItem = ({
|
||||
const router = useRouter();
|
||||
styles.useVariants({
|
||||
normalizedThumbnail: illustrationProps?.normalizedThumbnail ?? false,
|
||||
hasIllustration: noIllustration !== true,
|
||||
hasIllustration: leading === undefined,
|
||||
});
|
||||
const onLongPressCallback = useCallback(() => {
|
||||
if (onLongPress) {
|
||||
onLongPress();
|
||||
} else if (contextMenu) {
|
||||
Haptics.onContextMenuOpen();
|
||||
openContextMenu();
|
||||
}
|
||||
}, [onLongPress, contextMenu, openContextMenu]);
|
||||
@@ -82,7 +89,7 @@ export const ListItem = ({
|
||||
onLongPress={onLongPressCallback}
|
||||
style={[styles.root]}
|
||||
>
|
||||
{noIllustration !== true && (
|
||||
{leading === undefined ? (
|
||||
<View style={styles.illustration}>
|
||||
<IllustrationComponent
|
||||
{...illustrationProps}
|
||||
@@ -92,7 +99,9 @@ export const ListItem = ({
|
||||
quality="low"
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
) : leading !== null ? (
|
||||
<View style={styles.illustration}>{leading}</View>
|
||||
) : null}
|
||||
<View style={styles.textContainer}>
|
||||
<LoadableText
|
||||
content={title}
|
||||
|
||||
@@ -11,7 +11,7 @@ export const GenreItem = ({ genre }: Props) => {
|
||||
const { openContextMenu } = useContextMenu(ctxMenu);
|
||||
return (
|
||||
<ListItem
|
||||
noIllustration
|
||||
leading={null}
|
||||
title={genre?.name}
|
||||
subtitle={null}
|
||||
onPress={openContextMenu}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type Label from "@/models/label";
|
||||
import { Chip } from "~/components/chip";
|
||||
import { useContextMenu } from "~/components/context-menu";
|
||||
import { useLabelContextMenu } from "~/components/context-menu/resource/label";
|
||||
|
||||
type Props = { label: Label | undefined };
|
||||
|
||||
export const LabelChip = ({ label }: Props) => {
|
||||
const ctxMenu = useLabelContextMenu(label);
|
||||
const { openContextMenu } = useContextMenu(ctxMenu);
|
||||
return <Chip title={label?.name} onPress={openContextMenu} />;
|
||||
};
|
||||
@@ -8,6 +8,7 @@ import type { ContextMenuBuilder } from "~/components/context-menu";
|
||||
import { useContextMenu } from "~/components/context-menu";
|
||||
import { Illustration } from "~/components/illustration";
|
||||
import { LoadableText } from "~/components/loadable_text";
|
||||
import * as Haptics from "~/haptics";
|
||||
|
||||
//TODO press effect
|
||||
|
||||
@@ -75,7 +76,10 @@ export const Tile = ({
|
||||
props.illustrationProps?.normalizedThumbnail ?? false,
|
||||
});
|
||||
const onLongPress = useCallback(() => {
|
||||
contextMenu && openContextMenu();
|
||||
if (contextMenu) {
|
||||
openContextMenu();
|
||||
Haptics.onContextMenuOpen();
|
||||
}
|
||||
}, [contextMenu, openContextMenu]);
|
||||
const router = useRouter();
|
||||
const onShortPress = useCallback(() => {
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { useBottomSheetModal } from "@gorhom/bottom-sheet";
|
||||
import { openBrowserAsync } from "expo-web-browser";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Controller, useForm, useWatch } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Keyboard, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { getSettings } from "@/api/queries";
|
||||
import type { Settings } from "@/models/settings";
|
||||
import { ErrorIcon, OpenExternalIcon } from "@/ui/icons";
|
||||
import { getAPI_ } from "~/api";
|
||||
import { useModal } from "~/components/bottom-modal-sheet";
|
||||
import { Button } from "~/primitives/button";
|
||||
import { Divider } from "~/primitives/divider";
|
||||
import { Text } from "~/primitives/text";
|
||||
import { TextInput } from "~/primitives/text_input";
|
||||
|
||||
type LoginFormProps = {
|
||||
onLogin: (data: { instanceUrl: string; token: string }) => Promise<void>;
|
||||
};
|
||||
|
||||
//TODO Risk of overflow when in horizontal mode on tablet
|
||||
|
||||
export const useLoginForm = (props: LoginFormProps) => {
|
||||
const content = useCallback(() => {
|
||||
return <LoginForm {...props} />;
|
||||
}, [props]);
|
||||
const { openModal } = useModal({
|
||||
content,
|
||||
onDismiss: () => {},
|
||||
});
|
||||
return { openLoginForm: openModal };
|
||||
};
|
||||
|
||||
export const LoginForm = ({ onLogin }: LoginFormProps) => {
|
||||
const { t } = useTranslation();
|
||||
const defaultValues = {
|
||||
url: "https://",
|
||||
username: "",
|
||||
password: "",
|
||||
confirm: "",
|
||||
};
|
||||
const { dismiss } = useBottomSheetModal();
|
||||
const [isLoading, setLoading] = useState(false);
|
||||
const [errorMsg, setErrorMessage] = useState<string>();
|
||||
const [formType, setFormType] = useState<"url" | "login">("url");
|
||||
const [allowSignup, setAllowSignup] = useState(false);
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm({ defaultValues });
|
||||
const instanceUrl = useWatch({ control, name: "url" });
|
||||
const onSubmit = (data: typeof defaultValues) => {
|
||||
const instanceUrl = data.url.replace(/\/$/, "");
|
||||
const api = getAPI_(null, instanceUrl);
|
||||
setLoading(true);
|
||||
setErrorMessage(undefined);
|
||||
if (formType === "url") {
|
||||
//Note: we dont want/need to cache the settings
|
||||
//It would prank when the instance url changes
|
||||
Promise.race([timeout(5000), getSettings().exec(api)()])
|
||||
.then(({ enableUserRegistration }: Settings) => {
|
||||
setAllowSignup(enableUserRegistration);
|
||||
setFormType("login");
|
||||
})
|
||||
.catch((e) => {
|
||||
setErrorMessage(e.message ?? e.toString());
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
} else {
|
||||
Promise.race([
|
||||
timeout(5000),
|
||||
api.login({
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
}),
|
||||
])
|
||||
.then(async ({ access_token }) => {
|
||||
Keyboard.dismiss();
|
||||
await onLogin({ instanceUrl, token: access_token });
|
||||
setLoading(false);
|
||||
dismiss();
|
||||
})
|
||||
.catch((e) => {
|
||||
setLoading(false);
|
||||
setErrorMessage(e.message ?? e.toString());
|
||||
});
|
||||
}
|
||||
};
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="url"
|
||||
rules={{
|
||||
required: {
|
||||
value: true,
|
||||
message: t("form.auth.instanceUrlIsRequired"),
|
||||
},
|
||||
}}
|
||||
render={({ field: { onChange, onBlur, value } }) => (
|
||||
<TextInput
|
||||
inModal
|
||||
placeholder={t("form.auth.instanceUrl")}
|
||||
textContentType="URL"
|
||||
autoCorrect={false}
|
||||
autoCapitalize="none"
|
||||
onBlur={onBlur}
|
||||
onChangeText={(e) => {
|
||||
if (formType !== "url") {
|
||||
setFormType("url");
|
||||
}
|
||||
onChange(e);
|
||||
}}
|
||||
error={errors.url?.message}
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{formType !== "url" && (
|
||||
<>
|
||||
<Controller
|
||||
control={control}
|
||||
name="username"
|
||||
rules={{
|
||||
required: {
|
||||
value: true,
|
||||
message: t("form.auth.usernameTooShort"),
|
||||
},
|
||||
minLength: {
|
||||
value: 4,
|
||||
message: t("form.auth.usernameTooShort"),
|
||||
},
|
||||
}}
|
||||
render={({ field: { onChange, onBlur, value } }) => (
|
||||
<TextInput
|
||||
inModal
|
||||
placeholder={t("form.auth.username")}
|
||||
textContentType="username"
|
||||
autoCorrect={false}
|
||||
autoCapitalize="none"
|
||||
onBlur={onBlur}
|
||||
onChangeText={onChange}
|
||||
value={value}
|
||||
autoComplete={
|
||||
formType === "login"
|
||||
? "username"
|
||||
: undefined
|
||||
}
|
||||
error={errors.username?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="password"
|
||||
rules={{
|
||||
required: {
|
||||
value: true,
|
||||
message: t("form.auth.passwordIsRequired"),
|
||||
},
|
||||
minLength: {
|
||||
value: 6,
|
||||
message: t("form.auth.passwordTooShort"),
|
||||
},
|
||||
}}
|
||||
render={({ field: { onChange, onBlur, value } }) => (
|
||||
<TextInput
|
||||
inModal
|
||||
placeholder={t("form.auth.password")}
|
||||
textContentType={
|
||||
formType === "login"
|
||||
? "password"
|
||||
: "newPassword"
|
||||
}
|
||||
onBlur={onBlur}
|
||||
onChangeText={onChange}
|
||||
value={value}
|
||||
autoComplete={
|
||||
formType === "login"
|
||||
? "password"
|
||||
: undefined
|
||||
}
|
||||
error={errors.password?.message}
|
||||
secureTextEntry
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<View style={styles.submitButton}>
|
||||
<Button
|
||||
disabled={isLoading}
|
||||
onPress={handleSubmit(onSubmit)}
|
||||
width="fitContent"
|
||||
title={t(
|
||||
formType === "url"
|
||||
? "auth.connectButton"
|
||||
: formType === "login"
|
||||
? "auth.loginButton"
|
||||
: "auth.signupButton",
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
{errorMsg && (
|
||||
<View style={styles.errorContainer}>
|
||||
<ErrorIcon style={styles.errorMsg} />
|
||||
<Text style={styles.errorMsg}>{errorMsg}</Text>
|
||||
</View>
|
||||
)}
|
||||
{allowSignup && formType !== "url" && (
|
||||
<>
|
||||
<Divider h />
|
||||
<View style={styles.signupButton}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
title={t("auth.signup")}
|
||||
icon={OpenExternalIcon}
|
||||
iconPosition="right"
|
||||
onPress={() => openBrowserAsync(instanceUrl)}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
function timeout(ms: number) {
|
||||
return new Promise<any>((_, reject) => {
|
||||
setTimeout(() => reject(new Error("Request timed out")), ms);
|
||||
});
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
errorContainer: {
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.gap(1),
|
||||
},
|
||||
errorMsg: {
|
||||
maxWidth: "80%",
|
||||
color: theme.colors.error,
|
||||
textAlign: "center",
|
||||
},
|
||||
signupText: { textDecorationLine: "underline" },
|
||||
submitButton: { paddingVertical: theme.gap(2) },
|
||||
signupButton: { paddingTop: theme.gap(2) },
|
||||
root: {
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: theme.gap(1),
|
||||
paddingBottom: theme.gap(2),
|
||||
},
|
||||
}));
|
||||
@@ -1,5 +1,6 @@
|
||||
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import uuid from "react-native-uuid";
|
||||
import {
|
||||
type onLoadData,
|
||||
type onPlaybackStateChangeData,
|
||||
@@ -8,9 +9,10 @@ import {
|
||||
VideoPlayer,
|
||||
} from "react-native-video";
|
||||
import type API from "@/api";
|
||||
import { getSettings } from "@/api/queries";
|
||||
import { cursorAtom, skipTrackAtom, type TrackState } from "@/state/player";
|
||||
import formatArtists from "@/utils/format-artists";
|
||||
import { useAPI, useQueryClient } from "~/api";
|
||||
import { useAPI, useQuery, useQueryClient } from "~/api";
|
||||
import {
|
||||
currentTrackAtom,
|
||||
durationAtom,
|
||||
@@ -21,10 +23,14 @@ import {
|
||||
progressAtom,
|
||||
requestedProgressAtom,
|
||||
} from "./state";
|
||||
|
||||
// Should be used as a readonly handle, mainly for the VideoView component
|
||||
export const videoPlayerAtom = atom<VideoPlayer | null>(null);
|
||||
|
||||
// These can be used
|
||||
export const useHLSAtom = atom(false);
|
||||
const _canUseHLSAtom = atom(false);
|
||||
export const canUseHLSAtom = atom((get) => get(_canUseHLSAtom));
|
||||
|
||||
export const PlayerContext = () => {
|
||||
const api = useAPI();
|
||||
const playerRef = useRef<VideoPlayer | null>(null);
|
||||
@@ -44,7 +50,13 @@ export const PlayerContext = () => {
|
||||
const setDuration = useSetAtom(durationAtom);
|
||||
const currentTrack = useAtomValue(currentTrackAtom);
|
||||
const onProgressRef = useRef<(d: onProgressData) => void>(null);
|
||||
const [isHLS, setIsHLS] = useAtom(useHLSAtom);
|
||||
const [canUseHLS, setCanUseHLS] = useAtom(_canUseHLSAtom);
|
||||
const { data: settings } = useQuery(getSettings);
|
||||
|
||||
useEffect(() => {
|
||||
setCanUseHLS(settings?.transcoderAvailable === true);
|
||||
}, [settings]);
|
||||
useEffect(() => {
|
||||
if (!playerRef.current) {
|
||||
return;
|
||||
@@ -78,6 +90,7 @@ export const PlayerContext = () => {
|
||||
setMarkedAsPlayed(false);
|
||||
setProgress(0);
|
||||
setRequestedProgress(null);
|
||||
setIsHLS(false);
|
||||
if (!currentTrack) {
|
||||
playerRef.current?.pause();
|
||||
pause();
|
||||
@@ -114,10 +127,17 @@ export const PlayerContext = () => {
|
||||
}
|
||||
});
|
||||
playerRef.current.volume = 1;
|
||||
playerRef.current.addEventListener("onError", (e) =>
|
||||
playerRef.current.addEventListener("onError", (e) => {
|
||||
if (
|
||||
!isHLS &&
|
||||
e.code === "source/unsupported-content-type" &&
|
||||
canUseHLS
|
||||
) {
|
||||
setIsHLS(true);
|
||||
}
|
||||
// biome-ignore lint/suspicious/noConsole: For debug
|
||||
console.error(e),
|
||||
);
|
||||
console.error(e);
|
||||
});
|
||||
playerRef.current.play();
|
||||
play();
|
||||
setPlayer(playerRef.current);
|
||||
@@ -142,6 +162,18 @@ export const PlayerContext = () => {
|
||||
setMarkedAsPlayed(false);
|
||||
}
|
||||
}, [requestedProgress]);
|
||||
useEffect(() => {
|
||||
if (!playerRef.current || !currentTrack) {
|
||||
return;
|
||||
}
|
||||
const timestamp = playerRef.current?.currentTime;
|
||||
playerRef.current
|
||||
.replaceSourceAsync(mkSource(currentTrack, api, isHLS))
|
||||
.then(() => {
|
||||
playerRef.current!.seekTo(timestamp);
|
||||
playerRef.current!.play();
|
||||
});
|
||||
}, [isHLS]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!playerRef.current) {
|
||||
@@ -169,19 +201,29 @@ export const PlayerContext = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const clientId = uuid.v4();
|
||||
|
||||
const mkSource = (
|
||||
{ track, artist, featuring }: TrackState,
|
||||
api: API,
|
||||
useTranscoding = false,
|
||||
): VideoConfig => ({
|
||||
uri: api.getDirectStreamURL(track.sourceFileId),
|
||||
uri: useTranscoding
|
||||
? api.getTranscodeStreamURL(track.sourceFileId, track.type)
|
||||
: api.getDirectStreamURL(track.sourceFileId),
|
||||
headers: {
|
||||
Authorization: `Bearer ${api.accessToken}`,
|
||||
...(useTranscoding
|
||||
? {
|
||||
"X-CLIENT-ID": clientId,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
metadata: {
|
||||
title: track.name,
|
||||
artist: formatArtists(artist, featuring),
|
||||
imageUri: track.illustration
|
||||
? api.getIllustrationURL(track.illustration.url, "medium", true)
|
||||
? api.getIllustrationURL(track.illustration.url, "high", true)
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
@@ -1,10 +1,7 @@
|
||||
import {
|
||||
BottomSheetScrollView,
|
||||
type BottomSheetScrollViewMethods,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { type LayoutChangeEvent, View } from "react-native";
|
||||
import { type LayoutChangeEvent, ScrollView, View } from "react-native";
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
withSpring,
|
||||
@@ -77,7 +74,7 @@ const SyncedLyrics = ({
|
||||
}: {
|
||||
syncedLyrics: SyncedLyricModel[];
|
||||
}) => {
|
||||
const scrollViewRef = useRef<BottomSheetScrollViewMethods>(null);
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
const [scrollViewHeight, setScrollViewHeight] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
@@ -134,7 +131,7 @@ const SyncedLyrics = ({
|
||||
return () => clearInterval(interval);
|
||||
}, [onInterval]);
|
||||
return (
|
||||
<BottomSheetScrollView
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
scrollEnabled={false}
|
||||
showsVerticalScrollIndicator={false}
|
||||
@@ -167,7 +164,7 @@ const SyncedLyrics = ({
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</BottomSheetScrollView>
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useBottomSheetModal } from "@gorhom/bottom-sheet";
|
||||
import { BottomSheetView, useBottomSheetModal } from "@gorhom/bottom-sheet";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { type ComponentProps, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { VideoView } from "react-native-video";
|
||||
@@ -13,21 +14,24 @@ import {
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
RewindIcon,
|
||||
SettingsIcon,
|
||||
} from "@/ui/icons";
|
||||
import formatDuration from "@/utils/format-duration";
|
||||
import { useQuery, useQueryClient } from "~/api";
|
||||
import { SelectModalButton } from "~/components/bottom-modal-sheet/select";
|
||||
import { useContextMenu } from "~/components/context-menu";
|
||||
import { useArtistContextMenu } from "~/components/context-menu/resource/artist";
|
||||
import { useTrackContextMenu } from "~/components/context-menu/resource/track";
|
||||
import { Illustration } from "~/components/illustration";
|
||||
import { LoadableText } from "~/components/loadable_text";
|
||||
import * as Haptics from "~/haptics";
|
||||
import { useAccentColor } from "~/hooks/accent-color";
|
||||
import { Button } from "~/primitives/button";
|
||||
import { Icon } from "~/primitives/icon";
|
||||
import { Pressable } from "~/primitives/pressable";
|
||||
import { Text } from "~/primitives/text";
|
||||
import { breakpoints } from "~/theme";
|
||||
import { videoPlayerAtom } from "../context";
|
||||
import { canUseHLSAtom, useHLSAtom, videoPlayerAtom } from "../context";
|
||||
import { getTrackForContextMenu } from "../queries";
|
||||
import { Slider } from "../slider";
|
||||
import {
|
||||
@@ -44,21 +48,23 @@ import { useFormattedArtistName } from "../utils";
|
||||
export const Main = () => {
|
||||
const { rt: _rt } = useUnistyles();
|
||||
return (
|
||||
<>
|
||||
<BottomSheetView style={styles.root}>
|
||||
<View style={styles.illustrationContainer}>
|
||||
<View style={styles.illustration}>
|
||||
<IllustrationOrVideo />
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.controls}>
|
||||
<WithFullScreenButton>
|
||||
<TrackNameButton />
|
||||
</WithFullScreenButton>
|
||||
<ArtistNameButton />
|
||||
<View style={styles.textColumn}>
|
||||
<WithFullScreenAndTranscodeButton>
|
||||
<TrackNameButton />
|
||||
</WithFullScreenAndTranscodeButton>
|
||||
<ArtistNameButton />
|
||||
</View>
|
||||
<PlayControls />
|
||||
<ProgressControls />
|
||||
</View>
|
||||
</>
|
||||
</BottomSheetView>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -72,13 +78,16 @@ const PlayControls = () => {
|
||||
return (
|
||||
<View style={styles.playControls}>
|
||||
<Pressable onPress={rewindTrack}>
|
||||
<Icon icon={RewindIcon} />
|
||||
<Icon icon={RewindIcon} style={styles.playControlButton} />
|
||||
</Pressable>
|
||||
<Pressable onPress={() => (isPlaying ? pause() : play())}>
|
||||
<Icon icon={isPlaying ? PauseIcon : PlayIcon} />
|
||||
<Icon
|
||||
icon={isPlaying ? PauseIcon : PlayIcon}
|
||||
style={styles.playControlButton}
|
||||
/>
|
||||
</Pressable>
|
||||
<Pressable onPress={() => skipTrack(queryClient)}>
|
||||
<Icon icon={ForwardIcon} />
|
||||
<Icon icon={ForwardIcon} style={styles.playControlButton} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
@@ -112,35 +121,35 @@ const ProgressControls = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const WithFullScreenButton = ({ children }: ComponentProps<any>) => {
|
||||
const WithFullScreenAndTranscodeButton = ({
|
||||
children,
|
||||
}: ComponentProps<any>) => {
|
||||
const [currentTrack] = useAtom(currentTrackAtom);
|
||||
const [useHLS, setUseHLS] = useAtom(useHLSAtom);
|
||||
const canUseHLS = useAtomValue(canUseHLSAtom);
|
||||
const isVideo = currentTrack?.track.type === "Video";
|
||||
const router = useRouter();
|
||||
const onPress = useCallback(() => {
|
||||
const onFullscreenPress = useCallback(() => {
|
||||
router.navigate("/video-player");
|
||||
}, [router]);
|
||||
|
||||
styles.useVariants({ isVideo });
|
||||
return (
|
||||
<View style={styles.fullscreenButtonRow}>
|
||||
<View style={styles.videoButtonRow}>
|
||||
{isVideo && (
|
||||
<Button
|
||||
size="small"
|
||||
icon={FullscreenIcon}
|
||||
onPress={onPress}
|
||||
width="fitContent"
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
|
||||
{/* NOTE: it's for the horzontal padding of the title to be symmetrical */}
|
||||
{isVideo && (
|
||||
<View style={{ opacity: 0 }}>
|
||||
<View style={styles.videoButton}>
|
||||
<Button
|
||||
size="small"
|
||||
width="fitContent"
|
||||
icon={FullscreenIcon}
|
||||
onPress={() => {}}
|
||||
onPress={onFullscreenPress}
|
||||
width="fitContent"
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{children}
|
||||
{isVideo && (
|
||||
<View style={styles.videoButton}>
|
||||
<SelectTranscodingButton
|
||||
{...{ canUseHLS, useHLS, setUseHLS }}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
@@ -148,6 +157,44 @@ const WithFullScreenButton = ({ children }: ComponentProps<any>) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const SelectTranscodingButton = ({
|
||||
useHLS,
|
||||
canUseHLS,
|
||||
setUseHLS,
|
||||
}: {
|
||||
useHLS: boolean;
|
||||
canUseHLS: boolean;
|
||||
setUseHLS: (b: boolean) => void;
|
||||
}) => {
|
||||
const fields = [
|
||||
"player.stream.directStream",
|
||||
"player.stream.transcode",
|
||||
] as const;
|
||||
|
||||
const { t } = useTranslation();
|
||||
const onPress = useCallback(
|
||||
(t: (typeof fields)[number]) => {
|
||||
setUseHLS(t === fields[1]);
|
||||
},
|
||||
[setUseHLS],
|
||||
);
|
||||
return (
|
||||
<SelectModalButton
|
||||
closeOnSelect
|
||||
buttonProps={{
|
||||
disabled: !canUseHLS,
|
||||
size: "small",
|
||||
icon: SettingsIcon,
|
||||
width: "fitContent",
|
||||
}}
|
||||
values={fields}
|
||||
onSelect={onPress}
|
||||
isSelected={(v) => (v === fields[0] ? !useHLS : useHLS)}
|
||||
formatItem={(v) => t(v)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const TrackNameButton = () => {
|
||||
const router = useRouter();
|
||||
const { dismiss } = useBottomSheetModal();
|
||||
@@ -169,21 +216,29 @@ const TrackNameButton = () => {
|
||||
dismiss();
|
||||
}
|
||||
}, [currentTrack]);
|
||||
const onLongPress = useCallback(() => {
|
||||
openContextMenu();
|
||||
Haptics.onContextMenuOpen();
|
||||
}, [openContextMenu]);
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={
|
||||
!currentTrack?.track.songId && !currentTrack?.track.releaseId
|
||||
}
|
||||
onLongPress={openContextMenu}
|
||||
>
|
||||
<LoadableText
|
||||
content={currentTrack?.track.name}
|
||||
variant="h4"
|
||||
skeletonWidth={20}
|
||||
numberOfLines={1}
|
||||
/>
|
||||
</Pressable>
|
||||
<View style={styles.trackName}>
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}
|
||||
disabled={
|
||||
!currentTrack?.track.songId &&
|
||||
!currentTrack?.track.releaseId
|
||||
}
|
||||
>
|
||||
<LoadableText
|
||||
content={currentTrack?.track.name}
|
||||
variant="h4"
|
||||
style={styles.trackNameText}
|
||||
skeletonWidth={20}
|
||||
numberOfLines={1}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -204,10 +259,13 @@ const ArtistNameButton = () => {
|
||||
dismiss();
|
||||
router.navigate(`/artists/${currentTrack?.artist.id}`);
|
||||
}, [currentTrack]);
|
||||
|
||||
const onLongPress = useCallback(() => {
|
||||
openContextMenu();
|
||||
Haptics.onContextMenuOpen();
|
||||
}, [openContextMenu]);
|
||||
const formattedArtistName = useFormattedArtistName();
|
||||
return (
|
||||
<Pressable onPress={onPress} onLongPress={openContextMenu}>
|
||||
<Pressable onPress={onPress} onLongPress={onLongPress}>
|
||||
<LoadableText
|
||||
content={formattedArtistName}
|
||||
variant="h5"
|
||||
@@ -230,13 +288,13 @@ const IllustrationOrVideo = () => {
|
||||
illustration={currentTrack?.track.illustration}
|
||||
dropShadow
|
||||
quality="original"
|
||||
useBlurhash
|
||||
variant="center"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create((theme, _rt) => ({
|
||||
root: { width: "100%", height: "100%", display: "flex" },
|
||||
illustrationContainer: {
|
||||
width: "100%",
|
||||
aspectRatio: 1,
|
||||
@@ -256,6 +314,7 @@ const styles = StyleSheet.create((theme, _rt) => ({
|
||||
justifyContent: "space-evenly",
|
||||
alignItems: "center",
|
||||
},
|
||||
textColumn: { gap: theme.gap(1.5), alignItems: "center" },
|
||||
sliderContainer: {
|
||||
width: "100%",
|
||||
maxWidth: breakpoints.md,
|
||||
@@ -271,7 +330,8 @@ const styles = StyleSheet.create((theme, _rt) => ({
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-evenly",
|
||||
},
|
||||
fullscreenButtonRow: {
|
||||
playControlButton: { size: theme.fontSize.rem(2) } as {},
|
||||
videoButtonRow: {
|
||||
flexDirection: "row",
|
||||
width: "100%",
|
||||
justifyContent: "space-between",
|
||||
@@ -283,6 +343,9 @@ const styles = StyleSheet.create((theme, _rt) => ({
|
||||
},
|
||||
},
|
||||
},
|
||||
videoButton: { flex: 0 },
|
||||
trackName: { flex: 1, alignItems: "center" },
|
||||
trackNameText: theme.fontStyles.semiBold,
|
||||
video: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { DeleteIcon } from "@/ui/icons";
|
||||
import { useQueryClient } from "~/api";
|
||||
import { ListItem } from "~/components/item/list-item";
|
||||
import * as Haptics from "~/haptics";
|
||||
import { Divider } from "~/primitives/divider";
|
||||
import { Icon } from "~/primitives/icon";
|
||||
import { Pressable } from "~/primitives/pressable";
|
||||
@@ -61,16 +62,17 @@ export const Queue = () => {
|
||||
<View style={styles.root}>
|
||||
<Divider h />
|
||||
<BottomSheetDraggableView>
|
||||
{/* @ts-expect-error */}
|
||||
<DraggableFlatList
|
||||
data={queue}
|
||||
onDragEnd={({ from, to }) => onDragEnd(from, to)}
|
||||
keyExtractor={([t, idx]) => `${t.track.id}-${idx}`}
|
||||
renderItem={({ item: [t, idx], drag }) => (
|
||||
/* @ts-expect-error */
|
||||
<ScaleDecorator>
|
||||
<QueueItem
|
||||
onDrag={drag}
|
||||
onDrag={() => {
|
||||
drag();
|
||||
Haptics.onDragStart();
|
||||
}}
|
||||
track={t}
|
||||
onPress={() => onItemPress(idx)}
|
||||
onDelete={() => onItemDelete(idx)}
|
||||
|
||||
@@ -8,6 +8,7 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useAnimatedTheme } from "react-native-unistyles/reanimated";
|
||||
import { VideoView } from "react-native-video";
|
||||
import { playlistLoadingAtom, skipTrackAtom } from "@/state/player";
|
||||
import { ForwardIcon, PauseIcon, PlayIcon, RewindIcon } from "@/ui/icons";
|
||||
import { useQuery, useQueryClient } from "~/api";
|
||||
@@ -15,9 +16,11 @@ import { useContextMenu } from "~/components/context-menu";
|
||||
import { useTrackContextMenu } from "~/components/context-menu/resource/track";
|
||||
import { Illustration } from "~/components/illustration";
|
||||
import { LoadableText } from "~/components/loadable_text";
|
||||
import * as Haptics from "~/haptics";
|
||||
import { useAccentColor } from "~/hooks/accent-color";
|
||||
import { Icon } from "~/primitives/icon";
|
||||
import { Pressable } from "~/primitives/pressable";
|
||||
import { videoPlayerAtom } from "./context";
|
||||
import { expandPlayerAtom } from "./expanded/state";
|
||||
import { getTrackForContextMenu } from "./queries";
|
||||
import {
|
||||
@@ -30,7 +33,6 @@ import {
|
||||
rewindTrackAtom,
|
||||
} from "./state";
|
||||
import { ColorBackground, useFormattedArtistName } from "./utils";
|
||||
|
||||
export const MinimisedPlayer = () => {
|
||||
const expandPlayer = useSetAtom(expandPlayerAtom);
|
||||
const queryClient = useQueryClient();
|
||||
@@ -46,6 +48,7 @@ export const MinimisedPlayer = () => {
|
||||
const isLoading = useAtomValue(playlistLoadingAtom);
|
||||
const formattedArtistName = useFormattedArtistName();
|
||||
|
||||
const player = useAtomValue(videoPlayerAtom);
|
||||
const { data: track } = useQuery(
|
||||
getTrackForContextMenu,
|
||||
currentTrack?.track.id,
|
||||
@@ -53,24 +56,38 @@ export const MinimisedPlayer = () => {
|
||||
|
||||
const trackContextMenu = useTrackContextMenu(track);
|
||||
const { openContextMenu } = useContextMenu(trackContextMenu);
|
||||
const onLongPress = useCallback(() => {
|
||||
openContextMenu();
|
||||
Haptics.onContextMenuOpen();
|
||||
}, [openContextMenu]);
|
||||
styles.useVariants({ loading: isLoading });
|
||||
return (
|
||||
<RNPRessable
|
||||
android_disableSound
|
||||
style={styles.root}
|
||||
onPress={expandPlayer}
|
||||
onLongPress={openContextMenu}
|
||||
onLongPress={onLongPress}
|
||||
>
|
||||
<ColorBackground />
|
||||
<View style={styles.content}>
|
||||
<View style={styles.illustration}>
|
||||
<Illustration
|
||||
illustration={currentTrack?.track.illustration}
|
||||
normalizedThumbnail={isVideo}
|
||||
variant="center"
|
||||
useBlurhash
|
||||
quality={isVideo ? "medium" : "low"}
|
||||
/>
|
||||
{currentTrack?.track.type === "Video" && player ? (
|
||||
<View style={styles.videoContainer}>
|
||||
<VideoView
|
||||
player={player}
|
||||
resizeMode="cover"
|
||||
style={styles.video}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<Illustration
|
||||
illustration={currentTrack?.track.illustration}
|
||||
normalizedThumbnail={isVideo}
|
||||
variant="center"
|
||||
useBlurhash
|
||||
quality={isVideo ? "medium" : "low"}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.text}>
|
||||
<LoadableText
|
||||
@@ -191,7 +208,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
loading: { true: {}, false: {} },
|
||||
},
|
||||
},
|
||||
|
||||
videoContainer: { borderRadius: theme.borderRadius, overflow: "hidden" },
|
||||
video: {
|
||||
aspectRatio: 16 / 9,
|
||||
height: "100%",
|
||||
},
|
||||
illustration: {
|
||||
height: theme.gap(5.5),
|
||||
variants: {
|
||||
|
||||
@@ -84,6 +84,7 @@ export const CreateUpdatePlaylistForm = ({
|
||||
}}
|
||||
render={({ field: { onChange, onBlur, value } }) => (
|
||||
<TextInput
|
||||
inModal
|
||||
placeholder={t("browsing.controls.sort.name")}
|
||||
autoFocus
|
||||
autoCorrect={false}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { FlashList, type FlashListRef } from "@shopify/flash-list";
|
||||
import type { Href } from "expo-router";
|
||||
import type React from "react";
|
||||
import { createRef, useMemo } from "react";
|
||||
import { FlatList, View, type ViewStyle } from "react-native";
|
||||
import { View, type ViewStyle } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type Resource from "@/models/resource";
|
||||
import { EmptyState } from "~/components/empty-state";
|
||||
import { SectionHeader } from "./section-header";
|
||||
|
||||
type Props<T> = {
|
||||
export type RowProps<T> = {
|
||||
items?: (T | undefined)[];
|
||||
header?: string;
|
||||
style?: ViewStyle;
|
||||
@@ -17,7 +18,7 @@ type Props<T> = {
|
||||
hideIfEmpty?: true;
|
||||
};
|
||||
|
||||
export const Row = <T extends Resource>({ items, ...props }: Props<T>) => {
|
||||
export const Row = <T extends Resource>({ items, ...props }: RowProps<T>) => {
|
||||
const itemList = useMemo(() => {
|
||||
return items ?? [undefined];
|
||||
}, [items]);
|
||||
@@ -25,7 +26,7 @@ export const Row = <T extends Resource>({ items, ...props }: Props<T>) => {
|
||||
return <RowBase {...props} items={itemList} />;
|
||||
};
|
||||
|
||||
const RowBase = <T,>({
|
||||
const RowBase = <T extends Resource>({
|
||||
items,
|
||||
header,
|
||||
hideIfEmpty,
|
||||
@@ -33,8 +34,8 @@ const RowBase = <T,>({
|
||||
render,
|
||||
onEndReached,
|
||||
seeMore,
|
||||
}: Props<T>) => {
|
||||
const flatListRef = createRef<FlatList<unknown>>();
|
||||
}: RowProps<T>) => {
|
||||
const flatListRef = createRef<FlashListRef<unknown>>();
|
||||
return (
|
||||
(!hideIfEmpty || (items && items.length > 0)) && (
|
||||
<View style={[styles.root, style]}>
|
||||
@@ -56,17 +57,19 @@ const RowBase = <T,>({
|
||||
/>
|
||||
)}
|
||||
{items?.length !== 0 ? (
|
||||
<ResponsiveFlatList
|
||||
horizontal
|
||||
snapToAlignment="start"
|
||||
decelerationRate={"normal"}
|
||||
ref={flatListRef}
|
||||
<ResponsiveFlashList
|
||||
data={items}
|
||||
onEndReached={onEndReached}
|
||||
horizontal
|
||||
contentContainerStyle={styles.row}
|
||||
onEndReached={onEndReached}
|
||||
ref={flatListRef}
|
||||
snapToAlignment="start"
|
||||
keyExtractor={(item, idx) =>
|
||||
item ? `item-${(item as T).id}` : `skeleton-${idx}`
|
||||
}
|
||||
renderItem={({ item, index }) => {
|
||||
return (
|
||||
<View style={[styles.item(index)]}>
|
||||
<View style={styles.item(index)}>
|
||||
{render(item as T | undefined)}
|
||||
</View>
|
||||
);
|
||||
@@ -83,7 +86,7 @@ const RowBase = <T,>({
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create((theme, rt) => ({
|
||||
root: { display: "flex", alignItems: "flex-start", width: "100%" },
|
||||
root: { width: "100%" },
|
||||
emptyState: {
|
||||
aspectRatio: 2.5, // TODO this an approximate, would be nice to compute this correctly
|
||||
width: "100%",
|
||||
@@ -96,9 +99,7 @@ const styles = StyleSheet.create((theme, rt) => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const ResponsiveFlatList = withUnistyles(FlatList, (theme, rt) => ({
|
||||
// @ts-expect-error
|
||||
initialNumToRender: theme.layout.grid.columnCount[rt.breakpoint!],
|
||||
const ResponsiveFlashList = withUnistyles(FlashList, (theme, rt) => ({
|
||||
snapToInterval:
|
||||
// TODO The interval is not correct,
|
||||
// the further we scroll the more the snap position is shifted to the left
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FlashList, type FlashListProps } from "@shopify/flash-list";
|
||||
import {
|
||||
ScrollView,
|
||||
type ScrollViewProps,
|
||||
@@ -8,6 +9,29 @@ import {
|
||||
|
||||
import { useRootViewStyle } from "~/hooks/root-view-style";
|
||||
|
||||
export const SafeFlashList = <T,>(
|
||||
props: Omit<FlashListProps<T>, "contentContainerStyle"> & {
|
||||
contentContainerStyle?: ViewStyle[];
|
||||
},
|
||||
) => {
|
||||
const { paddingTop, ...rootStyle } = useRootViewStyle();
|
||||
return (
|
||||
<FlashList
|
||||
{...props}
|
||||
contentContainerStyle={[
|
||||
...(props.contentContainerStyle ?? []),
|
||||
rootStyle,
|
||||
{
|
||||
paddingTop: mergePaddingTop(
|
||||
paddingTop,
|
||||
props.contentContainerStyle,
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// ScrollView that adds padding at the top and bottom to avoid header + tabbar
|
||||
export const SafeScrollView = (
|
||||
props: Omit<ScrollViewProps, "contentContainerStyle"> & {
|
||||
|
||||
@@ -22,7 +22,7 @@ type Song = SongWithRelations<
|
||||
"artist" | "featuring" | "master" | "illustration"
|
||||
>;
|
||||
|
||||
type Props = {
|
||||
export type SongGridProps = {
|
||||
songs: Song[] | undefined;
|
||||
style?: ViewStyle;
|
||||
seeMore?: Href;
|
||||
@@ -44,7 +44,7 @@ export const SongGrid = ({
|
||||
seeMore,
|
||||
parentArtistId,
|
||||
...props
|
||||
}: Props) => {
|
||||
}: SongGridProps) => {
|
||||
const playTracks = useSetAtom(playTracksAtom);
|
||||
const scrollViewRef = createRef<ScrollView>();
|
||||
const [columnWidth, setColumnWidth] = useState<number>();
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
export const onContextMenuOpen = () => {
|
||||
if (Platform.OS === "android") {
|
||||
Haptics.performAndroidHapticsAsync(
|
||||
Haptics.AndroidHaptics.Context_Click,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const onDragStart = () => {
|
||||
if (Platform.OS === "android") {
|
||||
Haptics.performAndroidHapticsAsync(
|
||||
Haptics.AndroidHaptics.Gesture_Start,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,7 +1,8 @@
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { type ComponentProps, useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ScrollView, View, type ViewStyle } from "react-native";
|
||||
import { View, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import {
|
||||
getAlbum,
|
||||
@@ -12,6 +13,9 @@ import {
|
||||
} from "@/api/queries";
|
||||
import type Album from "@/models/album";
|
||||
import type Genre from "@/models/genre";
|
||||
import type Label from "@/models/label";
|
||||
import type { ReleaseWithRelations } from "@/models/release";
|
||||
import type { SongWithRelations } from "@/models/song";
|
||||
import type { TracklistItemWithRelations } from "@/models/tracklist";
|
||||
import type { VideoWithRelations } from "@/models/video";
|
||||
import { playTracksAtom } from "@/state/player";
|
||||
@@ -30,6 +34,7 @@ import {
|
||||
import { AlbumTile } from "~/components/item/resource/album";
|
||||
import { ArtistTile } from "~/components/item/resource/artist";
|
||||
import { GenreChip } from "~/components/item/resource/genre";
|
||||
import { LabelChip } from "~/components/item/resource/label";
|
||||
import { PlaylistTile } from "~/components/item/resource/playlist";
|
||||
import ReleaseTile from "~/components/item/resource/release";
|
||||
import { VideoTile } from "~/components/item/resource/video";
|
||||
@@ -48,9 +53,70 @@ import {
|
||||
} from "./queries";
|
||||
import { Tracklist } from "./tracklist";
|
||||
|
||||
type ReleasePageSection =
|
||||
| {
|
||||
type: "header";
|
||||
props: ComponentProps<typeof Header>;
|
||||
}
|
||||
| {
|
||||
type: "tracklist";
|
||||
props: ComponentProps<typeof Tracklist>;
|
||||
}
|
||||
| {
|
||||
type: "genre-row";
|
||||
props: ComponentProps<typeof GenreRow>;
|
||||
}
|
||||
| {
|
||||
type: "row";
|
||||
props: ComponentProps<typeof Row>;
|
||||
}
|
||||
| {
|
||||
type: "song-grid";
|
||||
props: ComponentProps<typeof SongGrid>;
|
||||
}
|
||||
| {
|
||||
type: "extra-section";
|
||||
props: ComponentProps<typeof ExtraSection>;
|
||||
}
|
||||
| {
|
||||
type: "external-description";
|
||||
props: ComponentProps<typeof ExternalMetadataDescriptionSection>;
|
||||
}
|
||||
| {
|
||||
type: "label-footer";
|
||||
props: ComponentProps<typeof RecordLabelSection>;
|
||||
}
|
||||
| {
|
||||
type: "external-source";
|
||||
props: ComponentProps<typeof ExternalMetadataSourcesSection>;
|
||||
};
|
||||
|
||||
const renderSection = (section: ReleasePageSection) => {
|
||||
switch (section.type) {
|
||||
case "header":
|
||||
return <Header {...section.props} />;
|
||||
case "tracklist":
|
||||
return <Tracklist {...section.props} />;
|
||||
case "genre-row":
|
||||
return <GenreRow {...section.props} />;
|
||||
case "row":
|
||||
return <Row {...section.props} />;
|
||||
case "label-footer":
|
||||
return <RecordLabelSection {...section.props} />;
|
||||
case "song-grid":
|
||||
return <SongGrid {...section.props} />;
|
||||
case "extra-section":
|
||||
return <ExtraSection {...section.props} />;
|
||||
case "external-source":
|
||||
return <ExternalMetadataSourcesSection {...section.props} />;
|
||||
case "external-description":
|
||||
return <ExternalMetadataDescriptionSection {...section.props} />;
|
||||
}
|
||||
};
|
||||
|
||||
export default function ReleasePage({ releaseId }: { releaseId: string }) {
|
||||
const { data: release } = useQuery(() =>
|
||||
getRelease(releaseId, ["illustration", "discs"]),
|
||||
getRelease(releaseId, ["illustration", "discs", "label"]),
|
||||
);
|
||||
|
||||
const { data: album } = useQuery(
|
||||
@@ -77,53 +143,59 @@ export default function ReleasePage({ releaseId }: { releaseId: string }) {
|
||||
})) ?? []
|
||||
);
|
||||
}, [tracks_]);
|
||||
const postSections = usePostTracklistSections({
|
||||
release,
|
||||
album: album,
|
||||
tracks: tracks_ ?? [],
|
||||
albumArtistId: album?.artistId,
|
||||
});
|
||||
const sections: ReleasePageSection[] = [
|
||||
{
|
||||
type: "header",
|
||||
props: {
|
||||
isMixed: isMixed,
|
||||
release: release,
|
||||
album: album,
|
||||
tracks: tracks,
|
||||
totalDuration: totalDuration,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "tracklist",
|
||||
props: {
|
||||
// Note: We wait for the album to be loaded to avoid a shift in the tracklist
|
||||
// if the song artist isn't the album's
|
||||
albumArtistId: album?.artistId,
|
||||
tracks: album ? tracks : undefined,
|
||||
tracklist: album ? (tracklist as any) : undefined, // TODO Avoid cast
|
||||
discs: album ? release?.discs : undefined,
|
||||
},
|
||||
},
|
||||
...postSections,
|
||||
];
|
||||
|
||||
useSetKeyIllustration(release);
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
isMixed={isMixed}
|
||||
release={release}
|
||||
album={album}
|
||||
tracks={tracks}
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
<Tracklist
|
||||
// Note: We wait for the album to be loaded to avoid a shift in the tracklist
|
||||
// if the song artist isn't the album's
|
||||
albumArtistId={album?.artistId}
|
||||
tracks={album ? tracks : undefined}
|
||||
tracklist={album ? (tracklist as any) : undefined} // TODO Avoid cast
|
||||
discs={album ? release?.discs : undefined}
|
||||
/>
|
||||
{/* No need to mount this as long as the release is not loaded */}
|
||||
{release !== undefined && (
|
||||
<>
|
||||
{/* TODO Show only exclusive tracks */}
|
||||
{/* TODO Label */}
|
||||
<PostTracklistSections
|
||||
releaseId={release?.id}
|
||||
album={album}
|
||||
tracks={tracks_ ?? []}
|
||||
albumArtistId={album?.artistId}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
<FlashList
|
||||
data={sections}
|
||||
getItemType={({ type }) => type}
|
||||
renderItem={({ item }) => renderSection(item)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const PostTracklistSections = ({
|
||||
const usePostTracklistSections = ({
|
||||
album,
|
||||
releaseId,
|
||||
release,
|
||||
albumArtistId,
|
||||
tracks,
|
||||
}: {
|
||||
album: Album | undefined;
|
||||
releaseId: number | undefined;
|
||||
release: ReleaseWithRelations<"label"> | undefined;
|
||||
albumArtistId: number | undefined | null;
|
||||
tracks: TracklistItemWithRelations<"artist" | "featuring">[];
|
||||
}) => {
|
||||
}): ReleasePageSection[] => {
|
||||
const releaseId = release?.id;
|
||||
const playTracks = useSetAtom(playTracksAtom);
|
||||
const { t } = useTranslation();
|
||||
const { data: featuringArtists } = useQuery(
|
||||
@@ -188,150 +260,274 @@ const PostTracklistSections = ({
|
||||
const cursor = items.findIndex(({ id }) => id === videoId);
|
||||
playTracks({ tracks, cursor });
|
||||
},
|
||||
[],
|
||||
[playTracks],
|
||||
);
|
||||
return (
|
||||
<View style={styles.bottomSections}>
|
||||
<GenreRow genres={genres} style={styles.section} />
|
||||
<SongGrid
|
||||
hideIfEmpty
|
||||
style={styles.section}
|
||||
header={t("album.bonusTracks")}
|
||||
const labelFooter: ReleasePageSection | null = useMemo(
|
||||
() =>
|
||||
release?.label
|
||||
? {
|
||||
type: "label-footer",
|
||||
props: {
|
||||
style: styles.section,
|
||||
label: release.label,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
[release],
|
||||
);
|
||||
const genreRow: ReleasePageSection = useMemo(
|
||||
() => ({ type: "genre-row", props: { genres, style: styles.section } }),
|
||||
[genres],
|
||||
);
|
||||
const bonusTracks: ReleasePageSection = useMemo(
|
||||
() => ({
|
||||
type: "song-grid",
|
||||
props: {
|
||||
hideIfEmpty: true,
|
||||
style: styles.section,
|
||||
header: t("album.bonusTracks"),
|
||||
// Avoid shift when bsides are loaded before main artist
|
||||
songs={albumArtistId === undefined ? undefined : bSides}
|
||||
parentArtistId={albumArtistId ?? undefined}
|
||||
subtitle={
|
||||
!bSides
|
||||
? null
|
||||
: (song) =>
|
||||
song.artistId === albumArtistId &&
|
||||
song.featuring.length === 0
|
||||
? null
|
||||
: "artists"
|
||||
}
|
||||
/>
|
||||
songs: albumArtistId === undefined ? undefined : bSides,
|
||||
parentArtistId: albumArtistId ?? undefined,
|
||||
subtitle: !bSides
|
||||
? null
|
||||
: (song) =>
|
||||
song.artistId === albumArtistId &&
|
||||
song.featuring.length === 0
|
||||
? null
|
||||
: "artists",
|
||||
},
|
||||
}),
|
||||
[albumArtistId, bSides],
|
||||
);
|
||||
const releasesSection: ReleasePageSection = useMemo(
|
||||
() => ({
|
||||
type: "row",
|
||||
props: {
|
||||
hideIfEmpty: true,
|
||||
style: styles.section,
|
||||
header: t("album.otherAlbumReleases"),
|
||||
items: relatedReleases,
|
||||
render: (release) => <ReleaseTile release={release as any} />, // TODO avoid cast
|
||||
},
|
||||
}),
|
||||
[relatedReleases],
|
||||
);
|
||||
|
||||
<Row
|
||||
hideIfEmpty
|
||||
style={styles.section}
|
||||
header={t("album.otherAlbumReleases")}
|
||||
items={relatedReleases}
|
||||
render={(release) => <ReleaseTile release={release} />}
|
||||
/>
|
||||
{(
|
||||
const videoSections: ReleasePageSection[] = useMemo(
|
||||
() =>
|
||||
(
|
||||
[
|
||||
["musicVideos", videos],
|
||||
["livePerformances", liveVideos],
|
||||
] as const
|
||||
).map(([label, items]) => (
|
||||
<Row
|
||||
key={label}
|
||||
hideIfEmpty
|
||||
items={videoItems === undefined ? undefined : items}
|
||||
header={t(`browsing.sections.${label}`)}
|
||||
style={styles.section}
|
||||
render={(video) => (
|
||||
<VideoTile
|
||||
onPress={() =>
|
||||
video && onVideoPress(video.id, items)
|
||||
}
|
||||
illustrationProps={{
|
||||
normalizedThumbnail: true,
|
||||
}}
|
||||
video={video}
|
||||
subtitle="duration"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
{videoItems === undefined ||
|
||||
bSidesItems === undefined ||
|
||||
((videoExtras.length > 0 || audioExtras.length > 0) && (
|
||||
<View style={{ width: "100%" }}>
|
||||
<SectionHeader
|
||||
content={
|
||||
videoItems === undefined ||
|
||||
bSidesItems === undefined
|
||||
? undefined
|
||||
: t("browsing.sections.extras")
|
||||
}
|
||||
skeletonWidth={10}
|
||||
/>
|
||||
|
||||
<Row
|
||||
hideIfEmpty
|
||||
items={
|
||||
videoItems === undefined
|
||||
? undefined
|
||||
: videoExtras
|
||||
}
|
||||
render={(video) => (
|
||||
).map(
|
||||
([label, items]) =>
|
||||
({
|
||||
type: "row",
|
||||
props: {
|
||||
hideIfEmpty: true,
|
||||
items: videoItems === undefined ? undefined : items,
|
||||
header: t(`browsing.sections.${label}`),
|
||||
style: styles.section,
|
||||
render: (video) => (
|
||||
<VideoTile
|
||||
onPress={() =>
|
||||
video &&
|
||||
onVideoPress(video.id, videoExtras)
|
||||
video && onVideoPress(video.id, items)
|
||||
}
|
||||
illustrationProps={{
|
||||
normalizedThumbnail: true,
|
||||
}}
|
||||
video={video}
|
||||
video={video as any}
|
||||
subtitle="duration"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SongGrid
|
||||
hideIfEmpty
|
||||
subtitle={() => null}
|
||||
songs={
|
||||
bSidesItems === undefined
|
||||
? undefined
|
||||
: audioExtras
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
),
|
||||
},
|
||||
}) satisfies ReleasePageSection,
|
||||
),
|
||||
[videos, videoItems, liveVideos],
|
||||
);
|
||||
|
||||
<Row
|
||||
hideIfEmpty
|
||||
style={styles.section}
|
||||
header={t("album.relatedAlbums")}
|
||||
items={relatedAlbums}
|
||||
render={(album) => <AlbumTile album={album} subtitle="year" />}
|
||||
/>
|
||||
const extraSection: ReleasePageSection | null = useMemo(() => {
|
||||
if (
|
||||
videoItems === undefined ||
|
||||
bSidesItems === undefined ||
|
||||
videoExtras.length > 0 ||
|
||||
audioExtras.length > 0
|
||||
) {
|
||||
return {
|
||||
type: "extra-section",
|
||||
props: {
|
||||
audioExtras: audioExtras,
|
||||
videoExtras: videoExtras,
|
||||
onVideoPress: (id: number) => onVideoPress(id, videoExtras),
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}, [videoItems, bSidesItems, videoExtras, audioExtras]);
|
||||
|
||||
<Row
|
||||
hideIfEmpty
|
||||
style={styles.section}
|
||||
header={t("album.onThisAlbum")}
|
||||
items={
|
||||
const relatedAlbumsSection: ReleasePageSection = useMemo(
|
||||
() => ({
|
||||
type: "row",
|
||||
props: {
|
||||
hideIfEmpty: true,
|
||||
style: styles.section,
|
||||
header: t("album.relatedAlbums"),
|
||||
items: relatedAlbums,
|
||||
render: (album) => (
|
||||
<AlbumTile album={album as any} subtitle="year" />
|
||||
),
|
||||
},
|
||||
}),
|
||||
[relatedAlbums],
|
||||
);
|
||||
const relatedArtistsSection: ReleasePageSection = useMemo(
|
||||
() => ({
|
||||
type: "row",
|
||||
props: {
|
||||
hideIfEmpty: true,
|
||||
style: styles.section,
|
||||
header: t("album.onThisAlbum"),
|
||||
items:
|
||||
// We don't want to list them until we know who the album artist is
|
||||
albumArtistId === undefined
|
||||
? undefined
|
||||
: featuringArtists?.filter(
|
||||
({ id }) => id !== albumArtistId,
|
||||
)
|
||||
),
|
||||
render: (artist) => <ArtistTile artist={artist as any} />,
|
||||
},
|
||||
}),
|
||||
[albumArtistId, featuringArtists],
|
||||
);
|
||||
const playlistsSection: ReleasePageSection = useMemo(
|
||||
() => ({
|
||||
type: "row",
|
||||
props: {
|
||||
hideIfEmpty: true,
|
||||
style: styles.section,
|
||||
header: t("browsing.sections.featuredOnPlaylists"),
|
||||
items: relatedPlaylists,
|
||||
render: (playlist) => (
|
||||
<PlaylistTile playlist={playlist as any} />
|
||||
),
|
||||
},
|
||||
}),
|
||||
[relatedPlaylists],
|
||||
);
|
||||
const externalMetadataSections: ReleasePageSection[] = useMemo(() => {
|
||||
if (externalMetadata !== null) {
|
||||
return [
|
||||
{
|
||||
type: "external-description",
|
||||
props: {
|
||||
externalMetadata: externalMetadata,
|
||||
style: styles.section,
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
type: "external-source",
|
||||
props: {
|
||||
externalMetadata: externalMetadata,
|
||||
style: styles.section,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}, [externalMetadata]);
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
[
|
||||
labelFooter,
|
||||
genreRow,
|
||||
bonusTracks,
|
||||
releasesSection,
|
||||
...videoSections,
|
||||
extraSection,
|
||||
relatedAlbumsSection,
|
||||
relatedArtistsSection,
|
||||
playlistsSection,
|
||||
...externalMetadataSections,
|
||||
].filter((s): s is ReleasePageSection => s !== null),
|
||||
[
|
||||
labelFooter,
|
||||
genreRow,
|
||||
bonusTracks,
|
||||
releasesSection,
|
||||
videoSections,
|
||||
extraSection,
|
||||
relatedAlbumsSection,
|
||||
relatedArtistsSection,
|
||||
playlistsSection,
|
||||
externalMetadataSections,
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
type ExtraSectionProps = {
|
||||
videoExtras:
|
||||
| VideoWithRelations<"illustration" | "master" | "artist">[]
|
||||
| undefined;
|
||||
onVideoPress: (id: number) => void;
|
||||
audioExtras:
|
||||
| SongWithRelations<
|
||||
"illustration" | "master" | "artist" | "featuring"
|
||||
>[]
|
||||
| undefined;
|
||||
};
|
||||
|
||||
const ExtraSection = ({
|
||||
videoExtras,
|
||||
onVideoPress,
|
||||
audioExtras,
|
||||
}: ExtraSectionProps) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View style={styles.extraSection}>
|
||||
<SectionHeader
|
||||
content={
|
||||
videoExtras === undefined && audioExtras === undefined
|
||||
? undefined
|
||||
: t("browsing.sections.extras")
|
||||
}
|
||||
render={(artist) => <ArtistTile artist={artist} />}
|
||||
skeletonWidth={10}
|
||||
/>
|
||||
|
||||
<Row
|
||||
hideIfEmpty
|
||||
style={styles.section}
|
||||
header={t("browsing.sections.featuredOnPlaylists")}
|
||||
items={relatedPlaylists}
|
||||
render={(playlist) => <PlaylistTile playlist={playlist} />}
|
||||
items={videoExtras}
|
||||
render={(video) => (
|
||||
<VideoTile
|
||||
onPress={() => video && onVideoPress(video.id)}
|
||||
illustrationProps={{
|
||||
normalizedThumbnail: true,
|
||||
}}
|
||||
video={video}
|
||||
subtitle="duration"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{externalMetadata !== null && (
|
||||
<>
|
||||
<ExternalMetadataDescriptionSection
|
||||
externalMetadata={externalMetadata}
|
||||
style={styles.section}
|
||||
/>
|
||||
<ExternalMetadataSourcesSection
|
||||
externalMetadata={externalMetadata}
|
||||
style={styles.section}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<SongGrid hideIfEmpty subtitle={() => null} songs={audioExtras} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const RecordLabelSection = ({
|
||||
style,
|
||||
label,
|
||||
}: {
|
||||
style: ViewStyle;
|
||||
label: Label;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View style={[style, styles.labelSection]}>
|
||||
<Text content={`${t("models.label")}:`} variant="subtitle" />
|
||||
<LabelChip label={label} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -348,19 +544,22 @@ const GenreRow = ({
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<ScrollView
|
||||
<FlashList
|
||||
horizontal
|
||||
contentContainerStyle={styles.genreRow}
|
||||
style={style}
|
||||
>
|
||||
<Text content={`${t("models.genre_plural")}:`} variant="subtitle" />
|
||||
|
||||
{(genres ?? generateArray(2)).map(
|
||||
(genre: Genre | undefined, idx) => (
|
||||
<GenreChip genre={genre} key={genre?.slug ?? idx} />
|
||||
),
|
||||
data={genres ?? generateArray(2)}
|
||||
CellRendererComponent={(props) => (
|
||||
<View {...props} style={[props.style, styles.genreChip]} />
|
||||
)}
|
||||
</ScrollView>
|
||||
renderItem={({ item: genre }) => <GenreChip genre={genre} />}
|
||||
ListHeaderComponent={
|
||||
<Text
|
||||
content={`${t("models.genre_plural")}:`}
|
||||
variant="subtitle"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -374,8 +573,19 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
genreRow: {
|
||||
gap: theme.gap(1.5),
|
||||
paddingHorizontal: theme.gap(2),
|
||||
alignItems: "center",
|
||||
},
|
||||
genreChip: { paddingLeft: theme.gap(1) },
|
||||
extraSection: { width: "100%" },
|
||||
labelSection: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.gap(1),
|
||||
paddingHorizontal: theme.gap(2),
|
||||
paddingBottom: theme.gap(2.5),
|
||||
},
|
||||
labelText: {
|
||||
textDecorationLine: "underline",
|
||||
},
|
||||
}));
|
||||
@@ -20,6 +20,7 @@ import { useSongContextMenu } from "~/components/context-menu/resource/song";
|
||||
import { useVideoContextMenu } from "~/components/context-menu/resource/video";
|
||||
import { LoadableText } from "~/components/loadable_text";
|
||||
import { currentTrackAtom } from "~/components/player/state";
|
||||
import * as Haptics from "~/haptics";
|
||||
import { Divider } from "~/primitives/divider";
|
||||
import { Icon } from "~/primitives/icon";
|
||||
import { Pressable } from "~/primitives/pressable";
|
||||
@@ -189,11 +190,17 @@ const TrackItem = ({
|
||||
return songContextMenu;
|
||||
}, [songWithTrack, videoWithTrack, videoContextMenu, songContextMenu]);
|
||||
const { openContextMenu } = useContextMenu(contextMenu);
|
||||
const onLongPress = useCallback(() => {
|
||||
if (contextMenu) {
|
||||
openContextMenu();
|
||||
Haptics.onContextMenuOpen();
|
||||
}
|
||||
}, [contextMenu, openContextMenu]);
|
||||
return (
|
||||
<Pressable
|
||||
disabled={track === undefined}
|
||||
style={styles.trackButton}
|
||||
onLongPress={contextMenu ? openContextMenu : undefined}
|
||||
onLongPress={onLongPress}
|
||||
onPress={onPress}
|
||||
>
|
||||
<View style={styles.trackIndex(maxTrackIndex)}>
|
||||
|
||||
@@ -58,7 +58,7 @@ const VideoName = () => {
|
||||
<View>
|
||||
<LoadableText
|
||||
content={track?.track.name}
|
||||
style={styles.text}
|
||||
style={[styles.text, styles.titleText]}
|
||||
variant="h4"
|
||||
skeletonWidth={10}
|
||||
numberOfLines={1}
|
||||
@@ -113,6 +113,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
illustration: {},
|
||||
sliderColumn: { flex: 1, gap: theme.gap(1) },
|
||||
text: { color: theme.colors.text.primary },
|
||||
titleText: theme.fontStyles.semiBold,
|
||||
progressRow: {
|
||||
width: "100%",
|
||||
flexDirection: "row",
|
||||
|
||||
@@ -16,9 +16,14 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useBottomSheetInternal } from "@gorhom/bottom-sheet";
|
||||
import { useCallback } from "react";
|
||||
import {
|
||||
findNodeHandle,
|
||||
type NativeSyntheticEvent,
|
||||
TextInput as RNTextInput,
|
||||
type TextInputProps as RNTextInputProps,
|
||||
type TextInputFocusEventData,
|
||||
View,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
@@ -32,6 +37,7 @@ import { useAnimatedTheme } from "react-native-unistyles/reanimated";
|
||||
|
||||
type TextInputProps = RNTextInputProps & {
|
||||
error?: string;
|
||||
inModal: boolean;
|
||||
containerStyle?: ViewStyle;
|
||||
};
|
||||
|
||||
@@ -86,6 +92,7 @@ export const TextInput = ({
|
||||
style,
|
||||
error,
|
||||
containerStyle,
|
||||
inModal,
|
||||
...props
|
||||
}: TextInputProps) => {
|
||||
const isFocused = useSharedValue(false);
|
||||
@@ -94,7 +101,9 @@ export const TextInput = ({
|
||||
);
|
||||
const animatedTheme = useAnimatedTheme();
|
||||
const springConfig = { damping: 100, stiffness: 500 };
|
||||
|
||||
const { handleOnBlur, handleOnFocus } = inModal
|
||||
? useKeyboardFocusEvents()
|
||||
: { handleOnFocus: () => {}, handleOnBlur: () => {} };
|
||||
const innerlabelStyle = useAnimatedStyle(() => {
|
||||
const labelIsRaised = isFocused.value || !isEmpty.value;
|
||||
return {
|
||||
@@ -166,10 +175,12 @@ export const TextInput = ({
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
isFocused.value = false;
|
||||
handleOnBlur(e);
|
||||
props.onBlur?.(e);
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
isFocused.value = true;
|
||||
handleOnFocus(e);
|
||||
props.onFocus?.(e);
|
||||
}}
|
||||
style={[styles.input, style]}
|
||||
@@ -178,3 +189,50 @@ export const TextInput = ({
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// https://gorhom.dev/react-native-bottom-sheet/keyboard-handling
|
||||
//
|
||||
// Code is from https://github.com/gorhom/react-native-bottom-sheet/blob/d12f3f7da19e5152fb541c4cfd36340cf5274473/src/components/bottomSheetTextInput/BottomSheetTextInput.tsx#L28
|
||||
const useKeyboardFocusEvents = () => {
|
||||
const { animatedKeyboardState, textInputNodesRef } =
|
||||
useBottomSheetInternal();
|
||||
|
||||
const handleOnFocus = useCallback(
|
||||
(args: NativeSyntheticEvent<TextInputFocusEventData>) => {
|
||||
animatedKeyboardState.set((state) => ({
|
||||
...state,
|
||||
target: args.nativeEvent.target,
|
||||
}));
|
||||
},
|
||||
[animatedKeyboardState],
|
||||
) as NonNullable<TextInputProps["onFocus"]>;
|
||||
const handleOnBlur = useCallback(
|
||||
(args: NativeSyntheticEvent<TextInputFocusEventData>) => {
|
||||
const keyboardState = animatedKeyboardState.get();
|
||||
const currentFocusedInput = findNodeHandle(
|
||||
//@ts-expect-error
|
||||
RNTextInput.State.currentlyFocusedInput(),
|
||||
);
|
||||
|
||||
/**
|
||||
* we need to make sure that we only remove the target
|
||||
* if the target belong to the current component and
|
||||
* if the currently focused input is not in the targets set.
|
||||
*/
|
||||
const shouldRemoveCurrentTarget =
|
||||
keyboardState.target === args.nativeEvent.target;
|
||||
const shouldIgnoreBlurEvent =
|
||||
currentFocusedInput &&
|
||||
textInputNodesRef.current.has(currentFocusedInput);
|
||||
|
||||
if (shouldRemoveCurrentTarget && !shouldIgnoreBlurEvent) {
|
||||
animatedKeyboardState.set((state) => ({
|
||||
...state,
|
||||
target: undefined,
|
||||
}));
|
||||
}
|
||||
},
|
||||
[animatedKeyboardState, textInputNodesRef],
|
||||
) as NonNullable<TextInputProps["onBlur"]>;
|
||||
return { handleOnBlur, handleOnFocus };
|
||||
};
|
||||
@@ -16,42 +16,72 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { DeviceType, deviceType } from "expo-device";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { StyleSheet, type UnistylesVariants } from "react-native-unistyles";
|
||||
import type { ToastConfigParams } from "toastify-react-native/utils/interfaces";
|
||||
import Toast, { type ToastConfig } from "react-native-toast-message";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { CheckIcon, ErrorIcon } from "@/ui/icons";
|
||||
import { Text } from "~/primitives/text";
|
||||
|
||||
export const Toast = ({
|
||||
text1,
|
||||
variant,
|
||||
hide,
|
||||
}: ToastConfigParams & UnistylesVariants<typeof styles>) => {
|
||||
type ToastProps = { text: string };
|
||||
|
||||
export const showSuccessToast = (t: ToastProps) => {
|
||||
Toast.hide();
|
||||
Toast.show({ type: "success", text1: t.text });
|
||||
};
|
||||
export const showErrorToast = (t: ToastProps) => {
|
||||
Toast.hide();
|
||||
Toast.show({ type: "error", text1: t.text });
|
||||
};
|
||||
|
||||
type ToastComponentProps = { text: string; variant: "success" | "error" };
|
||||
|
||||
export const ToastComponent = ({ text, variant }: ToastComponentProps) => {
|
||||
const Icon = variant === "success" ? CheckIcon : ErrorIcon;
|
||||
styles.useVariants({ variant });
|
||||
styles.useVariants({ variant, isTablet: deviceType === DeviceType.TABLET });
|
||||
return (
|
||||
<Pressable style={styles.root} onPress={hide}>
|
||||
<Pressable style={styles.root} onPress={() => Toast.hide()}>
|
||||
<View style={styles.icon}>
|
||||
<Icon variant="Bold" style={styles.icon} />
|
||||
</View>
|
||||
<Text variant="subtitle" style={styles.text}>
|
||||
{text1}
|
||||
<Text variant="body" style={styles.text}>
|
||||
{text}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create((theme, rt) => ({
|
||||
const toastConfig: ToastConfig = {
|
||||
success: ({ text1 }) => (
|
||||
<ToastComponent text={text1 ?? ""} variant="success" />
|
||||
),
|
||||
|
||||
error: ({ text1 }) => <ToastComponent text={text1 ?? ""} variant="error" />,
|
||||
};
|
||||
|
||||
export const ToastManager = () => {
|
||||
return <Toast config={toastConfig} />;
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: {
|
||||
//TODO Max width
|
||||
width: rt.screen.width - theme.gap(1 * 2),
|
||||
variants: {
|
||||
isTablet: {
|
||||
true: {
|
||||
alignSelf: "flex-end",
|
||||
marginRight: theme.gap(2),
|
||||
},
|
||||
false: {},
|
||||
},
|
||||
},
|
||||
width: "auto",
|
||||
backgroundColor: theme.colors.background,
|
||||
borderRadius: theme.borderRadius,
|
||||
borderRadius: theme.borderRadius * 1.5,
|
||||
padding: theme.gap(2),
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: theme.gap(2),
|
||||
gap: theme.gap(1.5),
|
||||
elevation: 10,
|
||||
},
|
||||
icon: {
|
||||
@@ -64,7 +94,6 @@ const styles = StyleSheet.create((theme, rt) => ({
|
||||
aspectRatio: 1,
|
||||
},
|
||||
text: {
|
||||
flex: 1,
|
||||
fontSize: theme.fontSize.rem(1.5),
|
||||
},
|
||||
}));
|
||||
@@ -16,40 +16,151 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Buffer } from "buffer";
|
||||
import { atom } from "jotai";
|
||||
import * as yup from "yup";
|
||||
import { storage } from "~/utils/storage";
|
||||
|
||||
const AccessTokenKey = "access-token";
|
||||
const InstanceUrlKey = "instance-url";
|
||||
const CurrentInstanceKey = "current-instance";
|
||||
const OtherInstancesKey = "other-instances";
|
||||
|
||||
export const instanceUrlAtom = atom<string | null, [string | null], void>(
|
||||
(get) => get(_instanceUrlAtom),
|
||||
(_, set, update) => {
|
||||
if (update !== null) {
|
||||
storage.set(InstanceUrlKey, update);
|
||||
export type MeeloInstance = {
|
||||
url: string;
|
||||
accessToken: string;
|
||||
// For visual purposes
|
||||
username: string;
|
||||
};
|
||||
|
||||
const MeeloInstanceValidator = yup.object({
|
||||
url: yup.string().required(),
|
||||
accessToken: yup.string().required(),
|
||||
username: yup.string().required(),
|
||||
});
|
||||
|
||||
const validateInstance = (instance: MeeloInstance): MeeloInstance | null => {
|
||||
try {
|
||||
return MeeloInstanceValidator.validateSync(instance);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeInstance = (instance: MeeloInstance): MeeloInstance => {
|
||||
return {
|
||||
...instance,
|
||||
url: instance.url.replace(/\/$/, ""),
|
||||
};
|
||||
};
|
||||
|
||||
const removeDuplicates = (instances: MeeloInstance[]): MeeloInstance[] => {
|
||||
return instances.filter((instance, idx) => {
|
||||
const otherInstanceIndex = instances.findIndex(
|
||||
(instance_) =>
|
||||
instance_.url === instance.url &&
|
||||
instance_.username === instance.username,
|
||||
);
|
||||
return otherInstanceIndex === idx;
|
||||
});
|
||||
};
|
||||
|
||||
const restoreCurrentInstanceFromLocalStorage = () => {
|
||||
const currentInstanceStr = storage.getString(CurrentInstanceKey);
|
||||
if (!currentInstanceStr) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const currentInstance = JSON.parse(
|
||||
Buffer.from(currentInstanceStr, "base64").toString("utf-8"),
|
||||
);
|
||||
return validateInstance(currentInstance);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const restoreOtherInstancesFromLocalStorage = () => {
|
||||
const otherInstancesStr = storage.getString(OtherInstancesKey);
|
||||
if (!otherInstancesStr) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const otherInstances = JSON.parse(
|
||||
Buffer.from(otherInstancesStr, "base64").toString("utf-8"),
|
||||
);
|
||||
if (!Array.isArray(otherInstances)) {
|
||||
return [];
|
||||
}
|
||||
return otherInstances
|
||||
.map((i) => validateInstance(i))
|
||||
.filter((i) => i !== null);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const _currentInstanceAtom = atom<MeeloInstance | null>(
|
||||
restoreCurrentInstanceFromLocalStorage(),
|
||||
);
|
||||
const _otherInstancesAtom = atom<MeeloInstance[]>(
|
||||
restoreOtherInstancesFromLocalStorage(),
|
||||
);
|
||||
|
||||
const serialiseInstance = (i: MeeloInstance | MeeloInstance[]) => {
|
||||
return Buffer.from(JSON.stringify(i), "utf-8").toString("base64");
|
||||
};
|
||||
|
||||
export const currentInstanceAtom = atom<
|
||||
MeeloInstance | null,
|
||||
[MeeloInstance | null],
|
||||
void
|
||||
>(
|
||||
(get) => get(_currentInstanceAtom),
|
||||
(_, set, data) => {
|
||||
if (data) {
|
||||
data = normalizeInstance(data);
|
||||
}
|
||||
set(_currentInstanceAtom, data);
|
||||
if (data !== null) {
|
||||
storage.set(CurrentInstanceKey, serialiseInstance(data));
|
||||
} else {
|
||||
storage.remove(InstanceUrlKey);
|
||||
storage.remove(CurrentInstanceKey);
|
||||
}
|
||||
set(_instanceUrlAtom, update);
|
||||
},
|
||||
);
|
||||
|
||||
const _instanceUrlAtom = atom(storage.getString(InstanceUrlKey) ?? null);
|
||||
|
||||
export const accessTokenAtom = atom<string | null, [string | null], void>(
|
||||
(get) => get(_accessToken),
|
||||
(_, set, update) => {
|
||||
if (update !== null) {
|
||||
const expires = new Date();
|
||||
|
||||
expires.setMonth(expires.getMonth() + 1);
|
||||
storage.set(AccessTokenKey, update);
|
||||
} else {
|
||||
storage.remove(AccessTokenKey);
|
||||
}
|
||||
set(_accessToken, update);
|
||||
export const otherInstancesAtom = atom<
|
||||
MeeloInstance[],
|
||||
[MeeloInstance[]],
|
||||
void
|
||||
>(
|
||||
(get) => get(_otherInstancesAtom),
|
||||
(_, set, data) => {
|
||||
data = removeDuplicates(data.map(normalizeInstance));
|
||||
set(_otherInstancesAtom, data);
|
||||
storage.set(OtherInstancesKey, serialiseInstance(data));
|
||||
},
|
||||
);
|
||||
const _accessToken = atom<string | null>(
|
||||
storage.getString(AccessTokenKey) ?? null,
|
||||
|
||||
// Move current instance (if any) to the list of other instances
|
||||
export const popCurrentInstanceAtom = atom(null, (get, set) => {
|
||||
const currentInstance = get(currentInstanceAtom);
|
||||
const otherInstances = get(otherInstancesAtom);
|
||||
if (currentInstance)
|
||||
set(otherInstancesAtom, [currentInstance, ...otherInstances]);
|
||||
set(currentInstanceAtom, null);
|
||||
});
|
||||
|
||||
export const deleteOtherInstanceAtom = atom(
|
||||
null,
|
||||
(get, set, instance: MeeloInstance) => {
|
||||
const otherInstances = get(otherInstancesAtom);
|
||||
set(
|
||||
otherInstancesAtom,
|
||||
otherInstances.filter((i) => {
|
||||
return !(
|
||||
i.url === instance.url && i.username === instance.username
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -24,7 +24,14 @@ const config = {
|
||||
];
|
||||
return config;
|
||||
},
|
||||
transpilePackages: ["models", "api", "state", "utils", "ui"],
|
||||
transpilePackages: [
|
||||
"models",
|
||||
"api",
|
||||
"state",
|
||||
"utils",
|
||||
"ui",
|
||||
"@mui/x-data-grid",
|
||||
],
|
||||
experimental: {
|
||||
externalDir: true,
|
||||
},
|
||||
|
||||
+23
-26
@@ -14,46 +14,43 @@
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/server": "^11.11.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@mui/material": "^7.0.1",
|
||||
"@mui/material-nextjs": "^7.0.0",
|
||||
"@mui/utils": "^7.0.1",
|
||||
"@mui/x-data-grid": "^7.28.3",
|
||||
"@mui/x-date-pickers": "^7.28.3",
|
||||
"@tanstack/react-query": "^5.77.0",
|
||||
"@tanstack/react-query-devtools": "^5.77.0",
|
||||
"@mui/material": "^7.3.6",
|
||||
"@mui/material-nextjs": "^7.3.6",
|
||||
"@mui/utils": "^7.3.6",
|
||||
"@mui/x-data-grid": "^8.23.0",
|
||||
"@mui/x-date-pickers": "^8.23.0",
|
||||
"@tanstack/react-query": "^5.90.14",
|
||||
"@tanstack/react-query-devtools": "^5.91.2",
|
||||
"accept-language-parser": "^1.5.0",
|
||||
"blurhash": "^2.0.5",
|
||||
"change-case": "^5.1.2",
|
||||
"cookies-next": "^5.1.0",
|
||||
"core-js": "^3.26.1",
|
||||
"core-js": "^3.47.0",
|
||||
"d3-array": "^3.2.0",
|
||||
"format-duration": "^2.0.0",
|
||||
"hls.js": "^1.5.11",
|
||||
"i18next": "^23.7.16",
|
||||
"jotai": "^2.12.1",
|
||||
"hls.js": "^1.6.15",
|
||||
"i18next": "^25.0.0",
|
||||
"jotai": "^2.16.0",
|
||||
"material-ui-confirm": "^3.0.7",
|
||||
"mui-react-hook-form-plus": "^1.3.2",
|
||||
"next": "15.5.6",
|
||||
"mui-react-hook-form-plus": "^1.5.1",
|
||||
"next": "15.5.7",
|
||||
"react": "19.1.0",
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
"react-blurhash": "^0.3.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-error-boundary": "^4.0.10",
|
||||
"react-hook-form": "^7.39.3",
|
||||
"react-hot-toast": "^2.3.0",
|
||||
"react-i18next": "^12.2.2",
|
||||
"react-error-boundary": "^4.1.2",
|
||||
"react-hook-form": "^7.68.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-i18next": "^16.0.0",
|
||||
"react-infinite-scroller": "^1.2.6",
|
||||
"react-use": "^17.5.1",
|
||||
"sharp": "^0.33.4",
|
||||
"type-fest": "^4.33.0",
|
||||
"usehooks-ts": "^2.9.5",
|
||||
"uuid": "^10.0.0",
|
||||
"yup": "^1.0.0"
|
||||
"react-use": "^17.6.0",
|
||||
"type-fest": "^4.41.0",
|
||||
"usehooks-ts": "^2.16.0",
|
||||
"uuid": "^10.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/accept-language-parser": "^1.5.3",
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/node": "18.7.7",
|
||||
"@types/d3-array": "^3.2.2",
|
||||
"@types/node": "18.19.130",
|
||||
"@types/react": "~19.1.10",
|
||||
"@types/react-beautiful-dnd": "^13.1.3",
|
||||
"@types/react-dom": "19.1.7",
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Divider,
|
||||
IconButton,
|
||||
Stack,
|
||||
} from "@mui/material";
|
||||
import { HookTextField, useHookForm } from "mui-react-hook-form-plus";
|
||||
import { useCallback } from "react";
|
||||
import { useFieldArray, useWatch } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
getExternalProviders,
|
||||
getResourceExternalMetadata,
|
||||
} from "@/api/queries";
|
||||
import { CommonExternalMetadata } from "@/models/external-metadata";
|
||||
import type {
|
||||
CreateExternalMetadataDto,
|
||||
MatchableResourceType,
|
||||
} from "@/models/matcher";
|
||||
import { store } from "@/state/store";
|
||||
import {
|
||||
AddIcon,
|
||||
DeleteIcon,
|
||||
EditExternalLinksIcon,
|
||||
ProviderIcon,
|
||||
} from "@/ui/icons";
|
||||
import { useInfiniteQuery, useQuery, useQueryClient } from "~/api";
|
||||
import { userAtom } from "~/state/user";
|
||||
import Illustration from "../illustration";
|
||||
import type Action from ".";
|
||||
|
||||
export const EditExternalLinksAction = (
|
||||
resourceType: MatchableResourceType,
|
||||
resourceId: number,
|
||||
): Action => ({
|
||||
label: "actions.editExternalLinks",
|
||||
disabled: store.get(userAtom)?.admin !== true,
|
||||
icon: <EditExternalLinksIcon />,
|
||||
dialog: ({ close }) => {
|
||||
const { data: oldExternalMetadata } = useQuery(() =>
|
||||
getResourceExternalMetadata(
|
||||
resourceId,
|
||||
resourceType,
|
||||
CommonExternalMetadata,
|
||||
),
|
||||
);
|
||||
const queryClient = useQueryClient();
|
||||
const onSubmit = useCallback(
|
||||
async (links: ValidatedFormField[]) => {
|
||||
if (!oldExternalMetadata) {
|
||||
return;
|
||||
}
|
||||
const newMetadata: CreateExternalMetadataDto<CommonExternalMetadata> =
|
||||
{
|
||||
...oldExternalMetadata,
|
||||
sources: links,
|
||||
};
|
||||
await queryClient.api.submitExternalMetadata(newMetadata);
|
||||
close();
|
||||
await queryClient.api.refreshExternalMetadata(
|
||||
{
|
||||
[`${resourceType}Id` as "artistId"]: resourceId,
|
||||
},
|
||||
true,
|
||||
);
|
||||
await queryClient.client.invalidateQueries({
|
||||
queryKey: ["api", "external-metadata"],
|
||||
});
|
||||
|
||||
await queryClient.client.invalidateQueries({
|
||||
queryKey: [`${resourceType}s`, resourceId.toString()],
|
||||
});
|
||||
},
|
||||
[oldExternalMetadata],
|
||||
);
|
||||
return (
|
||||
<EditExternalLinksForm
|
||||
resourceType={resourceType}
|
||||
externalMetadata={oldExternalMetadata}
|
||||
onSave={onSubmit}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
type Props = {
|
||||
onSave: (sources: ValidatedFormField[]) => void;
|
||||
resourceType: MatchableResourceType;
|
||||
externalMetadata: CommonExternalMetadata | null | undefined;
|
||||
};
|
||||
|
||||
type FormField = { url: string; providerId: number | null };
|
||||
type ValidatedFormField = { url: string; providerId: number };
|
||||
|
||||
const ProviderFormIcon = ({
|
||||
providerId,
|
||||
}: Partial<Pick<FormField, "providerId">>) => {
|
||||
const { items } = useInfiniteQuery(() => getExternalProviders());
|
||||
const iconId = items?.find((p) => p.id === providerId)?.illustrationId;
|
||||
return (
|
||||
<Box sx={{ width: 30 }}>
|
||||
{providerId !== null && (
|
||||
<Illustration
|
||||
url={`/illustrations/${iconId}`}
|
||||
quality="original"
|
||||
fallback={<ProviderIcon />}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const EditExternalLinksForm = ({
|
||||
onSave,
|
||||
resourceType,
|
||||
externalMetadata,
|
||||
}: Props) => {
|
||||
const isLoading = externalMetadata === undefined;
|
||||
const queryClient = useQueryClient();
|
||||
const { control, handleSubmit, registerState } = useHookForm<{
|
||||
urls: FormField[];
|
||||
}>({
|
||||
values: {
|
||||
urls: externalMetadata?.sources ?? [{ url: "", providerId: null }],
|
||||
},
|
||||
mode: "onBlur",
|
||||
});
|
||||
const urlsState = useWatch({ control, name: "urls" });
|
||||
const { fields, remove, append, update } = useFieldArray({
|
||||
control,
|
||||
name: "urls",
|
||||
});
|
||||
const { t } = useTranslation();
|
||||
const validateField = useCallback(
|
||||
async (url: string, index: number) => {
|
||||
const res = await queryClient.api.resolveUrl({
|
||||
url: url as string,
|
||||
resourceType,
|
||||
});
|
||||
if (res === null) {
|
||||
return false;
|
||||
}
|
||||
const otherIndex = urlsState.findIndex(
|
||||
(u) => u.providerId === res.providerId,
|
||||
);
|
||||
if (otherIndex === -1 || otherIndex === index) {
|
||||
update(index, res);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[urlsState],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogTitle>{t("actions.editExternalLinks")}</DialogTitle>
|
||||
<form
|
||||
onSubmit={handleSubmit(({ urls }) =>
|
||||
onSave(
|
||||
// NOTE: Urls shouldn't be filtered out
|
||||
urls.filter(
|
||||
(u): u is ValidatedFormField =>
|
||||
u.providerId !== null,
|
||||
),
|
||||
),
|
||||
)}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
>
|
||||
<DialogContent>
|
||||
<Stack spacing={2}>
|
||||
{fields.map((field, index) => (
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
key={`${field.id}-${index}-${field.providerId}`}
|
||||
sx={{ alignItems: "center" }}
|
||||
>
|
||||
<ProviderFormIcon
|
||||
providerId={field.providerId}
|
||||
/>
|
||||
<HookTextField
|
||||
{...registerState(`urls.${index}.url`)}
|
||||
textFieldProps={{
|
||||
fullWidth: true,
|
||||
label: "URL",
|
||||
disabled: isLoading,
|
||||
}}
|
||||
rules={{
|
||||
required: true,
|
||||
validate: (f) =>
|
||||
validateField(f as string, index),
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
disabled={isLoading}
|
||||
onClick={() => remove(index)}
|
||||
color="error"
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
))}
|
||||
|
||||
<Button
|
||||
onClick={() =>
|
||||
append({ url: "", providerId: null })
|
||||
}
|
||||
startIcon={<AddIcon />}
|
||||
disabled={isLoading}
|
||||
variant="contained"
|
||||
>
|
||||
{t("form.editExternalLinks.addLink")}
|
||||
</Button>
|
||||
<Divider />
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={
|
||||
isLoading ||
|
||||
!!urlsState.find((u) => u.providerId === null)
|
||||
}
|
||||
>
|
||||
{t("form.editExternalLinks.saveLinks")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -16,98 +16,180 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Button, Checkbox, Grid } from "@mui/material";
|
||||
import { Box, Button, Checkbox, Divider, Stack } from "@mui/material";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import type { ArraySlice } from "type-fest";
|
||||
import type API from "@/api";
|
||||
import { MetadataRefreshIcon } from "@/ui/icons";
|
||||
import { useAPI } from "~/api";
|
||||
import { useAPI, useQueryClient } from "~/api";
|
||||
import type Action from "./";
|
||||
|
||||
type APIMethodParams = ArraySlice<Parameters<API["refreshMetadata"]>, 0, -1>;
|
||||
type RefreshableResourceType =
|
||||
| Parameters<API["refreshLocalMetadata"]>[0]
|
||||
| "artist";
|
||||
|
||||
const RefreshMetadataActionContent = ({
|
||||
t,
|
||||
params,
|
||||
resourceType,
|
||||
resourceId,
|
||||
close,
|
||||
}: {
|
||||
t: Translator;
|
||||
params: APIMethodParams;
|
||||
resourceType: RefreshableResourceType;
|
||||
resourceId: number;
|
||||
close: () => void;
|
||||
}) => {
|
||||
const [force, setForce] = useState(false);
|
||||
const [reuseSources, setReuseSources] = useState(true);
|
||||
const api = useAPI();
|
||||
|
||||
const canRefreshLocalMetadata = resourceType !== "artist";
|
||||
const canRefreshExternalMetadata = ["artist", "song", "album"].includes(
|
||||
resourceType,
|
||||
);
|
||||
const queryClient = useQueryClient();
|
||||
return (
|
||||
<Grid container spacing={2} sx={{ padding: 2 }}>
|
||||
<Grid
|
||||
size={{ xs: 12 }}
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={force} onClick={() => setForce((f) => !f)} />
|
||||
{t("tasks.refreshMetadataForm.forceLabel")}
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
api.refreshMetadata(...[...params, force])
|
||||
.then(() =>
|
||||
toast.success(
|
||||
t("toasts.refreshMetadata.started"),
|
||||
),
|
||||
<Stack spacing={2} sx={{ padding: 2 }}>
|
||||
{canRefreshLocalMetadata && (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={force}
|
||||
onClick={() => setForce((f) => !f)}
|
||||
/>
|
||||
{t("tasks.refreshMetadataForm.forceLabel")}
|
||||
</Box>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
api.refreshLocalMetadata(
|
||||
resourceType,
|
||||
resourceId,
|
||||
force,
|
||||
)
|
||||
.catch(() =>
|
||||
toast.error(t("toasts.refreshMetadata.failed")),
|
||||
);
|
||||
close();
|
||||
}}
|
||||
>
|
||||
{t("tasks.refreshMetadata")}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
.then(() =>
|
||||
toast.success(
|
||||
t("toasts.refreshMetadata.started"),
|
||||
),
|
||||
)
|
||||
.catch(() =>
|
||||
toast.error(
|
||||
t("toasts.refreshMetadata.failed"),
|
||||
),
|
||||
);
|
||||
close();
|
||||
}}
|
||||
>
|
||||
{t("tasks.refreshLocalMetadata")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{canRefreshLocalMetadata && canRefreshExternalMetadata && (
|
||||
<Divider />
|
||||
)}
|
||||
{canRefreshExternalMetadata && (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={reuseSources}
|
||||
onClick={() => setReuseSources((f) => !f)}
|
||||
/>
|
||||
{t("tasks.refreshMetadataForm.reuseLabel")}
|
||||
</Box>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
toast
|
||||
.promise(
|
||||
api.refreshExternalMetadata(
|
||||
{
|
||||
[`${resourceType}Id` as "artistId"]:
|
||||
resourceId,
|
||||
},
|
||||
reuseSources,
|
||||
),
|
||||
{
|
||||
loading: t(
|
||||
"toasts.refreshMetadata.started",
|
||||
),
|
||||
success: "Metadata Refreshed!",
|
||||
error: t(
|
||||
"toasts.refreshMetadata.failed",
|
||||
),
|
||||
},
|
||||
)
|
||||
.then(async () => {
|
||||
await queryClient.client.invalidateQueries({
|
||||
queryKey: ["api", "external-metadata"],
|
||||
});
|
||||
});
|
||||
close();
|
||||
}}
|
||||
>
|
||||
{t("tasks.refreshExternalMetadata")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const RefreshMetadataAction = (
|
||||
t: Translator,
|
||||
...params: APIMethodParams
|
||||
resourceType: RefreshableResourceType,
|
||||
resourceId: number,
|
||||
): Action => ({
|
||||
label: "tasks.refreshMetadata",
|
||||
icon: <MetadataRefreshIcon />,
|
||||
dialog: ({ close }) => (
|
||||
<RefreshMetadataActionContent t={t} params={params} close={close} />
|
||||
<RefreshMetadataActionContent
|
||||
t={t}
|
||||
resourceId={resourceId}
|
||||
resourceType={resourceType}
|
||||
close={close}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
export const RefreshLibraryMetadataAction = (
|
||||
librarySlugOrId: number | string,
|
||||
librarySlugOrId: number,
|
||||
t: Translator,
|
||||
) => RefreshMetadataAction(t, "library", librarySlugOrId);
|
||||
|
||||
export const RefreshArtistMetadataAction = (
|
||||
artistSlugOrId: number,
|
||||
t: Translator,
|
||||
) => RefreshMetadataAction(t, "artist", artistSlugOrId);
|
||||
|
||||
export const RefreshAlbumMetadataAction = (
|
||||
albumSlugOrId: number | string,
|
||||
albumSlugOrId: number,
|
||||
t: Translator,
|
||||
) => RefreshMetadataAction(t, "album", albumSlugOrId);
|
||||
|
||||
export const RefreshReleaseMetadataAction = (
|
||||
releaseSlugOrId: number | string,
|
||||
releaseSlugOrId: number,
|
||||
t: Translator,
|
||||
) => RefreshMetadataAction(t, "release", releaseSlugOrId);
|
||||
|
||||
export const RefreshSongMetadataAction = (
|
||||
songSlugOrId: number | string,
|
||||
songSlugOrId: number,
|
||||
t: Translator,
|
||||
) => RefreshMetadataAction(t, "song", songSlugOrId);
|
||||
|
||||
export const RefreshTrackMetadataAction = (
|
||||
trackSlugOrId: number | string,
|
||||
trackSlugOrId: number,
|
||||
t: Translator,
|
||||
) => RefreshMetadataAction(t, "track", trackSlugOrId);
|
||||
@@ -16,8 +16,7 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Chip, Grid } from "@mui/material";
|
||||
import type { useConfirm } from "material-ui-confirm";
|
||||
import { Box, Chip, Grid } from "@mui/material";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -36,6 +35,7 @@ import { VideoType } from "@/models/video";
|
||||
import { store } from "@/state/store";
|
||||
import { EditIcon } from "@/ui/icons";
|
||||
import { userAtom } from "~/state/user";
|
||||
import { closeModalAtom, openModalAtom } from "../modal";
|
||||
import type Action from "./";
|
||||
|
||||
const ResourceTypeForm = <Enum extends string>(props: {
|
||||
@@ -75,21 +75,20 @@ const ChangeResourceType = <
|
||||
label: TranslationKey,
|
||||
queryClient: QueryClient,
|
||||
onSelect: (newType: TypeEnum) => Promise<void>,
|
||||
confirm: ReturnType<typeof useConfirm>,
|
||||
): Action => {
|
||||
return {
|
||||
label: label,
|
||||
icon: <EditIcon />,
|
||||
disabled: store.get(userAtom)?.admin !== true,
|
||||
onClick: () =>
|
||||
confirm({
|
||||
title: <br />,
|
||||
description: (
|
||||
store.set(openModalAtom, () => (
|
||||
<Box sx={{ paddingX: 4, paddingY: 6 }}>
|
||||
<ResourceTypeForm
|
||||
defaultValue={resource.type}
|
||||
values={types}
|
||||
translate={translateType}
|
||||
onSelect={(type) =>
|
||||
onSelect={(type) => {
|
||||
store.set(closeModalAtom);
|
||||
toast
|
||||
.promise(onSelect(type), {
|
||||
loading: "Updating...",
|
||||
@@ -112,21 +111,15 @@ const ChangeResourceType = <
|
||||
queryClient.client.invalidateQueries({
|
||||
queryKey: ["bsides"],
|
||||
});
|
||||
})
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
cancellationButtonProps: { sx: { display: "none" } },
|
||||
confirmationText: "OK",
|
||||
}),
|
||||
</Box>
|
||||
)),
|
||||
};
|
||||
};
|
||||
|
||||
const ChangeSongType = (
|
||||
s: Song,
|
||||
client: QueryClient,
|
||||
confirm: ReturnType<typeof useConfirm>,
|
||||
) =>
|
||||
const ChangeSongType = (s: Song, client: QueryClient) =>
|
||||
ChangeResourceType(
|
||||
s,
|
||||
SongType.filter((t) => t !== "Unknown"),
|
||||
@@ -142,14 +135,9 @@ const ChangeSongType = (
|
||||
client.client.invalidateQueries({ queryKey: ["bsides"] });
|
||||
return res;
|
||||
}),
|
||||
confirm,
|
||||
);
|
||||
|
||||
const ChangeAlbumType = (
|
||||
a: Album,
|
||||
client: QueryClient,
|
||||
confirm: ReturnType<typeof useConfirm>,
|
||||
) =>
|
||||
const ChangeAlbumType = (a: Album, client: QueryClient) =>
|
||||
ChangeResourceType(
|
||||
a,
|
||||
AlbumType,
|
||||
@@ -161,14 +149,9 @@ const ChangeAlbumType = (
|
||||
client.client.invalidateQueries({ queryKey: ["albums"] });
|
||||
return res;
|
||||
}),
|
||||
confirm,
|
||||
);
|
||||
|
||||
const ChangeVideoType = (
|
||||
v: Video,
|
||||
client: QueryClient,
|
||||
confirm: ReturnType<typeof useConfirm>,
|
||||
) =>
|
||||
const ChangeVideoType = (v: Video, client: QueryClient) =>
|
||||
ChangeResourceType(
|
||||
v,
|
||||
VideoType,
|
||||
@@ -180,7 +163,6 @@ const ChangeVideoType = (
|
||||
client.client.invalidateQueries({ queryKey: ["videos"] });
|
||||
return res;
|
||||
}),
|
||||
confirm,
|
||||
);
|
||||
|
||||
export { ChangeSongType, ChangeAlbumType, ChangeVideoType };
|
||||
@@ -52,7 +52,6 @@ const Blurhash = ({ blurhash, ...props }: BlurhashProps) => {
|
||||
return (
|
||||
<Box suppressHydrationWarning {...containerProps}>
|
||||
{blurhash && (
|
||||
// @ts-expect-error
|
||||
<RBlurhash
|
||||
hash={blurhash}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { getMasterRelease } from "@/api/queries";
|
||||
import type { AlbumWithRelations } from "@/models/album";
|
||||
import { useQueryClient } from "~/api";
|
||||
import { DownloadReleaseAsyncAction } from "~/components/actions/download";
|
||||
import { EditExternalLinksAction } from "~/components/actions/edit-external-links";
|
||||
import { GoToAlbumAction, GoToArtistAction } from "~/components/actions/link";
|
||||
import { PlayReleaseAction } from "~/components/actions/play-album";
|
||||
import { AddToPlaylistAction } from "~/components/actions/playlist";
|
||||
@@ -63,8 +64,9 @@ const AlbumContextualMenu = (props: AlbumContextualMenuProps) => {
|
||||
]
|
||||
: [],
|
||||
[
|
||||
ChangeAlbumType(props.album, queryClient, confirm),
|
||||
RefreshAlbumMetadataAction(albumSlug, t),
|
||||
ChangeAlbumType(props.album, queryClient),
|
||||
RefreshAlbumMetadataAction(props.album.id, t),
|
||||
EditExternalLinksAction("album", props.album.id),
|
||||
],
|
||||
[
|
||||
DownloadReleaseAsyncAction(
|
||||
|
||||
@@ -19,12 +19,14 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type Artist from "@/models/artist";
|
||||
import { useQueryClient } from "~/api";
|
||||
import { EditExternalLinksAction } from "~/components/actions/edit-external-links";
|
||||
import {
|
||||
GoToArtistAction,
|
||||
GoToArtistAlbumsAction,
|
||||
GoToArtistSongsAction,
|
||||
} from "~/components/actions/link";
|
||||
import { AddToPlaylistAction } from "~/components/actions/playlist";
|
||||
import { RefreshArtistMetadataAction } from "~/components/actions/refresh-metadata";
|
||||
import { ShareArtistAction } from "~/components/actions/share";
|
||||
import { UpdateArtistIllustrationAction } from "~/components/actions/update-illustration";
|
||||
import { ContextualMenu } from "..";
|
||||
@@ -52,7 +54,14 @@ const ArtistContextualMenu = (props: ArtistContextualMenuProps) => {
|
||||
queryClient,
|
||||
),
|
||||
],
|
||||
[UpdateArtistIllustrationAction(queryClient, props.artist.id)],
|
||||
[
|
||||
UpdateArtistIllustrationAction(
|
||||
queryClient,
|
||||
props.artist.id,
|
||||
),
|
||||
RefreshArtistMetadataAction(props.artist.id, t),
|
||||
EditExternalLinksAction("artist", props.artist.id),
|
||||
],
|
||||
[ShareArtistAction(artistSlug, t)],
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -22,6 +22,7 @@ import type Artist from "@/models/artist";
|
||||
import type { TrackWithRelations } from "@/models/track";
|
||||
import { useQueryClient } from "~/api";
|
||||
import { DownloadAction } from "~/components/actions/download";
|
||||
import { EditExternalLinksAction } from "~/components/actions/edit-external-links";
|
||||
import {
|
||||
GoToArtistAction,
|
||||
GoToRelatedTracksAction,
|
||||
@@ -34,7 +35,7 @@ import {
|
||||
PlayAfterAction,
|
||||
PlayNextAction,
|
||||
} from "~/components/actions/playlist";
|
||||
import { RefreshTrackMetadataAction } from "~/components/actions/refresh-metadata";
|
||||
import { RefreshSongMetadataAction } from "~/components/actions/refresh-metadata";
|
||||
import { ChangeSongType } from "~/components/actions/resource-type";
|
||||
import { ShareSongAction } from "~/components/actions/share";
|
||||
import { ShowTrackFileInfoAction } from "~/components/actions/show-track-info";
|
||||
@@ -90,16 +91,21 @@ const ReleaseTrackContextualMenu = (props: ReleaseTrackContextualMenuProps) => {
|
||||
: [],
|
||||
[
|
||||
...(props.track.song
|
||||
? [ChangeSongType(props.track.song, queryClient)]
|
||||
: []),
|
||||
UpdateTrackIllustrationAction(queryClient, props.track.id),
|
||||
...(props.track.songId
|
||||
? [
|
||||
ChangeSongType(
|
||||
props.track.song,
|
||||
queryClient,
|
||||
confirm,
|
||||
RefreshSongMetadataAction(
|
||||
props.track.songId,
|
||||
t,
|
||||
),
|
||||
EditExternalLinksAction(
|
||||
"song",
|
||||
props.track.songId,
|
||||
),
|
||||
]
|
||||
: []),
|
||||
UpdateTrackIllustrationAction(queryClient, props.track.id),
|
||||
RefreshTrackMetadataAction(props.track.id, t),
|
||||
],
|
||||
[ShowTrackFileInfoAction(confirm, props.track.id)],
|
||||
[
|
||||
|
||||
@@ -26,10 +26,11 @@ import type { ReleaseWithRelations } from "@/models/release";
|
||||
import { MasterIcon, UpgradeIcon } from "@/ui/icons";
|
||||
import { useQueryClient } from "~/api";
|
||||
import { DownloadReleaseAction } from "~/components/actions/download";
|
||||
import { EditExternalLinksAction } from "~/components/actions/edit-external-links";
|
||||
import { GoToAlbumAction, GoToArtistAction } from "~/components/actions/link";
|
||||
import { PlayReleaseAction } from "~/components/actions/play-album";
|
||||
import { AddToPlaylistAction } from "~/components/actions/playlist";
|
||||
import { RefreshReleaseMetadataAction } from "~/components/actions/refresh-metadata";
|
||||
import { RefreshAlbumMetadataAction } from "~/components/actions/refresh-metadata";
|
||||
import { ChangeAlbumType } from "~/components/actions/resource-type";
|
||||
import { ShareReleaseAction } from "~/components/actions/share";
|
||||
import { UpdateReleaseIllustrationAction } from "~/components/actions/update-illustration";
|
||||
@@ -121,12 +122,13 @@ const ReleaseContextualMenu = (props: ReleaseContextualMenuProps) => {
|
||||
],
|
||||
|
||||
[
|
||||
ChangeAlbumType(props.release.album, queryClient, confirm),
|
||||
ChangeAlbumType(props.release.album, queryClient),
|
||||
UpdateReleaseIllustrationAction(
|
||||
queryClient,
|
||||
props.release.id,
|
||||
),
|
||||
RefreshReleaseMetadataAction(props.release.id, t),
|
||||
RefreshAlbumMetadataAction(props.release.albumId, t),
|
||||
EditExternalLinksAction("album", props.release.albumId),
|
||||
],
|
||||
[
|
||||
DownloadReleaseAction(
|
||||
|
||||
@@ -25,6 +25,7 @@ import type { SongWithRelations } from "@/models/song";
|
||||
import { DeleteIcon } from "@/ui/icons";
|
||||
import { useQueryClient } from "~/api";
|
||||
import { DownloadAsyncAction } from "~/components/actions/download";
|
||||
import { EditExternalLinksAction } from "~/components/actions/edit-external-links";
|
||||
import {
|
||||
GoToArtistAction,
|
||||
GoToRelatedTracksAction,
|
||||
@@ -92,8 +93,9 @@ const SongContextualMenu = (props: SongContextualMenuProps) => {
|
||||
GoToRelatedTracksAction(songSlug),
|
||||
],
|
||||
[
|
||||
ChangeSongType(props.song, queryClient, confirm),
|
||||
ChangeSongType(props.song, queryClient),
|
||||
RefreshSongMetadataAction(props.song.id, t),
|
||||
EditExternalLinksAction("song", props.song.id),
|
||||
MergeSongAction(props.song, queryClient),
|
||||
],
|
||||
[
|
||||
|
||||
@@ -27,6 +27,7 @@ import { MasterIcon } from "@/ui/icons";
|
||||
import { useQueryClient } from "~/api";
|
||||
import type Action from "~/components/actions";
|
||||
import { DownloadAction } from "~/components/actions/download";
|
||||
import { EditExternalLinksAction } from "~/components/actions/edit-external-links";
|
||||
import {
|
||||
GoToReleaseAction,
|
||||
GoToSongInfoAction,
|
||||
@@ -120,16 +121,14 @@ const TrackContextualMenu = (props: TrackContextualMenuProps) => {
|
||||
: [],
|
||||
[
|
||||
...(props.track.song
|
||||
? [
|
||||
ChangeSongType(
|
||||
props.track.song,
|
||||
queryClient,
|
||||
confirm,
|
||||
),
|
||||
]
|
||||
? [ChangeSongType(props.track.song, queryClient)]
|
||||
: []),
|
||||
UpdateTrackIllustrationAction(queryClient, props.track.id),
|
||||
RefreshTrackMetadataAction(props.track.id, t),
|
||||
|
||||
...(props.track.songId
|
||||
? [EditExternalLinksAction("song", props.track.songId)]
|
||||
: []),
|
||||
...(props.track.song !== null && props.track.songId !== null
|
||||
? [ReassignTrackAction(props.track as any, queryClient)]
|
||||
: []),
|
||||
|
||||
@@ -90,7 +90,7 @@ const VideoContextualMenu = (props: VideoContextualMenuProps) => {
|
||||
]
|
||||
: [],
|
||||
[
|
||||
ChangeVideoType(props.video, queryClient, confirm),
|
||||
ChangeVideoType(props.video, queryClient),
|
||||
UpdateTrackIllustrationAction(
|
||||
queryClient,
|
||||
props.video.master.id,
|
||||
|
||||
@@ -59,7 +59,6 @@ const InfiniteScroll = <T extends Resource, F extends Resource>(
|
||||
actions={props.emptyState?.actions ?? []}
|
||||
/>
|
||||
)}
|
||||
{/* @ts-expect-error */}
|
||||
<IScroll.default
|
||||
pageStart={0}
|
||||
loadMore={() => {
|
||||
@@ -76,7 +75,6 @@ const InfiniteScroll = <T extends Resource, F extends Resource>(
|
||||
hasMore={hasNextPage}
|
||||
threshold={500}
|
||||
>
|
||||
{/* @ts-expect-error */}
|
||||
{props.render(
|
||||
items === undefined
|
||||
? generateArray(3)
|
||||
|
||||
@@ -16,7 +16,15 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { IconButton, Typography, type TypographyProps } from "@mui/material";
|
||||
import {
|
||||
Box,
|
||||
IconButton,
|
||||
Slider,
|
||||
styled,
|
||||
Typography,
|
||||
type TypographyProps,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { getSong } from "@/api/queries";
|
||||
import type Artist from "@/models/artist";
|
||||
@@ -34,6 +42,8 @@ type PlayerButtonControlsProps = {
|
||||
onPlay: () => void;
|
||||
onSkipTrack: () => void;
|
||||
onRewind: () => void;
|
||||
volume: number;
|
||||
setVolume: (n: number) => void;
|
||||
};
|
||||
|
||||
export type PlayerControlsProps = ComponentProps<typeof PlayerSlider> &
|
||||
@@ -86,3 +96,61 @@ export const DurationComponent = ({
|
||||
}: { time?: number } & TypographyProps) => (
|
||||
<Typography {...props}>{formatDuration(time)}</Typography>
|
||||
);
|
||||
|
||||
export type VolumeSliderProps = {
|
||||
// The current volume. In range [0, 1]
|
||||
volume: number;
|
||||
setVolume: (n: number) => void;
|
||||
};
|
||||
|
||||
const volumeClasses = { thumb: "sider-thumb" };
|
||||
|
||||
const StyledSlider = styled(Slider)({
|
||||
":hover": {
|
||||
[`& .${volumeClasses.thumb}`]: {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
[`& .${volumeClasses.thumb}`]: {
|
||||
opacity: 0,
|
||||
":hover": {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const VolumeSliderThumb = ({ children, sx, ...props }: any) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Box
|
||||
{...props}
|
||||
className={volumeClasses.thumb}
|
||||
sx={{
|
||||
...sx,
|
||||
width: 13,
|
||||
left: 7,
|
||||
height: 4,
|
||||
position: "absolute",
|
||||
backgroundColor: theme.palette.text.primary,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const VolumeSlider = ({ volume, setVolume }: VolumeSliderProps) => {
|
||||
return (
|
||||
<StyledSlider
|
||||
slots={{ thumb: VolumeSliderThumb }}
|
||||
size="small"
|
||||
min={0}
|
||||
max={100}
|
||||
value={volume * 100}
|
||||
onChange={(_, e) => setVolume((e as number) / 100)}
|
||||
orientation="vertical"
|
||||
color="secondary"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
Divider,
|
||||
Grid,
|
||||
IconButton,
|
||||
Popover,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Typography,
|
||||
@@ -31,7 +32,13 @@ import {
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { type LegacyRef, type ReactNode, useCallback, useState } from "react";
|
||||
import {
|
||||
type LegacyRef,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { DragDropContext, Draggable, Droppable } from "react-beautiful-dnd";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -54,6 +61,9 @@ import {
|
||||
PlayerIcon,
|
||||
PlaylistIcon,
|
||||
TrackIcon,
|
||||
VolumeHighIcon,
|
||||
VolumeLowIcon,
|
||||
VolumeSilentIcon,
|
||||
} from "@/ui/icons";
|
||||
import formatArtists from "@/utils/format-artists";
|
||||
import formatDuration from "@/utils/format-duration";
|
||||
@@ -70,10 +80,60 @@ import {
|
||||
parentSongQuery,
|
||||
playerTextStyle,
|
||||
SkipButton,
|
||||
VolumeSlider,
|
||||
type VolumeSliderProps,
|
||||
} from "./common";
|
||||
import { LyricsComponent } from "./lyrics";
|
||||
import PlayerSlider from "./slider";
|
||||
|
||||
const VolumeButton = (props: VolumeSliderProps & { disabled?: boolean }) => {
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
const handleClose = () => setAnchorEl(null);
|
||||
const Icon = useMemo(() => {
|
||||
if (props.volume < 0.1) {
|
||||
return VolumeSilentIcon;
|
||||
}
|
||||
|
||||
if (props.volume < 0.5) {
|
||||
return VolumeLowIcon;
|
||||
}
|
||||
return VolumeHighIcon;
|
||||
}, [props.volume]);
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
disabled={props.disabled}
|
||||
onClick={handleClick}
|
||||
color="inherit"
|
||||
>
|
||||
<Icon />
|
||||
</IconButton>
|
||||
<Popover
|
||||
open={open}
|
||||
sx={{ zIndex: "tooltip" }}
|
||||
anchorEl={anchorEl}
|
||||
onClose={handleClose}
|
||||
anchorOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "left",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "left",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ height: 100, width: "auto", padding: 1 }}>
|
||||
<VolumeSlider {...props} />
|
||||
</Box>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ExpandedPlayerControls = (
|
||||
props: PlayerControlsProps & { videoRef: LegacyRef<HTMLVideoElement> },
|
||||
) => {
|
||||
@@ -249,22 +309,14 @@ export const ExpandedPlayerControls = (
|
||||
)}
|
||||
</Box>
|
||||
<Container maxWidth="md" disableGutters>
|
||||
<Stack spacing={3} sx={{ width: "100%" }}>
|
||||
<Grid
|
||||
container
|
||||
sx={{
|
||||
...playerTextStyle,
|
||||
width: "100%",
|
||||
flexGrow: 1,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Stack spacing={4} sx={{ width: "100%" }}>
|
||||
<Grid container>
|
||||
<Grid
|
||||
size={{ xs: 1 }}
|
||||
size={1}
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "end",
|
||||
justifyContent: "flex-end",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{props.track?.type === "Video" && (
|
||||
@@ -274,141 +326,177 @@ export const ExpandedPlayerControls = (
|
||||
)}
|
||||
</Grid>
|
||||
<Grid
|
||||
size={{ xs: 10 }}
|
||||
size={10}
|
||||
sx={{
|
||||
...playerTextStyle,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
flexDirection: "column",
|
||||
gap: { xs: 0.5, md: 0 },
|
||||
}}
|
||||
>
|
||||
{props.artist && props.track ? (
|
||||
<Link
|
||||
href={
|
||||
props.track.releaseId
|
||||
? `/releases/${props.track.releaseId}`
|
||||
: props.track.songId
|
||||
? `/songs/${props.track.songId}/lyrics`
|
||||
: {}
|
||||
}
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
onClick={() =>
|
||||
props.track &&
|
||||
props.onExpand(false)
|
||||
}
|
||||
sx={{
|
||||
textTransform: "none",
|
||||
color: "inherit",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
fontWeight: "bold",
|
||||
...playerTextStyle,
|
||||
}}
|
||||
>
|
||||
{props.track?.name}
|
||||
</Typography>
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Skeleton
|
||||
animation={
|
||||
props.playlistLoading
|
||||
? "wave"
|
||||
: false
|
||||
}
|
||||
width={"70%"}
|
||||
/>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid size={{ xs: 1 }}>
|
||||
{
|
||||
props.track && props.artist ? (
|
||||
<ReleaseTrackContextualMenu
|
||||
artist={props.artist}
|
||||
track={{
|
||||
...props.track,
|
||||
song: props.track.songId
|
||||
? (parentSong.data ?? null)
|
||||
: null,
|
||||
}}
|
||||
onSelect={() =>
|
||||
props.onExpand(false)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<IconButton disabled>
|
||||
<ContextualMenuIcon />
|
||||
</IconButton>
|
||||
)
|
||||
|
||||
// To avoid slight shift on loaded
|
||||
}
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{props.track && props.artist ? (
|
||||
<Link
|
||||
href={`/artists/${props.artist.slug}`}
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
<Box
|
||||
sx={{
|
||||
...playerTextStyle,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
onClick={() => props.onExpand(false)}
|
||||
sx={{
|
||||
textTransform: "none",
|
||||
color: "inherit",
|
||||
width: "100%",
|
||||
{props.artist && props.track ? (
|
||||
<Link
|
||||
href={
|
||||
props.track.releaseId
|
||||
? `/releases/${props.track.releaseId}`
|
||||
: props.track.songId
|
||||
? `/songs/${props.track.songId}/lyrics`
|
||||
: {}
|
||||
}
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
onClick={() =>
|
||||
props.track &&
|
||||
props.onExpand(false)
|
||||
}
|
||||
sx={{
|
||||
textTransform: "none",
|
||||
color: "inherit",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
fontWeight: "bold",
|
||||
...playerTextStyle,
|
||||
}}
|
||||
>
|
||||
{props.track?.name}
|
||||
</Typography>
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Skeleton
|
||||
animation={
|
||||
props.playlistLoading
|
||||
? "wave"
|
||||
: false
|
||||
}
|
||||
width={"70%"}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
...playerTextStyle,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{props.track && props.artist ? (
|
||||
<Link
|
||||
href={`/artists/${props.artist.slug}`}
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
onClick={() =>
|
||||
props.onExpand(false)
|
||||
}
|
||||
sx={{
|
||||
textTransform: "none",
|
||||
color: "inherit",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{ ...playerTextStyle }}
|
||||
>
|
||||
{formatArtists(
|
||||
props.artist,
|
||||
props.featuring ??
|
||||
parentSong.data
|
||||
?.featuring,
|
||||
)}
|
||||
</Typography>
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Skeleton
|
||||
animation={
|
||||
props.playlistLoading
|
||||
? "wave"
|
||||
: false
|
||||
}
|
||||
sx={{ margin: 1.5 }}
|
||||
width={"50%"}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid
|
||||
size={1}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{props.track && props.artist ? (
|
||||
<ReleaseTrackContextualMenu
|
||||
artist={props.artist}
|
||||
track={{
|
||||
...props.track,
|
||||
song: props.track.songId
|
||||
? (parentSong.data ?? null)
|
||||
: null,
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ ...playerTextStyle }}>
|
||||
{formatArtists(
|
||||
props.artist,
|
||||
props.featuring ??
|
||||
parentSong.data?.featuring,
|
||||
)}
|
||||
</Typography>
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Skeleton
|
||||
animation={
|
||||
props.playlistLoading ? "wave" : false
|
||||
}
|
||||
sx={{ margin: 1 }}
|
||||
width={"50%"}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Stack
|
||||
spacing={2}
|
||||
onSelect={() => props.onExpand(false)}
|
||||
/>
|
||||
) : (
|
||||
<IconButton disabled>
|
||||
<ContextualMenuIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid
|
||||
container
|
||||
sx={{
|
||||
justifyContent: "center",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
direction="row"
|
||||
>
|
||||
<PreviousButton onClick={props.onRewind} />
|
||||
<PlayButton
|
||||
onPause={props.onPause}
|
||||
onPlay={props.onPlay}
|
||||
isPlaying={props.playing}
|
||||
/>
|
||||
<SkipButton onClick={props.onSkipTrack} />
|
||||
</Stack>
|
||||
<Grid size={1}>
|
||||
<IconButton />
|
||||
</Grid>
|
||||
<Stack
|
||||
spacing={{ xs: 7, sm: 5 }}
|
||||
sx={{
|
||||
justifyContent: "center",
|
||||
display: "flex",
|
||||
}}
|
||||
direction="row"
|
||||
>
|
||||
<PreviousButton onClick={props.onRewind} />
|
||||
<PlayButton
|
||||
onPause={props.onPause}
|
||||
onPlay={props.onPlay}
|
||||
isPlaying={props.playing}
|
||||
/>
|
||||
<SkipButton onClick={props.onSkipTrack} />
|
||||
</Stack>
|
||||
<Grid size={1}>
|
||||
<VolumeButton
|
||||
disabled={!props.track}
|
||||
volume={props.volume}
|
||||
setVolume={props.setVolume}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Container maxWidth={false}>
|
||||
<PlayerSlider
|
||||
onSlide={props.onSlide}
|
||||
@@ -437,6 +525,7 @@ export const ExpandedPlayerControls = (
|
||||
progress={props.progress}
|
||||
setProgress={props.onSlide}
|
||||
playerIsExpanded={props.expanded}
|
||||
trackIsVideo={props.track?.type === "Video"}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
@@ -451,7 +540,6 @@ export const ExpandedPlayerControls = (
|
||||
maxWidth: theme.breakpoints.values.md,
|
||||
}}
|
||||
>
|
||||
{/* @ts-expect-error */}
|
||||
<DragDropContext
|
||||
onDragEnd={(result) => {
|
||||
if (result.destination) {
|
||||
@@ -465,7 +553,6 @@ export const ExpandedPlayerControls = (
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* @ts-expect-error */}
|
||||
<Droppable droppableId="droppable">
|
||||
{(provided) => (
|
||||
<div
|
||||
@@ -476,7 +563,6 @@ export const ExpandedPlayerControls = (
|
||||
.slice(cursor + 1)
|
||||
.map((playlistItem, index) => (
|
||||
<>
|
||||
{/* @ts-expect-error */}
|
||||
<Draggable
|
||||
draggableId={index.toString()}
|
||||
key={index}
|
||||
|
||||
@@ -27,14 +27,16 @@ export const LyricsComponent = ({
|
||||
progress,
|
||||
setProgress,
|
||||
playerIsExpanded,
|
||||
trackIsVideo,
|
||||
}: {
|
||||
lyrics: Lyrics | null | undefined;
|
||||
songName?: string;
|
||||
progress?: MutableRefObject<number | null>;
|
||||
setProgress: (newProgress: number) => void;
|
||||
playerIsExpanded: boolean;
|
||||
trackIsVideo: boolean;
|
||||
}) => {
|
||||
if (lyrics?.synced && progress) {
|
||||
if (lyrics?.synced && progress && !trackIsVideo) {
|
||||
return (
|
||||
<SyncedLyricsComponent
|
||||
syncedLyrics={lyrics.synced}
|
||||
@@ -80,7 +82,7 @@ const SyncedLyricsComponent = ({
|
||||
const [syncedLyricsWithNext, setLyricsWithNext] =
|
||||
useState<SyncedLyricWithNext[]>();
|
||||
const [currentLyricIndex, setCurrentLyricIndex] = useState<number>(-1);
|
||||
const intervalRef = useRef<NodeJS.Timer>(null);
|
||||
const intervalRef = useRef<NodeJS.Timeout>(null);
|
||||
|
||||
// Do this async-ly, to avoid lag
|
||||
useEffect(() => {
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
parentSongQuery,
|
||||
playerTextStyle,
|
||||
SkipButton,
|
||||
VolumeSlider,
|
||||
} from "./common";
|
||||
|
||||
const ProgressBar = ({
|
||||
@@ -253,6 +254,17 @@ export const MinimizedPlayerControls = (props: PlayerControlsProps) => {
|
||||
<Grid size="grow">
|
||||
<SkipButton onClick={props.onSkipTrack} />
|
||||
</Grid>
|
||||
<Grid
|
||||
size="auto"
|
||||
sx={{
|
||||
display: { xs: "none", sm: "block" },
|
||||
}}
|
||||
>
|
||||
<VolumeSlider
|
||||
volume={props.volume}
|
||||
setVolume={props.setVolume}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</ButtonBase>
|
||||
|
||||
@@ -69,6 +69,7 @@ const Player = () => {
|
||||
() => playlist[cursor + 1],
|
||||
[cursor, playlist],
|
||||
);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const throwawayAudioPlayer = useRef<HTMLAudioElement>(null);
|
||||
const audioPlayer = useRef<HTMLAudioElement>(null);
|
||||
const [useTranscoding, setUseTranscoding] = useState(false);
|
||||
@@ -157,10 +158,9 @@ const Player = () => {
|
||||
crossfade != null &&
|
||||
nextTrackIsAudio &&
|
||||
currentTrackIsAudio &&
|
||||
!Number.isNaN(player!.current!.duration) &&
|
||||
Math.abs(
|
||||
player!.current!.currentTime - player!.current!.duration,
|
||||
) <= crossfade
|
||||
!Number.isNaN(player.current!.duration) &&
|
||||
Math.abs(player.current!.currentTime - player.current!.duration) <=
|
||||
crossfade
|
||||
) {
|
||||
const newId = throwawayAudioPlayer.current!.id;
|
||||
throwawayAudioPlayer.current = document.getElementById(
|
||||
@@ -190,6 +190,7 @@ const Player = () => {
|
||||
};
|
||||
|
||||
const startPlayback = (isTrancoding: boolean) => {
|
||||
// biome-ignore lint: false positive
|
||||
player
|
||||
?.current!.play()
|
||||
.then(() => {
|
||||
@@ -231,6 +232,10 @@ const Player = () => {
|
||||
player.current!.onplay = () => {
|
||||
setPlaying(true);
|
||||
};
|
||||
|
||||
player.current!.onvolumechange = () => {
|
||||
setVolume(player?.current?.volume ?? 1);
|
||||
};
|
||||
player.current!.onplaying = () => {
|
||||
setPlaying(true);
|
||||
};
|
||||
@@ -289,6 +294,12 @@ const Player = () => {
|
||||
return undefined;
|
||||
};
|
||||
}, [playing]);
|
||||
useEffect(() => {
|
||||
if (!player?.current) {
|
||||
return;
|
||||
}
|
||||
player.current.volume = volume;
|
||||
}, [volume]);
|
||||
useEffect(() => {
|
||||
if (useTranscoding && currentTrack) {
|
||||
if (!Hls.isSupported()) {
|
||||
@@ -380,6 +391,7 @@ const Player = () => {
|
||||
setDuration(currentTrack.track.duration ?? undefined);
|
||||
const newIllustrationURL = currentTrack.track.illustration?.url;
|
||||
|
||||
// biome-ignore lint: false positive
|
||||
player!.current!.src = api.getDirectStreamURL(
|
||||
currentTrack.track.sourceFileId,
|
||||
);
|
||||
@@ -457,6 +469,8 @@ const Player = () => {
|
||||
onExpand: (expand: boolean) => setExpanded(expand),
|
||||
duration: duration,
|
||||
progress: progress,
|
||||
volume,
|
||||
setVolume,
|
||||
onSkipTrack: onSkipTrack,
|
||||
onRewind: onRewind,
|
||||
videoRef: videoPlayer as unknown as LegacyRef<HTMLVideoElement>,
|
||||
|
||||
@@ -102,7 +102,9 @@ const UsersSettings = () => {
|
||||
} else if (status.admin === false) {
|
||||
toastMessages.push(t("toasts.users.nowNotAdmin"));
|
||||
}
|
||||
toastMessages.forEach((message) => toast.success(message));
|
||||
toastMessages.forEach((message) => {
|
||||
toast.success(message);
|
||||
});
|
||||
queryClient.client.invalidateQueries();
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -16,8 +16,6 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/* eslint-disable jsx-a11y/alt-text */
|
||||
|
||||
import { Box } from "@mui/material";
|
||||
import type { StaticImport } from "next/dist/shared/lib/get-img-props";
|
||||
import Image from "next/image";
|
||||
|
||||
@@ -31,7 +31,9 @@ const Toaster = () => {
|
||||
toasts
|
||||
.filter((toast) => toast.visible)
|
||||
.filter((_, index) => index >= TOAST_LIMIT)
|
||||
.forEach((toast) => Toasts.toast.dismiss(toast.id));
|
||||
.forEach((toast) => {
|
||||
Toasts.toast.dismiss(toast.id);
|
||||
});
|
||||
}, [toasts]);
|
||||
return (
|
||||
<Toasts.Toaster
|
||||
|
||||
@@ -50,7 +50,10 @@ export async function middleware(request: NextRequest) {
|
||||
if (pathname === "/albums" || pathname === "/albums/") {
|
||||
return;
|
||||
}
|
||||
const albumId = pathname.match("/albums/(?<slug>[^/]*)")?.at(1)!;
|
||||
const albumId = pathname.match("/albums/(?<slug>[^/]*)")?.at(1);
|
||||
if (albumId === null || albumId === undefined) {
|
||||
return;
|
||||
}
|
||||
if (albumId === "compilations") {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -123,7 +123,6 @@ type DragAndDropPlaylistProps = {
|
||||
|
||||
const DragAndDropPlaylist = (props: DragAndDropPlaylistProps) => {
|
||||
return (
|
||||
/* @ts-expect-error */
|
||||
<DragDropContext
|
||||
onDragEnd={(result) => {
|
||||
if (result.destination) {
|
||||
@@ -135,13 +134,11 @@ const DragAndDropPlaylist = (props: DragAndDropPlaylistProps) => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* @ts-expect-error */}
|
||||
<Droppable droppableId="droppable-playlist-entries">
|
||||
{(provided) => (
|
||||
<div {...provided.droppableProps} ref={provided.innerRef}>
|
||||
<Stack spacing={1}>
|
||||
{props.entries.map((playlistItem, index) => (
|
||||
/* @ts-expect-error */
|
||||
<Draggable
|
||||
draggableId={index.toString()}
|
||||
key={index}
|
||||
|
||||
@@ -16,9 +16,6 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/* eslint-disable max-len */
|
||||
/* eslint-disable no-shadow */
|
||||
/* eslint-disable init-declarations */
|
||||
// From https://gist.github.com/mattiaz9/53cb67040fa135cb395b1d015a200aff.
|
||||
// Thanks to the author
|
||||
import { decode } from "blurhash";
|
||||
|
||||
+2
-2
@@ -15,8 +15,8 @@
|
||||
"packages/*"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.1.3",
|
||||
"jest": "^29.7.0",
|
||||
"@biomejs/biome": "2.3.8",
|
||||
"jest": "^30.2.0",
|
||||
"typescript": "5.3.3"
|
||||
},
|
||||
"packageManager": "yarn@4.4.0"
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
"name": "api",
|
||||
"sideEffects": false,
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.77.0",
|
||||
"jotai": "^2.12.1",
|
||||
"@tanstack/react-query": "^5.90.14",
|
||||
"jotai": "^2.16.0",
|
||||
"react": "19.1.0",
|
||||
"type-fest": "^4.33.0",
|
||||
"yup": "^1.0.0"
|
||||
"type-fest": "^4.41.0",
|
||||
"yup": "^1.7.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "5.3.3"
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
useQuery as useReactQuery,
|
||||
useQueryClient as useReactQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useMemo } from "react";
|
||||
import type Resource from "@/models/resource";
|
||||
import type API from ".";
|
||||
import {
|
||||
@@ -49,11 +49,6 @@ export const mkUseQuery = <
|
||||
);
|
||||
const hook = useReactQuery(queryOpts);
|
||||
|
||||
useEffect(() => {
|
||||
if (queryOpts.enabled) {
|
||||
hook.refetch();
|
||||
}
|
||||
}, [api]);
|
||||
return hook;
|
||||
};
|
||||
|
||||
@@ -76,13 +71,6 @@ export const mkUseQueries = <ReturnType, Params extends any[][]>(
|
||||
);
|
||||
const hook = useReactQueries({ queries: queryOpts });
|
||||
|
||||
useEffect(() => {
|
||||
queryOpts.forEach((queryOpt, idx) => {
|
||||
if (queryOpt.enabled) {
|
||||
hook[idx].refetch();
|
||||
}
|
||||
});
|
||||
}, [api]);
|
||||
return hook;
|
||||
};
|
||||
|
||||
|
||||
@@ -20,8 +20,15 @@ import type { RequireExactlyOne } from "type-fest";
|
||||
import * as yup from "yup";
|
||||
import type { AlbumType } from "@/models/album";
|
||||
import { ResourceNotFound } from "@/models/exceptions";
|
||||
import type { CommonExternalMetadata } from "@/models/external-metadata";
|
||||
import type { IllustrationQuality } from "@/models/illustration";
|
||||
import Library from "@/models/library";
|
||||
import {
|
||||
type CreateExternalMetadataDto,
|
||||
type RefreshMetadataDto,
|
||||
type ResolveUrlDto,
|
||||
ResolveUrlResponse,
|
||||
} from "@/models/matcher";
|
||||
import type { PaginationParameters } from "@/models/pagination";
|
||||
import Playlist, {
|
||||
type CreatePlaylistDto,
|
||||
@@ -166,6 +173,47 @@ export default class API {
|
||||
});
|
||||
}
|
||||
|
||||
//// External Metadata
|
||||
async refreshExternalMetadata(
|
||||
dto: RefreshMetadataDto,
|
||||
reuseSources: boolean,
|
||||
): Promise<void> {
|
||||
return this.fetch({
|
||||
route: `/match`,
|
||||
errorMessage: "Refreshing Metadata Failed",
|
||||
parameters: {},
|
||||
data: { ...dto, reuseSources },
|
||||
service: Service.Matcher,
|
||||
method: "POST",
|
||||
emptyResponse: true,
|
||||
});
|
||||
}
|
||||
|
||||
async resolveUrl(dto: ResolveUrlDto): Promise<ResolveUrlResponse | null> {
|
||||
return this.fetch({
|
||||
route: `/resolve-url`,
|
||||
errorMessage: "Refreshing Metadata Failed",
|
||||
parameters: {},
|
||||
otherParameters: { ...dto, resource_type: dto.resourceType },
|
||||
service: Service.Matcher,
|
||||
method: "GET",
|
||||
validator: ResolveUrlResponse,
|
||||
}).catch(() => null);
|
||||
}
|
||||
|
||||
async submitExternalMetadata<T extends CommonExternalMetadata>(
|
||||
dto: CreateExternalMetadataDto<T>,
|
||||
): Promise<void> {
|
||||
return this.fetch({
|
||||
route: `/external-metadata`,
|
||||
errorMessage: "Submitting Metadata Failed",
|
||||
parameters: {},
|
||||
data: dto,
|
||||
method: "POST",
|
||||
emptyResponse: true,
|
||||
});
|
||||
}
|
||||
|
||||
//// Tasks
|
||||
|
||||
async cleanLibraries(): Promise<TaskResponse> {
|
||||
@@ -213,7 +261,7 @@ export default class API {
|
||||
});
|
||||
}
|
||||
|
||||
async refreshMetadata(
|
||||
async refreshLocalMetadata(
|
||||
parentResourceType: "library" | "album" | "song" | "release" | "track",
|
||||
resourceSlugOrId: number | string,
|
||||
force: boolean,
|
||||
|
||||
@@ -33,12 +33,15 @@ import {
|
||||
import {
|
||||
AlbumExternalMetadata,
|
||||
ArtistExternalMetadata,
|
||||
type CommonExternalMetadata,
|
||||
ExternalProvider,
|
||||
SongExternalMetadata,
|
||||
} from "@/models/external-metadata";
|
||||
import File from "@/models/file";
|
||||
import Genre, { type GenreSortingKeys } from "@/models/genre";
|
||||
import Label, { type LabelSortingKeys } from "@/models/label";
|
||||
import Library from "@/models/library";
|
||||
import type { MatchableResourceType } from "@/models/matcher";
|
||||
import PaginatedResponse, {
|
||||
type PaginationParameters,
|
||||
} from "@/models/pagination";
|
||||
@@ -466,12 +469,12 @@ export const getAlbumExternalMetadata = (slugOrId: string | number) =>
|
||||
AlbumExternalMetadata,
|
||||
);
|
||||
|
||||
const getResourceExternalMetadata = <
|
||||
T,
|
||||
export const getResourceExternalMetadata = <
|
||||
T extends CommonExternalMetadata,
|
||||
V extends yup.Schema<T> = yup.Schema<T>,
|
||||
>(
|
||||
resourceSlugOrId: string | number,
|
||||
resourceType: "artist" | "album" | "song",
|
||||
resourceType: MatchableResourceType,
|
||||
validator: V,
|
||||
): Query<T | null> => {
|
||||
const query = _mkSimpleQuery({
|
||||
@@ -514,18 +517,20 @@ export const getSearchHistory = (): Query<SearchResult[]> => {
|
||||
});
|
||||
};
|
||||
|
||||
export const getExternalProviders = (): InfiniteQuery<ExternalProvider> => {
|
||||
return _mkSimplePaginatedQuery({
|
||||
route: "/external-providers",
|
||||
validator: PaginatedResponse(ExternalProvider),
|
||||
});
|
||||
};
|
||||
|
||||
/// Users
|
||||
|
||||
export const getCurrentUserStatus = (): Query<User> => {
|
||||
return {
|
||||
key: ["users", "me"],
|
||||
exec: (api) => () =>
|
||||
api.fetch({
|
||||
route: "/users/me",
|
||||
parameters: {},
|
||||
validator: User,
|
||||
}),
|
||||
};
|
||||
return _mkSimpleQuery({
|
||||
route: "/users/me",
|
||||
validator: User,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUsers = (
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
"typescript": "5.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": "^23.7.16",
|
||||
"react-i18next": "^12.2.2"
|
||||
"i18next": "^23.16.8 || ^25.0.0",
|
||||
"react-i18next": "^12.3.1 || ^16.0.0"
|
||||
},
|
||||
"packageManager": "yarn@4.4.0"
|
||||
}
|
||||
Loaded 100 of 309 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user