Compare commits

...
136 Commits
Author SHA1 Message Date
Arthur Jamet 12714421fb Front: Setup Czech and Slovak translations 2026-01-05 10:15:16 +01:00
Weblate (bot)andMark Rieder 4c70188eb8 Translations update from Hosted Weblate (#1355)
* Added translation using Weblate (Czech)

* Added translation using Weblate (Slovak)

* Translated using Weblate (Czech)

Currently translated at 1.2% (4 of 333 strings)

Translation: Meelo/front
Translate-URL: https://hosted.weblate.org/projects/meelo/front/cs/

* Translated using Weblate (Slovak)

Currently translated at 1.2% (4 of 333 strings)

Translation: Meelo/front
Translate-URL: https://hosted.weblate.org/projects/meelo/front/sk/

---------

Co-authored-by: Mark Rieder <markrieder111@gmail.com>
2026-01-05 10:06:58 +01:00
renovate[bot]andArthur Jamet 6fe375aab5 Update react-query to v5.90.14 (minor) (#1341)
* Update react-query to v5.90.14

* Front: Bump react-query

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Arthur Jamet <arthur.jamet@gmail.com>
2026-01-05 10:05:30 +01:00
renovate[bot] 004824e7cb Update i18next 2026-01-04 18:47:36 +01:00
renovate[bot]andArthur Jamet 09ec31cf94 Update mui/x-data-grid to v8 (#1348)
* Update mui to v8

* Web: Transpile mui's data grid

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Arthur Jamet <arthur.jamet@gmail.com>
2026-01-04 17:13:06 +01:00
Arthur Jamet f7d6bb9d4d Server: Bump Nestjs 2026-01-04 14:38:10 +01:00
Arthur Jamet df72fa2690 Server: Fix failing tests caused by typescript's strict flag 2026-01-04 14:38:10 +01:00
Arthur Jamet b25a137d41 Server: Format 2026-01-04 14:38:10 +01:00
Arthur Jamet 129c5be62d Server: Move Jest config out of package.json
To load .env for prisma
2026-01-04 14:38:10 +01:00
Arthur Jamet 1080d6a663 Server: CI: Adapt for Prisma v7 2026-01-04 14:38:10 +01:00
Arthur Jamet c678b86c40 Server: Patch prisma dto generator to use custom prisma client path 2026-01-04 14:38:10 +01:00
Arthur Jamet e32bff7425 Server: Bump Prisma 2026-01-04 14:38:10 +01:00
Arthur Jamet d3bd82b91b Server: Bump Type-fest 2026-01-04 14:38:10 +01:00
Arthur Jamet dc0d7fc706 Server: Bump TypeScript to v5 2026-01-04 14:38:10 +01:00
Arthur Jamet 70cb9f4ac2 Server: Use Node 24 2026-01-04 14:38:10 +01:00
renovate[bot] 98a6282c95 Update mui (minor) (#1346) 2026-01-02 22:15:57 +01:00
Arthur Jamet 24a6c6bd09 Renove: Fix cron for package groups 2026-01-02 21:30:31 +01:00
Arthur Jamet a5d7e62d79 Renovate: Extend recommended config 2026-01-02 16:47:05 +01:00
renovate[bot]andArthur Jamet 76507ff05f Update module github.com/labstack/echo/v4 to v4.14.0 (#1335)
* Update module github.com/labstack/echo/v4 to v4.14.0

* Scanner: Bump echo

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Arthur Jamet <arthur.jamet@gmail.com>
2026-01-02 16:46:12 +01:00
renovate[bot]andArthur Jamet d824ca6e70 Update module github.com/stretchr/testify to v1.11.1 (#1337)
* Update module github.com/stretchr/testify to v1.11.1

* Scanner: Bump test lib

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Arthur Jamet <arthur.jamet@gmail.com>
2026-01-02 10:50:01 +00:00
renovate[bot]andArthur Jamet 5abc6dfc8c Update module github.com/rs/zerolog to v1.34.0 (#1336)
* Update module github.com/rs/zerolog to v1.34.0

* Scanner: Bump zerolog

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Arthur Jamet <arthur.jamet@gmail.com>
2026-01-02 11:06:45 +01:00
renovate[bot] 90b783cfd5 Update module github.com/go-playground/validator/v10 to v10.30.1 (#1334) 2026-01-02 09:20:05 +01:00
Arthur Jamet 65c95e6221 Matcher: Remove debug print 2026-01-01 13:30:55 +01:00
Arthur Jamet 0394fe3984 Server: Fix frame max value after bumping rabbitmq 2026-01-01 13:30:55 +01:00
renovate[bot]andArthur Jamet ddcb41b6a0 Update dependency usehooks-ts to v2.16.0 (#1331)
* Update dependency usehooks-ts to v2.16.0

* Web: Bump usehooks-ts

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Arthur Jamet <arthur.jamet@gmail.com>
2026-01-01 11:15:04 +00:00
renovate[bot] 66ee3e1f79 Update golang Docker tag to v1.25.5 (#1333)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-01 12:01:11 +01:00
renovate[bot]andArthur Jamet f1dc815b04 Align Python versions across CI (#1339)
* Update python Docker tag to v3.14

* Matcher: Nix: Align Python version

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Arthur Jamet <arthur.jamet@gmail.com>
2026-01-01 11:29:39 +01:00
renovate[bot] 610b6ae11f Update nginx Docker tag to v1.29.4 2026-01-01 11:17:41 +01:00
renovate[bot] 1c294e05df Update rabbitmq Docker tag to v4.2 2026-01-01 11:12:55 +01:00
renovate[bot] 3fcd28122b Update dependency yup to v1.7.1 (#1332) 2025-12-31 15:32:43 +00:00
renovate[bot]andArthur Jamet 56c6c11f52 Update dependency type-fest to v4.41.0 (#1330)
* Update dependency type-fest to v4.41.0

* Server + Front: Bump type-fest

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Arthur Jamet <arthur.jamet@gmail.com>
2025-12-31 14:05:53 +01:00
renovate[bot]andArthur Jamet dbb0b86599 Update dependency tsconfig-paths to v3.15.0 (#1329)
* Update dependency tsconfig-paths to v3.15.0

* Web: Align version of tsconfig-paths

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Arthur Jamet <arthur.jamet@gmail.com>
2025-12-31 13:43:56 +01:00
renovate[bot] c98e7efc55 Update dependency sharp to v0.34.5 2025-12-31 12:27:17 +01:00
renovate[bot]andArthur Jamet febb844153 Update dependency react-use to v17.6.0 (#1327)
* Update dependency react-use to v17.6.0

* Web: Bump react-use

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Arthur Jamet <arthur.jamet@gmail.com>
2025-12-31 11:08:20 +00:00
renovate[bot] 07eee96e8f Update dependency react-native-mmkv to v4.1.0 2025-12-31 11:51:27 +01:00
renovate[bot] e56b9a849b Update dependency fastapi to v0.127.0 2025-12-31 11:38:13 +01:00
renovate[bot] 8398e02205 Update dependency react-native-svg to v15.15.1 2025-12-31 11:29:38 +01:00
Arthur Jamet e5c68e5985 Scanner: Hide log saying that .lrc file will be ignored 2025-12-30 18:43:32 +01:00
Arthur Jamet faa1578258 Web: Expanded Player: Group song and artist names together 2025-12-30 16:47:56 +01:00
Arthur Jamet 04052c3889 Server: Bump class-validator
Fix dependabot alter #187
2025-12-29 18:30:34 +01:00
Arthur Jamet d25953076b Web: Disable synced lyrics when track is video 2025-12-29 17:24:46 +01:00
Arthur Jamet ebd62a20b2 Edit External links (#1311)
* Matcher: API: Proper status code on error

* Matcher: Add provider feature to know if url is valid

* Front: Proper models for Matcher's DTOs and Responses

* Front: Add icon to change external link

* Matcher: Endpoint to resolve URL from user

* Web: Action + Form to edit external links

* Matcher: Make 'url to id' features more flexible
2025-12-29 16:10:41 +01:00
Arthur Jamet 04b7d10415 Matcher: Allow reusing sources when requesting a rematch (#1310)
* API: Allow microservice to retreive external metadata

* Web: Add checkbox when refreshing external metadata

* Matcher: Artist: Reuse old sources

* Matcher: Album: Reuse old sources

* Matcher: Song: Reuse old sources

* Matcher: Match functions: default arg as none for sources
2025-12-27 11:13:37 +00:00
renovate[bot]andArthur Jamet d479205bae Update dependency go to v1.25.5 (#1295)
* Update dependency go to v1.25.5

* Scanner: add pkgconfig to shell.nix

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Arthur Jamet <arthur.jamet@gmail.com>
2025-12-25 08:30:48 +01:00
renovate[bot]andArthur Jamet 51724a580b Update dependency react-error-boundary to v4.1.2 (#1305)
* Update dependency react-error-boundary to v4.1.2

* Web: bump error boundary

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Arthur Jamet <arthur.jamet@gmail.com>
2025-12-25 06:53:07 +00:00
Arthur Jamet 5bb3d36e91 Docker Compose: Increase healthcheck timeouts (#1291) 2025-12-25 07:52:36 +01:00
renovate[bot]andArthur Jamet 67f14c9df2 Align React version across front packages (#1303)
* Update dependency react to v18.3.1

* Fix version of react

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Arthur Jamet <arthur.jamet@gmail.com>
2025-12-25 07:37:33 +01:00
renovate[bot]andArthur Jamet a863a00582 Update dependency react-hook-form to v7.68.0 (#1306)
* Update dependency react-hook-form to v7.68.0

* Front: Bump react-hook-form

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Arthur Jamet <arthur.jamet@gmail.com>
2025-12-25 07:18:36 +01:00
renovate[bot]andArthur Jamet 6397114f33 Update dependency react-hot-toast to v2.6.0 (#1307)
* Update dependency react-hot-toast to v2.6.0

* Web: Bump hot toast

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Arthur Jamet <arthur.jamet@gmail.com>
2025-12-25 06:00:50 +00:00
renovate[bot]andArthur Jamet 0c182622be Front: Bump i18next (#1298)
* Update dependency i18next to v23.16.8

* Front: Bump i18next

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Arthur Jamet <arthur.jamet@gmail.com>
2025-12-25 06:49:27 +01:00
renovate[bot]andArthur Jamet 6b3e7fa9ea Update dependency mui-react-hook-form-plus to v1.5.1 (#1301)
* Update dependency mui-react-hook-form-plus to v1.5.1

* Web: Bump mui-react-hook-form-plus

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Arthur Jamet <arthur.jamet@gmail.com>
2025-12-25 05:31:03 +00:00
renovate[bot]andArthur Jamet 19774fc5fc Update dependency jotai to v2.16.0 (#1299)
* Update dependency jotai to v2.16.0

* Front: Bump Jotai

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Arthur Jamet <arthur.jamet@gmail.com>
2025-12-25 06:14:54 +01:00
Arthur Jamet 77ceb9b242 Web: Bump HLS (#1308) 2025-12-24 10:25:09 +00:00
renovate[bot] 942ff7840e Update dependency hls.js to v1.6.15 (#1297)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-24 11:20:04 +01:00
renovate[bot] 8c42fb7219 Update dependency fastapi to v0.124.4 (#1294)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-24 11:09:13 +01:00
renovate[bot]andArthur Jamet 27fd0ea086 Update dependency core-js to v3.47.0 (#1293)
* Update dependency core-js to v3.47.0

* Web: Bump core-js

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Arthur Jamet <arthur.jamet@gmail.com>
2025-12-24 09:59:55 +00:00
renovate[bot]andArthur Jamet d7a49bd541 Update dependency meilisearch to ^0.54.0 (#1300)
* Update dependency meilisearch to ^0.54.0

* Server: Bump meilisearch lib

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Arthur Jamet <arthur.jamet@gmail.com>
2025-12-24 09:46:36 +00:00
renovate[bot]andArthur Jamet 97b65f8c39 Update dependency helmet to v6.2.0 (#1296)
* Update dependency helmet to v6.2.0

* Server: Bump Helmet

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Arthur Jamet <arthur.jamet@gmail.com>
2025-12-24 07:56:52 +00:00
renovate[bot] a83e5d62de Update dependency passport to ^0.7.0 (#1302)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-24 08:22:31 +01:00
Arthur Jamet 8640b7f69a Add shell.nix files (#1290)
* Setup shell.nix for matcher, scanner and server

* Setup shell.nix for front
2025-12-22 20:33:55 +00:00
renovate[bot] f217d6d20b Update dependency @types/react to v18.3.27 (#1281) 2025-12-19 06:41:53 +00:00
renovate[bot]andArthur Jamet da04f7dc25 Update dependency @babel/core to v7.28.5 (#1273)
* Update dependency @babel/core to v7.28.5

* Mobile: Bump babel

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Arthur Jamet <arthur.jamet@gmail.com>
2025-12-18 17:52:54 +00:00
renovate[bot]andArthur Jamet 7f00155c65 Update dependency @d11/react-native-fast-image to v8.13.0 (#1275)
* Update dependency @d11/react-native-fast-image to v8.13.0

* Mobile: Bump fast-image + disable svg support

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Arthur Jamet <arthur.jamet@gmail.com>
2025-12-18 15:15:06 +00:00
renovate[bot] 15712cace1 Update dependency @shopify/flash-list to v2.2.0 (#1276)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-18 12:12:20 +00:00
Arthur Jamet 989a4f99b5 Server: Test B-Side resolution 2025-12-18 12:01:52 +00:00
renovate[bot]andArthur Jamet 246e24cedb Update dependency @types/node to v18.19.130 (#1280)
* Update dependency @types/node to v18.19.130

* Web: Fix typing of timer/timeout

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Arthur Jamet <arthur.jamet@gmail.com>
2025-12-18 11:52:55 +00:00
renovate[bot] 4f3ac52784 Update dependency axios to v1.13.2 (#1282)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-17 14:29:23 +00:00
Arthur Jamet 5695ba2703 Web: Remove unused dependency (#1284) 2025-12-17 14:18:48 +00:00
renovate[bot]andArthur Jamet 7f3ea159db Update dependency @biomejs/biome to v2.3.8 (#1274)
* Update dependency @biomejs/biome to v2.3.8

* Server: Fix biome lints

* Front: Fix biome lints

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Arthur Jamet <arthur.jamet@gmail.com>
2025-12-17 14:11:57 +00:00
renovate[bot] e5af60cfba Update dependency @types/d3-array to v3.2.2 (#1279)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-17 14:08:45 +00:00
Arthur Jamet f0fadf2cd2 Renovate: Add Labels to PRs 2025-12-17 13:32:28 +00:00
renovate[bot] 0f6df898a2 Update dependency @shopify/react-native-skia to v2.4.7 2025-12-17 13:29:39 +00:00
Arthur Jamet f004c7f9fe Renovate: Increase PR Hour Limit 2025-12-17 09:08:56 +00:00
Arthur Jamet 0025e902c1 Renovate: Split minor/patches PRs 2025-12-17 08:51:14 +00:00
Arthur Jamet 51a1760945 Fix error on scan when local illustrations are big (#1271)
* Server: Patch Jimp to pass decoder options

* Server: Increase memory limit from jpeg decoder
2025-12-16 13:35:55 +00:00
Arthur Jamet c8bac0ca8d Get sort names from embedded metadata (#1269)
* Get Artist's Sort name from embedded metadata

* Get Album and Song's Sort name from embedded metadata

* Get Video's Sort name from embedded metadata

* Fix doc + condition to ignore song sort name
2025-12-15 20:01:45 +00:00
Arthur Jamet 50a0eac2ce Fix sorting of artists using sort names 2025-12-15 17:44:13 +00:00
Arthur Jamet e27b88db60 Release Note Template: Add translation section + add ignore filter 2025-12-15 11:05:19 +00:00
Arthur Jamet 9a73ceaff0 Handle track indexes set to zero (#1264)
* Scanner: Accept zero as valid track index

* API: Registration: Accept zero track index
2025-12-15 09:46:56 +00:00
Arthur Jamet 38fdf8d4c4 Mobile: Prevent useless refetch 2025-12-13 12:57:22 +00:00
Arthur Jamet 6c51a705e6 Mobile: Record Label Chip on album page 2025-12-12 21:49:16 +00:00
Arthur Jamet 8c98d0e39f Release Note Template: Change order 2025-12-12 15:39:45 +00:00
Arthur Jamet 678dcbab84 Mobile: Video Player: emphasis on title 2025-12-12 15:39:17 +00:00
Arthur Jamet c21b7ef82e Mobile: Use Flashlist on home, artist and release pages (#1255)
* Mobile: Use Flashlist in home page

* Mobile: Use Flashlist for genres on home page

* Mobile: Define Safe Flashlist

* Mobile: Use Flashlist on artist page

* Mobile: Use Flashlist on release page
2025-12-12 12:59:37 +00:00
Louis Painter 8a9357806d Translated using Weblate (French)
Currently translated at 100.0% (327 of 327 strings)

Translation: Meelo/front
Translate-URL: https://hosted.weblate.org/projects/meelo/front/fr/
2025-12-10 15:10:27 +00:00
Arthur Jamet 6fe0a40f83 Renovate: ignore patches 2025-12-10 15:09:24 +00:00
renovate[bot] d018da536f Update dependency @gorhom/bottom-sheet to v5.2.7 2025-12-10 15:06:34 +00:00
Arthur Jamet 4f7e761e65 Renovate: ignore slug package 2025-12-10 14:35:39 +00:00
Arthur Jamet d79ec36c40 Server: Bump Nestjs 2025-12-10 14:35:39 +00:00
Arthur Jamet bb3a1b374f Server: Bump Jest 2025-12-10 14:35:39 +00:00
Arthur Jamet c808de3e4e Front: Bump Jest 2025-12-10 14:35:39 +00:00
Arthur Jamet cb9a79ce7d Renovate: Define groups 2025-12-10 14:35:39 +00:00
Arthur Jamet 11803aa468 CI: Bump Docker-related actions 2025-12-10 14:35:39 +00:00
Arthur Jamet 092ba31ffb CI: Bump paths-filter 2025-12-10 14:35:39 +00:00
Arthur Jamet 30844de0ce Dev Docker: Align postgres version with prod 2025-12-10 14:35:39 +00:00
renovate[bot] 07c4aa79c4 Add renovate.json 2025-12-10 14:35:39 +00:00
Arthur Jamet bb9e94ccd8 CI: Bump Dependencies (#1247)
* CI: Bump checkout

* CI: Bump setup-go

* CI: Bump PR Labeler

* CI: Bump Cache Action

* CI: Bump Setup Node

* CI: Bump Setup Java

* CI: Bump Setup Node

* CI: Bump updload/download artifacts
2025-12-08 18:55:23 +00:00
Arthur Jamet fc6ff0a230 Web: Bump Next to 15.5.7 2025-12-05 09:51:27 +00:00
Arthur Jamet f3dce49bb9 Refresh external metadata manually (#1242)
* Matcher: Endpoint to refresh metadata

* Web: Add button to refresh external metadata
2025-12-04 19:15:03 +00:00
Arthur Jamet fd31839d04 Server: Allow overwritting external metadata (#1241) 2025-12-03 12:00:49 +00:00
Arthur Jamet de4df47b9e Matcher: Refactor for async (#1239)
* Matcher: Setup Provider Thread + use it for artists

* Matcher: searching artist can return data

* Matcher: Factorise boilerplate for shared values + use threads to match albums

* Matcher: use threads to match songs

* Matcher: Use aiohttp

* Matcher: API client uses aiohttp

* Matcher: Use async mq client

* Matcher: Use one session per provider

* Matcher: Remove threading, use only asyncio

* Matcher: Use pytest

* Matcher: Setup session with header

* Matcher: Use CachedSession for musicbrainz

* Matcher: Use 'asyncify' function

* Matcher: Retry test on fail

* Matcher: Define get_provider_or_raise and remove pyright annotations

* Matcher: Set higher rate limit for musicbrainz when CI
2025-12-03 11:34:43 +00:00
Arthur Jamet 9ea445d4ca Make sure the message queue is persisted on disk 2025-11-29 16:33:21 +00:00
Arthur Jamet 1da5276c97 Scanner: Do not rely on streams to identify file type, only use path 2025-11-29 09:52:37 +00:00
Arthur Jamet c9dbc73690 Scanner: README: Mention gocv 2025-11-29 09:52:37 +00:00
Arthur Jamet d391b061fc Matcher: Optimise matching of songs when using LrcLib + remove pika logs at startup (#1236)
* Matcher: Musicbrainz test: fix

* Matcher: When resolving providers from musicbrainz, filter out resolved providers

* tmp

* Matcher: Use async in tests
2025-11-29 08:02:22 +00:00
Arthur Jamet e8154b3709 Mobile: Change dependency for toasts 2025-11-29 07:56:09 +00:00
Arthur Jamet 66456ee9b5 Mobile: Minimised player: Display video if it is one 2025-11-27 13:05:50 +00:00
Arthur Jamet bb21f6fb07 Mobile: Player: Use HQ illustration in notification controls 2025-11-27 13:05:50 +00:00
Arthur Jamet 1d0ce69a7f Mobile: Expanded Player: Make text hierarchy more obvious 2025-11-27 13:05:50 +00:00
Arthur Jamet b3a459770f Mobile: Fix Expande dplayer not collapsing after switching tab 2025-11-27 13:05:50 +00:00
Arthur Jamet 1b6903122c Server: remove outdated warning when setting master item on the fly 2025-11-27 06:31:32 +00:00
Arthur Jamet 444d529c8a CI: Upload base settings files as artefact on release 2025-11-26 20:34:14 +00:00
Arthur Jamet 23abdf8b2a Mobile: Make the app boot on iOS 2025-11-26 17:35:40 +00:00
Arthur Jamet 2f9d31b7b4 Docker Compose for prod: remove 'latest' as default tag value 2025-11-26 17:34:38 +00:00
Arthur Jamet bc83d51cc9 .envexample: Reorder fields, make tag version clearer 2025-11-26 17:34:38 +00:00
Arthur Jamet ad0d12f89e Mobile: Fix keyboard hiding modal 2025-11-26 13:59:55 +00:00
Arthur Jamet db5743ec27 Mobile: Refacto list item to allow custom leading component 2025-11-26 12:36:47 +00:00
Arthur Jamet ea83410532 Mobile: Infinite View: Mount flashlist after controls layout to avoid shift 2025-11-26 12:36:47 +00:00
Arthur Jamet 714bda20a6 Mobile: Infinite view controls: Group actions into nowrap row 2025-11-26 12:36:47 +00:00
Arthur Jamet b8d1e26c65 Mobile: Close modal on resource type change 2025-11-26 12:36:47 +00:00
Arthur Jamet d48adefc9a Front: Sorting menu for songs: order types for usability 2025-11-26 12:36:47 +00:00
Arthur Jamet 664f204c88 Web: Close modal on resource type change
Using our modal atom instead of confirm
2025-11-26 12:36:47 +00:00
Arthur Jamet 6d876ccd11 Github: Issue templates: Set the triage tag 2025-11-26 09:38:22 +00:00
Arthur Jamet e75466aad4 Github: Enhancement issue template 2025-11-26 09:38:22 +00:00
Arthur Jamet c6c1ee4ea9 Mobile: Haptic Feedback when opening ctx menu and dragging 2025-11-26 09:28:38 +00:00
Arthur Jamet 05e28e9028 Server: Fix 'transcoder is available' status 2025-11-25 15:00:37 +00:00
Arthur Jamet 10864de858 Mobile: Row: Use flashlist 2025-11-25 14:49:14 +00:00
Arthur Jamet 433b9cb421 Remove unused eslint config 2025-11-25 11:37:44 +00:00
Arthur Jamet 2c0523aa05 DB: Fix trailing / leading dashes in sort slugs 2025-11-24 19:13:15 +00:00
Arthur Jamet 6113c0b7da Sort names (#1214) 2025-11-24 15:54:18 +00:00
Arthur Jamet be821793ad Server: Settings route says if transcoder is available 2025-11-21 10:05:24 +00:00
Arthur Jamet cb098d2451 Mobile: Player: use transcoder on demand or if format not supported 2025-11-21 10:05:24 +00:00
Arthur Jamet 23a00f1e77 Mobile: Reset all tabs when changing instance 2025-11-19 17:00:14 +00:00
Arthur Jamet 1edb13bd29 Mobile: Switch Meelo Server (#1205)
* Mobile: Auth form is in a modal

* Mobile: State: Save current/other meelo instances

* Mobile: Fix typing

* Mobile: Switch server from settings

* Mobile: Select server from auth page

* Mobile: Settings: Refresh cache when connecting a new server

* Mobile: Validate Instances objects when restoring from local storage
2025-11-17 13:53:21 +00:00
Arthur Jamet 12ed528088 Web: Volume Control (#1203)
* Web: Volume slider on minimised player

* Front: Add Volume icons

* Web: Volume slider on expanded player
2025-11-14 11:53:06 +00:00
Arthur Jamet 0008b57285 Server: Fix misleading error message when INTERNAL_CONFIG_DIR is invalid 2025-11-12 14:23:10 +00:00
309 changed files with 12638 additions and 6442 deletions

No files matched your search

+31 -30
View File
@@ -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
-58
View File
@@ -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 -2
View File
@@ -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.
+1 -1
View File
@@ -2,7 +2,7 @@
name: Feature request
about: Suggest an idea for this project
title: ''
labels: Feature
labels: ['Feature', 'Triage']
assignees: Arthi-chaud
---
+14 -13
View File
@@ -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-
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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"
+1 -1
View File
@@ -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
+26 -26
View File
@@ -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
View File
@@ -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
+8 -7
View File
@@ -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 }}
+7 -7
View File
@@ -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
+10 -10
View File
@@ -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 }}
+5 -5
View File
@@ -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
+32
View File
@@ -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
+8 -8
View File
@@ -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 }}
+24 -20
View File
@@ -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
View File
@@ -20,4 +20,7 @@ docs/generated
meelo/
data/
.env
.env
**/.direnv
**/.envrc
+7 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1
View File
@@ -39,3 +39,4 @@ yarn-error.*
app-example
android
*.apk
ios
+13 -5
View File
@@ -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",
},
});
+17 -15
View File
@@ -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),
+3 -3
View File
@@ -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}
+18 -10
View File
@@ -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"],
@@ -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"],
)}
@@ -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) },
}));
+8 -17
View File
@@ -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>
);
+88 -301
View File
@@ -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}
+18 -17
View File
@@ -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>();
+18
View File
@@ -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,
);
}
};
+374 -164
View File
@@ -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 };
};
+47 -18
View File
@@ -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),
},
}));
+136 -25
View File
@@ -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
);
}),
);
},
);
+8 -1
View File
@@ -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
View File
@@ -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>
+18 -4
View File
@@ -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";
+3 -1
View File
@@ -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
+4 -1
View File
@@ -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
View File
@@ -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"
+4 -4
View File
@@ -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"
+1 -13
View File
@@ -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;
};
+49 -1
View File
@@ -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,
+17 -12
View File
@@ -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