Compare commits

...
665 Commits
Author SHA1 Message Date
Arthur Jamet a50ab158de Front: Mobile: Remove/update TODOs 2025-07-23 15:14:22 +01:00
Arthur Jamet 96455e7bd4 Front: Mobile: Fix babel plugin for reanimated 2025-07-23 13:51:48 +01:00
Arthur Jamet 28a5fdab6d Front: Models: Fix translation of EPs' plural 2025-07-23 13:51:47 +01:00
Arthur Jamet 96731d9380 Front: Mobile: Add local packages as dependencies 2025-07-23 13:51:47 +01:00
Arthur Jamet 059ef0d2ba Front: Mobile: Remove default README 2025-07-23 13:51:47 +01:00
Arthur Jamet 9e610b8800 Front: Mobile: Infinite view: fix position of empty state 2025-07-23 13:51:47 +01:00
Arthur Jamet 5ee42742f8 Front: Mobile: Dorpdown Component 2025-07-23 13:51:47 +01:00
Arthur Jamet 77f9e92211 Front: Mobile: language persistance + settings 2025-07-23 13:51:46 +01:00
Arthur Jamet 109d71ca86 Front: Move Languages list to translation folder 2025-07-23 13:51:46 +01:00
Arthur Jamet bb4a36f460 Front: Mobile: Refacto color scheme management + add setting" 2025-07-23 13:51:46 +01:00
Arthur Jamet 5ae31c7a17 Front: Mobile: Root layout: disable layout to avoid bug related to blurview 2025-07-23 13:51:46 +01:00
Arthur Jamet 60713c43ee Front: Mobile: Auth form: add scheme to instance url field 2025-07-23 13:51:46 +01:00
Arthur Jamet ebd346cff8 Front: Mobile: Move list-items and tiles to the same files 2025-07-23 13:51:45 +01:00
Arthur Jamet 1f8473eb33 Front: Lint 2025-07-23 13:51:45 +01:00
Arthur Jamet e94d62fd66 Front: Mobile: Search page with search history 2025-07-23 13:51:45 +01:00
Arthur Jamet 6b6d63c6c1 Front: Mobile: text input: remove bg color 2025-07-23 13:51:45 +01:00
Arthur Jamet 31fb09c492 Front: Mobile: List Items: add format subtitle + onpress props 2025-07-23 13:51:45 +01:00
Arthur Jamet 572b498936 Front: Mobile: Artist Page: Show 'see all' conditionally 2025-07-23 13:51:44 +01:00
Arthur Jamet ab31c7606f Front: Mobile: Home: Show 'see all' button when has next page 2025-07-23 13:51:44 +01:00
Arthur Jamet 4a72d190ec Front: Mobile: Songs page: Change title when browsing 'rare' songs 2025-07-23 13:51:44 +01:00
Arthur Jamet 9efc8b5213 Front: Mobile: Row: Simplify props 2025-07-23 13:51:44 +01:00
Arthur Jamet ab59d06867 Front: Mobile: add button to expand description when needed 2025-07-23 13:51:44 +01:00
Arthur Jamet 4c1e50262c Front: Mobile: Open external metadata link in web view 2025-07-23 13:51:43 +01:00
Arthur Jamet 27880c17a4 Front: Mobile: Videos View: set subtitle to duration when artist is defined 2025-07-23 13:51:43 +01:00
Arthur Jamet cd37d66dbc Front: Mobile: Albums View: Set subtitle to year when artist is defined 2025-07-23 13:51:43 +01:00
Arthur Jamet 28f1c40ea2 Front: Mobile: Infinite View: Remove state for header height 2025-07-23 13:51:43 +01:00
Arthur Jamet f4eca20180 Front: Mobile: Small button variant + use it for section headers 2025-07-23 13:51:43 +01:00
Arthur Jamet f0cc1bd121 Front: Mobile: Lint 2025-07-23 13:51:42 +01:00
Arthur Jamet 5b6a106366 Front: Mobile: Home + Artist Page: Add 'see more' buttons 2025-07-23 13:51:42 +01:00
Arthur Jamet 132ba3b530 Front: Mobile: Infinite Controls: Filters: init state using query params 2025-07-23 13:51:42 +01:00
Arthur Jamet cc6b580ba9 Front: Mobile: Albums + Videos view: Add artist header + filters 2025-07-23 13:51:42 +01:00
Arthur Jamet 58f5051fbd Front: Mobile: Row: Add 'see more' href prop 2025-07-23 13:51:42 +01:00
Arthur Jamet ac52d0953b Front: Model: Derive SortingKey types for albums, artists and videos 2025-07-23 13:51:41 +01:00
Arthur Jamet 4076aa19bf Front: Mobile: Lint 2025-07-23 13:51:41 +01:00
Arthur Jamet 09198b98c5 Front: Mobile: Song Grids: add 'see more' button href 2025-07-23 13:51:41 +01:00
Arthur Jamet 6fe6caf51b Front: Mobile: Infinite View: optional header 2025-07-23 13:51:41 +01:00
Arthur Jamet 026f82a8b6 Front: Model: Derive SongSortingKey type 2025-07-23 13:51:41 +01:00
Arthur Jamet 096198c550 Front: Mobile: Songs view: handle artist + sort query params 2025-07-23 13:51:40 +01:00
Arthur Jamet 9489c1f01d Front: Mobile: Section Header: Add optional 'see more' button 2025-07-23 13:51:40 +01:00
Arthur Jamet 488fd35bd4 Front: Mobile: define helper type to help with sorting query params 2025-07-23 13:51:40 +01:00
Arthur Jamet 8f1248a33c Front: Mobile: Move Artist Page header to component 2025-07-23 13:51:40 +01:00
Arthur Jamet 6afb405b85 Front: Web: Lint 2025-07-23 13:51:40 +01:00
Arthur Jamet a6f1a18a1e Front: Mobile: Lint 2025-07-23 13:51:39 +01:00
Arthur Jamet fac3a330e2 Front: Mobile: Release Page 2025-07-23 13:51:39 +01:00
Arthur Jamet 2833abd05a Front: Mobile: Memo-ise screen options for shared routes 2025-07-23 13:51:39 +01:00
Arthur Jamet 089c91a230 Front: Mobile: Allow disabling pressable 2025-07-23 13:51:39 +01:00
Arthur Jamet 7ae872ff25 Front: Mobile: Video Tile: Don't require artist relation when subtitle is duration 2025-07-23 13:51:39 +01:00
Arthur Jamet e00016af3f Front: Mobile: Remove debug log 2025-07-23 13:51:38 +01:00
Arthur Jamet b7f796d05b Front: Mobile: Row + Song Grid: optional header + extract header into component 2025-07-23 13:51:38 +01:00
Arthur Jamet 073f3669ee Front: Web: Move Logic 'useMemo' of release page to ui package 2025-07-23 13:51:38 +01:00
Arthur Jamet c3d95208bc Front: Mobile: External metadata component 2025-07-23 13:51:38 +01:00
Arthur Jamet 60ce04e82f Front: Mobile: Button Primitive: labelProps -> labelStyle 2025-07-23 13:51:38 +01:00
Arthur Jamet 428b86753a Front: Mobile: settings page: setup scaffold 2025-07-23 13:51:37 +01:00
Arthur Jamet c157966038 Front: Mobile: Text: Consistent line height depending on font size 2025-07-23 13:51:37 +01:00
Arthur Jamet d7004a038f Front: Mobile: Home Page: Reduce bottom padding of last section 2025-07-23 13:51:37 +01:00
Arthur Jamet 17788fa198 Front: Mobile: Icon Primitive: allow setting size 2025-07-23 13:51:37 +01:00
Arthur Jamet 59b6c3b1e4 Front: Mobile: Text: Consistent line height depending on font size 2025-07-23 13:51:37 +01:00
Arthur Jamet 14c7b2e2ed Front: Mobile: Blur header 2025-07-23 13:51:36 +01:00
Arthur Jamet 99aed8631b Front: Move accent color to utils 2025-07-23 13:51:36 +01:00
Arthur Jamet c2838b4216 Front: Mobile: Artist Page: Add missing 'hideIfEmpty' 2025-07-23 13:51:36 +01:00
Arthur Jamet 31ff4f4291 Front: Mobile: home page: Sync query results to limit rerenders 2025-07-23 13:51:36 +01:00
Arthur Jamet 32b7840b16 Front: Mobile: Song Grid: Prevent single-column grid to be scrollable 2025-07-23 13:51:36 +01:00
Arthur Jamet 2913255da0 Front: Mobile: Songs Page: Fix subtitle 2025-07-23 13:51:35 +01:00
Arthur Jamet 6704598a94 Front: Mobile: Illustration: Add style prop 2025-07-23 13:51:35 +01:00
Arthur Jamet e98931316c Front: Mobile: Define Icon and pressable primitives 2025-07-23 13:51:35 +01:00
Arthur Jamet 4f3a876538 Front: Mobile: Song Grid: Set alignement to start 2025-07-23 13:51:35 +01:00
Arthur Jamet 91adc8a8f8 Front: Mobile: Infinite View: Set 'on end reached' threshold 2025-07-23 13:51:35 +01:00
Arthur Jamet ba626af255 Front: Mobile: Artist Page: Typing of params 2025-07-23 13:51:34 +01:00
Arthur Jamet fc234e7361 Front: Mobile: Artist Page: Rare Songs 2025-07-23 13:51:34 +01:00
Arthur Jamet 3b751f80cb Front: Mobile: Add meelo icon in home page 2025-07-23 13:51:34 +01:00
Arthur Jamet 964e3b1825 Front: Mobile: Blur Tab bar 2025-07-23 13:51:34 +01:00
Arthur Jamet b79349980f Front: Mobile: Auth: Disable autocorrect for instance 2025-07-23 13:51:34 +01:00
Arthur Jamet ffb749d75d Front: Mobile: Router: Share routes with search tab 2025-07-23 13:51:33 +01:00
Arthur Jamet fb5da9a3a3 Front: Mobile: Background Gradient: Fix opacity on dark mode toggle 2025-07-23 13:51:33 +01:00
Arthur Jamet 69696e163e Front: Mobile: Setting up shared routes w/ header 2025-07-23 13:51:33 +01:00
Arthur Jamet 7e0a91d6ef Front: API: Infinite query items: use memo instread of state for items 2025-07-23 13:51:33 +01:00
Arthur Jamet 3794d51ad2 Front: Mobile: Album Tile: subtitle 2025-07-23 13:51:33 +01:00
Arthur Jamet e3e2ac3626 Front: Mobile: Song Grid: Set deceleration rate 2025-07-23 13:51:32 +01:00
Arthur Jamet cca2d5ea08 Front: Mobile: Loadable text: simply ternary 2025-07-23 13:51:32 +01:00
Arthur Jamet dea0d0f93e Front: Mobile: text skeleton: remove pulse 2025-07-23 13:51:32 +01:00
Arthur Jamet 35617f7591 Front: Mobile: Illustration: Remove animation, enable recylcing 2025-07-23 13:51:32 +01:00
Arthur Jamet a3ee2b4cbf Front: Mobile: Infinite Views: Use flashlist 2025-07-23 13:51:32 +01:00
Arthur Jamet e436b0e973 Front: Mobile: Backgroun Gradient: allow key item to be undefined 2025-07-23 13:51:31 +01:00
Arthur Jamet 17469baa99 Front: Mobile: Non-infinite rows 2025-07-23 13:51:31 +01:00
Arthur Jamet e9b24cc76f Front: Mobile: Song Grid 2025-07-23 13:51:31 +01:00
Arthur Jamet 0372932f9c Front: Mobile: Song Grid 2025-07-23 13:51:31 +01:00
Arthur Jamet d55c1f5ad9 Front: Mobile: release tile + add row to home page 2025-07-23 13:51:31 +01:00
Arthur Jamet 49dff2eb5f Front: Mobile: row: Attempt to add snapping 2025-07-23 13:51:30 +01:00
Arthur Jamet c3d7d8f67f Front: Mobile: Shared route setup 2025-07-23 13:51:30 +01:00
Arthur Jamet 8162ca6797 Front: Mobile: Lint 2025-07-23 13:51:30 +01:00
Arthur Jamet 6fa8ac8728 Front: Mobile: add blur view deps 2025-07-23 13:51:30 +01:00
Arthur Jamet 7e053505be Front: Mobile: todos 2025-07-23 13:51:30 +01:00
Arthur Jamet f0836b4235 Front: Mobile: Row: Fix loading state of header 2025-07-23 13:51:29 +01:00
Arthur Jamet e19962b325 Front: Mobile: Loadable Text: Remove fade 2025-07-23 13:51:29 +01:00
Arthur Jamet 0f9ab87252 Front: Mobile: tab bar add border radius 2025-07-23 13:51:29 +01:00
Arthur Jamet e20a7c0d85 Front: Mobile: Artist Page, w/o routing 2025-07-23 13:51:29 +01:00
Arthur Jamet 36b58bd40d Front: Mobile: avoid content behind tab bar 2025-07-23 13:51:29 +01:00
Arthur Jamet 1ae6932c5e Front: Mobile: Better handling of keyboard on login page 2025-07-23 13:51:28 +01:00
Arthur Jamet 37c845fe97 Front: Mobile: remove gradient background on logout 2025-07-23 13:51:28 +01:00
Arthur Jamet b4d84a5fa1 Front: Mobile: Avoid controls overflowing over infinite view content 2025-07-23 13:51:28 +01:00
Arthur Jamet 5254395231 Front: Mobile: Move Background component to the root of the app 2025-07-23 13:51:28 +01:00
Arthur Jamet 7b555dd5ef Front: Mobile: background gradient: smooth transition 2025-07-23 13:51:28 +01:00
Arthur Jamet d67fe9f712 Front: Mobile: Video tile/item: normalised 16/9 options 2025-07-23 13:51:27 +01:00
Arthur Jamet 9c79b6f269 Front: Mobile: background gradient: update atom only when illustration changes 2025-07-23 13:51:27 +01:00
Arthur Jamet f61b9890f6 Front: Mobile: Infinite Compilation view 2025-07-23 13:51:27 +01:00
Arthur Jamet b7a698d694 Front: Mobile: Infinite Video view 2025-07-23 13:51:27 +01:00
Arthur Jamet 9f5b1c1771 Front: Mobile: Infinite Controls: Filters 2025-07-23 13:51:27 +01:00
Arthur Jamet 84455dad97 Front: Mobile: fix top padding in flatlist 2025-07-23 13:51:26 +01:00
Arthur Jamet dd30ba764a Front: Mobile: Infinite Song View 2025-07-23 13:51:26 +01:00
Arthur Jamet 403fe6f2fc Front: Mobile: Basic Background gradient 2025-07-23 13:51:26 +01:00
Arthur Jamet 9aaf67a3d5 Front: Mobile: Infinite Controls: Sorting 2025-07-23 13:51:26 +01:00
Arthur Jamet b439c9d3fb Front: Mobile: Button: Remove unused props + allow propagating to parent 2025-07-23 13:51:25 +01:00
Arthur Jamet 9948fd9ddd Front: Mobile: Setup Infinite View Controls w/ layout 2025-07-23 13:51:25 +01:00
Arthur Jamet 328121cbd0 Front: Mobile: Infinite: Empty State 2025-07-23 13:51:25 +01:00
Arthur Jamet 2acb315a9a Front: Mobile: Move infnite list / view into single component 2025-07-23 13:51:25 +01:00
Arthur Jamet 597979224c Front: Mobile: Infinite Row: Avoid layout shift when data is loaded 2025-07-23 13:51:25 +01:00
Arthur Jamet 4cf452e85a Front: Mobile: InfiniteView 2025-07-23 13:51:24 +01:00
Arthur Jamet 3b75fc4390 Front: Web: Infinite: Controls for layout and sort 2025-07-23 13:51:24 +01:00
Arthur Jamet fb7fb0487a Front: Web: Infinite controls: Remove duplicate type definition 2025-07-23 13:51:24 +01:00
Arthur Jamet acb3a95659 Front: Fix tsc on plural resource types 2025-07-23 13:51:24 +01:00
Arthur Jamet 1d97374198 Front: Infinite View Controls: Move filters to library 2025-07-23 13:51:24 +01:00
Arthur Jamet 5a7d450535 Front: i18n: add back missing translations 2025-07-23 13:51:23 +01:00
Arthur Jamet e33cf292cc Front: Init infinite controls package with layout and sort 2025-07-23 13:51:23 +01:00
Arthur Jamet 3d3c83880d Front: Web: Set image quality for media session + notification 2025-07-23 13:51:23 +01:00
Arthur Jamet d2c28e859f Front: Mobile: Row: Style hover 2025-07-23 13:51:23 +01:00
Arthur Jamet c750111f4a Front: Mobile: Infinite List: make padding consisten w/ grid 2025-07-23 13:51:23 +01:00
Arthur Jamet fbad084f64 Front: Mobile: List Item: Pressable 2025-07-23 13:51:22 +01:00
Arthur Jamet cca102361d Front: Mobile: Tile: easier props for href 2025-07-23 13:51:22 +01:00
Arthur Jamet 1fdb23d389 Front: Mobile: Fix default value of variant for text skeleton 2025-07-23 13:51:22 +01:00
Arthur Jamet fc46804545 Front: Mobile: Home: add title 2025-07-23 13:51:22 +01:00
Arthur Jamet 5868f55a78 Front: Mobile: Text: Set h2 to bold 2025-07-23 13:51:22 +01:00
Arthur Jamet 79e16b499e Front: Mobile: Illustration: Disable animation when in list 2025-07-23 13:51:21 +01:00
Arthur Jamet b72b087e6b Front: Mobile: Header + back button for browse views 2025-07-23 13:51:21 +01:00
Arthur Jamet 4e3831d01b Front: Home Page + Infinite Rows 2025-07-23 13:51:21 +01:00
Arthur Jamet cbf008372a Front: Mobile: Tile: avoid using flex 2025-07-23 13:51:21 +01:00
Arthur Jamet eb08e8ea82 Front: Mobile: List Item + Infinite List 2025-07-23 13:51:21 +01:00
Arthur Jamet 552e0e8b8a Front: Mobile: Grid: Change column count based on viewport size 2025-07-23 13:51:20 +01:00
Arthur Jamet 5f47dc4a97 Front: Mobile: avoid having 'undefined' in tile urls 2025-07-23 13:51:20 +01:00
Arthur Jamet a3212b8a81 Front: Mobile: Use FastImage 2025-07-23 13:51:20 +01:00
Arthur Jamet cd62c4727f Front: Mobile: No blurhash on infinite grids 2025-07-23 13:51:20 +01:00
Arthur Jamet 7f49c46212 Front: Mobile: Image Quality 2025-07-23 13:51:20 +01:00
Arthur Jamet b29ae4a714 Front: Move illustration quality to models 2025-07-23 13:51:19 +01:00
Arthur Jamet 7d3d3e9c63 Front: Mobile: Infinite Grid: generate dummy array to index items 2025-07-23 13:51:19 +01:00
Arthur Jamet d42cd733ee Front: Mobile: Move animation values to theme 2025-07-23 13:51:19 +01:00
Arthur Jamet 9ab7765aff Front: Mobile: Browsing views for artists and albums + artist tiles + round illustration 2025-07-23 13:51:19 +01:00
Arthur Jamet 97c9a22255 Front: Mobile: Inifnite Scroll + album tiles 2025-07-23 13:51:19 +01:00
Arthur Jamet ce38413017 tmp 2025-07-23 13:51:18 +01:00
Arthur Jamet 0f5f03f02f Front: Mobile: root view bg color + remove header bar + status bar style 2025-07-23 13:51:18 +01:00
Arthur Jamet a360961a54 Front: Mobile: Fix color of nav bar in dark mode 2025-07-23 13:51:18 +01:00
Arthur Jamet 053136d45d Front: API: Make access token public to allow illutration component to make requsts with it 2025-07-23 13:51:18 +01:00
Arthur Jamet 55c667226e Front: Mobile: Illustration component 2025-07-23 13:51:18 +01:00
Arthur Jamet ad4e27fd90 Front: Mobile: Loadable Text component 2025-07-23 13:51:17 +01:00
Arthur Jamet b0d4aeeabe Front: Mobile: Text Skeleton: Pulse animation 2025-07-23 13:51:17 +01:00
Arthur Jamet ef44875eb1 Front: Mobile: Text Skeleton 2025-07-23 13:51:17 +01:00
Arthur Jamet e7cf70eb5b Biome: Ignore android dir 2025-07-23 13:51:17 +01:00
Arthur Jamet b651b5cb19 Front: Mobile: Custom toast component 2025-07-23 13:51:17 +01:00
Arthur Jamet 7c2e077224 Front: Mobile: Auth: Signup + Toast 2025-07-23 13:51:16 +01:00
Arthur Jamet af922a7024 Front: Mobile: Text Input: set 'is empty' state according to inital value 2025-07-23 13:51:16 +01:00
Arthur Jamet 5191fb566f Front: Mobile: Use local storage to persist access token 2025-07-23 13:51:16 +01:00
Arthur Jamet 311996b721 Front: Mobile: User auth state 2025-07-23 13:51:16 +01:00
Arthur Jamet 8bfcc415a1 Front: Move user-state back into web app 2025-07-23 13:51:16 +01:00
Arthur Jamet 9bec858d67 Front: Move user-state back into web app 2025-07-23 13:51:15 +01:00
Arthur Jamet 207f11a6e6 Front: Move web-specific API hook out of API package 2025-07-23 13:51:15 +01:00
Arthur Jamet b10b60597e Front: Mobile: Controller for authentication form 2025-07-23 13:51:15 +01:00
Arthur Jamet d26c0463d5 Front: Mobile: Auth: Instance URL + safe area hook 2025-07-23 13:51:15 +01:00
Arthur Jamet eeb51a18c5 Front: Mobile: Bottom nav bar 2025-07-23 13:51:15 +01:00
Arthur Jamet b3b8010104 Front: Mobile: Auth Screen Layout 2025-07-23 13:51:14 +01:00
Arthur Jamet 3ca8c1c90f Front: Translate Login/Signup button 2025-07-23 13:51:14 +01:00
Arthur Jamet 456e159df3 Front: Setup basic protected/auth layout 2025-07-23 13:51:14 +01:00
Arthur Jamet f62d81d931 Front: Mobile: Divider 2025-07-23 13:51:14 +01:00
Arthur Jamet 833b6149c2 Front: Mobile: TextInput: Move the raised placeholder up 2025-07-23 13:51:14 +01:00
Arthur Jamet 8941f476f0 Front: Mobile: TextInput: Set width 2025-07-23 13:51:13 +01:00
Arthur Jamet 7360cb3f04 Front: Mobile: ColorScheme hook: on update, also use unistyle rt 2025-07-23 13:51:13 +01:00
Arthur Jamet 5b5f0da20d Front: Mobile: Add banner assets + component 2025-07-23 13:51:13 +01:00
Arthur Jamet a30f56e8c7 Front: Mobile: Change button scale on press 2025-07-23 13:51:13 +01:00
Arthur Jamet 3eb85143fb Front: Mobile: TextInput Primitive 2025-07-23 13:51:13 +01:00
Arthur Jamet 8d2e77b2ff Front: Mobile: Button: add dampness/stiffness to animation 2025-07-23 13:51:12 +01:00
Arthur Jamet a0159d939d Front: Dev Docker: Fix loading of public assets 2025-07-23 13:51:12 +01:00
Arthur Jamet 28e02ddb0f Front: Dockerfile: Do not copy mobile app's files 2025-07-23 13:51:12 +01:00
Arthur Jamet cae9088438 Front: Mobile: Button: Add leading icon 2025-07-23 13:51:12 +01:00
Arthur Jamet f82b8fbabb Front: Mobile: Fix paths of packages 2025-07-23 13:51:12 +01:00
Arthur Jamet 6ef8988a69 Front: Cast icons type to make them typecheck w/ react native 2025-07-23 13:51:11 +01:00
Arthur Jamet 78005b7b5a Front: Mobile Setup Reanimated + use it to animate button 2025-07-23 13:51:11 +01:00
Arthur Jamet 84d8126b78 Front: Mobile: Theme: Get opposite theme 2025-07-23 13:51:11 +01:00
Arthur Jamet 93d8bfe1f4 Front: Mobile: constant for border radius 2025-07-23 13:51:11 +01:00
Arthur Jamet 900f836ed1 Front: Mobile: Button: width variant 2025-07-23 13:51:11 +01:00
Arthur Jamet a2ea4afd6c Front: Mobile: Button: Simple outline/filled variant 2025-07-23 13:51:10 +01:00
Arthur Jamet f0e969aef9 Front: Mobile: setup Dark-mode colors 2025-07-23 13:51:10 +01:00
Arthur Jamet a70415cbd0 Front: Mobile: Bump nitro-modules 2025-07-23 13:51:10 +01:00
Arthur Jamet f3d976c255 Front: Mobile: Setup unistyles 2025-07-23 13:51:10 +01:00
Arthur Jamet dacb17db75 Front: Mobile: Bump deps 2025-07-23 13:51:09 +01:00
Arthur Jamet 83c5ab7987 Front: Mobile: src directory 2025-07-23 13:51:09 +01:00
Arthur Jamet 7b9bca4bb2 Front: Mobile: Init 2025-07-23 13:51:09 +01:00
Arthur Jamet 52a81b32a0 Front: remove reference to old components package 2025-07-23 13:51:09 +01:00
Arthur Jamet 73bb5bf440 Front: Biome 2025-07-23 13:51:09 +01:00
Arthur Jamet 7cd87e89dc Front: Monorepo: Move def of translation key in web app 2025-07-23 13:51:08 +01:00
Arthur Jamet 40fa954293 Front: Monorepo: Move some utils back to web app 2025-07-23 13:51:08 +01:00
Arthur Jamet eae3419158 Front: Monorepo: Move i18n type file into translation folder 2025-07-23 13:51:08 +01:00
Arthur Jamet e5a8382478 Front: Monorepo: Move components back to web app + ui package with icons 2025-07-23 13:51:08 +01:00
Arthur Jamet 2698ff50cd Server: Use prefix for transcoder 2025-07-23 13:50:33 +01:00
483449a6d2 Translations update from Hosted Weblate (#1071)
* Translated using Weblate (Italian)

Currently translated at 64.7% (184 of 284 strings)

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

* Translated using Weblate (Italian)

Currently translated at 64.7% (184 of 284 strings)

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

* Translated using Weblate (Italian)

Currently translated at 64.7% (184 of 284 strings)

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

* Translated using Weblate (Italian)

Currently translated at 64.7% (184 of 284 strings)

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

* Translation: Fix plurals in italien

---------

Co-authored-by: Soy Bau <soybau03@gmail.com>
Co-authored-by: Arthur Jamet <arthur.jamet@gmail.com>
2025-07-15 11:23:41 +01:00
Arthur Jamet f64e81eff9 Scanner: Do not use 'fatal' log when lyric post fails 2025-07-10 21:19:11 +02:00
Arthur Jamet e42731c0e7 Front: Setup German Translation (#1065) 2025-07-03 14:30:27 +01:00
eead3856c3 Translations update from Hosted Weblate (#1063)
* Translated using Weblate (French)

Currently translated at 99.2% (281 of 283 strings)

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

* Added translation using Weblate (German)

* Translated using Weblate (German)

Currently translated at 9.8% (28 of 283 strings)

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

---------

Co-authored-by: Arthur Jamet <arthur.jamet@gmail.com>
Co-authored-by: jackctra <georgbaur@gmx.de>
2025-07-03 14:22:15 +01:00
097021bf99 Translations update from Hosted Weblate (#1061)
* Translated using Weblate (French)

Currently translated at 99.2% (281 of 283 strings)

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

* Translated using Weblate (French)

Currently translated at 99.2% (281 of 283 strings)

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

* Translated using Weblate (French)

Currently translated at 99.2% (281 of 283 strings)

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

* Fix plural

---------

Co-authored-by: Lumiraptor <hmtz@gmx.fr>
Co-authored-by: Arthur Jamet <arthur.jamet@gmail.com>
2025-06-30 10:14:42 +01:00
Arthur Jamet 68206e7ee0 Front: Make paddings around synced/plain lyrics more consistent 2025-06-27 06:46:14 +01:00
Arthur Jamet 2487fa3881 Front: Settings: Language Menu: Each item is translated into their target language 2025-06-26 13:24:12 +01:00
Arthur Jamet ccbb9a6a96 Front: Fix russian being present twice in the language list 2025-06-26 13:24:12 +01:00
Ramazan 9b288e911b Front: Fix ProgressBar (#1057)
* Fix ProgressBar

* Applied linting
2025-06-26 13:02:57 +01:00
f6b14bee63 Translations update from Hosted Weblate (#1050)
* Translated using Weblate (French)

Currently translated at 98.2% (280 of 285 strings)

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

* Translated using Weblate (French)

Currently translated at 99.3% (284 of 286 strings)

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

* Translated using Weblate (English)

Currently translated at 100.0% (286 of 286 strings)

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

* Translated using Weblate (French)

Currently translated at 99.6% (285 of 286 strings)

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

* Translated using Weblate (French)

Currently translated at 99.6% (285 of 286 strings)

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

* Translated using Weblate (Russian)

Currently translated at 98.6% (282 of 286 strings)

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

* Translated using Weblate (French)

Currently translated at 99.3% (284 of 286 strings)

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

* Fix plural

---------

Co-authored-by: Lumiraptor <hmtz@gmx.fr>
Co-authored-by: Arthur Jamet <arthur.jamet@gmail.com>
2025-06-25 10:59:40 +01:00
Arthur Jamet ee6f8e763e Front: Resource types: fix translation keys for singular 2025-06-25 08:48:01 +01:00
Arthur Jamet ce5f077722 Filter for 'primary' artists (#1049)
* API: Replace 'albumArtistOnly' filter with 'primaryArtistsOnly'

* Front: Use primaryArtistsOnly filter
2025-06-21 08:38:52 +00:00
Arthur Jamet 5f2302366b EP album type (#1048)
* Server: Define EP album type

* Matcher: Consider EP album type with MusicBrainz

* Front: EP album type + Function to translate resource types

* Server: Parser service: Fix tests
2025-06-21 09:32:03 +01:00
Arthur Jamet cc29b2d613 Scanner: Ignore hidden files 2025-06-20 14:56:09 +01:00
Arthur Jamet be85753ed0 Scanner: Consider '.ignore' files a-la jellyfin 2025-06-20 14:56:09 +01:00
Arthur Jamet fb929ca0d9 Server: Label Service: Fix log on housekeeping 2025-06-20 14:56:09 +01:00
Arthur Jamet e8d6309c52 Env: Add 'enable user registration' field 2025-06-18 13:09:49 +01:00
Arthur Jamet 3dee0699bd Disable user registration (#1043)
* Server: Settings: Add 'Enable user registration' field

* Server: User Controller: Disable registration endpoint according to settings

* Front: Disable signup button when registration is disabled

* Server: Test disabled user registration + fix permission tests
2025-06-17 14:25:32 +01:00
Arthur Jamet ca5868f5e3 Server: Add natural sort when sorting by name (#1037) 2025-06-13 10:04:52 +01:00
Arthur Jamet 04ef469dbe Front: Setup italien and indonesian translations 2025-06-12 09:28:53 +01:00
aea5e1e161 Translations update from Hosted Weblate (#1022)
* Added translation using Weblate (Indonesian)

* Translated using Weblate (Indonesian)

Currently translated at 13.7% (39 of 283 strings)

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

* Added translation using Weblate (Italian)

* Translated using Weblate (Italian)

Currently translated at 37.4% (106 of 283 strings)

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

---------

Co-authored-by: Arthur Jamet <arthur.jamet@gmail.com>
Co-authored-by: Soy Bau <soybau03@gmail.com>
2025-06-12 09:20:21 +01:00
Arthur Jamet b5a019ae75 Front: Bump Nestjs-express 2025-06-06 18:28:05 +01:00
Arthur Jamet 2b58f53af7 Front: Player Slider: Add smooth transition 2025-06-06 12:53:54 +01:00
Arthur Jamet 5a3548cb6c Front: Minimised Player: Progress bar 2025-06-06 12:53:54 +01:00
Arthur Jamet dd3845026c Front: Minimised Player: Rewind button on larger viewports 2025-06-06 12:53:54 +01:00
Arthur Jamet 7bb77e9538 README: fix link of logo 2025-05-29 06:52:13 +01:00
Arthur Jamet 33acdfe0d5 Server: Bump Express for Nestjs 2025-05-28 12:03:46 +01:00
Arthur Jamet 2976c05833 Front: Use index file from translations to load JSONs (#1026) 2025-05-25 17:22:53 +01:00
Arthur Jamet 7ea240c1f8 Front: Bump React Query from v3 to v5 (#1025) 2025-05-25 17:00:05 +01:00
Arthur Jamet a5082243e6 Front: Setup monorepo (#1009)
* Front: Setup workspace for web app

* Front: Move models to their own package

* Front: API: Move logic of 'isDev', 'isSSR' to hook

* Front: Move sorting parameters to model package

* Front: Move union for yup to models package

* Front: Define base tsconfigs

* Front: Model package: add typescript as dev dep to be able to run tsc

* Front: Make test (finally) typecheck

* Front: Add tsc command to root package.json

* Front: Change name + import path of libs
Removing '@meelo' prefix

* Front: Move api, state and utils to packages

* Front: Monorepo: Move translations out of web project

* Front: Update Dockerfile to work with monorepo

* Front: Update Dev Docker for monorepo

* CI: Front: Use monorepo path + remove test job

* CI: Front: Trigger when packages and translation changed

* Front: Init 'components' package + move ctxt menu + tiles there

* Front: Move infinite scroll + list items to component package

* Front: format

* Front: remove dependency to iconsax in web add

* Front: Docker: Copy component package

* Front: API: Fix url of illustrationsin SSR
2025-05-25 14:26:17 +01:00
Arthur Jamet 00fc457cca Server: Illustration test: Fix external url 2025-05-23 14:59:46 +01:00
Arthur Jamet 18b98012c0 Server: Bump Express for Nestjs 2025-05-23 14:59:46 +01:00
RamazanandArthur Jamet ecc51ac6f0 Added russian lang support 🇷🇺 (#1021)
* Added russian lang support

* Added russian lang support

* Fixed indents

* Fixed indent on 98 line

* Added line ending

* Changed spaces to tabs

* Russian Translation: Fix indentation

---------

Co-authored-by: Arthur Jamet <arthur.jamet@gmail.com>
2025-05-21 20:05:11 +01:00
Arthur Jamet a9bc1b8151 Front: Fix double 'marking song as played' in crossfade handler 2025-05-21 18:56:40 +01:00
Arthur Jamet e3b030d6f2 Scrobbler Support (#1015)
* Front: Mark a song as played according to last fm's scrobbling rules

See 'https://www.last.fm/api/scrobbling#when-is-a-scrobble-a-scrobble'

* Prisma: Add Scrobbler Table

* Server: Add Scheduler/Cron job module

* Server: Scrobbler Service + Controller + Implem LastFM

* Front: Queries + Model for Scrobbler Statuses

* Front: Callback handler for LastFM Auth

* Front: Settings: Add Section for Scrobblers

* Doc: Add LastFM Fields in .env

* Server: Scrobbler Controller: Only authed requests can get lastfm auth url

* Server: Scrobbler: Error handling when pushing scrobbles fail

* Front: Scrobbler Settings: Make things a bit more generic

* Server: Scrobbler: Implement ListenBrainz

* Server: Scrobbler: Implement ListenBrainz

* Front: Settings: Add form to connect ListenBrainz

* README: Mention Scrobbling feature

* Server: Scrobbler: Unlink

* Front: Settings: Unlink Scrobbler account

* Server: README: List Lastfm env vars

* Server: ListenBrainz: remove ununsed model

* Server: Scrobbler Service: Avoid crash when deleting scrobbler that is not connected

* Server: Scrobbler: Test CRUD
2025-05-21 12:09:12 +01:00
Arthur Jamet 52e6b6b57d Server: Lint 2025-05-21 11:05:40 +01:00
Arthur Jamet c65b202111 Server: Search: Fix returning every items in the table if no results 2025-05-21 11:05:40 +01:00
Arthur Jamet cc2d69f1f0 Server: Illustration controller: change url in tests
URL didn't seem to work anymore
2025-05-21 11:05:40 +01:00
Arthur Jamet a57aa8c3c0 Front: Avoid adding redundant page size query parameters to api requests 2025-05-19 14:39:19 +01:00
Arthur Jamet 771e8867c6 CI: Dockerisation: Fix publish of ARM images (#1014) 2025-05-17 14:47:59 +01:00
Arthur Jamet 528cd64269 Server: Fix queries to select items from search history 2025-05-17 13:03:51 +01:00
Arthur Jamet ac766d4070 Front: Fix infinite queries' key, to fix invalidation 2025-05-16 19:52:07 +01:00
Arthur Jamet 009b34997a CI: Dockerisation: Split ARM and AMD into 2 steps 2025-05-16 19:14:02 +01:00
Arthur Jamet 3a636e2c3c Server: Fix bad null check in Illustration Controller 2025-05-16 18:17:47 +01:00
Arthur Jamet 0af23c0a45 Server: Fix sorting songs by user play count 2025-05-14 12:05:22 +01:00
Weblate (bot)andArthur Jamet f7acf4b4d4 Integrate Weblate (#1004)
* Translated using Weblate (English)

Currently translated at 100.0% (268 of 268 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (268 of 268 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (268 of 268 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (268 of 268 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (268 of 268 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (268 of 268 strings)

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

* README: Add link to weblate

* Front: Lint: Ignore JSON from weblate

---------

Co-authored-by: Arthur Jamet <arthur.jamet@gmail.com>
2025-05-10 18:56:50 +01:00
Arthur Jamet c036c8f123 Front: Refactor translation files + update translation keys (#1003)
* Front: Translation: Structure file for english

* Front: i18next: only use english json structure for typing

* Front: Use new translation keys

* Front: i18next: update french translation file structure
2025-05-10 17:44:44 +01:00
Arthur Jamet 983cc3a629 Front: Release page: add 'mixed' in header if all tracks are mixed 2025-05-05 18:53:16 +01:00
Arthur Jamet 8810b04073 Front: Track Item: Add 'mixed' when track is mixed 2025-05-05 18:53:16 +01:00
Arthur Jamet 0017e7fcb4 Server: Add 'mixed' field in track model + parse 2025-05-05 18:53:16 +01:00
Arthur Jamet 2e83324a58 Matcher: Genius: Fix selector on Lyrics and song description (#1001) 2025-05-05 15:07:17 +01:00
Arthur Jamet d368f8a332 Front: Release page: fix alignment of album artist name 2025-05-05 14:49:25 +01:00
Arthur Jamet 6f1f9cc084 Server: Avoid using deepemerge for Prisma queries (#999)
* Server: Prisma queries w/o deepmerge

* Server: Prevent empty deletion list to select all elements

* Server: Remove dependency on deepmerge
2025-05-05 14:43:40 +01:00
Arthur Jamet 72dd8e302c Front: Sort albums by release date when coming from the artist page 2025-05-05 11:50:20 +01:00
Arthur Jamet 6ef9808ca6 Front: Home Page: Add top labels 2025-05-04 15:24:07 +01:00
Arthur Jamet 13635e57b4 API: Allow sorting labels by release count 2025-05-04 15:24:07 +01:00
Arthur Jamet f86025a0d8 Front: Label page 2025-05-04 15:24:07 +01:00
Arthur Jamet 0d29db2df1 Front: Release Page: Add label of the release 2025-05-04 15:24:07 +01:00
Arthur Jamet 7d93263c0c API: Fix including label with releases 2025-05-04 15:24:07 +01:00
Arthur Jamet b525f240a5 Front: Define model and queries for labels 2025-05-04 15:24:07 +01:00
Arthur Jamet 89cd012a7c Scanner: Get and push label from embedded metadata and path 2025-05-04 15:24:07 +01:00
Arthur Jamet 7e92c2b43b API: Get one/many label + filter by artist and album 2025-05-04 15:24:07 +01:00
Arthur Jamet 49664306d3 API: Get artists, albums and releases by label 2025-05-04 15:24:07 +01:00
Arthur Jamet a9764ff197 Server: Label Model, base service and add it to registration dto 2025-05-04 15:24:07 +01:00
Arthur Jamet d185e3c21d Server: Fix Track Service Test 2025-05-03 18:57:59 +01:00
Arthur Jamet 07ec640757 Front: Action to reassign track 2025-05-03 18:57:59 +01:00
Arthur Jamet 22d61732cf Front: Action to merge songs 2025-05-03 18:57:59 +01:00
Arthur Jamet e3d3dd3619 Front: Refresh Metadata Action: Fix broken checkbox 2025-05-03 18:57:59 +01:00
Arthur Jamet 6a080c69a5 API: Allow merging songs 2025-05-03 18:57:59 +01:00
Arthur Jamet c84c6948f3 API: Allow reassigning a track to a song 2025-05-03 18:57:59 +01:00
Arthur Jamet 92df81dd89 Front: Expanded Player: Set dummy ctxt menu as disabled 2025-05-01 16:29:42 +01:00
Arthur Jamet 5f286b1eb0 Front: Player: Remove thumb from slider + avoid shift caused by progress text 2025-05-01 16:29:42 +01:00
Arthur Jamet 10d419dc29 Front: Relation Page Header: Change spacing in row + set subtitle to h5 2025-05-01 16:29:42 +01:00
Arthur Jamet 4e2c097d22 Front: Relation Page Header: Limit secondary title to 3 lines 2025-05-01 16:29:42 +01:00
Arthur Jamet 374688e135 Front: List Item: No hover styling when hovering trailing component 2025-05-01 16:29:42 +01:00
Arthur Jamet ede743b97f Front: Synced Lyrics: Avoid shifts and line breaks caused with font size/Weight change 2025-04-30 18:56:09 +01:00
Arthur Jamet 986a24545d Front: Fix Pagination for 'play' and 'shuffle' actions in infinite song lists 2025-04-29 12:10:33 +01:00
Arthur Jamet 31f5699f17 Front: Fix infinite setstate in search page 2025-04-28 20:48:00 +01:00
Arthur Jamet f6233bdd5c Front: Player queue: fix overflow on long titles 2025-04-28 20:48:00 +01:00
Arthur Jamet 8dd2384801 Front: Release Tracklist: Move 'isVideo' icon, replacing track index 2025-04-28 20:48:00 +01:00
Arthur Jamet a369a2316e Front: Release Tracklist: Fix missing ellipsis on overflow 2025-04-28 20:48:00 +01:00
Arthur Jamet e80fb98ba1 Front: Fix CSR URLs to API (#988) 2025-04-27 20:25:31 +01:00
Arthur Jamet 601aa49789 Server: Fix Playlist migration (#987) 2025-04-27 20:02:05 +01:00
Arthur Jamet b730326130 Server: Fix Playlist migration (#986) 2025-04-27 19:45:49 +01:00
Arthur Jamet 3c58225483 Playlist: Add visibility settings (#985)
* Prisma: add visibility field to playlist table

* Server: Test: add user to dummy repository

* Server: Fix permission of route that accept both anonymous requests and authed ones

* Server: add 'isPublic' and 'allowChanges' to playlist

* Prisma: Make Migration for playlist visibility non breaking

* Front: Playlist Form: add visibility fields

* Front: Playlist Menu: Filter out update action if not owner

* API: allow filter playlist by 'allowChanges'

* Front: Filter out playlist that cannot be edited in 'select playlist' form

* Front: Playlist: Disable edit + delete button depending on user permissions

* Front: Fix uploading illustration, because 'this' wasn't bound

* Server: Test 'changeable' selector in playlist controller

* Front: Remove debug msg

* Server: Playlist Service: Make query make more sense
2025-04-27 17:30:50 +01:00
Arthur Jamet ee0fb392a9 Front: Restructure API Wrapper + 'useQuery' stuff (#984)
* Front: Refactor API + Query

* Front: Use New API + Query hooks

* Front: Fix 'transform' in player state + simplify pagination handling in API

* Front: Prevent undefined error in player after logout

* Front: Fix Pagination in API

* Front: Remove comment

* Front: Fix wrong toas tmessage on user un-admin

* Front: Setting Grids: fix color of header
2025-04-25 10:12:59 +01:00
Arthur Jamet d5f2bdb527 Front: Player has loading state (#981)
* Front: Player State: add loading field

* Front: Minimised Player: Skeleton when queue is loading
2025-04-20 10:20:27 +01:00
Arthur Jamet 230f84245d Front: Release Tracklist: Better colors for 'playing' + video icons 2025-04-19 15:01:24 +01:00
Arthur Jamet bb9925c616 Front: fix null coertion in player when playing audio track after video 2025-04-19 15:01:24 +01:00
Arthur Jamet aea005baad New Icon + Banner 2025-04-19 14:53:19 +01:00
Marie bc301f685a Docker: remove unused profile definitions 2025-04-18 16:09:40 +01:00
Arthur Jamet b96a744336 Server: Bump nest-related deps 2025-04-17 15:10:24 +01:00
Arthur Jamet 2ce5ad7d56 Scanner: Bump net dep 2025-04-17 15:01:26 +01:00
Arthur Jamet f662a7aceb Front: Clicking on a song in an infinite view adds the rest of the song to the queue 2025-04-17 14:49:26 +01:00
Arthur Jamet dfff5a43c4 Front: Fade Between Tracks (#971)
* tmp

* Front: Easy Gapless playback

* Front: Gapless Playback: Only between audio tracks

* Front: Settings: Crossfade
2025-04-17 14:26:27 +01:00
Arthur Jamet 9b4d1de727 Front: Release Page: Disc index/names are clickable 2025-04-14 14:20:14 +01:00
Arthur Jamet 70c959bdeb Front: Clicking on a Song Grid item also adds the other songs in the queue 2025-04-13 12:48:03 +01:00
Arthur Jamet 7bd8010137 Front: Bump to MUI v7 (#967)
* Front: Bump MUI to v6

* Front: Bump MUI DataGrid to v6

* Front: MUI: Use Grid2 for library form + list items

* Front: MUI: Use Grid2 for artist page + page sections component

* Front: MUI: Use Grid2 for playlist page

* Front: MUI: Use Grid2 for UI settings

* Front: MUI: Use Grid2 for Release Page

* Front: MUI: Use Grid2 for Highlight Cards

* Front: MUI: Use Grid2 for empty state

* Front: MUI: Use Grid2 for Library Settings

* Front: MUI: Use Grid2 for Genre tiles

* Front: MUI: Use Grid2 for Player Slider

* Front: MUI: Use Grid2 for Track items

* Front: MUI: Use Grid2 for Relation Page Header

* Front: MUI: Use Grid2 for Actions + Infinite view controls

* Front: MUI: Use Grid2 for Player's minimized controls

* Front: MUI: Use Grid2 for Keyboard Biding Modal

* Front: MUI: Use Grid2 for Player's expanded controls

* Front: MUI: Use Grid2 for Auth form

* Front: Lint

* Front: MUI 7: Use new Grid import

* Front: MUI 7: Re-setup Style in SSR

* Front: Lint

* Front: Bump MUI DataGrid to v7

* Front: Fix MUI Migration
2025-04-06 16:19:18 +00:00
Arthur Jamet cd78bd408a Front: Fix Link in contextual menus 2025-04-01 13:21:47 +01:00
Arthur Jamet 1de13e7354 Front: Genres Tile, Infinite List and radio 2025-03-30 10:54:00 +01:00
Arthur Jamet eda1529a6a Front: Player Controls: add button to force load next queue page 2025-03-29 20:39:08 +00:00
Arthur Jamet ed680064d7 Front: Fix Key Warning on Action Button Group 2025-03-29 20:39:08 +00:00
Arthur Jamet 4d5bf293f5 Front: Player State: Add infinite query to lazyload queue 2025-03-29 20:39:08 +00:00
Arthur Jamet 3f633803f4 Front: Restructure Internal File System (#963)
* Front: Front: Rename Auth Files

* Front: Front: Rename Contextual Menu Files

* Front: Front: Rename Highlight Card Files

* Front: Rename Infinite Scroll Files

* Front: Rename List Item Files

* Front: Rename Contextual Menu Files

* Front: Rename Player Files

* Front: Rename Relation Page Header Files

* Front: Rename Scaffold Files

* Front: Rename Action Files

* Front: Rename Settings Files

* Front: Rename Tile Files

* Front: Rename Tile Files

* Front: Avoid relative imports

* Front: Lint

* Front: Avoid relative imports

* Front: Add tilde to differentiate local imports from dependencies

* Front: Rename API File

* Front: rollback tests
2025-03-29 11:24:46 +00:00
Arthur Jamet a3095b4dfa Front: Rewrite Infinite Views' Controls
* Front: Start new Controls + Actio  Group

* Front: New Controls: Layout Button

* Front: New Controls: Split into subcomponents

* Front: New Controls: Layout Hook

* Front: New Controls: Sort Hook

* Front: New Controls: Filter Hook

* Front: TMP

* Front: New Controls: Fixes for Sorting

* Front: New Controls: Specialize filter for single/multiple choices

* Front: New Controls: Fix typing for song groups

* Front: New Controls: Use in Album View

* Front: New Controls: Use in Video View

* Front: New Controls: Use in Track View

* Front: New Controls: Use in Playlist View

* Front: New Controls: Fix opeing actions' dialog

* Front: New Controls: Use in Artist View

* Front: Remove Old controls

* Front: New Controls: Rename files, shorten paths

* Front: New Controls: Fix initial state of layout when SSR

* Front: New Controls: Filters and Sort have dedicated button groups

* Front: New Controls: Fix 'selected' state when filter has multiple choices

* Front: New Controls: Remove 'defaultSortingKey' from Sort Control
2025-03-28 10:29:19 +00:00
Arthur Jamet 385ca37848 Front: Bump Nextjs 2025-03-24 17:47:16 +00:00
Arthur Jamet ec02d780ea Scanner: LRC-formatted Lyrics (#960)
* Scanner: Defined plain/synced lyrics type

* Scanner: Parse LRC

* Scanner: Look for LRC files

* Scanner: Look for LRC files

* README: Mention LRC support
2025-03-17 11:34:21 +00:00
Arthur Jamet ecdb82782f Front: Remove Dependency to spinner library (#959) 2025-03-16 10:32:45 +00:00
Matthieu LAURENT ace1d78505 Capture "/" key event to prevent quick find from opening (#957) 2025-03-16 10:07:56 +00:00
Matthieu LAURENT 87dc8a3f66 Only skip using the embedded thumbnail as illustrations for videos (#958) 2025-03-16 10:00:44 +00:00
Arthur Jamet e8b6db6ed5 README: Add Synced Lyrics GIF 2025-03-15 10:06:00 +00:00
Matthieu LAURENT 7aa484b1e4 Use the embeded video illustration as thumbnail if one exists (#949)
* Use the embeded video illustration as thumbnail if one exists.
This falls back to the existing implementation of using a frame from the video if no embeded illustration is present.

* Add aspect ratio check

* Revert "Add aspect ratio check"

This reverts commit 73a0c737156feda2c9a1a05e6736cb8d07d4c5d4.

* Add "useEmbeddedThumbnails" option

* Format code
2025-03-15 09:55:19 +00:00
Arthur Jamet 988a2e38f0 Support Disc Names (#954)
* Scanner: Get Disc Name

* Prisma: Define Disc View

* Server: Persist Disc Name

* API: Get Disc from release

* Front: Release Page: Show Disc Names
2025-03-14 15:16:48 +00:00
Arthur Jamet af359358d5 Front: Fix padding around song-type icons (#953) 2025-03-13 16:38:05 +00:00
Matthieu LAURENT fd3e5c9e5f Add an icon with the song type (#948)
* Add song type icons next to the name

* Use translated name for song type tooltip

* Move icons to song info page and track info modal

* Change icons for cut and edit song types

* Remove leftover code edits

* Change demo song type icon

* Improve songType french translation

* Remove unnecessary casts

* Make the track info song type icon fit the font size
This also makes it smaller overall

* Remove song type icon tooltip

* Handle the case where a track has no song during song type display

* Show a skeleton for song type while the data is loading
This also makes the icon a bit smaller

* Fix type check, lint & format
2025-03-13 16:26:06 +00:00
Arthur Jamet bae28fb94a Scanner: Bump Version of Go + Bump dep 2025-03-13 14:37:36 +00:00
Arthur Jamet 8964086a61 Server: Bump Axios 2025-03-13 13:05:21 +00:00
Arthur Jamet e0c2711fac Synced Lyrics (#944) 2025-03-10 19:04:54 +00:00
Arthur Jamet 27146f2bb0 Scanner: Get embedded tags from streams 2025-03-09 20:07:17 +00:00
Arthur Jamet e7e89a4baf Allow library path to be ‘./‘ (#937) 2025-03-06 07:26:37 +00:00
Arthur Jamet 315807aca2 Add BPM (#936)
* Scanner: Parse BPM

* Scanner: Add BPM to Registration DTO

* Server: Persist BPM

* API: Sort Songs by BPM

* Front: Show BPM in Track Info Modal

* Front: Make Info Icon look like an info icon

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

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

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

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

* CI: Fix Cache hit for Front

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

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

* Front: Lint

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

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

* Front: Define Default Item Size

* Front: Lint

* Front: Grid Resize: Avoid rerender

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

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

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

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

* Scanner: set progress for refresh task

* Scanner: set progress for cleaning task
2025-02-03 13:29:12 +00:00
Arthur Jamet a3fa306ac1 Docs: Remove mkdoc, use GitHub Wiki 2025-02-02 10:14:46 +00:00
Arthur Jamet a7162352ec Front: Make modal open on library metadata refresh 2025-02-01 19:22:13 +00:00
Arthur Jamet 51de0d1a03 Scanner: Use ID3 Compilation tag 2025-02-01 19:18:26 +00:00
Arthur Jamet 43f5d9f973 Scanner: POST embedded lyrics 2025-02-01 13:27:21 +00:00
Arthur Jamet a1e1060ae3 Front: Format 2025-02-01 10:55:09 +00:00
Arthur Jamet 3063fad0d2 Front: Add translation 2025-02-01 10:55:09 +00:00
Arthur Jamet 4ad434a71c Front: Refresh Metadata Action: add checkbox to force refresh 2025-02-01 10:55:09 +00:00
Arthur Jamet 8be291d9d2 Front: Bump type-fest 2025-02-01 10:55:09 +00:00
Arthur Jamet 9b07d2ecbc Scanner: Add force parameter to force metadata refresh 2025-02-01 10:55:09 +00:00
Arthur Jamet 6264d094c9 CI: Front + Server: Bump version of up/download artifact steps 2025-01-31 19:17:38 +00:00
Arthur Jamet 280cf19742 Scanner: Fix log message order + change format (#855)
* Scanner: Remove calls to glg outside of tasks

* Scanner: Replace glg by zerolog

* Scanner: Routes: Factorise log
2025-01-31 18:34:51 +00:00
Arthur Jamet 4625a738ef Scanner: More flexible way of handling multiple tags for a single metadata field 2025-01-30 20:35:58 +00:00
Arthur Jamet 698913bbaf Scanner: Fix to support uppercase keys in ffprobe output 2025-01-30 19:23:06 +00:00
Arthur Jamet d072fbf7af CI: Scanner: Bump Version of artifact-related steps 2025-01-30 19:23:06 +00:00
Arthur Jamet 3517e2650b README: Add Video 2025-01-30 08:48:08 +00:00
Arthur Jamet b118fd20a6 Front: Setup biome (#846)
* Front: Setup Biome, remove elsint + prettier

* Front: Fromat

* Front: Use 'type' import

* Front: Lint

* CI: Front: Update for Biome

* Front: Order imports

* Front: Remove eslint comments
2025-01-28 15:44:24 +00:00
Arthur Jamet 0a42fd6460 Server: Setup biome (#845)
* Server: Biome config files

* Server: Formar Using Biome

* Server: Lint using Biome + remove eslint

* Server: Sort Imports

* CI: Server: Use Biome

* Server: Update Yarn.lock

* Server: Fix 'type'-only imports
2025-01-26 14:04:35 +00:00
Arthur Jamet f5b80e1873 Docker: Silent Meilisearch Logs from healthcheck 2025-01-18 18:19:54 +00:00
Arthur Jamet 016c33143d Server: Remove dependency to compute illustration colors (#842)
* Server: Test Getting Image Colors

* Server: Remove dependency to compute image colors
2025-01-18 18:00:16 +00:00
Arthur Jamet cdb29d8cab Server: Bump to Nestjs 11 2025-01-18 15:20:31 +00:00
Arthur Jamet 5f63a5122d Server: Bump Jimp 2025-01-18 14:42:02 +00:00
Arthur Jamet b6283838ce CI: Server: Bump node version 2025-01-18 14:29:36 +00:00
Arthur Jamet 7c3dd7738c Server: Prod Dockerfile: Bump node version 2025-01-18 14:29:36 +00:00
Arthur Jamet aed577a855 Server: Dev Dockerfile: Bump node version 2025-01-18 14:29:36 +00:00
Arthur Jamet 928445af44 Server: Fix typo in package.json 2025-01-18 14:29:36 +00:00
Arthur Jamet 526c546f58 Server: Bump Cookie Parser + Prisma Type Generator deps 2025-01-18 11:03:43 +00:00
Arthur Jamet 64050bbdb9 Server: Bump Prisma + apply necessary migration 2025-01-17 20:51:31 +00:00
Arthur Jamet 95e55b9307 Server: Remove ununsed scheduling module + deps 2025-01-17 20:51:31 +00:00
Arthur Jamet dc9d141072 Server: Remove unused dependencies 2025-01-17 20:51:31 +00:00
Arthur Jamet e4bc45aa04 Front: Bump 'cookies-next' dependency 2025-01-17 19:45:28 +00:00
Arthur Jamet 41285090f7 Server: Refacto Seed for tests, using 'createMany' 2025-01-17 08:17:56 +00:00
Arthur Jamet d091cb550b Front: Bump Next 2025-01-16 19:19:41 +00:00
Arthur Jamet e3ce0a843b Server: Bump http-proxy-middleware dependency 2025-01-16 14:06:05 +00:00
Arthur Jamet 7ba90a6e0d Scanner: Bump echo Dependency (#830) 2025-01-16 13:24:21 +00:00
Arthur Jamet ce298f5340 Scanner: Bump echo Dependency 2025-01-16 13:12:28 +00:00
Arthur Jamet 21b3df3b00 Front: Disable Spellcheck in Search Field 2025-01-15 12:27:15 +00:00
Arthur Jamet e92e0cf394 Docker: Prod: Add missing env var for nginx 2025-01-10 20:45:08 +00:00
Arthur Jamet 9d12a9ae2c Merge pull request #753 from Arthi-chaud/v3
v3.0.0
2025-01-10 20:07:18 +00:00
Arthur Jamet 2596726088 Server: Track Service: allow getting many by slug or id + allow deleting many 2025-01-10 17:57:48 +00:00
Arthur Jamet 79d5aaf5dd Server: Album Service: allow getting many by slug or id + allow deleting many 2025-01-10 17:57:48 +00:00
Arthur Jamet c757027286 Server: Artist Service: allow getting many by slug or id + allow deleting many 2025-01-10 17:57:48 +00:00
Arthur Jamet 85ce3fbbf4 Server: Genre Service: allow getting many by slug or id + allow deleting many 2025-01-10 17:57:48 +00:00
Arthur Jamet 7c65d8f113 Server: Release Service: allow getting many by slug or id + allow deleting many 2025-01-10 17:57:48 +00:00
Arthur Jamet a2a283e146 Server: Song Service: allow getting many by slug or id + allow deleting many 2025-01-10 17:57:48 +00:00
Arthur Jamet 24c5c3aead Server: Video Service: allow getting many by slug or id + allow deleting many 2025-01-10 17:57:48 +00:00
Arthur Jamet 3dddcf4c36 Matcher: Musicbrainz: Search Album: Better Handling for compilation albums 2025-01-10 15:44:19 +00:00
Arthur Jamet ec64e16553 Matcher: Musicbrainz: Search Album: handle 'Remixes' suffix 2025-01-10 15:44:19 +00:00
Arthur Jamet ff31958623 Matcher: Musicbrainz: Search Album: Sanitize acronyms 2025-01-10 15:44:19 +00:00
Arthur Jamet e31c7f49b6 Server: Search Module: Define function to handle search-related union type 2025-01-08 18:36:20 +00:00
Arthur Jamet 16cc16830c Server: Registration: Prevent Race condition when featuring artist is mentioned multiple times 2025-01-08 18:25:19 +00:00
Arthur Jamet fbbc2fbae5 API Cleanup (#821) 2025-01-08 17:26:05 +00:00
Arthur Jamet 3d6f60e850 Front: Player Control: Handle case when track is standalon + not related to a song 2025-01-08 14:44:42 +00:00
Arthur Jamet d788a5f704 Matcher: Artist is optional in Album domain model 2025-01-08 12:01:53 +00:00
Arthur Jamet affd84272c Matcher: Musicbrainz: Identify Live Albums 2025-01-08 11:48:36 +00:00
Arthur Jamet 634b0f2de4 Rewrite Videos (#818)
* Prisma: Define Video Model

* Server: Artist + Song Housekeeping: Consider Videos before deleting

* Server: Parse Video Type

* Server: Parse Video Extensions

* Server: B-Roll videos are behind-the-scene videos

* Prisma: Add registration date to video model

* Server: Video Service: Get Many

* Server: Video Controller: Selector

* Server: Video Response Builder

* Front: New Video Model

* Server: Video API: Include Featurings

* Server: Fix houseleeping order between song and videos

* Server: Fix linking song to track depending on video type

* Server: Allow including video for tracks

* Front: Use New Video Model

* Server: If Video, Set Track title as video name, not parsed song

* Prisma: Rename video table + add master track field to video

* Server: Allow setting a video track as master

* Server: API: Get Video Master Track

* Server: Make API for Video more standard

* Front: Use New, Updated Video Model

* Server: Fix bug where track could have no song nor track

* Front: Release-Tracklist: Simplify typing

* Front: Song Page: Add Video tab

* API: Get Tracks by Video

* Server: If Video becomes extra, unlink from song

* README: Update feature list about music videos

* API: Videos Controller: Search

* Front: Infinite Video View can be list

* Front: Search Videos

* Server: Videos in 'search all'

* Server: Get Album's Videos: Consider videos from singles

* Front: Release Page: Order Video, moving secondary videos at the end

* Server: Fix detection of live videos

* Front: Release Page: Fix ordering of videos when song not on tracklist
2025-01-05 20:15:22 +00:00
Arthur Jamet 32a52ec102 Front: Player: Add link for standalone videos 2024-12-30 18:40:31 +01:00
Arthur Jamet 118484b766 Matcher: Do not fetch album genres if settings say so 2024-12-29 15:52:30 +01:00
Arthur Jamet 50647a3e86 Server: Parser Test: Refactor tests for splitgroups 2024-12-29 11:40:08 +01:00
Arthur Jamet 655728b657 Server: Parser Test: Refactor tests for extraction of track names 2024-12-29 11:40:08 +01:00
Arthur Jamet 79aee767cf Server: Parser Test: Refactor tests for extraction of release names 2024-12-29 11:40:08 +01:00
Arthur Jamet 2cd96e10dc Server: Parser Test: Refactor tests for song type detection 2024-12-29 11:40:08 +01:00
Arthur Jamet f05b51300c Server: Parser Test: Refactor tests for artist extraction in song names 2024-12-29 11:40:08 +01:00
Arthur Jamet 2fa3946af7 Server: Parser Test: Refactor tests for album type detection 2024-12-29 11:40:08 +01:00
Arthur Jamet b00badc0ca Server: Add 'Original Score' keyword to identify soundtrack albums 2024-12-28 10:51:27 +01:00
Arthur Jamet 95dc125712 Scanner: Remove black bars on thumbnails (#810)
* Scanner: Crop black bars on thumbnails

* Docker: Scanner: Move to alpine to get a newer ffmpeg version
2024-12-25 18:00:24 +01:00
Arthur Jamet 7fe4de658e Matcher: Musicbrainz: Use genres instead of tags for songs 2024-12-25 14:54:15 +01:00
Arthur Jamet b254796fc7 Scanner: Embedded Metadata: Use 'year' key to get release date 2024-12-22 12:15:42 +00:00
Arthur Jamet 556df14a87 Server: Fix detection of BSides when video from related Video Album 2024-12-22 11:51:19 +00:00
Arthur Jamet de2cfc18f7 Server: Remove Dead code about settings 2024-12-22 11:28:11 +00:00
Arthur Jamet 68229e5e0c Doc: Update Provider features 2024-12-22 11:04:12 +00:00
Arthur Jamet f02d3e3b6a Matcher: Use pika consumer for MQ events + set correct prefetch count for priority 2024-12-22 10:49:59 +00:00
Arthur Jamet e9b9577673 Server: Set Track as master on registration if previous master is video 2024-12-22 10:35:40 +00:00
Arthur Jamet 28f220c4cb Matcher: Musicbrainz: Support more formats for release dates 2024-12-22 10:28:03 +00:00
Arthur Jamet 3f200dcf3a Front: Remove 'Fetch Metadata' button in settings 2024-12-22 10:27:50 +00:00
Arthur Jamet db9150af9f Matcher: Push Genres only if settings is true 2024-12-22 10:12:47 +00:00
Arthur Jamet 5aaaa63a63 v3: Merge 2024-12-21 18:10:58 +00:00
Arthur Jamet 0f50129c21 Doc: settings.json: add regex for standalone tracks 2024-12-21 17:58:13 +00:00
Arthur Jamet a913482e18 Front: Fix loading state for artist external description 2024-12-21 17:43:15 +00:00
Arthur Jamet c86b8f0fb7 Scanner: Accept multiple format for dates from ffprobe 2024-12-21 17:26:50 +00:00
Arthur Jamet f95127a105 Docker Compose: Attach config volume to matcher container 2024-12-21 17:12:18 +00:00
Arthur Jamet 6f485b3099 Doc: Fix default regex in settings.json 2024-12-21 17:11:56 +00:00
Arthur Jamet a07b78e697 Scanner Dockerfile: Fix install of fpcalc 2024-12-21 17:11:40 +00:00
Arthur Jamet d11dc7308b Standalone Tracks (#808)
* Server: Thumbnails are independent from release illustrations

* Server: Tracks can have illustration without release

* Server: Track can have no release

* API: Registration: Allow Album/Release to be both null

* Scanner: Accept Album/Release to be null

* Migration: Fix join for standalon track illustrations

* Front: Update Models to support standalone tracks

* Server: Fix tests
2024-12-21 16:48:22 +00:00
Arthur Jamet 42ecd2d930 Server: Slug: Consider Ellipsis as word separator 2024-12-20 15:11:52 +00:00
Arthur Jamet 225babc1d0 Server: Fix Lint 2024-12-20 14:59:29 +00:00
Arthur Jamet be1503633a CI: Trigger on PR/Push on v3 branch 2024-12-20 14:59:29 +00:00
Arthur Jamet fc3fdcf19c Server: Rare Songs and BSides: Look into Video albums for audio tracks 2024-12-20 14:44:49 +00:00
Arthur Jamet 390a7fe2f4 Server: B-Sides: Avoid getting songs from older albums 2024-12-20 13:46:07 +00:00
Arthur Jamet dc4a5ce0da Scanner: Fix nullable fingerpint in DTO 2024-12-20 12:40:09 +00:00
Arthur Jamet d915a4dfdb Server: Define Enum for Event Priority 2024-12-20 11:41:28 +00:00
Arthur Jamet 7cbdca9439 Front: Release Page: Medley BSides in the extra section 2024-12-20 10:39:28 +00:00
Arthur Jamet d7b6feec2b Server: Getting BSides for a release includes Medleys 2024-12-20 10:39:28 +00:00
Arthur Jamet 0c05dfee29 Matcher: Match Songs with acoustid fingerprint if exists 2024-12-20 10:16:54 +00:00
Arthur Jamet 026eb05cd3 Server: Allow microservice to GET files 2024-12-20 10:16:54 +00:00
Arthur Jamet 02d5bc95e8 Server: Registration: Save AcoustId 2024-12-20 10:16:54 +00:00
Arthur Jamet ce4153a954 Scanner: Compute Fingerprint for AcoustID 2024-12-20 10:16:54 +00:00
Arthur Jamet 3aa66792c9 Matcher: Do not use Discogs for Artist description 2024-12-19 19:27:06 +00:00
Arthur Jamet 24222e3623 Docker: MQ: Fix persistence of messages by setting a nodename 2024-12-19 17:01:35 +00:00
Arthur Jamet a908d6756c Matcher: Fetch messages by hand to ensure priority is respected 2024-12-19 17:01:35 +00:00
Arthur Jamet 3479713970 Server: DELETE Library: await 2024-12-19 15:26:35 +00:00
Arthur Jamet 40b2a3b010 Merge pull request #791 from Arthi-chaud/external-metadata
Matcher: Fetch External Metadata
2024-12-19 15:03:27 +00:00
Arthur Jamet 1ef9026776 Server: Set Priority to MQ events 2024-12-19 14:55:01 +00:00
Arthur Jamet 19549ba923 Server: Reject External Metadata DTO when 2 sources from the same provider 2024-12-19 13:40:54 +00:00
Arthur Jamet 878579a0b8 Matcher: Catch Ctrl+C 2024-12-19 13:24:35 +00:00
Arthur Jamet d0603e0141 Matcher: Match Songs (#796)
* Matcher: Define Features for Songs

* Matcher: Setup relation between providers and wikidata for songs

* Matcher: Search Song with MB

* Matcher: Get Song Genres from MB

* Matcher: Get Song Lyrics and Description from Genius

* Matcher: Search Songs with Genius

* Matcher: Genius: Refactor tests using subtests

* Matcher: Discogs: Refactor tests using subtests

* Matcher: Musicbrainz: Refactor tests using subtests

* Matcher: Test Match Song

* Matcher: Define Song Model from API + POST Lyrics

* Server: POST Song Genres

* Matcher: POST Song Genres

* Matcher: Fix test on CI
2024-12-18 16:34:10 +00:00
Arthur Jamet 5089aa0c3f Matcher: Get Album type (#794)
* Matcher: Define feature to get album types

* Matcher: MusicBrainz: Get Album Type

* Matcher: Fix serialisation of enum
2024-12-15 16:25:04 +00:00
Arthur Jamet ab29710e99 Matcher: Lint 2024-12-15 14:23:12 +00:00
Arthur Jamet 0a05e20497 Server: Fix updating album date when its given by the matcher 2024-12-15 14:23:12 +00:00
Arthur Jamet 4bc542e360 Matcher: Do not POST release date if too far from the release date from API 2024-12-15 14:23:12 +00:00
Arthur Jamet b65590c1eb Matcher: Musicbrainz: Avoid None deref when album does not have a primary type 2024-12-15 14:23:12 +00:00
Arthur Jamet 3f176700bb Matcher: Sanitize search features when 'and' or '&' in artist name 2024-12-15 14:23:12 +00:00
Arthur Jamet dc9cbbd442 Matcher: Musicbrainz Provider: Silence Warning about JSON 2024-12-15 14:23:12 +00:00
Arthur Jamet 5dc0ed1623 Matcher: Discogs Provider: Try getting the most square image 2024-12-15 14:23:12 +00:00
Arthur Jamet a9ab63591a Matcher: Use new route to POST Artist Illustration 2024-12-14 20:06:46 +00:00
Arthur Jamet 135bb7d40d V3: Merge 2024-12-14 19:56:08 +00:00
Arthur Jamet 72559e7a8f API: Unique route to POST illustrations (#790)
* API: Rename endpoint to POST embedded illustration

* Server: Factor all routes to POST illustration using url

* Front: Use new route to POST illustrations
2024-12-14 19:46:48 +00:00
Arthur Jamet b042bdf9cb Front: Release Page: Order videos in order of tracklist 2024-12-13 20:43:58 +00:00
Arthur Jamet 0226ca166c Front: Add group id field in song model 2024-12-13 20:43:58 +00:00
Arthur Jamet 77abd9f3e6 Matcher: Provider 'features' (#786)
* Matcher: Define Feature types

* Matcher: Define Provider Boilerplate + Set features for metacritic

* Matcher: Set features for genius

* Matcher: Set features for allmusic and wikipedia

* Matcher: Set features for musicbrainz

* Matcher: Set features for discogs

* Matcher: Update types in Context

* Matcher: Use Features to know if provider is useful

* CI: Matcher: Bump Python version

* Matcher: Fix Lint
2024-12-13 19:38:16 +00:00
Arthur Jamet 26772e2b76 Server: Fix Spelling of 'Acappella' (#785)
* Server: Parser Service: Accept various spellings of acappella

* Server: Migration for new spelling of acappella

* Front: Use new spelling of acappella
2024-12-12 09:36:36 +00:00
Arthur Jamet 321f2b3d88 Matcher: Match albums (#784)
* Matcher: Bootstrap for matching albums

* Matcher: Common module for resource matchers

* Matcher: Flow for Matching albums

* Matcher: Match albums for Musicbrainz

* Matcher: Get Album Description with Wikidata

* Matcher: Search Album and release date With Genius

* Matcher: Get Album Rating with Allmusic

* Matcher: Use library for slugs

* Matcher: Get rating from metacritic

* Matcher: Unit Tests for Metacritic, Wikipedia and Allmusic

* Matcher: Unit Tests for Musicbrainz

* Matcher: Unit Tests for Genius

* Matcher: Unit Tests for Discogs

* Matcher: Fix Typing for External Metadata's description

* Matcher: Allow Microservices to GET albums

* Matcher: Fix Unserilisation of Albums from API

* Matcher: Fix search for Musicbrainz for non-singles

* Matcher: Fix Lint

* Matcher: Fix Tests for CI

* Matcher: Size down provider icons

* Server: POST Album Release Date + add permission to microservice

* Matcher: POST Album Release Date

* Matcher: Workaroung Musicbrainz library to get album's genres

* Matcher: Get Album genres from musicbrainz + use JSON API of MB, instead of XML

* Server: POST Album Genres

* Server: Do not remove illustration file on stream failure

* Matcher: More strict when searching album, manually sort by release date and filter by artist
2024-12-10 14:45:37 +00:00
Arthur Jamet 37c34f6e3e Front: Highlight card: set scrollbar color to accent color 2024-12-06 16:00:24 +00:00
Arthur Jamet 4bbe1a4514 Front: Search All: Show featuring artists for songs 2024-12-06 16:00:24 +00:00
Arthur Jamet d521415458 Matcher: match artists (#782)
* Matcher: Move Context Bootstrap to its own file

* Matcher: Matching artists: use MB to resolve other providers

* Matcher: Matching artists: Get Description and Image URL and POST

* Server: Fix permissions to POST Illustrations

* Matcher: Matching artists: Implem search for discogs and Genius

* Matcher: Matching artists: Link using Wikidata

* Matcher: Matching artists: Link Wikipedia using Wikidata

* Matcher: Matching artists: Get description when possible

* Matcher: Do not use library for Genius

* Matcher: Use MQ event for artists + fix logs from musicbrainz

* Matcher: Fix artist url being a tuple

* Matcher: Lint

* Matcher: Use loadenv for tests

* Matcher: Genius: Ignore placeholder images

* Matcher: test matching artists

* Matcher: Fix test on CI
2024-12-04 11:43:50 +00:00
Arthur Jamet fcc2407d26 Server: External Metadata: Reponse contains parent resource id 2024-11-26 14:19:18 +00:00
Arthur Jamet 76bc2410ec Front: Use New External Metadata Model 2024-11-26 14:19:18 +00:00
Arthur Jamet 2c91936682 Front: Fix SSR for search page 2024-11-03 08:56:58 +00:00
Arthur Jamet c411bc7b37 Front: Search: Show history when no query 2024-11-03 08:56:58 +00:00
Arthur Jamet 1cb6820c2b Search history: Add endpoint to GET entries 2024-11-03 08:56:58 +00:00
Arthur Jamet 3a26a696dc Search Controller: POST Search History 2024-11-03 08:56:58 +00:00
Arthur Jamet a3e6bdefff Front: Filter Videos by type 2024-11-02 17:13:24 +00:00
Arthur Jamet 09777dcfc1 Video Service: Filter Videos by type 2024-11-02 17:13:23 +00:00
Arthur Jamet b10a1b0cba Front: Infinite Video View: Add 'play all' and 'shuffle' buttons 2024-11-02 17:13:23 +00:00
Arthur Jamet 5fb615013d Video Service: Uses random query param to shuffler videos 2024-11-02 17:13:23 +00:00
Arthur Jamet 7607f09396 Server: Remove SongGroupcontroller form AppModule 2024-11-02 17:13:21 +00:00
Arthur Jamet 99805cf9d6 README: Mention song groups 2024-11-02 17:13:09 +00:00
Arthur Jamet faa837fff7 Front: Infinite Song Groups 2024-11-02 17:13:09 +00:00
Arthur Jamet fb0756aa23 Server: Fix Pagination for song groups 2024-11-02 17:13:08 +00:00
Arthur Jamet 1d8f4963d4 Front: Model for song group 2024-11-02 17:13:08 +00:00
Arthur Jamet 98e1fdff2e Server: Song Group: Test API 2024-11-02 17:13:08 +00:00
Arthur Jamet 6e36c4988b Server: Song Service: Add method to get many song groups 2024-11-02 17:13:06 +00:00
Arthur Jamet 6c335874e1 Server: Rework getting random songs/albums by shuffling outside database 2024-11-02 17:12:40 +00:00
Arthur Jamet 34ca446bc9 Front: Hide 'show exclusive tracks' button if album does not have an artist 2024-11-02 17:12:40 +00:00
Arthur Jamet b415eba73a Matcher: Provider Classes + Factory 2024-11-02 10:24:06 +00:00
Arthur Jamet 8fe7db2ea6 Matcher: Setup Singleton Context 2024-11-02 10:24:06 +00:00
Arthur Jamet 751997d6cb Matcher: POST Provider icons 2024-11-02 10:24:06 +00:00
Arthur Jamet cb1013fde1 Matcher: POST Providers at startup 2024-11-02 10:24:06 +00:00
Arthur Jamet 33eb777d4f Matcher: Models for Providers, Page + GET providers 2024-11-02 10:24:06 +00:00
Arthur Jamet 9c0c039a0f Matcher: Assets for providers 2024-11-02 10:24:06 +00:00
Arthur Jamet ebf9c57318 Server: Route to GET external providers 2024-11-02 10:24:06 +00:00
Arthur Jamet 84f6868390 Workflows: Matcher: Run tests 2024-10-26 16:24:16 +01:00
Arthur Jamet b44fba44a3 Matcher: Test Settings Parsing 2024-10-26 16:24:16 +01:00
Arthur Jamet b1a585cec4 Workflows: Matcher: Lint job ignore tests 2024-10-26 16:24:16 +01:00
Arthur Jamet 16a1ad0810 Matcher: Test API Constructor 2024-10-26 16:24:16 +01:00
Arthur Jamet 160513e82b Matcher: Settings: Parse 2024-10-26 16:24:16 +01:00
Arthur Jamet 6f6e03bda5 Matcher: Settings: Open file in constructor 2024-10-26 16:24:16 +01:00
Arthur Jamet 19ef1cc6b8 Illustration Service: Resize down the image before computing the blurhash. Better speed 2024-10-21 17:58:28 +01:00
Arthur Jamet a609f1058e Workflow: Basic CI for Matcher 2024-10-17 18:34:11 +01:00
Arthur Jamet b0e6a361bb Matcher: Configure API Client + Ping API at Startup 2024-10-17 18:34:11 +01:00
Arthur Jamet 9f05114be2 Matcher: Parse MQ event 2024-10-17 18:34:11 +01:00
Arthur Jamet bbbe62f880 Matcher: Connect to MQ + Print received events 2024-10-17 18:34:11 +01:00
Arthur Jamet 023c74d1c2 Server: Use nestjs native Event Publication system 2024-10-17 18:34:11 +01:00
Arthur Jamet 07da1f5428 Matcher: Hello World + Setup Docker 2024-10-17 18:34:11 +01:00
Arthur Jamet dbf75b8d20 Server: Send Event on Album and Song Creation 2024-09-29 11:38:44 +01:00
Arthur Jamet a2e351c670 Server: Send Event on Artist Creation 2024-09-29 11:38:44 +01:00
Arthur Jamet bb147d567f Server: Fix setup of rabbitmq for tests 2024-09-29 11:38:44 +01:00
Arthur Jamet 4e82669f4b Server: README with expected environment variables 2024-09-28 12:50:50 +01:00
Arthur Jamet 31a4ae5b9a Docker: Add MQ Service 2024-09-22 10:19:28 +01:00
Arthur Jamet 606ad93395 Server: Setup Connection to MQ + Mock for test env 2024-09-22 10:19:28 +01:00
Arthur Jamet b078ba20a4 API: External Metadata Model (#760)
* Server: Prisma Rewrite External Metadata and provider models

* Server: External Metadata: CRUD for new provider

* Server: External Metadata: CRUD for new external metadata model

* Server: Remove Previous Provider Models and services

* Server: test Provider Controller

* Server: External Metadata: Service, Controller and tests
2024-09-20 16:52:17 +01:00
Arthur Jamet 26dd43c2ee Merge branch 'main' of github.com:Arthi-chaud/Meelo into v3 2024-09-14 15:08:08 +01:00
Arthur Jamet 87d82b3928 Exclusive songs: do not consider videos 2024-09-14 14:46:06 +01:00
Arthur Jamet 93771c4551 Front: Fix Keybinding Trigger when focus on input (#758) 2024-09-14 14:46:05 +01:00
Arthur Jamet a5d151470e Exclusive songs (#757)
* Server: Get Tracklist: Add query parameter to get exclusive tracks only

* Front: If release is a compilation, add button to filter exclusive tracks

* README: Add text about exclusive songs filtering
2024-09-14 14:46:05 +01:00
Arthur Jamet 7d65700a70 Front: Basic keyboard bindings (#756)
* Front: Bindings: Setup Context + Hook

* Front: Binding to open keyboard binding list

* Front: Keyboard Binding Modals: Style for binding + Close button

* Front: Settings: Add section for keyboard bindings

* Front: Unfocus search text field on escape key press

* Front: Binding: Fix usestate loop + toggle playback on space press
2024-09-14 14:46:05 +01:00
Arthur Jamet 5f58e26314 Server: fix discogs tests (#755)
* Server: Do not use Discogs' notes as description, as plaintext is not available

* Docs: Discogs does not provide album description anymore

* Server: Format
2024-09-14 14:46:04 +01:00
Arthur Jamet 4968ea0a40 Server: Compute Illustration Blurhash using aspect ratio (#754) 2024-09-08 09:47:32 +02:00
Arthur Jamet 3560edb27b Merge pull request #752 from Arthi-chaud/scanner-microservice
Scanner Microservice
2024-09-07 22:14:32 +02:00
Arthur Jamet 67c7e2b80b Merge branch 'main' of github.com:Arthi-chaud/Meelo into scanner-microservice 2024-09-07 17:50:49 +02:00
Arthur Jamet 1dc7819162 Readme: Change First screenshot 2024-09-07 17:50:08 +02:00
Arthur Jamet 169383519b Merge pull request #751 from Arthi-chaud/front/use-scanner-microservice
Front: Use scanner microservice
2024-09-07 17:43:43 +02:00
Arthur Jamet e2a9ea96fd Docker Compose: Use PUBLIC_URL env var + connect front to scanner 2024-09-07 17:39:27 +02:00
Arthur Jamet 20d7f69ef2 Server: Remove deps to glob 2024-09-07 17:32:26 +02:00
Arthur Jamet f68dc3a394 Scanner: Dockerfile: add wget for healthcheck 2024-09-07 17:23:00 +02:00
Arthur Jamet 502f01f8b2 Front: Use Scanner's Endpoints 2024-09-07 16:50:48 +02:00
Arthur Jamet 4d47ba66a2 Server: Change Response on task start to match scanner's response 2024-09-07 16:46:59 +02:00
Arthur Jamet 25d5e194dc Server: Format + remove redis container in test setup 2024-09-07 14:17:38 +02:00
Arthur Jamet 4bdbf6340b Docker Compose: Cache front + server deps in dev + Add scanner to prod 2024-09-07 14:17:38 +02:00
Arthur Jamet 5e5cfd8722 Server: Remove use of Redis 2024-09-07 14:17:38 +02:00
Arthur Jamet 2e1337d5d7 Server: Move Illustration Registration to Registration Service + remove ununsed deps and ffmpeg 2024-09-07 14:17:38 +02:00
Arthur Jamet 3ad0cc92b6 Service: Parser Module, Registration Module + start removal of Task service 2024-09-07 14:17:38 +02:00
Arthur Jamet db52907042 Server: Housekeeping service 2024-09-07 14:17:38 +02:00
Arthur Jamet 7c41127075 Rename checksum (#749)
* Server: Rename md5Checkum to checksum

* Server: Rename md5Checkum to checksum

* Front: Remove unused field from File API Model

* Scanner: Rename checksum field from API model
2024-09-06 14:15:15 +02:00
Arthur Jamet 777e68c869 Merge pull request #747 from Arthi-chaud/scanner/refresh-metadata
Scanner: Refresh metadata
2024-09-06 10:43:29 +02:00
Arthur Jamet 62087ae13b Server: Discogs does not provide description anymore 2024-09-06 10:39:46 +02:00
Arthur Jamet e86115740e Scanner: Refresh Metadata when file changes 2024-09-06 10:32:32 +02:00
Arthur Jamet c4b79f37f7 Scanner: API Request to get files w/ selector 2024-09-06 10:32:32 +02:00
Arthur Jamet 18e0b0d00c Scanner: Setup for Metadata Refresh Task 2024-09-06 10:32:32 +02:00
Arthur Jamet 52e3130fea Server: Metadata Controller: Update Metadata 2024-09-06 10:32:31 +02:00
Arthur Jamet 59d49e1617 Server: Remove ununsed dep in metadata module 2024-09-06 10:32:31 +02:00
Arthur Jamet 85ee9baf51 Server: File Controller: Select files from album, release, song and track 2024-09-06 10:32:31 +02:00
Arthur Jamet 5042cca9f0 Docs: Mention that we use Golang-flavored regular expressions 2024-09-06 10:31:38 +02:00
Arthur Jamet 866041bfb6 Merge pull request #746 from Arthi-chaud/scanner/parallel-illustration-scanning 2024-09-05 07:54:01 +02:00
Arthur Jamet 0723e0e9a1 Scanner: Use new route to post cover/thumbnail 2024-09-04 20:58:59 +02:00
Arthur Jamet d564252623 Server: Rework route for Illustration Registration 2024-09-04 19:17:24 +02:00
Arthur Jamet 2124d23cb9 Scanner: Illustration submodule 2024-09-04 18:16:14 +02:00
Arthur Jamet 7faa482135 Merge pull request #745 from Arthi-chaud/scanner/clean
Scanner: Clean Libraries
2024-09-04 13:08:58 +02:00
Arthur Jamet 992d022ba2 Server: Format 2024-09-04 11:24:09 +02:00
Arthur Jamet f22e397de3 Scanner: Clean Files 2024-09-04 11:18:15 +02:00
Arthur Jamet 6d785d4459 Server: Endpoint to delete files 2024-09-04 11:17:55 +02:00
Arthur Jamet e96c86b85b Merge pull request #744 from Arthi-chaud/scanner/push-metadata
Scanner: Push metadata
2024-09-04 10:25:17 +02:00
Arthur Jamet a530fd04d6 Server: Illustration Registration: Prevent order of registration from using the wrong disc illustration for release 2024-09-04 10:20:41 +02:00
Arthur Jamet 220959124a Server: Metadata: Fix validation of compilation tag 2024-09-03 21:29:24 +02:00
Arthur Jamet 1b842094f7 Server: Metadata: Fix file path parsing 2024-09-03 19:02:50 +02:00
Arthur Jamet 96c7e6a99c Scanner: Extract Thumbnail + Fix type detection for OGG 2024-09-03 17:26:48 +02:00
Arthur Jamet 00ca276859 Server: Library Controller: Add permissions for microservice 2024-09-03 16:53:22 +02:00
Arthur Jamet 0c765703e0 Scanner: Add API endpoint to scan a single library 2024-09-03 16:42:14 +02:00
Arthur Jamet 7f65834614 Scanner: Format 2024-09-02 20:30:02 +02:00
Arthur Jamet 88a79348c5 Server: Avoid crash on clean 2024-09-02 20:29:44 +02:00
Arthur Jamet 90882b8e60 Server: Endpoint to POST Thumbnail 2024-09-02 17:28:51 +02:00
Arthur Jamet 4ae9de61d7 Server: Metadata DTO: Fix parsing of dates and booleans 2024-09-02 16:18:58 +02:00
Arthur Jamet 5f7ae2eb30 Scanner: Hide ffmpeg log + remove json key from metadata object 2024-09-02 16:18:58 +02:00
Arthur Jamet 8bcc0fb29d Scanner: checksum: fix 2024-09-02 16:18:58 +02:00
Arthur Jamet cd8a1185f3 Scanner: Parser: Fix Compilation/Album Artist Handling 2024-09-02 16:18:58 +02:00
Arthur Jamet e4cd30a3e4 Scanner: API: Push Metadata 2024-09-02 16:18:58 +02:00
Arthur Jamet 11c7be61cd Server: Metadata Controller: Save Illustration 2024-09-02 16:18:55 +02:00
Arthur Jamet 17089b20c7 Server: Metadata: Save Illustration 2024-09-01 15:58:51 +02:00
Arthur Jamet 76dffbd280 Server: Test Metadata Creation result 2024-09-01 15:15:21 +02:00
Arthur Jamet 005006b02a Server: Metadata Controller: Test error handling 2024-09-01 11:08:39 +02:00
Arthur Jamet de5bca03d9 Scanner: Metadata Parsing: Add Path, checksum and registration date to model 2024-09-01 10:17:26 +02:00
Arthur Jamet c0a2527284 Server: Setup DTO for pushing Metadata 2024-08-31 18:05:42 +02:00
Arthur Jamet 4787a204ab Scanner: Scan: Parse metadata by chunks 2024-08-31 16:44:28 +02:00
Arthur Jamet 9cf58eb3f9 Scanner: Scan Task: Filter unregistered paths 2024-08-31 13:08:16 +02:00
Arthur Jamet 6886c9d757 Server: File API: Add route to get many files 2024-08-31 10:09:56 +02:00
Arthur Jamet 4ab088fd34 Server: Fix JWT Guard 2024-08-28 16:54:46 +02:00
Arthur Jamet fed101e8ae Scanner: Setup Task Queue 2024-08-28 16:40:44 +02:00
Arthur Jamet ea5b8d440e Scanner: Merge 2024-08-28 16:05:11 +02:00
Arthur Jamet b3f7f2206f Scanner: Endpoints: 401 if user is not admin 2024-08-28 14:08:07 +02:00
Arthur Jamet 4d58545b3b Scanner: Check if requesting user is admin 2024-08-28 14:08:07 +02:00
Arthur Jamet 4f3d542159 Server: Api Key Service + Guard 2024-08-28 14:08:03 +02:00
Arthur Jamet e5b9c2e3b2 Scanner: Add API Key to config 2024-08-28 14:07:08 +02:00
Arthur Jamet f3e56f32c0 Scanner: Know where to look for an illustration 2024-08-28 14:07:08 +02:00
Arthur Jamet 2aa6eb8499 Scanner: Test Metadata Merging + sanitization 2024-08-28 14:07:07 +02:00
Arthur Jamet ed03dca79e Scanner: Test Metadata Parser 2024-08-28 14:07:07 +02:00
Arthur Jamet 2e438f12f4 Scanner: Parse embedded data, core parsing function + tests 2024-08-28 14:07:07 +02:00
Arthur Jamet 28fdc0154d Scanner: Merge Metadata 2024-08-28 14:07:07 +02:00
Arthur Jamet 60eb19e29f Scanner: Use Golang Regex + Test path metadata 2024-08-28 14:07:06 +02:00
Arthur Jamet f69ba8a7f1 Scanner: Parse Metadata from path 2024-08-28 14:07:06 +02:00
Arthur Jamet 0fac03a214 Scanner: Path Metadata Parsing: Setup for regexes 2024-08-28 14:07:06 +02:00
Arthur Jamet dca04b202b Scanner: Use error type instead of plain strings 2024-08-28 14:07:06 +02:00
Arthur Jamet 155cb9e039 Scanner: Metadata model + validator 2024-08-28 14:07:06 +02:00
Arthur Jamet 858b9e0464 CI: Scanner: Fix coverage integration on sonarcloud 2024-08-28 14:07:05 +02:00
Arthur Jamet b04cbfad92 Scanner: Cohesive variable namings 2024-08-28 14:07:05 +02:00
Arthur Jamet 762b31108e Scanner: Better validation error messages for settings 2024-08-28 14:07:05 +02:00
Arthur Jamet bb430aea37 Scanner: Test Parsing of User Settings 2024-08-28 14:07:05 +02:00
Arthur Jamet 2a541cb643 Scanner: Parse settings.json 2024-08-28 14:07:04 +02:00
Arthur Jamet ad3fbd7388 Scanner: get data dir env var 2024-08-28 14:07:04 +02:00
Arthur Jamet ee40992008 Scanner: get config dir env var 2024-08-28 14:07:04 +02:00
Arthur Jamet 1beeeb1836 Scanner: Dockerfile: avoid using root 2024-08-28 14:07:04 +02:00
Arthur Jamet e97e811998 CI: Scanner: Install ffmpeg only for tests 2024-08-28 14:07:04 +02:00
Arthur Jamet ffe576d601 CI: Add Static analysis to Scanner 2024-08-28 14:07:03 +02:00
Arthur Jamet 30def99958 Scanner: Connect to API 2024-08-28 14:07:03 +02:00
Arthur Jamet 76c1ecd71c Scanner: func to get video thumbnail + format 2024-08-28 14:07:03 +02:00
Arthur Jamet 16d62a7e19 Scanner: CI + coverage Report 2024-08-28 14:07:03 +02:00
Arthur Jamet 522330d14d Scanner: Add to docker-compose + nginx reverse proxy 2024-08-28 14:07:03 +02:00
Arthur Jamet 7c9d78e875 Scanner: Setup Project + Echo server 2024-08-28 14:07:02 +02:00
Arthur Jamet aae10672dd Server: Rework handling of access control when multiple methods possible + tests 2024-08-24 10:01:47 +02:00
Arthur Jamet 3ebac555fa Server: Add Microservice to Role guards 2024-08-23 20:42:07 +02:00
Arthur Jamet 75788e478b Server: Merge 2024-08-23 20:25:32 +02:00
Arthur Jamet 73ba1ca398 wip 2024-08-23 11:32:44 +02:00
Arthur Jamet 527ca4df5d Scanner: Add Tasks to context 2024-08-21 15:22:22 +02:00
Arthur Jamet 27b2b9d813 Scanner: Endpoints: 401 if user is not admin 2024-08-20 14:48:22 +02:00
Arthur Jamet ae44cd5574 Scanner: Check if requesting user is admin 2024-08-20 14:48:22 +02:00
Arthur Jamet 31831e25ce Server: Api Key Service + Guard 2024-08-20 14:48:22 +02:00
Arthur Jamet ba9fc80f82 Scanner: Add API Key to config 2024-08-20 14:48:22 +02:00
Arthur Jamet f45d4df3fe Scanner: Know where to look for an illustration 2024-08-18 19:58:15 +02:00
Arthur Jamet 5ff9ad6953 Scanner: Test Metadata Merging + sanitization 2024-08-17 09:03:32 +02:00
Arthur Jamet 6eb0055ca5 Scanner: Test Metadata Parser 2024-08-17 09:03:32 +02:00
Arthur Jamet aa009b4352 Scanner: Parse embedded data, core parsing function + tests 2024-08-17 09:03:32 +02:00
Arthur Jamet 05b69097e9 Scanner: Merge Metadata 2024-08-16 16:35:30 +02:00
Arthur Jamet ab204507e7 Scanner: Use Golang Regex + Test path metadata 2024-08-16 16:35:30 +02:00
Arthur Jamet 879f3a8805 Scanner: Parse Metadata from path 2024-08-16 16:35:30 +02:00
Arthur Jamet 571cde7010 Scanner: Path Metadata Parsing: Setup for regexes 2024-08-16 16:35:30 +02:00
Arthur Jamet 6c643b6f95 Scanner: Use error type instead of plain strings 2024-08-16 16:35:30 +02:00
Arthur Jamet 5d686259c7 Scanner: Metadata model + validator 2024-08-16 16:35:30 +02:00
Arthur Jamet f0ced36c54 CI: Scanner: Fix coverage integration on sonarcloud 2024-08-15 16:20:40 +02:00
Arthur Jamet 65341ebc2f Scanner: Cohesive variable namings 2024-08-15 13:51:34 +02:00
Arthur Jamet b873869559 Scanner: Better validation error messages for settings 2024-08-15 13:51:34 +02:00
Arthur Jamet 967328f494 Scanner: Test Parsing of User Settings 2024-08-15 13:51:34 +02:00
Arthur Jamet 88e8359d2c Scanner: Parse settings.json 2024-08-15 13:51:34 +02:00
Arthur Jamet 4f76203dcc Scanner: get data dir env var 2024-08-15 13:51:34 +02:00
Arthur Jamet b2002336e1 Scanner: get config dir env var 2024-08-15 13:51:34 +02:00
Arthur Jamet b39dc06298 Scanner: Dockerfile: avoid using root 2024-08-14 20:57:24 +02:00
Arthur Jamet 9db513b786 CI: Scanner: Install ffmpeg only for tests 2024-08-14 20:57:24 +02:00
Arthur Jamet 4091703afe CI: Add Static analysis to Scanner 2024-08-14 20:57:24 +02:00
Arthur Jamet aa17aba53c Scanner: Connect to API 2024-08-14 20:35:49 +02:00
Arthur Jamet 7e7aacfead Scanner: func to get video thumbnail + format 2024-08-14 20:26:55 +02:00
Arthur Jamet e2f3b6c07d Scanner: CI + coverage Report 2024-08-14 20:26:55 +02:00
Arthur Jamet 0b93b767f2 Scanner: Add to docker-compose + nginx reverse proxy 2024-08-14 20:26:55 +02:00
Arthur Jamet d4286349e9 Scanner: Setup Project + Echo server 2024-08-14 20:26:55 +02:00
821 changed files with 51391 additions and 30158 deletions

No files matched your search

+31 -4
View File
@@ -1,3 +1,10 @@
#################### Version
# The version of meelo to use (edge, latest, vX.X.X)
# See available tags here: https://hub.docker.com/r/arthichaud/meelo-front/tags
# The default value is 'latest'
# Please read the changelog before changing this value
# https://github.com/Arthi-chaud/Meelo/releases
TAG=
#################### Database
# The port on the host where meelo will be accessible
PORT=
@@ -7,6 +14,10 @@ POSTGRES_USER=
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=
#################### Config
# The directory that contains the `settings.json` file (and where the illustrations will be stored) (on the host machine)
CONFIG_DIR=
@@ -16,17 +27,33 @@ DATA_DIR=
# Set to 1 if you want to allow anonymous request
# This will not affect front-end behaviour
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
# URL of the server/api that would be accessible to the front app's server or any client
# If PORT=5000, it should look like 'http://0.0.0.0:5000/api' (mind the '/api')
PUBLIC_SERVER_URL=
# Public URL to access Meelo, without trailing slash
# Example: http://localhost:5000
PUBLIC_URL=
#################### Security
# Random String used to sign JWT Tokens
JWT_SIGNATURE=
# Key used to authenticate the Meilisearch Instance
# Should be a random string, must be at least 16 bytes
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
INTERNAL_DATA_DIR=/data
INTERNAL_CONFIG_DIR=/config
INTERNAL_CONFIG_DIR=/config
+3 -3
View File
@@ -11,6 +11,6 @@ runs:
with:
path: |
./front/node_modules
./front/.next
key: front
restore-keys: front
./front/apps/web/.next
key: front-${{ hashFiles('front/yarn.lock') }}
restore-keys: front-
+20
View File
@@ -0,0 +1,20 @@
name: 'Cache (Matcher)'
description: 'Cache for Matcher'
runs:
using: "composite"
steps:
- run: apk add --update --no-progress lsb-release python3 py3-pip tar
shell: sh
- run: mkdir ./pip-cache
shell: sh
- name: Cache
uses: actions/cache@v3
with:
path: |
./pip-cache
key: matcher-${{ hashFiles('matcher/requirements.txt') }}
restore-keys: matcher-
- run: pip3 install -r matcher/requirements.txt
shell: sh
env:
PIP_CACHE_DIR: ./pip-cache
+16
View File
@@ -0,0 +1,16 @@
name: 'Cache (Scanner)'
description: 'Cache for Scanner'
runs:
using: "composite"
steps:
- run: |
cd scanner
go install github.com/swaggo/swag/cmd/swag@latest
~/go/bin/swag init -d app -o ./app/docs
shell: bash
- uses: actions/setup-go@v5
with:
check-latest: true
go-version-file: 'scanner/go.mod'
cache-dependency-path: scanner/go.sum
go-version: '1.24.1'
+2 -2
View File
@@ -12,5 +12,5 @@ runs:
path: |
./server/node_modules
./server/dist
key: server
restore-keys: server
key: server-${{ hashFiles('server/yarn.lock') }}
restore-keys: server-
+59
View File
@@ -0,0 +1,59 @@
name: 'Dockerisation'
description: 'Build using Docker, and publish if necessary'
inputs:
docker-username:
description: 'Username to publish Docker image under'
required: true
docker-token:
description: 'Token to publish Docker image'
required: true
github-token:
description: 'GitHub Token'
required: true
image-suffix:
description: "Suffix to 'meelo-'"
required: true
publish:
description: 'Publish to registry'
default: 'false'
context:
description: 'Directory of the Dockerfile'
required: true
runs:
using: "composite"
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ inputs.docker-username }}
password: ${{ inputs.docker-token }}
- name: Log in to the GitHub Container registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ inputs.github-token }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v4
with:
images: |
arthichaud/meelo-${{ inputs.image-suffix }}
ghcr.io/${{ github.repository }}-${{ inputs.image-suffix }}
- name: Build and push Docker images
uses: docker/build-push-action@v3
with:
context: ${{ inputs.context }}
platforms: ${{ (inputs.publish == 'true' || inputs.publish == true) && 'linux/amd64,linux/arm64' || 'linux/amd64' }}
push: ${{ inputs.publish == 'true' || inputs.publish == true }}
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || format('arthichaud/meelo-{0}:edge', inputs.image-suffix) }}
labels: ${{ steps.meta.outputs.labels }}
-40
View File
@@ -1,40 +0,0 @@
name: Documentation
on:
push:
branches: [ main ]
permissions:
contents: write
jobs:
Changes:
runs-on: ubuntu-latest
permissions:
pull-requests: read
outputs:
docs: ${{ steps.filter.outputs.docs }}
steps:
- name: Check out the repo
uses: actions/checkout@v3
- uses: dorny/paths-filter@v2
id: filter
with:
filters: |
docs:
- 'docs/**'
- '.github/workflows/documentation.yml'
- 'assets/**'
Build:
needs: changes
name: Deploy
runs-on: ubuntu-latest
if: ${{ needs.changes.outputs.docs == 'true' }}
steps:
- name: Checkout main
uses: actions/checkout@v3
- name: Deploy docs
uses: mhausenblas/mkdocs-deploy-gh-pages@master
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CONFIG_FILE: docs/mkdocs.yml
REQUIREMENTS: docs/requirements.txt
+50 -99
View File
@@ -1,9 +1,9 @@
name: "Front"
on:
pull_request:
branches: [ main, v2 ]
branches: [ main, v3 ]
push:
branches: [ main, v2 ]
branches: [ main, v3 ]
release:
types: [ published ]
@@ -26,8 +26,12 @@ jobs:
with:
filters: |
front:
- 'front/**'
- 'front/apps/web/**'
- 'front/translations/**'
- 'front/packages/**'
- '.github/workflows/front.yml'
- '.github/actions/cache-front/**'
- '.github/actions/dockerize/**'
Build:
needs: changes
if: ${{ needs.changes.outputs.front == 'true' && github.event_name == 'pull_request' }}
@@ -41,12 +45,15 @@ jobs:
- name: Install Dependencies
run: yarn install
- name: Build
run: yarn build
Lint:
run: yarn build:web
Biome:
if: ${{ needs.changes.outputs.front == 'true' && github.event_name == 'pull_request' }}
container: node:18-alpine3.18
container: node:22-alpine3.20
runs-on: ubuntu-latest
needs: [ Build ]
permissions:
contents: read
pull-requests: write
steps:
- name: Check out the repo
uses: actions/checkout@v3
@@ -55,118 +62,62 @@ jobs:
- name: Install Dependencies
run: yarn install
- name: Run Linter
run: yarn lint
Format:
if: ${{ needs.changes.outputs.front == 'true' && github.event_name == 'pull_request' }}
container: node:18-alpine3.18
runs-on: ubuntu-latest
needs: [ Build ]
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Restore Dependencies
uses: ./.github/actions/cache-front
- name: Install Dependencies
run: yarn install
- name: Check Format
run: yarn prettier --check src
Tests:
runs-on: ubuntu-latest
container: node:19-alpine
needs: [ Build ]
if: ${{ needs.changes.outputs.front == 'true' && always() }}
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Restore Dependencies
uses: ./.github/actions/cache-front
- name: Install Dependencies
run: yarn install
- name: Run unit tests with coverage
run: yarn test:cov
- name: Upload Unit Tests artifact
if: ${{ always() }}
uses: actions/upload-artifact@v3
with:
name: coverage-report-front
path: ./front/coverage
run: yarn run biome ci .
Analysis:
needs: [ Tests ]
needs: [ Build ]
name: "Static Analysis"
runs-on: ubuntu-latest
if: ${{ needs.changes.outputs.front == 'true' && always() }}
steps:
- uses: actions/checkout@v3
- name: Downlad Coverage artifact
uses: actions/download-artifact@v3
with:
name: coverage-report-front
path: ./front/coverage
# - name: Downlad Coverage artifact
# uses: actions/download-artifact@v4
# with:
# name: coverage-report-front
# path: ./front/coverage
- name: Run SonarCloud Scan
uses: SonarSource/sonarcloud-github-action@master
uses: SonarSource/sonarqube-scan-action@master
with:
projectBaseDir: ./front
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_FRONT }}
Dockerize:
if: ${{ needs.changes.outputs.front == 'true' && github.event_name == 'pull_request' }}
needs: [ Build ]
runs-on: ubuntu-latest
permissions:
packages: read
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to the GitHub Container registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker
uses: docker/build-push-action@v3
with:
context: ./front
push: false
Publish:
needs: changes
runs-on: ubuntu-latest
if: ${{ github.event_name == 'release' || (needs.changes.outputs.front == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
if: ${{ github.event_name == 'release' || (needs.changes.outputs.front == 'true' && (github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main'))) }}
permissions:
packages: write
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to the GitHub Container registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v4
with:
images: |
arthichaud/meelo-front
ghcr.io/${{ github.repository }}-front
- name: Build and push Docker images
uses: docker/build-push-action@v3
with:
- uses: ./.github/actions/dockerize
with:
docker-username: ${{ secrets.DOCKERHUB_USERNAME }}
docker-token: ${{ secrets.DOCKERHUB_TOKEN }}
github-token: ${{ secrets.GITHUB_TOKEN }}
context: ./front
push: true
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || 'arthichaud/meelo-front:edge' }}
labels: ${{ steps.meta.outputs.labels }}
image-suffix: front
publish: ${{ github.event_name == 'release' || (needs.changes.outputs.front == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
# Tests:
# runs-on: ubuntu-latest
# container: node:19-alpine
# needs: [ Build ]
# if: ${{ needs.changes.outputs.front == 'true' && always() }}
# steps:
# - name: Check out the repo
# uses: actions/checkout@v3
# - name: Restore Dependencies
# uses: ./.github/actions/cache-front
# - name: Install Dependencies
# run: yarn install
# - name: Run unit tests with coverage
# run: yarn test:cov
# - name: Upload Unit Tests artifact
# if: ${{ always() }}
# uses: actions/upload-artifact@v4
# with:
# name: coverage-report-front
# path: ./front/coverage
+91
View File
@@ -0,0 +1,91 @@
name: "Matcher"
on:
pull_request:
branches: [ main, v3 ]
push:
branches: [ main, v3 ]
release:
types: [ published ]
defaults:
run:
working-directory: ./matcher
jobs:
Changes:
runs-on: ubuntu-latest
permissions:
pull-requests: read
outputs:
matcher: ${{ steps.filter.outputs.matcher }}
steps:
- name: Check out the repo
uses: actions/checkout@v3
- uses: dorny/paths-filter@v2
id: filter
with:
filters: |
matcher:
- 'matcher/**'
- '.github/workflows/matcher.yml'
- '.github/actions/cache-matcher/**'
- '.github/actions/dockerize/**'
Typecheck:
needs: changes
if: ${{ needs.changes.outputs.matcher == 'true' && github.event_name == 'pull_request' }}
container: python:3.14.0a2-alpine3.21
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Restore Dependencies
uses: ./.github/actions/cache-matcher
- uses: jakebailey/pyright-action@v2
with:
working-directory: './matcher/matcher'
Lint:
if: ${{ needs.changes.outputs.matcher == 'true' && github.event_name == 'pull_request' }}
container: python:3.14.0a2-alpine3.21
runs-on: ubuntu-latest
needs: [ Typecheck ]
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Restore Dependencies
uses: ./.github/actions/cache-matcher
- run: pip install ruff
- run: ruff check matcher
Tests:
runs-on: ubuntu-latest
needs: [ Typecheck ]
container: python:3.14.0a2-alpine3.21
if: ${{ needs.changes.outputs.matcher == 'true' && always() }}
env:
GENIUS_ACCESS_TOKEN: "${{ secrets.GENIUS_ACCESS_TOKEN }}"
DISCOGS_ACCESS_TOKEN: "${{ secrets.DISCOGS_ACCESS_TOKEN }}"
CI: "1"
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Restore Dependencies
uses: ./.github/actions/cache-matcher
- run: python3 -m tests
Dockerize:
runs-on: ubuntu-latest
needs: changes
if: ${{ github.event_name == 'release' || (needs.changes.outputs.matcher == 'true' && (github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main'))) }}
permissions:
packages: write
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v3
- uses: ./.github/actions/dockerize
with:
docker-username: ${{ secrets.DOCKERHUB_USERNAME }}
docker-token: ${{ secrets.DOCKERHUB_TOKEN }}
github-token: ${{ secrets.GITHUB_TOKEN }}
context: ./matcher
image-suffix: matcher
publish: ${{ github.event_name == 'release' || (needs.changes.outputs.matcher == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
+115
View File
@@ -0,0 +1,115 @@
name: "Scanner"
on:
pull_request:
branches: [ main, v3 ]
push:
branches: [ main, v3 ]
release:
types: [ published ]
defaults:
run:
shell: bash
working-directory: ./scanner
jobs:
Changes:
runs-on: ubuntu-latest
permissions:
pull-requests: read
outputs:
scanner: ${{ steps.filter.outputs.scanner }}
steps:
- name: Check out the repo
uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
scanner:
- 'scanner/**'
- '.github/workflows/scanner.yml'
- '.github/actions/cache-scanner/**'
- '.github/actions/dockerize/**'
Build:
needs: changes
if: ${{ needs.changes.outputs.scanner == 'true' && github.event_name == 'pull_request' }}
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Setup Go
uses: ./.github/actions/cache-scanner
- name: Install Dependencies and Build
run: go build -o scanner ./app
Format:
if: ${{ needs.changes.outputs.scanner == 'true' && github.event_name == 'pull_request' }}
runs-on: ubuntu-latest
needs: [ Build ]
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Setup Go
uses: ./.github/actions/cache-scanner
- uses: Jerome1337/gofmt-action@v1.0.5
with:
gofmt-path: './scanner'
gofmt-flags: '-l'
Tests:
runs-on: ubuntu-latest
needs: [ Build ]
if: ${{ needs.changes.outputs.scanner == 'true' && always() }}
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Setup Go
uses: ./.github/actions/cache-scanner
- run: sudo apt-get update -y && sudo apt-get install -y ffmpeg
- name: Install Coverage Report
run: go install github.com/jandelgado/gcov2lcov@latest
- name: Run tests
run: go test ./... -coverprofile=coverage.out
- name: Upload Unit Tests artifact
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: coverage-report-scanner
path: ./scanner/coverage.out
Analysis:
needs: [ Tests ]
name: "Static Analysis"
runs-on: ubuntu-latest
if: ${{ needs.changes.outputs.scanner == 'true' && always() }}
steps:
- uses: actions/checkout@v4
- name: Downlad Coverage artifact
uses: actions/download-artifact@v4
with:
name: coverage-report-scanner
path: ./scanner
- name: Run SonarCloud Scan
uses: SonarSource/sonarqube-scan-action@master
with:
projectBaseDir: ./scanner
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_SCANNER }}
Dockerize:
runs-on: ubuntu-latest
needs: changes
if: ${{ github.event_name == 'release' || (needs.changes.outputs.scanner == 'true' && (github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main'))) }}
permissions:
packages: write
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v3
- uses: ./.github/actions/dockerize
with:
docker-username: ${{ secrets.DOCKERHUB_USERNAME }}
docker-token: ${{ secrets.DOCKERHUB_TOKEN }}
github-token: ${{ secrets.GITHUB_TOKEN }}
context: ./scanner
image-suffix: scanner
publish: ${{ github.event_name == 'release' || (needs.changes.outputs.scanner == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
+23 -85
View File
@@ -1,9 +1,9 @@
name: "Server"
on:
pull_request:
branches: [ main, v2 ]
branches: [ main, v3 ]
push:
branches: [ main, v2 ]
branches: [ main, v3 ]
release:
types: [ published ]
@@ -28,10 +28,12 @@ jobs:
server:
- 'server/**'
- '.github/workflows/server.yml'
- '.github/actions/cache-server/**'
- '.github/actions/dockerize/**'
Build:
needs: changes
if: ${{ needs.changes.outputs.server == 'true' && github.event_name == 'pull_request' }}
container: node:18-alpine3.18
container: node:22-alpine3.20
runs-on: ubuntu-latest
steps:
- name: Check out the repo
@@ -42,11 +44,14 @@ jobs:
run: yarn install
- name: Build
run: yarn build
Lint:
Biome:
if: ${{ needs.changes.outputs.server == 'true' && github.event_name == 'pull_request' }}
container: node:18-alpine3.18
container: node:22-alpine3.20
runs-on: ubuntu-latest
needs: [ Build ]
permissions:
contents: read
pull-requests: write
steps:
- name: Check out the repo
uses: actions/checkout@v3
@@ -55,29 +60,14 @@ jobs:
- name: Install Dependencies
run: yarn install
- name: Run Linter
run: yarn lint
Format:
if: ${{ needs.changes.outputs.server == 'true' && github.event_name == 'pull_request' }}
container: node:18-alpine3.18
runs-on: ubuntu-latest
needs: [ Build ]
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Restore Dependencies
uses: ./.github/actions/cache-server
- name: Install Dependencies
run: yarn install
- name: Check Format
run: yarn prettier --check src
run: yarn run biome ci .
Tests:
runs-on: ubuntu-latest
container: node:18-alpine3.18
container: node:22-alpine3.20
needs: [ Build ]
if: ${{ needs.changes.outputs.server == 'true' && always() }}
env:
NODE_OPTIONS: "--max_old_space_size=4096"
REDIS_HOST: "redis"
DATABASE_URL: "postgresql://test:test@postgres:5432/test?schema=public"
JWT_SIGNATURE: "12345"
GENIUS_ACCESS_TOKEN: "${{ secrets.GENIUS_ACCESS_TOKEN }}"
@@ -97,15 +87,6 @@ jobs:
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7.0-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
meilisearch:
image: getmeili/meilisearch:v1.5
ports:
@@ -125,7 +106,7 @@ jobs:
run: yarn --cwd server test --coverage --ci --no-cache --maxConcurrency=1
- name: Upload Unit Tests artifact
if: ${{ always() }}
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: coverage-report-server
path: ./server/coverage
@@ -137,75 +118,32 @@ jobs:
steps:
- uses: actions/checkout@v3
- name: Downlad Coverage artifact
uses: actions/download-artifact@v3
uses: actions/download-artifact@v4
with:
name: coverage-report-server
path: ./server/coverage
- name: Run SonarCloud Scan
uses: SonarSource/sonarcloud-github-action@master
uses: SonarSource/sonarqube-scan-action@master
with:
projectBaseDir: ./server
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_BACK }}
Dockerize:
if: ${{ needs.changes.outputs.server == 'true' && github.event_name == 'pull_request' }}
needs: [ Build ]
runs-on: ubuntu-latest
permissions:
packages: read
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to the GitHub Container registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker
uses: docker/build-push-action@v3
with:
context: ./server
push: false
Publish:
needs: changes
runs-on: ubuntu-latest
if: ${{ github.event_name == 'release' || (needs.changes.outputs.server == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
if: ${{ github.event_name == 'release' || (needs.changes.outputs.server == 'true' && (github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main'))) }}
permissions:
packages: write
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to the GitHub Container registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v4
with:
images: |
arthichaud/meelo-server
ghcr.io/${{ github.repository }}-server
- name: Build and push Docker images
uses: docker/build-push-action@v3
with:
- uses: ./.github/actions/dockerize
with:
docker-username: ${{ secrets.DOCKERHUB_USERNAME }}
docker-token: ${{ secrets.DOCKERHUB_TOKEN }}
github-token: ${{ secrets.GITHUB_TOKEN }}
context: ./server
push: true
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || 'arthichaud/meelo-server:edge' }}
labels: ${{ steps.meta.outputs.labels }}
image-suffix: server
publish: ${{ github.event_name == 'release' || (needs.changes.outputs.server == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
+24 -7
View File
@@ -1,4 +1,4 @@
# <img alt="Icon" src="./front/public/banner.png" width="150px">
# <img alt="Icon" src="./front/assets/banner1-white.png" width="150px">
Welcome! Meelo is a self-hosted music server and web app. It works similarly to Plex, Jellyfin, Koel and Black Candy, but focuses on flexibility, browsing and listening experiences. Actually, Meelo is designed for music collectors. What does it mean? Scroll down to see our killer features :arrow_heading_down:
@@ -10,8 +10,8 @@ While the software wants to be a flexible as possible, it requires a little bit
Meelo has all the basic features you might expect from a music player/server (playback, browsing, search, playlists, libraries ...). But it comes with a handful of features that make it unique :star_struck:
- Music Videos
- Access them directly from an album's or an artist's page
- Music Videos are first-class citizens
- Access them directly from an album's, artist's or song's page
- We also differentiate music video from interviews, behind-the-scenes, trailers...
- Identifies B-Sides
- Meelo shows you any tracks that could be related to an album directly on the album's page!
@@ -37,7 +37,8 @@ Meelo has all the basic features you might expect from a music player/server (pl
- Use either the embedded metadata or the file's name (or both!) to extract metadata
- Also works with album covers!
- Get genres, descriptions, and ratings using MusicBrainz, Genius, Wikipedia and many more providers!
- Download lyrics
- Push Scrobbles to ListenBrainz and LastFM!
- Download (synced) lyrics or get them from embedded metadata and `.lrc` files
Keep track of the upcoming features [here](https://github.com/Arthi-chaud/Meelo/issues)
@@ -46,7 +47,7 @@ Keep track of the upcoming features [here](https://github.com/Arthi-chaud/Meelo/
To use Meelo, you'll need a 'clean' collection: either with embedded metadata or standard file/folder architecture. Using iTunes or Beets is a great way to start.
Meelo is shipped though Docker images. You might need to know a bit about Regexes.
Look over [here](https://arthi-chaud.github.io/Meelo/) for setup instructions.
Look over [here](https://github.com/Arthi-chaud/Meelo/wiki) for setup instructions.
## :desktop_computer: Live Demo
@@ -57,23 +58,39 @@ We are actively working on a public demo, so that you can try out Meelo. Stay tu
<details>
<summary>Unfold to see what the web application looks like!</summary>
https://github.com/user-attachments/assets/48141241-25ec-4570-a4f1-696c0cdfcaaa
---
![Album View](./assets/examples/album-page.png)
![Artist Page](./assets/examples/artist-page.png)
![Player Page](./assets/examples/player.png)
![Release Page](./assets/examples/releases.png)
![Synced Lyrics](./assets/examples/synced-lyrics.gif)
More screenshots [here](./assets/examples/)
</details>
## :handshake: Contributing and Bugs reporting
## 🌐 Translation
:hammer: Would you like to contribute to Meelo? Feel free to [open a pull request](https://github.com/Arthi-chaud/Meelo/compare).
Meelo uses [Weblate](https://weblate.org/en-gb/) to handle translations.
Feel free to contribute [here](https://hosted.weblate.org/engage/meelo/)!
[![Stats](https://hosted.weblate.org/widget/meelo/front/multi-auto.svg)](https://hosted.weblate.org/engage/meelo/)
Any questions about this? Open a discussion [here](https://github.com/Arthi-chaud/Meelo/discussions/categories/translation)!
## :handshake: Contributing and Bugs reporting
:thinking: Is there a feature you would like to have? [Open an issue](https://github.com/Arthi-chaud/Meelo/issues/new/choose), and we will be happy to discuss it!
:wrench: Need help with the setup process? Open a discussion [here](https://github.com/Arthi-chaud/Meelo/discussions/categories/need-help-with-setting-up).
:bug: Have you encountered a bug? Ugh we don't like 'em here! Report it by [opening an issue](https://github.com/Arthi-chaud/Meelo/issues/new/choose).
:hammer: Would you like to contribute to Meelo? Feel free to [open a pull request](https://github.com/Arthi-chaud/Meelo/compare).
---
Get ready to make the most out of you music collection!
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 MiB

+55
View File
@@ -0,0 +1,55 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"vcs": {
"enabled": false,
"clientKind": "git",
"useIgnoreFile": true,
"defaultBranch": "main"
},
"files": {
"ignore": [
"**/.yarn/**",
"**/dist/**",
"**/package.json",
"**/.expo/**"
]
},
"formatter": {
"enabled": true,
"indentStyle": "tab",
"indentWidth": 4
},
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"style": {
"noParameterAssign": "off"
},
"performance": {
"noAccumulatingSpread": "off"
},
"complexity": {
"noBannedTypes": "off"
},
"suspicious": {
"noExplicitAny": "off",
"noConsole": "error"
},
"correctness": {
"noUnusedImports": "warn"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
},
"parser": {
"unsafeParameterDecoratorsEnabled": true
}
}
}
+85 -18
View File
@@ -10,12 +10,20 @@ services:
depends_on:
db:
condition: service_healthy
mq:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- localhost:4000"]
interval: 5s
timeout: 5s
retries: 10
volumes:
- ./server/src:/app/src
- ./server/prisma:/app/prisma
- ./server/test:/app/test
- ./server/package.json:/app/package.json
- ./server/.yarn:/app/.yarn
- server_deps:/app/node_modules
- ./server/.yarnrc.yml:/app/.yarnrc.yml
- ./server/tsconfig.json:/app/tsconfig.json
- ./server/tsconfig.build.json:/app/tsconfig.build.json
@@ -26,34 +34,75 @@ services:
- .env
environment:
- TRANSCODER_URL=http://transcoder:7666
- POSTGRES_HOST=db
- MEILI_HOST=http://meilisearch:7700
- REDIS_HOST=redis
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:5432/${POSTGRES_DB}?schema=public
- RABBITMQ_URL=amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@mq:5672
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public
scanner:
build:
context: ./scanner
dockerfile: Dockerfile.dev
restart: on-failure
depends_on:
server:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- localhost:8133"]
interval: 5s
timeout: 5s
retries: 5
ports:
- "8133:8133"
environment:
- API_URL=http://server:4000
- INTERNAL_CONFIG_DIR=${INTERNAL_CONFIG_DIR}
- INTERNAL_DATA_DIR=${INTERNAL_DATA_DIR}
- API_KEYS=${API_KEYS}
volumes:
- ./scanner:/app
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}:ro
front:
build:
context: ./front
dockerfile: Dockerfile.dev
ports:
- "3000:3000"
depends_on:
server:
condition: service_healthy
scanner:
condition: service_healthy
volumes:
- ./front/src:/app/src
- ./front/public:/app/public
- ./front/next.config.js:/app/next.config.js
- ./front/apps/web/:/app/apps/web/
- ./front/packages:/app/packages
- ./front/translations:/app/translations
- ./front/assets:/app/assets
- ./front/package.json:/app/package.json
- ./front/tsconfig.json:/app/tsconfig.json
- ./front/yarn.lock:/app/yarn.lock
- ./front/tsconfig.base.json:/app/tsconfig.base.json
- front_deps:/app/node_modules
environment:
# This can be commented out. see front/next.config.js
- PUBLIC_SERVER_URL=http://localhost:4000
- SSR_SERVER_URL=http://server:4000
redis:
image: redis:7.0-alpine
healthcheck:
test: ["CMD", "redis-cli","ping"]
interval: 5s
timeout: 5s
retries: 5
- PUBLIC_SCANNER_URL=http://localhost:8133
- SSR_SCANNER_URL=http://scanner:8133
matcher:
build:
context: ./matcher
restart: on-failure
depends_on:
server:
condition: service_healthy
mq:
condition: service_healthy
environment:
- API_URL=http://server:4000
- INTERNAL_CONFIG_DIR=${INTERNAL_CONFIG_DIR}
- API_KEYS=${API_KEYS}
- RABBITMQ_URL=amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@mq:5672
volumes:
- ./matcher:/app
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}:ro
meilisearch:
image: getmeili/meilisearch:v1.5
restart: on-failure
@@ -64,6 +113,7 @@ services:
environment:
- MEILI_ENV=development
- MEILI_MASTER_KEY=${MEILI_MASTER_KEY}
- MEILI_LOG_LEVEL=WARN
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--spider", "http://0.0.0.0:7700/health"]
timeout: 5s
@@ -84,7 +134,9 @@ services:
volumes:
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
- transcoder_cache:/cache
profiles: ['', 'cpu']
depends_on:
db:
condition: service_healthy
db:
image: postgres:alpine3.16
healthcheck:
@@ -94,7 +146,22 @@ services:
retries: 5
env_file:
- .env
mq:
image: rabbitmq:4.0-alpine
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
volumes:
- rabbitmq_data:/var/lib/rabbitmq
volumes:
search:
transcoder_cache:
transcoder_cache:
front_deps:
server_deps:
rabbitmq_data:
+65 -14
View File
@@ -1,7 +1,7 @@
# This compose allows to setup the whole project in the production environment
services:
server:
image: ghcr.io/arthi-chaud/meelo-server:latest
image: arthichaud/meelo-server:${TAG:-latest}
expose:
- 4000
restart: on-failure
@@ -10,6 +10,8 @@ services:
condition: service_healthy
meilisearch:
condition: service_healthy
mq:
condition: service_healthy
volumes:
- ${DATA_DIR}:${INTERNAL_DATA_DIR}
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}
@@ -18,31 +20,62 @@ services:
environment:
- TRANSCODER_URL=http://transcoder:7666
- MEILI_HOST=http://meilisearch:7700
- REDIS_HOST=redis
- POSTGRES_HOST=db
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:5432/${POSTGRES_DB}?schema=public
- RABBITMQ_URL=amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@mq:5672
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public
healthcheck:
test: ["CMD-SHELL", "wget -qO- localhost:4000"]
interval: 5s
timeout: 5s
retries: 5
scanner:
image: arthichaud/meelo-scanner:${TAG:-latest}
expose:
- 8133
depends_on:
server:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- localhost:8133"]
interval: 5s
timeout: 5s
retries: 10
environment:
- API_URL=http://server:4000
- INTERNAL_CONFIG_DIR=${INTERNAL_CONFIG_DIR}
- INTERNAL_DATA_DIR=${INTERNAL_DATA_DIR}
- API_KEYS=${API_KEYS}
volumes:
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}:ro
front:
image: ghcr.io/arthi-chaud/meelo-front:latest
image: arthichaud/meelo-front:${TAG:-latest}
expose:
- 3000
depends_on:
server:
condition: service_healthy
scanner:
condition: service_healthy
environment:
- PUBLIC_SERVER_URL=${PUBLIC_SERVER_URL}
- PUBLIC_SERVER_URL=${PUBLIC_URL}/api
- SSR_SERVER_URL=http://server:4000
redis:
image: redis:7.0-alpine
healthcheck:
test: ["CMD", "redis-cli","ping"]
interval: 5s
timeout: 5s
retries: 5
- PUBLIC_SCANNER_URL=${PUBLIC_URL}/scanner
- SSR_SCANNER_URL=http://scanner:8133
matcher:
image: arthichaud/meelo-matcher:${TAG:-latest}
restart: on-failure
depends_on:
server:
condition: service_healthy
mq:
condition: service_healthy
environment:
- API_URL=http://server:4000
- INTERNAL_CONFIG_DIR=${INTERNAL_CONFIG_DIR}
- API_KEYS=${API_KEYS}
- RABBITMQ_URL=amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@mq:5672
volumes:
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}:ro
db:
image: postgres:alpine3.14
healthcheck:
@@ -64,6 +97,7 @@ services:
environment:
- MEILI_ENV=production
- MEILI_MASTER_KEY=${MEILI_MASTER_KEY}
- MEILI_LOG_LEVEL=WARN
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--spider", "http://0.0.0.0:7700/health"]
timeout: 5s
@@ -84,7 +118,22 @@ services:
volumes:
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
- transcoder_cache:/cache
profiles: ['', 'cpu']
depends_on:
db:
condition: service_healthy
mq:
image: rabbitmq:4.0-alpine
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
volumes:
- rabbitmq_data:/var/lib/rabbitmq
nginx:
restart: on-failure
image: nginx:1.24.0-alpine
@@ -99,6 +148,7 @@ services:
- PORT=5000
- FRONT_URL=http://front:3000
- SERVER_URL=http://server:4000
- SCANNER_URL=http://scanner:8133
volumes:
- ./nginx.conf.template:/etc/nginx/templates/meelo.conf.template:ro
volumes:
@@ -107,3 +157,4 @@ volumes:
data:
search:
transcoder_cache:
rabbitmq_data:
+62 -9
View File
@@ -11,6 +11,8 @@ services:
condition: service_healthy
meilisearch:
condition: service_healthy
mq:
condition: service_healthy
volumes:
- ${DATA_DIR}:${INTERNAL_DATA_DIR}
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}
@@ -19,9 +21,8 @@ services:
environment:
- TRANSCODER_URL=http://transcoder:7666
- MEILI_HOST=http://meilisearch:7700
- REDIS_HOST=redis
- POSTGRES_HOST=db
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:5432/${POSTGRES_DB}?schema=public
- RABBITMQ_URL=amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@mq:5672
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public
healthcheck:
test: ["CMD-SHELL", "wget -qO- localhost:4000"]
interval: 5s
@@ -35,22 +36,59 @@ services:
depends_on:
server:
condition: service_healthy
scanner:
condition: service_healthy
environment:
- PUBLIC_SERVER_URL=${PUBLIC_SERVER_URL}
- PUBLIC_SERVER_URL=${PUBLIC_URL}/api
- SSR_SERVER_URL=http://server:4000
redis:
image: redis:7.0-alpine
- PUBLIC_SCANNER_URL=${PUBLIC_URL}/scanner
- SSR_SCANNER_URL=http://scanner:8133
scanner:
build:
context: ./scanner
expose:
- 8133
depends_on:
server:
condition: service_healthy
healthcheck:
test: ["CMD", "redis-cli","ping"]
test: ["CMD-SHELL", "wget -qO- localhost:8133"]
interval: 5s
timeout: 5s
retries: 5
retries: 10
environment:
- API_URL=http://server:4000
- INTERNAL_CONFIG_DIR=${INTERNAL_CONFIG_DIR}
- INTERNAL_DATA_DIR=${INTERNAL_DATA_DIR}
- API_KEYS=${API_KEYS}
volumes:
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}:ro
matcher:
build:
context: ./matcher
restart: on-failure
depends_on:
server:
condition: service_healthy
mq:
condition: service_healthy
environment:
- API_URL=http://server:4000
- INTERNAL_CONFIG_DIR=${INTERNAL_CONFIG_DIR}
- API_KEYS=${API_KEYS}
- RABBITMQ_URL=amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@mq:5672
volumes:
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}:ro
transcoder:
image: ghcr.io/zoriya/kyoo_transcoder:master
restart: on-failure
cpus: 1
expose:
- 7666
depends_on:
db:
condition: service_healthy
environment:
- GOCODER_SAFE_PATH=${INTERNAL_DATA_DIR}
- POSTGRES_USER=${POSTGRES_USER}
@@ -61,7 +99,6 @@ services:
volumes:
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
- transcoder_cache:/cache
profiles: ['', 'cpu']
db:
image: postgres:alpine3.14
healthcheck:
@@ -83,10 +120,24 @@ services:
environment:
- MEILI_ENV=production
- MEILI_MASTER_KEY=${MEILI_MASTER_KEY}
- MEILI_LOG_LEVEL=WARN
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--spider", "http://0.0.0.0:7700/health"]
timeout: 5s
retries: 5
mq:
image: rabbitmq:4.0-alpine
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
volumes:
- rabbitmq_data:/var/lib/rabbitmq
nginx:
restart: on-failure
image: nginx:1.24.0-alpine
@@ -101,6 +152,7 @@ services:
- PORT=5000
- FRONT_URL=http://front:3000
- SERVER_URL=http://server:4000
- SCANNER_URL=http://scanner:8133
volumes:
- ./nginx.conf.template:/etc/nginx/templates/meelo.conf.template:ro
volumes:
@@ -109,3 +161,4 @@ volumes:
data:
search:
transcoder_cache:
rabbitmq_data:
-1
View File
@@ -1 +0,0 @@
site/
-45
View File
@@ -1,45 +0,0 @@
site_name: Meelo - User Documentation
docs_dir: './src'
repo_url: https://github.com/Arthi-chaud/Meelo
nav:
- Setup:
- TLDR: setup/tldr.md
- Requirements: setup/requirements.md
- Prepare Music Library: setup/prepare-music-library.md
- Create Configuration Folder: setup/create-conf-folder.md
- Prepare Your Settings: setup/settings.md
- Configure Providers: setup/providers.md
- Environment Variables: setup/env.md
- Docker: setup/docker.md
- Start: setup/startup.md
- Create First Library: setup/create-library.md
- Album Types: album-types.md
- Song Types: song-types.md
- Featuring Artists: featuring-artists.md
theme:
name: material
language: en
palette:
scheme: default
titles_only: true
icon:
repo: fontawesome/brands/github
font:
text: Noto Sans
logo: ./icon.png
favicon: ./favicon.ico
features:
- navigation.instant
- navigation.tracking
- navigation.tabs
- navigation.tabs.sticky
- navigation.sections
- navigation.indexes
- toc.integrate
- navigation.top
extra_css:
- stylesheets/extra.css
plugins:
- search
markdown_extensions:
- mdx_truly_sane_lists
-2
View File
@@ -1,2 +0,0 @@
mkdocs-material
mdx_truly_sane_lists
-42
View File
@@ -1,42 +0,0 @@
# Meelo's Album Types
In Music Apps, we usually call any song container an *Album*. While this is technically incorrect, many music streaming services categorizes these *Albums* in sub-categories: Albums, Singles, Live, Compilations, etc.
## Available Types
Meelo supports this categorization, and the following album types:
- Studio Recording
- What we would usually call *Albums*
- Live Recording
- Compilation
- Single
- Soundtrack
- Remix Album
- Collections that would mainly have remixes of songs by a single artist, while not being a Single
- Video Album
- Any kind of collection that would only contain video tracks
## Type recognition
Meelo tries to be as independent of user interactions as possible. Therefore, type recognition is done automatically, when a new album is registered.
For now, Type recognition is solely based on the Album's title. Keywords to recognize an album's type were inspired by the way other music streaming services would categorize an album. Therefore, if your music collection's naming convention work a similar way to Spotify, Apple Music, ..., there might not be a lot to change if you want to fully enjoy this feature. However, if you consider that one of the following ways to recognize a type is incomplete or incorrect, feel free to [open an issue](https://github.com/Arthi-chaud/Meelo/issues).
### Keywords
| Album Type | Keywords |
|---|---|
| Studio Album | See note below |
| Compilations | - `best of`<br>- `hits`<br>- `greatest hits`<br>- `singles`<br>- `collection`<br>+ See note below |
| Live Recording | - '` tour`' (Notice the leading whitespace)<br>- `live from`<br>- `live at`<br>- `unplugged`<br>- `(live)` |
| Single | - `- single`<br>- `- ep`<br>- `(remixes)` |
| Video Album | - `music videos`<br>- `the video`<br>- `dvd` |
| Remix Album | - `remix album`<br>- `mixes`<br>- `the remixes`<br>- `remixed`<br>- `remixes`<br>- `best mixes` |
| Soundtrack | - `soundtrack`<br>- `from the motion picture`<br>- `music from and inspired by` |
Notes:
- Keywords are case-insensitive.
- Any album that would not fall into the other categories will be considered to be a Compilation IF the album is a 'compilation' album (see metadata)
- Any album that would not fall into the other categories will be considered to be a Studio Recording
-1
View File
@@ -1 +0,0 @@
../../front/public/banner-black.png
-1
View File
@@ -1 +0,0 @@
../../front/public/favicon.ico
-53
View File
@@ -1,53 +0,0 @@
# Meelo's Featuring Artist Management
Meelo is now able to parse and recognise featuring artists on a song, based on:
- Its name
- Its artist field (from the metadata)
It does not leverage the album artist.
For example, a song named 'My Song (feat. Another Artist)' by 'Primary Artist & Other Artist' would be parsed to 'My Song' by artist 'Primary artist', featuring 'Other Artist' and 'Another Artist'.
Therefore, each featuring artist will be added to the database, and treated like any other artist.
Parsing works with `()`, `[]` and `-` as delimiters.
## Examples
In this section, we list examples that would be successfully parsed by Meelo
- 'My Song (feat. B)' by 'A'
- Name: 'My Song'
- Artist: 'A'
- Featuring: 'B'
- 'My Song (Remix) [feat. B]' by 'A'
- Name: 'My Song (Remix)'
- Artist: 'A'
- Featuring: 'B'
- 'My Song' by 'A & B'
- Name: 'My Song'
- Artist: 'A'
- Featuring: 'B'
- 'My Song' by 'A, B & C'
- Name: 'My Song'
- Artist: 'A'
- Featuring: 'B', 'C'
- 'My Song - With D' by 'A, B & C'
- Name: 'My Song'
- Artist: 'A'
- Featuring: 'B', 'C', 'D'
- 'My Song - Version With Drums' by 'A'
- Name: 'My Song - Version With Drums'
- Artist: 'A'
- Featuring: None
- 'My Song' by 'A Vs. B'
- Name: 'My Song'
- Artist: 'A'
- Featuring: 'B'
## Special Case: Artist is already known
By default, we use '&' to find and split a list of artist. However, this behaviour changes if an artist containing '&' already exists.
For example, if we had an *album* artist named 'A & B' (since album artist are not parsed as much as song artists), a song named 'My Song (feat. A & B)' would be parsed as 'My Song', with featuring artist 'A & B'.
-1
View File
@@ -1 +0,0 @@
../../front/public/icon.png
-3
View File
@@ -1,3 +0,0 @@
![Meelo](./banner-black.png)
# Welcome to Meelo's User Documentation
-7
View File
@@ -1,7 +0,0 @@
# Create a configuration folder
Meelo is runnable through a Docker container. But it needs a way to be parameterized and persist some data.
Therefore, it is recommended that you create a dedicated folder on your computer for Meelo.
For the sake of clarity, in this documentation, we'll refer to this folder as `Meelo`.
-8
View File
@@ -1,8 +0,0 @@
# Create your first library
After creating the admin account (from Web App),
Visit the `/settings/libraries`, and click `Create Library` to create your first library.
Once your library is created, you should run a scan to collect the related files. To do so, click the *Scan* button.
Your Meelo server is ready to be used! Visit the `/` route on your favourite browser to enjoy your favourite music :).
-5
View File
@@ -1,5 +0,0 @@
# Docker and Docker-compose
In your `Meelo` folder, copy this [docker-compose file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/docker-compose.prod.yml) as `docker-compose.yml`. It can be used as-is.
Additionally, you should copy [nginx configuration file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/nginx.conf.template) as is in your `Meelo` folder.
-3
View File
@@ -1,3 +0,0 @@
# Environment variables
In your `Meelo` directory, copy this [`.env.example`](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/.env.example) as `.env` and fill the variables
-18
View File
@@ -1,18 +0,0 @@
# Prepare your music library
The toughest part is to have an structured music library. The way files are organised are up to you! But here is a recommended architecture:
```tree
Library's Folder
│
Artist 1
│ │
│ └───Album 1
│ │ 01 Track 1.m4a
│ │ 02 Track 2.m4a
| | cover.jpg
│ │ ...
│ ...
```
Meelo provides two ways of collecting metadata: using embedded tags, or using the path of the file. If a metadata source does not provide a specific field, you can use the other as a fallback. (You will configure this behaviour in the next sections). But before going any further, you should decide which metadata collection method to use.
-40
View File
@@ -1,40 +0,0 @@
# Providers
## Enabling Providers
In the `settings.json`, ff the `provider` object does not contain a provider's settings, this provider will be disabled.
Providers can also be disabled explicitly by setting the Provider's settings' field `enabled` at `false`. For example:
```json
// In settings.json
{
//... Other properties
"providers": {
"provider1": {}, // Provider 1 is enabled
// Provider 2 was not specified, it is therefore disabled
"provider3": {
"enabled": true // Provider 3 is enabled
},
"provider4": {
"enabled": false, // Provider 4 is disabled
"apiKey": "" // Other property of provider 4
}
}
}
```
If a provider is disabled, no call to its service will be done.
## Providers' properties and features
| Provider Name | Key | Additional Properties | Artist Description | Artist Illustration | Album Description | Album Rating | Song Description | Song Lyrics | Release Identification | Album Genres |
|---------------|---------------|--------------------------------------------------------------------------------------------|--------------------|---------------------|-------------------|--------------|------------------|-------------|------------------------|--------------|
| Genius | `genius` | `apiKey`: Access Token, you can get it [here](https://genius.com/api-clients) | x | x | x | | | x | | |
| MusicBrainz | `musicbrainz` | None | | | | | | | | x |
| AllMusic | `allmusic` | None | | | x | x | | | | |
| MetaCritic | `metacritic` | None | | | x | x | | | | |
| Wikipedia | `wikipedia` | None | x | | x | | x | | | |
| Discogs | `discogs` | `apiKey`: Access Token, you can get it [here](https://www.discogs.com/settings/developers) | x | | | | | | x | x |
**Warning**: Meelo heavily relies on Wikidata to optimize the metadata fetching. It is used if, and only if the MusicBrainz Provider is enabled. It is not possible to use one without the other.
-8
View File
@@ -1,8 +0,0 @@
# Requirements
Meelo is easy to setup! But you'll need the following things before continuing
- Have you music library ready (see [*this step*](../prepare-music-library))
- Know how `Docker` and `docker-compose` work
- Know how to create a `.env` file
- Know how to manipulate Regular Expressions (Especially the ECMAScript flavor)
-32
View File
@@ -1,32 +0,0 @@
# Prepare your settings
In your `Meelo` folder, download and fill [this `settings.json` file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/settings.json)
All fields are **required**
- `trackRegex`: Probably the most important setting for Meelo. This field takes an array of strings, each string being a Regular Expression string. The RegExp will be used to find the files to import (It will be matched against the **absolute path** of the file). As mentioned before, the path of the file can be used as a metadata source. Therefore, **the Regular Expression accepts groups to find metadata**:
- `AlbumArtist`: The artist of the parent album.
- Special Case: If it equals `Compilations`, related album will be considered as compilation albums (with no main artist, like soundtrack albums)
- `Artist`: The artist of the track. If it is not present, it will use the `AlbumArtist` field.
- `Release`: Usually no present in a standard file architecture; the name of the *release*. When it is not present, the `Album` field will be used.
- `Album`: The name of the album of the material. This field will then be parsed to group related album releases.
- `Year`: The Year of the release of the material
- `Disc`: The Index of the disc the track is on.
- `Index`: The index of the track in a playlist/on a disc.
- `Track`: The name of the track
- `Genre`: The genre of the material
- `DiscogsID`: The Discogs ID of the parent release. A Discogs ID should take the form of a digit-only string. This ID can be found in the URL of the release's Discogs page or in the top-right corner of that page (displayed as `[rXXXXXXXX]`)
- `metadata`: Configuration of the **metadata extraction** system:
- `source`: The **primary metadata source** to use:
- `embedded`: Using the embedded tags
- `path`: Using the Regular Expression's groups.
- `order`: Define the missing metadata fallback behaviour. *Note*: if this method is enabled, it will consider 'cover.*' file in the same directory as the media file as an illustration candidate.
- `only`: If a field is missing from the primary metadata source, will not use the other method to get it.
- `preferred`: If a field is missing from the primary metadata source, will try to use the other method to get it.
- `useExternalProviderGenres`: Set to `true` if you want genres from external metadata providers to be used. These genres will be used alongside with embedded genres (if parsing embedded metadata is enabled)
- `providers`: Please refer to [*Providers*](../providers)
- `compilations`:
- `artists`: Optional field. If an album artist in this list, their albums will be considered to be compilation albums.
- `useID3CompTag`: If true, use iTunes' non-standard ID3 tags for compilations.
*Note*: The provided Regular Expression uses the iTunes file architecture.
-16
View File
@@ -1,16 +0,0 @@
# Start Meelo
Once these three files are ready, run the following command from your `Meelo` folder:
```bash
docker-compose up
```
## Troubleshooting
Two things can prevent Meelo from starting normally:
- A bad `settings.json` file
- In that case, a message should tell you what went wrong
- Connection to database failed
- In that case, you should have a red message telling you a query failed. To fix, simply restart the server
-12
View File
@@ -1,12 +0,0 @@
# TLDR
Don't feel like reading? Here's how to setup Meelo quickly
- Download and fill [this `docker-compose` file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/docker-compose.prod.yml)
- Download and fill [this `nginx` configuration file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/nginx.conf.template)
- Download and fill [this `.env` file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/.env.example)
- Download and fill [this `settings.json` file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/settings.json)
You should be ready to go! If something goes wrong, please take the time to read the `Setup` section of this documentation.
Enjoy!
-29
View File
@@ -1,29 +0,0 @@
# Meelo's Album Types
It is possible to filter songs based on their *type*.
## Available Types
Meelo supports the following song types:
- Original
- Live
- Acoustic
- Remix
- Instrumental
- Acapella
- Edit
- Of the original version.
- If the song is an edit of a remix, it will be categorised as 'Remix'
- Clean
- Demo
## Type recognition
Songs' type recognition is solely based on keywords present in their name. For example:
- 'My Song (Remix)' would be typed as 'Remix'
- 'My Song (Live)' would be recognised as a Live rendition
- 'My Song (Rough Mix)' would be defined as 'Demo'
- 'My Song (Instrumental Mix)' would be an 'Instrumental'
- 'My Song (Instrumental Break Down Mix)' would be a 'Remix'
-3
View File
@@ -1,3 +0,0 @@
:root {
--md-primary-fg-color: #2F2C2B;
}
+1 -1
View File
@@ -1,2 +1,2 @@
node_modules/
.next/
apps/web/.next/
-83
View File
@@ -1,83 +0,0 @@
{
"extends": [
"next/core-web-vitals",
"plugin:prettier/recommended",
"plugin:react/recommended",
"../.eslintrc.json"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": ["./tsconfig.json"]
},
"plugins": ["react", "@typescript-eslint", "unused-imports", "import"],
"ignorePatterns": ["node_modules/", "jest.config.js", "__tests__"],
"rules": {
"react/prop-types": "off",
"react/react-in-jsx-scope": "off",
"react/sort-prop-types": "warn",
"react/jsx-props-no-multi-spaces": "warn",
"react/jsx-max-props-per-line": [
"error",
{ "maximum": 3, "when": "multiline" }
],
"react/jsx-key": "error",
"react/jsx-indent-props": ["warn", "tab"],
"react/jsx-curly-spacing": [
"warn",
{ "when": "never", "children": false }
],
"react/jsx-indent": ["warn", "tab"],
"unused-imports/no-unused-imports": "error",
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "react-query",
"importNames": [
"useQueryClient",
"useQuery",
"useInfiniteQuery",
"useQueries"
],
"message": "Use wrapper functions provided by src/api/query"
},
{
"name": "@mui/material",
"importNames": ["Link"],
"message": "Use Link from 'next/router"
},
{
"name": "@mui/material",
"importNames": ["Modal"],
"message": "Use 'confirm' from 'material-ui-confirm'"
},
{
"name": "@mui/material",
"importNames": ["ThemeProvider"],
"message": "Use wrapper from theme/provider"
},
{
"name": "format-duration",
"message": "Use wrapper"
},
{
"name": "@mui/material",
"importNames": ["Fade"],
"message": "Use wrapper from 'components/fade'"
},
{
"name": "@mui/icons-material",
"message": "Use icons in 'src/components/icons'"
}
],
"patterns": [
{
"group": ["@mui/icons-material/*"],
"message": "Use icons in 'src/components/icons'"
}
]
}
]
}
}
+1 -1
View File
@@ -9,7 +9,7 @@
/coverage
# next.js
/.next/
.next/
/out/
# production
-3
View File
@@ -1,3 +0,0 @@
.next/
node_modules/
.yarn/
-15
View File
@@ -1,15 +0,0 @@
{
"printWidth": 80,
"tabWidth": 4,
"useTabs": true,
"semi": true,
"singleQuote": false,
"quoteProps": "as-needed",
"jsxSingleQuote": false,
"trailingComma": "all",
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "always",
"endOfLine": "lf",
"singleAttributePerLine": false
}
+40 -17
View File
@@ -2,35 +2,58 @@
FROM node:18-alpine3.18 AS base
FROM base AS builder
WORKDIR /app
COPY ./*.json ./.yarnrc.yml ./*.lock ./
# Yarn stuff
COPY ./.yarnrc.yml ./*.lock ./
COPY ./.yarn ./.yarn
RUN yarn workspaces focus
COPY ./next.config.js .
## To Provide static assets at build time
COPY ./public ./public
COPY ./src ./src
ENV NEXT_TELEMETRY_DISABLED 1
RUN yarn build
# Package.json + tsconfig
## Root
COPY package.json tsconfig.base.json ./
## Apps
COPY apps/web/*.json apps/web/
## Libs
COPY packages/*.json packages/
COPY packages/api/*.json packages/api/
COPY packages/models/*.json packages/models/
COPY packages/state/*.json packages/state/
COPY packages/ui/*.json packages/ui/
COPY packages/utils/*.json packages/utils/
FROM base as runner
# Install Libraries
RUN yarn workspaces focus -A
# NextJS
COPY ./apps/web/ apps/web/
COPY ./packages/ packages/
COPY ./translations/ translations/
COPY ./assets/* apps/web/public/
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
RUN yarn build:web
######
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone/apps/web ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone/node_modules ./node_modules
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./public
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD node server.js
CMD ["node", "server.js"]
+12 -5
View File
@@ -1,12 +1,19 @@
FROM node:18-alpine3.18
WORKDIR /app
COPY ./*.json ./
COPY ./*.lock ./
COPY ./.yarnrc.yml ./
COPY ./.yarn /app/.yarn
COPY ./.yarnrc.yml ./*.lock ./
COPY ./.yarn ./.yarn
COPY package.json tsconfig.base.json ./
COPY apps/web/*.json apps/web/
COPY packages/*.json packages/
COPY packages/api/*.json packages/api/
COPY packages/models/*.json packages/models/
COPY packages/state/*.json packages/state/
COPY packages/utils/*.json packages/utils/
RUN yarn install
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
CMD yarn && yarn run start:dev
CMD yarn && cd apps/web && yarn run start:dev
-28
View File
@@ -1,28 +0,0 @@
import userReducer, {
setAccessToken,
setUserProfile,
unsetAccessToken,
} from "../../src/state/userSlice";
describe("User Slice", () => {
let state: ReturnType<typeof userReducer> | undefined = undefined;
it("Should set the access token", () => {
state = userReducer({}, setAccessToken("1234"));
expect(state).toStrictEqual({ accessToken: "1234" });
});
it("Should set the user profile", () => {
const user = { id: 1, name: "user", admin: true, enabled: true };
state = userReducer(state, setUserProfile(user));
expect(state).toStrictEqual({ accessToken: "1234", user });
});
it("Should unset the access token, and user profile", () => {
state = userReducer(state, unsetAccessToken());
expect(state.accessToken).toBeUndefined();
});
});
+41
View File
@@ -0,0 +1,41 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
app-example
android
*.apk
+62
View File
@@ -0,0 +1,62 @@
{
"expo": {
"name": "Meelo",
"slug": "meelo",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon-black.png",
"scheme": "mobile",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"android": {
"icon": "./assets/icon-black.png",
"edgeToEdgeEnabled": true,
"package": "com.arthichaud.meelo"
},
"plugins": [
"expo-router",
[
"expo-splash-screen",
{
"image": "./assets/icon-black.png",
"resizeMode": "contain",
"imageWidth": 200,
"backgroundColor": "#ffffff",
"dark": {
"image": "./assets/icon-white.png",
"backgroundColor": "#242120"
}
}
],
[
"expo-build-properties",
{
"android": {
"usesCleartextTraffic": true
}
}
],
[
"expo-asset",
{
"assets": [
"./assets/banner1_white.png",
"./assets/banner1_black.png"
]
}
]
],
"experiments": {
"typedRoutes": true
},
"extra": {
"router": {},
"eas": {
"projectId": "b8d8dcdb-a905-4674-9c62-db4256e0d94f"
}
},
"ios": {
"bundleIdentifier": "com.arthichaud.meelo"
}
}
}
+1
View File
@@ -0,0 +1 @@
../../../assets/banner1-black.png
+1
View File
@@ -0,0 +1 @@
../../../assets/banner1-white.png
+1
View File
@@ -0,0 +1 @@
../../../assets/icon-black.png
+1
View File
@@ -0,0 +1 @@
../../../assets/icon-white.png
+15
View File
@@ -0,0 +1,15 @@
module.exports = (api) => {
api.cache(true);
return {
presets: ["babel-preset-expo"],
plugins: [
[
"react-native-unistyles/plugin",
{
root: "src",
},
],
"react-native-reanimated/plugin", // Always keep last
],
};
};
+18
View File
@@ -0,0 +1,18 @@
{
"cli": {
"version": ">= 10.2.2"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal"
},
"production": {}
},
"submit": {
"production": {}
}
}
+2
View File
@@ -0,0 +1,2 @@
import "expo-router/entry";
import "./src/theme";
+52
View File
@@ -0,0 +1,52 @@
const { getDefaultConfig } = require("expo/metro-config");
const {
wrapWithReanimatedMetroConfig,
} = require("react-native-reanimated/metro-config");
/** @type {import('expo/metro-config').MetroConfig} */
const defaultConfig = getDefaultConfig(__dirname);
const path = require("node:path");
const ALIASES = {
"iconsax-react": "iconsax-react-nativejs",
};
const projectRoot = __dirname;
function addMonorepoSupport(config) {
const workspaceRoot = path.resolve(projectRoot, "../..");
return {
...config,
watchFolders: [...config.watchFolders, workspaceRoot],
resolver: {
...config.resolver,
nodeModulesPaths: [
...config.resolver.nodeModulesPaths,
path.resolve(projectRoot, "node_modules"),
path.resolve(workspaceRoot, "node_modules"),
],
disableHierarchicalLookup: true,
},
};
}
module.exports = wrapWithReanimatedMetroConfig(
addMonorepoSupport({
...defaultConfig,
resolver: {
...defaultConfig.resolver,
resolveRequest: (context, moduleName, platform) => {
// Ensure you call the default resolver.
return context.resolveRequest(
context,
ALIASES[moduleName] ?? moduleName,
platform,
);
},
requireCycleIgnorePatterns: [
...defaultConfig.resolver.requireCycleIgnorePatterns,
/.*/,
],
},
}),
);
+72
View File
@@ -0,0 +1,72 @@
{
"name": "mobile",
"main": "index.ts",
"version": "1.0.0",
"private": true,
"sideEffects": false,
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web"
},
"dependencies": {
"@expo-google-fonts/rubik": "^0.3.0",
"@shopify/flash-list": "^1.8.3",
"@shopify/react-native-skia": "^2.1.1",
"@tanstack/react-query": "^5.77.0",
"api": "workspace:^",
"expo": "~53.0.9",
"expo-asset": "~11.1.5",
"expo-blur": "^14.1.5",
"expo-build-properties": "^0.14.6",
"expo-constants": "~17.1.6",
"expo-dev-client": "~5.1.8",
"expo-font": "~13.3.1",
"expo-image": "~2.2.0",
"expo-linking": "~7.1.5",
"expo-router": "~5.0.7",
"expo-splash-screen": "~0.30.8",
"expo-status-bar": "~2.2.3",
"expo-system-ui": "^5.0.7",
"expo-web-browser": "^14.2.0",
"i18next": "^23.7.16",
"iconsax-react-nativejs": "^0.0.8",
"infinite-controls": "workspace:^",
"intl-pluralrules": "^2.0.1",
"jotai": "^2.12.5",
"lodash": "^4.17.21",
"models": "workspace:^",
"react": "19.0.0",
"react-hook-form": "^7.58.0",
"react-i18next": "^12.2.2",
"react-native": "0.79.3",
"react-native-blurhash": "^2.1.1",
"react-native-edge-to-edge": "^1.6.1",
"react-native-fast-image": "^8.6.3",
"react-native-keyboard-controller": "^1.17.5",
"react-native-mmkv": "^3.3.0",
"react-native-nitro-modules": "0.26.2",
"react-native-reanimated": "^3.17.3",
"react-native-safe-area-context": "^5.4.1",
"react-native-screens": "~4.11.1",
"react-native-select-dropdown": "^4.0.1",
"react-native-svg": "^15.12.0",
"react-native-unistyles": "^3.0.0-rc.4",
"state": "workspace:^",
"toastify-react-native": "^7.2.0",
"type-fest": "^4.33.0",
"ui": "workspace:^",
"utils": "workspace:^"
},
"installConfig": {
"hoistingLimits": "workspaces"
},
"devDependencies": {
"@babel/core": "^7.25.2",
"@types/lodash": "^4",
"@types/react": "~19.0.10",
"typescript": "~5.8.3"
},
"packageManager": "yarn@4.4.0"
}
+94
View File
@@ -0,0 +1,94 @@
/*
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
* Copyright (C) 2023
*
* Meelo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Meelo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import API from "@/api";
import {
mkUseInfiniteQuery,
mkUseQueries,
mkUseQuery,
mkUseQueryClient,
} from "@/api/hook";
import type { InfiniteQueryFn, QueryFn } from "@/api/query";
import type Resource from "@/models/resource";
import { store } from "@/state/store";
import { useAtom } from "jotai";
import { accessTokenAtom, instanceUrlAtom } from "~/state/user";
export const useAPI = () => {
const [accessToken] = useAtom(accessTokenAtom);
const [instanceUrl] = useAtom(instanceUrlAtom);
return getAPI_(accessToken ?? null, instanceUrl ?? "");
};
// 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 ?? "");
};
export const getAPI_ = (accessToken: string | null, instanceUrl: string) => {
instanceUrl = instanceUrl.replace(/\/$/, "");
const api = new API(accessToken, {
api: `${instanceUrl}/api`,
illustration: `${instanceUrl}/api`,
scanner: `${instanceUrl}/scanner`,
});
return api;
};
/**
* Wrapper for the react-query's *useQuery*
*/
export const useQuery = <
ReturnType,
Params extends any[],
Transformed = ReturnType,
>(
query: QueryFn<ReturnType, Transformed, Params>,
...queryParams: Partial<Params>
) => {
return mkUseQuery(getAPI, query, ...queryParams);
};
/**
* Wrapper for the react-query's *useQueries*
*/
export const useQueries = <ReturnType, Params extends any[][]>(
...queries: readonly Parameters<
typeof useQuery<ReturnType, Params[number], ReturnType>
>[]
) => {
return mkUseQueries<ReturnType, Params>(useAPI, ...(queries as any));
};
/**
* Wrapper for the react-query's *useInfiniteQuery*
*/
export const useInfiniteQuery = <
ReturnType extends Resource,
Params extends any[],
TransformedType = ReturnType,
>(
query: InfiniteQueryFn<ReturnType, TransformedType, Params>,
...queryParams: Partial<Params>
) => {
return mkUseInfiniteQuery(useAPI, query, ...queryParams);
};
export const useQueryClient = () => mkUseQueryClient(getAPI);
@@ -0,0 +1,76 @@
import {
AlbumIcon,
ArtistIcon,
CompilationIcon,
type Icon,
SongIcon,
VideoIcon,
} from "@/ui/icons";
import { type Href, useRouter } from "expo-router";
import { Fragment } from "react";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import { ListItem } from "~/components/item/list-item";
import { SafeView } from "~/components/safe-view";
import { Divider } from "~/primitives/divider";
const tabs: { title: TranslationKey; href: Href; icon: Icon }[] = [
{
title: "models.artist_plural",
href: "/(protected)/(browse)/artists",
icon: ArtistIcon,
},
{
title: "models.album_plural",
href: "/(protected)/(browse)/albums",
icon: AlbumIcon,
},
{
title: "models.song_plural",
href: "/(protected)/(browse)/songs",
icon: SongIcon,
},
{
title: "models.video_plural",
href: "/(protected)/(browse)/videos",
icon: VideoIcon,
},
{
title: "nav.compilations",
href: "/(protected)/(browse)/albums?compilations=true",
icon: CompilationIcon,
},
];
export default function BrowseList() {
const router = useRouter();
const { t } = useTranslation();
return (
<SafeView style={[styles.root]}>
{tabs.map(({ title, href, icon }) => (
<Fragment key={title}>
<ListItem
illustration={null}
illustrationProps={{ fallbackIcon: icon }}
title={t(title)}
subtitle={null}
onPress={() => router.push(href)}
/>
<Divider h withInsets />
</Fragment>
))}
</SafeView>
);
}
const styles = StyleSheet.create((theme) => ({
root: {
padding: theme.gap(0.5),
display: "flex",
height: "100%",
alignItems: "flex-start",
justifyContent: "flex-start",
},
}));
@@ -0,0 +1,185 @@
/*
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
* Copyright (C) 2023
*
* Meelo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Meelo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { getAlbums, getArtists, getReleases, getSongs } from "@/api/queries";
import type { AlbumSortingKey } from "@/models/album";
import type { ArtistSortingKey } from "@/models/artist";
import type { SongSortingKey } from "@/models/song";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import { useInfiniteQuery } from "~/api";
import { useSetKeyIllustration } from "~/components/background-gradient";
import { AlbumTile } from "~/components/item/resource/album";
import { ArtistTile } from "~/components/item/resource/artist";
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 type { Sorting } from "~/utils/sorting";
const styles = StyleSheet.create((theme) => ({
main: { paddingTop: theme.gap(2), gap: theme.gap(2) },
lastSection: {
paddingBottom: theme.gap(1),
},
title: { paddingLeft: theme.gap(2) },
}));
export default function Root() {
const { t } = useTranslation();
const newlyAddedAlbums = useInfiniteQuery(() =>
getAlbums({}, { sortBy: "addDate", order: "desc" }, [
"illustration",
"artist",
]),
);
const newlyAddedArtists = useInfiniteQuery(() =>
getArtists({}, { sortBy: "addDate", order: "desc" }, ["illustration"]),
);
const latestAlbums = useInfiniteQuery(() =>
getAlbums({}, { sortBy: "releaseDate", order: "desc" }, [
"illustration",
"artist",
]),
);
const newlyAddedReleases = useInfiniteQuery(() =>
getReleases({}, { sortBy: "addDate", order: "desc" }, ["illustration"]),
);
useSetKeyIllustration(newlyAddedAlbums.items?.at(0));
const topSongs = useInfiniteQuery(() =>
getSongs({}, { sortBy: "userPlayCount", order: "desc" }, [
"artist",
"featuring",
"master",
"illustration",
]),
);
const sync = useCallback(
<T,>(q: T) => {
if (
!!newlyAddedAlbums.data &&
!!newlyAddedArtists.data &&
!!latestAlbums.data &&
!!topSongs.data &&
!!newlyAddedReleases.data
) {
return q;
}
return undefined;
},
[
newlyAddedAlbums.data,
newlyAddedArtists.data,
latestAlbums.data,
topSongs.data,
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) => {
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) => {
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) => {
return <ReleaseTile release={release} />;
}}
/>
{/* TODO Genres*/}
<SongGrid
seeMore={
topSongs.hasNextPage
? {
pathname: "/songs",
params: {
sort: "userPlayCount",
order: "desc",
} satisfies Sorting<SongSortingKey>,
}
: undefined
}
header={t("home.mostPlayedSongs")}
songs={sync(topSongs.data?.pages.at(0)?.items)}
subtitle={() => "artists"}
style={styles.lastSection}
/>
</SafeScrollView>
);
}
@@ -0,0 +1,177 @@
import { BackIcon } from "@/ui/icons";
import { Stack, useNavigation } from "expo-router";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { BlurView } from "~/components/blur-view";
import { Icon as MeeloIcon } from "~/components/meelo";
import { Icon } from "~/primitives/icon";
import { Pressable } from "~/primitives/pressable";
export const unstable_settings = {
home: {
initialRouteName: "index",
},
};
const styles = StyleSheet.create((theme) => ({
screen: {
backgroundColor: "transparent",
flex: 1,
},
headerBgContainer: {
position: "absolute",
height: "100%",
width: "100%",
overflow: "hidden",
borderBottomLeftRadius: theme.borderRadius,
borderBottomRightRadius: theme.borderRadius,
},
headerBgContent: {
flex: 1,
},
icon: { marginRight: theme.gap(2) },
backButtonContainer: { paddingRight: theme.gap(2), paddingLeft: 0 },
headerTitle: {
color: theme.colors.text.primary,
...theme.fontStyles.regular,
},
}));
const BackButton = () => {
const navigator = useNavigation();
return (
navigator.canGoBack() && (
<Pressable
style={styles.backButtonContainer}
onPress={() => navigator.goBack()}
>
<Icon icon={BackIcon} />
</Pressable>
)
);
};
const SharedRoutes: {
name: string;
options: { headerTitle: TranslationKey | null };
}[] = [
{ name: "artists/index", options: { headerTitle: "models.artist_plural" } },
{ name: "artists/[id]", options: { headerTitle: null } },
{ name: "albums/index", options: { headerTitle: "models.album_plural" } },
{ name: "songs/index", options: { headerTitle: "models.song_plural" } },
{ name: "videos/index", options: { headerTitle: "models.video_plural" } },
{ name: "releases/[id]", options: { headerTitle: null } },
];
export default function Layout({ segment }: { segment: string }) {
const { t } = useTranslation();
// Cant memo-ise this, we need this to be updated when theme changes
const screenOptions = {
contentStyle: [styles.screen],
// headerStyle: styles.header,
headerTitleStyle: styles.headerTitle,
headerLeft: () => <BackButton />,
headerBackground: () => (
<View style={styles.headerBgContainer}>
<BlurView style={styles.headerBgContent} />
</View>
),
headerTransparent: true,
headerTintColor: styles.headerTitle.color,
};
switch (segment) {
case "(home)": {
return (
<Stack screenOptions={screenOptions}>
<Stack.Screen
name="index"
options={{
headerTitle: t("nav.home"),
headerLeft: () => <MeeloIcon style={styles.icon} />,
}}
/>
{SharedRoutes.map(
(
{ name, options: { headerTitle, ...options } },
idx,
) => (
<Stack.Screen
key={idx}
name={name}
options={{
headerTitle: headerTitle
? t(headerTitle)
: "",
...options,
}}
/>
),
)}
</Stack>
);
}
case "(browse)": {
return (
<Stack screenOptions={screenOptions}>
<Stack.Screen
name="index"
options={{
headerTitle: t("nav.browse"),
// TODO IDK why we canGoBack when we are at the browse's index
headerLeft: () => <View />,
}}
/>
{SharedRoutes.map(
(
{ name, options: { headerTitle, ...options } },
idx,
) => (
<Stack.Screen
key={idx}
name={name}
options={{
headerTitle: headerTitle
? t(headerTitle)
: "",
...options,
}}
/>
),
)}
</Stack>
);
}
case "(search)": {
return (
<Stack screenOptions={screenOptions}>
<Stack.Screen
name="index"
options={{
headerTitle: t("nav.search"),
// TODO IDK why we canGoBack when we are at the browse's index
headerLeft: () => <View />,
}}
/>
{SharedRoutes.map(
(
{ name, options: { headerTitle, ...options } },
idx,
) => (
<Stack.Screen
key={idx}
name={name}
options={{
headerTitle: headerTitle
? t(headerTitle)
: "",
...options,
}}
/>
),
)}
</Stack>
);
}
}
}
@@ -0,0 +1,98 @@
/*
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
* Copyright (C) 2023
*
* Meelo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Meelo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { getAlbums, getArtist } from "@/api/queries";
import { AlbumSortingKeys, AlbumType } from "@/models/album";
import { albumTypeToTranslationKey } from "@/models/utils";
import { Stack, useLocalSearchParams } from "expo-router";
import { useTranslation } from "react-i18next";
import { useQuery } from "~/api";
import { ArtistHeader } from "~/components/artist-header";
import {
useLibraryFiltersControl,
useTypeFiltersControl,
} from "~/components/infinite/controls/filters";
import { useLayoutControl } from "~/components/infinite/controls/layout";
import { useSortControl } from "~/components/infinite/controls/sort";
import { InfiniteView } from "~/components/infinite/view";
import { AlbumItem } from "~/components/item/resource/album";
import { AlbumTile } from "~/components/item/resource/album";
export default function AlbumBrowseView() {
const { t } = useTranslation();
const { compilations, artist: artistId } = useLocalSearchParams<{
compilations?: "true";
artist?: string;
}>();
const [{ layout }, layoutControl] = useLayoutControl({
defaultLayout: "grid",
enableToggle: true,
});
const [{ sort, order }, sortControl] = useSortControl({
sortingKeys: AlbumSortingKeys,
translate: (s) => `browsing.controls.sort.${s}`,
});
const [libraries, libraryFilterControl] = useLibraryFiltersControl();
const [types, albumTypeFilterControl] = useTypeFiltersControl({
types: AlbumType,
translate: (t) => albumTypeToTranslationKey(t, false),
});
const { data: artist } = useQuery(
(artistId) => getArtist(artistId, ["illustration"]),
artistId,
);
const Item = layout === "list" ? AlbumItem : AlbumTile;
return (
<>
<Stack.Screen
options={{
headerTitle: t(
compilations
? "nav.compilations"
: "models.album_plural",
),
}}
/>
<InfiniteView
layout={layout}
header={artistId ? <ArtistHeader artist={artist} /> : undefined}
controls={{
layout: layoutControl,
sort: sortControl,
filters: [libraryFilterControl, albumTypeFilterControl],
}}
query={getAlbums(
{
library: libraries,
type: types,
artist: compilations ? "compilations" : artistId,
},
{ sortBy: sort ?? "name", order: order ?? "asc" },
["artist", "illustration"],
)}
render={(album) => (
<Item
album={album}
subtitle={artistId ? "year" : "artistName"}
illustrationProps={{ simpleColorPlaceholder: true }}
/>
)}
/>
</>
);
}
@@ -0,0 +1,256 @@
import {
getAlbums,
getArtist,
getArtistExternalMetadata,
getSongs,
getVideos,
} from "@/api/queries";
import { type AlbumSortingKey, AlbumType } from "@/models/album";
import { albumTypeToTranslationKey } from "@/models/utils";
import { VideoTypeIsExtra } from "@/models/video";
import type { VideoSortingKey } from "@/models/video";
import { useLocalSearchParams } from "expo-router";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import { useInfiniteQuery, useQuery } from "~/api";
import { ArtistHeader } from "~/components/artist-header";
import { useSetKeyIllustration } from "~/components/background-gradient";
import {
ExternalMetadataDescriptionSection,
ExternalMetadataSourcesSection,
} from "~/components/external-metadata";
import { AlbumTile } from "~/components/item/resource/album";
import { VideoTile } from "~/components/item/resource/video";
import { Row } from "~/components/row";
import { SafeScrollView } from "~/components/safe-view";
import { SongGrid } from "~/components/song-grid";
import type { Sorting } from "~/utils/sorting";
const albumTypeQuery = (albumType: AlbumType, artistId: string) =>
getAlbums(
{ artist: artistId, type: [albumType] },
{ sortBy: "releaseDate", order: "desc" },
["illustration", "artist"],
);
// Note: we show the 'seeMore' button for song grids and video rows iff there's more items than ShowSeeMoreThreshold
// since the user may want to shuffle them through the dedicated page
const ShowSeeMoreThreshold = 3;
export default function ArtistView() {
const { id: artistId } = useLocalSearchParams<{ id: string }>();
const { t } = useTranslation();
const { data: artist } = useQuery(() =>
getArtist(artistId, ["illustration"]),
);
const topSongs = useInfiniteQuery(() =>
getSongs(
{ artist: artistId },
{ sortBy: "totalPlayCount", order: "desc" },
["artist", "featuring", "master", "illustration"],
),
);
const rareSongs = useInfiniteQuery(() =>
getSongs({ rare: artistId }, { sortBy: "releaseDate", order: "desc" }, [
"artist",
"featuring",
"master",
"illustration",
]),
);
const videos = useInfiniteQuery(() =>
getVideos({ artist: artistId }, { sortBy: "addDate", order: "desc" }, [
"artist",
"master",
"illustration",
]),
);
const relatedAlbums = useInfiniteQuery(() =>
getAlbums(
{ appearance: artistId },
{ sortBy: "releaseDate", order: "desc" },
["artist", "illustration"],
),
);
const { data: externalMetadata } = useQuery(
(artistId) => getArtistExternalMetadata(artistId),
artistId,
);
const { musicVideos, liveVideos, extras } = useMemo(() => {
return {
musicVideos: videos.items?.filter(
(video) =>
!VideoTypeIsExtra(video.type) && video.type !== "Live",
),
liveVideos: videos.items?.filter((video) => video.type === "Live"),
extras: videos.items?.filter((video) =>
VideoTypeIsExtra(video.type),
),
};
}, [videos]);
useSetKeyIllustration(artist ?? undefined);
return (
<>
<SafeScrollView>
<ArtistHeader artist={artist} />
<SongGrid
header={t("artist.topSongs")}
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}
/>
))}
<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}
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 }) => (
<Row
hideIfEmpty
key={label}
items={items}
style={styles.section}
header={t(`browsing.sections.${label}`)}
render={(item) => (
<VideoTile
video={item}
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
}
/>
))}
<Row
hideIfEmpty
style={styles.section}
header={t("artist.appearsOn")}
items={relatedAlbums.items}
render={(album) => (
<AlbumTile album={album} subtitle="artistName" />
)}
/>
{externalMetadata !== null && (
<>
<ExternalMetadataDescriptionSection
externalMetadata={externalMetadata}
style={styles.section}
/>
<ExternalMetadataSourcesSection
externalMetadata={externalMetadata}
style={styles.section}
/>
</>
)}
</SafeScrollView>
</>
);
}
const AlbumTypeRow = ({
type,
artistId,
}: { type: AlbumType; artistId: string }) => {
const { t } = useTranslation();
const query = useInfiniteQuery(() => albumTypeQuery(type, artistId));
return (
<Row
hideIfEmpty
style={styles.section}
header={t(albumTypeToTranslationKey(type, true))}
items={query.items}
render={(item) => <AlbumTile album={item} subtitle="year" />}
seeMore={
query.hasNextPage
? {
pathname: "/albums",
params: {
artist: artistId,
sort: "releaseDate",
order: "desc",
type: type,
} satisfies Sorting<AlbumSortingKey> & {
type: AlbumType;
artist: string;
},
}
: undefined
}
/>
);
};
const styles = StyleSheet.create((theme) => ({
root: {
flex: 1,
},
section: {
paddingBottom: theme.gap(1),
},
}));
@@ -0,0 +1,60 @@
/*
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
* Copyright (C) 2023
*
* Meelo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Meelo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { getArtists } from "@/api/queries";
import { ArtistSortingKeys } from "@/models/artist";
import { useLibraryFiltersControl } from "~/components/infinite/controls/filters";
import { useLayoutControl } from "~/components/infinite/controls/layout";
import { useSortControl } from "~/components/infinite/controls/sort";
import { InfiniteView } from "~/components/infinite/view";
import { ArtistItem } from "~/components/item/resource/artist";
import { ArtistTile } from "~/components/item/resource/artist";
export default function ArtistBrowseView() {
const [{ layout }, layoutControl] = useLayoutControl({
defaultLayout: "list",
enableToggle: true,
});
const [{ sort, order }, sortControl] = useSortControl({
sortingKeys: ArtistSortingKeys,
translate: (s) => `browsing.controls.sort.${s}`,
});
const [libraries, libraryFilterControl] = useLibraryFiltersControl();
const Item = layout === "list" ? ArtistItem : ArtistTile;
return (
<InfiniteView
layout={layout}
controls={{
layout: layoutControl,
sort: sortControl,
filters: [libraryFilterControl],
}}
query={getArtists(
{ library: libraries },
{ sortBy: sort ?? "name", order: order ?? "asc" },
["illustration"],
)}
render={(artist) => (
<Item
artist={artist}
illustrationProps={{ simpleColorPlaceholder: true }}
/>
)}
/>
);
}
@@ -0,0 +1,8 @@
import { useLocalSearchParams } from "expo-router";
import ReleasePage from "~/pages/release";
export default function Page() {
const { id: releaseId } = useLocalSearchParams<{ id: string }>();
return <ReleasePage releaseId={releaseId} />;
}
@@ -0,0 +1,114 @@
/*
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
* Copyright (C) 2023
*
* Meelo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Meelo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { getArtist, getSongs } from "@/api/queries";
import {
SongSortingKeys,
SongType,
type SongWithRelations,
} from "@/models/song";
import { songTypeToTranslationKey } from "@/models/utils";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "~/api";
import { ArtistHeader } from "~/components/artist-header";
import {
useLibraryFiltersControl,
useTypeFiltersControl,
} from "~/components/infinite/controls/filters";
import { useSortControl } from "~/components/infinite/controls/sort";
import { InfiniteView } from "~/components/infinite/view";
import { SongItem } from "~/components/item/resource/song";
// TODO Handle Genre Query param
// TODO Handle song version filtering
// TODO song subtitle: allow it to be album
export default function SongBrowseView() {
const nav = useNavigation();
const { t } = useTranslation();
const { artist: artistId, rare: rareArtistId } = useLocalSearchParams<{
artist?: string;
rare?: string;
}>();
const [{ sort, order }, sortControl] = useSortControl({
sortingKeys: SongSortingKeys,
translate: (s) => `browsing.controls.sort.${s}`,
});
const [libraries, libraryFilterControl] = useLibraryFiltersControl();
const [types, songTypeFilterControl] = useTypeFiltersControl({
types: SongType,
translate: (t) => songTypeToTranslationKey(t, false),
});
const { data: artist } = useQuery(
(artistId) => getArtist(artistId, ["illustration"]),
artistId ?? rareArtistId,
);
useEffect(() => {
if (rareArtistId !== undefined) {
nav.setOptions({ headerTitle: t("artist.rareSongs") });
}
}, [rareArtistId]);
const subtitle = useCallback(
(song: SongWithRelations<"featuring"> | undefined) => {
if (artistId === undefined && rareArtistId === undefined) {
return "artists";
}
if (!song || !artist) {
return null;
}
if (song.featuring.length > 0 || song.artistId !== artist.id) {
return "artists";
}
return null;
},
[artistId, rareArtistId],
);
return (
<InfiniteView
layout={"list"}
header={
(artistId ?? rareArtistId) !== undefined ? (
<ArtistHeader artist={artist} />
) : undefined
}
controls={{
sort: sortControl,
filters: [libraryFilterControl, songTypeFilterControl],
}}
query={getSongs(
{
library: libraries,
type: types,
artist: artistId,
rare: rareArtistId,
},
{ sortBy: sort ?? "name", order: order ?? "asc" },
["artist", "illustration", "featuring"],
)}
render={(song) => (
<SongItem
song={song}
subtitle={subtitle(song)}
illustrationProps={{ simpleColorPlaceholder: true }}
/>
)}
/>
);
}
@@ -0,0 +1,85 @@
/*
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
* Copyright (C) 2023
*
* Meelo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Meelo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { getArtist, getVideos } from "@/api/queries";
import { videoTypeToTranslationKey } from "@/models/utils";
import { VideoSortingKeys, VideoType } from "@/models/video";
import { useLocalSearchParams } from "expo-router";
import { useMemo } from "react";
import { useQuery } from "~/api";
import { ArtistHeader } from "~/components/artist-header";
import {
useLibraryFiltersControl,
useTypeFiltersControl,
} from "~/components/infinite/controls/filters";
import { useLayoutControl } from "~/components/infinite/controls/layout";
import { useSortControl } from "~/components/infinite/controls/sort";
import { InfiniteView } from "~/components/infinite/view";
import { VideoItem } from "~/components/item/resource/video";
import { VideoTile } from "~/components/item/resource/video";
export default function VideoBrowseView() {
const { artist: artistId } = useLocalSearchParams<{ artist?: string }>();
const [{ layout }, layoutControl] = useLayoutControl({
defaultLayout: "grid",
enableToggle: true,
});
const [{ sort, order }, sortControl] = useSortControl({
sortingKeys: VideoSortingKeys,
translate: (s) => `browsing.controls.sort.${s}`,
});
const [libraries, libraryFilterControl] = useLibraryFiltersControl();
const [types, albumTypeFilterControl] = useTypeFiltersControl({
types: VideoType,
translate: (t) => videoTypeToTranslationKey(t, false),
});
const Item = useMemo(
() => (layout === "list" ? VideoItem : VideoTile),
[layout],
);
const { data: artist } = useQuery(
(artistId) => getArtist(artistId, ["illustration"]),
artistId,
);
return (
<InfiniteView
layout={layout}
header={artistId ? <ArtistHeader artist={artist} /> : undefined}
controls={{
layout: layoutControl,
sort: sortControl,
filters: [libraryFilterControl, albumTypeFilterControl],
}}
query={getVideos(
{ library: libraries, type: types, artist: artistId },
{ sortBy: sort ?? "name", order: order ?? "asc" },
["artist", "illustration", "master"],
)}
render={(video) => (
<Item
video={video}
subtitle={artistId ? "duration" : "artistName"}
illustrationProps={{
simpleColorPlaceholder: true,
normalizedThumbnail: true,
}}
/>
)}
/>
);
}
@@ -0,0 +1,142 @@
/*
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
* Copyright (C) 2023
*
* Meelo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Meelo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { getSearchHistory, searchAll } from "@/api/queries";
import {
type InfiniteQuery,
type Query,
toInfiniteQuery,
transformPage,
} from "@/api/query";
import type Illustration from "@/models/illustration";
import type Resource from "@/models/resource";
import type { SaveSearchItem, SearchResult } from "@/models/search";
import { useMutation } from "@tanstack/react-query";
import { useCallback, useMemo, useRef, useState } from "react";
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useAPI, useQueryClient } from "~/api";
import { InfiniteView } from "~/components/infinite/view";
import { SearchResultItem } from "~/components/item/resource/search-result";
import { TextInput } from "~/primitives/text_input";
//TODO Allow filtering by type (artist, album, etc.)
export default function SearchView() {
const timeoutRef = useRef<number>(null);
const api = useAPI();
const queryClient = useQueryClient();
const [searchValue, setSearchValue] = useState("");
const debounceSearch = useCallback((searchValue: string) => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
setSearchValue(searchValue);
}, 500);
}, []);
const query = useMemo(() => {
if (searchValue.trim() === "") {
return searchResultQueryToResourceQuery(getSearchHistory());
}
return searchResultQueryToResourceQuery(searchAll(searchValue));
}, [searchValue]);
const saveSearch = useMutation({
mutationFn: async (item: SearchResult) => {
try {
const dto: SaveSearchItem = item.artist
? { artistId: item.artist.id }
: item.album
? { albumId: item.album.id }
: item.song
? { songId: item.song.id }
: { videoId: item.video.id };
await api.saveSearchHistoryEntry(dto);
// Sometimes, it refreshes to fast, and shifts the history
// before openning a page (for artists) is done
setTimeout(() => {
queryClient.client.invalidateQueries({
queryKey: getSearchHistory().key,
});
}, 500);
} catch (error) {
// biome-ignore lint/suspicious/noConsole: <explanation>
console.error(error);
}
},
});
return (
<InfiniteView
header={
<View style={styles.searchHeader}>
<TextInput
containerStyle={{ backgroundColor: "transparent" }}
onChangeText={debounceSearch}
/>
</View>
}
// NOTE: Since the original type of the query is SearchResult, it does not have an id,
// that's why it complains
query={
query as unknown as InfiniteQuery<
Resource,
IllustratedSearchResultWithId
>
}
controls={{}}
render={(item) => (
<SearchResultItem
searchResult={item}
onPress={() => item && saveSearch.mutate(item)}
/>
)}
layout="list"
/>
);
}
type IllustratedSearchResultWithId = {
id: number;
illustration: Illustration | null;
} & SearchResult;
const searchResultQueryToResourceQuery = (query: Query<SearchResult[]>) => {
return transformPage(toInfiniteQuery(query), (item) => {
return {
...item,
id: item.song
? `song-${item.song.id}`
: item.album
? `album-${item.album.id}`
: item.video
? `video-${item.video.id}`
: `artist-${item.artist.id}`,
illustration: (item.album ?? item.artist ?? item.song ?? item.video)
?.illustration,
};
});
};
const styles = StyleSheet.create((theme) => ({
searchHeader: {
paddingTop: theme.gap(2),
//To balance with the default top padding from root view style
paddingBottom: theme.gap(3),
alignItems: "center",
},
}));
@@ -0,0 +1,150 @@
/*
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
* Copyright (C) 2023
*
* Meelo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Meelo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { getCurrentUserStatus } from "@/api/queries";
import { toTanStackQuery } from "@/api/query";
import {
BrowseIcon,
HomeIcon,
type Icon as IconType,
SearchIcon,
SettingsIcon,
} from "@/ui/icons";
import { useQuery as useTanStackQuery } from "@tanstack/react-query";
import { Redirect, Tabs } from "expo-router";
import { useAtomValue } from "jotai";
import { useTranslation } from "react-i18next";
import { TouchableOpacity, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useAPI } from "~/api";
import { BlurView } from "~/components/blur-view";
import { Icon } from "~/primitives/icon";
import { accessTokenAtom, instanceUrlAtom } 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
//However, the latter does not work
const styles = StyleSheet.create((theme) => ({
screen: { backgroundColor: "transparent", flex: 1 },
headerTitle: {
color: theme.colors.text.primary,
...theme.fontStyles.regular,
},
headerBackgroundContainer: {
...StyleSheet.absoluteFillObject,
borderBottomLeftRadius: theme.borderRadius,
borderBottomRightRadius: theme.borderRadius,
overflow: "hidden",
},
headerBackgroundContent: { flex: 1 },
tabBar: {
position: "absolute",
paddingTop: theme.gap(1),
borderTopWidth: 0,
},
tabBarBackground: {
...StyleSheet.absoluteFillObject,
borderTopLeftRadius: theme.borderRadius,
borderTopRightRadius: theme.borderRadius,
overflow: "hidden",
backgroundColor: "transparent",
},
}));
const TabIcon = ({ icon, focused }: { icon: IconType; focused: boolean }) => {
return <Icon icon={icon} variant={focused ? "Bold" : "Outline"} />;
};
// Note: if we use our pressable, there is a delay between when the button become bold and when opacity gets back to 1
const TabButton = (props: any) => <TouchableOpacity {...props} />;
export default function ProtectedLayout() {
const accessToken = useAtomValue(accessTokenAtom);
const instanceUrl = useAtomValue(instanceUrlAtom);
const api = useAPI();
const user = useTanStackQuery({
...toTanStackQuery(api, getCurrentUserStatus),
enabled: !!(accessToken && instanceUrl),
});
const { t } = useTranslation();
if (!accessToken || !instanceUrl || user.error) {
return <Redirect href="/auth" />;
}
//TODO Proper handling of when user is loading
return (
<Tabs
screenOptions={{
animation: "none",
headerShown: false,
sceneStyle: styles.screen,
tabBarShowLabel: false,
tabBarStyle: styles.tabBar,
tabBarBackground: () => (
<BlurView style={styles.tabBarBackground} />
),
tabBarButton: TabButton,
}}
>
<Tabs.Screen
name="(home)"
options={{
title: t("nav.home"),
tabBarIcon: ({ focused }) => (
<TabIcon icon={HomeIcon} focused={focused} />
),
}}
/>
<Tabs.Screen
name="(browse)"
options={{
title: t("nav.browse"),
tabBarIcon: ({ focused }) => (
<TabIcon icon={BrowseIcon} focused={focused} />
),
}}
/>
<Tabs.Screen
name="(search)"
options={{
title: t("nav.search"),
tabBarIcon: ({ focused }) => (
<TabIcon icon={SearchIcon} focused={focused} />
),
}}
/>
<Tabs.Screen
name="settings"
options={{
headerShown: true,
headerTransparent: true,
headerTitleStyle: styles.headerTitle,
headerBackground: () => (
<View style={styles.headerBackgroundContainer}>
<BlurView style={styles.headerBackgroundContent} />
</View>
),
title: t("nav.settings"),
tabBarIcon: ({ focused }) => (
<TabIcon icon={SettingsIcon} focused={focused} />
),
}}
/>
</Tabs>
);
}
@@ -0,0 +1,179 @@
/*
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
* Copyright (C) 2023
*
* Meelo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Meelo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { CheckIcon, ExpandMoreIcon, UncheckIcon } from "@/ui/icons";
import i18next from "i18next";
import { useAtom, useSetAtom } from "jotai";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { Dropdown } from "~/components/dropdown";
import { SafeScrollView } from "~/components/safe-view";
import { SectionHeader } from "~/components/section-header";
import { useColorScheme } from "~/hooks/color-scheme";
import { Button } from "~/primitives/button";
import { Divider } from "~/primitives/divider";
import { Icon } from "~/primitives/icon";
import { Pressable } from "~/primitives/pressable";
import { Text } from "~/primitives/text";
import { colorSchemePreference } from "~/state/color-scheme";
import { languagePreference } from "~/state/lang";
import { accessTokenAtom, instanceUrlAtom } from "~/state/user";
import { Languages } from "../../../../../translations";
// TODO When setting dark/light mode using settings (not the auto mode)
// header text color is not updated
export default function SettingsView() {
const { t } = useTranslation();
const setAccessToken = useSetAtom(accessTokenAtom);
const setInstanceUrl = useSetAtom(instanceUrlAtom);
const [colorSchemePref, setColorSchemePref] = useAtom(
colorSchemePreference,
);
const [lng, setLng] = useAtom(languagePreference);
const actualColorScheme = useColorScheme();
return (
<SafeScrollView style={styles.root}>
<View style={styles.section}>
<SectionHeader
style={styles.sectionHeader}
content={t("settings.interface")}
skeletonWidth={0}
/>
<View style={styles.sectionRow}>
<Text
content={t("settings.ui.useSystemTheme")}
variant="h5"
/>
<Pressable
onPress={() => {
if (colorSchemePref === "system") {
setColorSchemePref(actualColorScheme);
} else {
setColorSchemePref("system");
}
}}
>
<Icon
variant={
colorSchemePref === "system"
? "Bold"
: "Outline"
}
icon={
colorSchemePref === "system"
? CheckIcon
: UncheckIcon
}
/>
</Pressable>
</View>
<View style={styles.sectionRow}>
<Text
content={t("settings.ui.useDarkTheme")}
variant="h5"
/>
<Pressable
disabled={colorSchemePref === "system"}
onPress={() => {
setColorSchemePref(
colorSchemePref === "dark" ? "light" : "dark",
);
}}
>
<Icon
style={
colorSchemePref === "system"
? styles.disabledCheckButton
: undefined
}
icon={
actualColorScheme === "dark"
? CheckIcon
: UncheckIcon
}
/>
</Pressable>
</View>
<View style={styles.sectionRow}>
<Text content={t("settings.ui.language")} variant="h5" />
<Dropdown
values={Languages}
isSelected={(item) => lng === item}
buttonProps={{
title: t(
`settings.ui.lang.${i18next.language as "en"}`,
),
icon: ExpandMoreIcon,
iconPosition: "right",
size: "small",
}}
onSelect={(selected) => {
setLng(selected);
}}
formatItem={(item) =>
t(`settings.ui.lang.${item}`, {
lng: item,
})
}
/>
</View>
</View>
<Divider h />
<View style={styles.section}>
<SectionHeader
style={styles.sectionHeader}
content={t("actions.logout")}
skeletonWidth={0}
/>
<Button
title={t("actions.logout")}
containerStyle={{ alignItems: "center" }}
labelStyle={styles.logoutButtonStyle}
onPress={() => {
setAccessToken(null);
setInstanceUrl(null);
}}
/>
</View>
</SafeScrollView>
);
}
const styles = StyleSheet.create((theme) => ({
root: { paddingHorizontal: theme.gap(1) },
section: {
paddingVertical: theme.gap(1),
gap: theme.gap(1),
},
sectionHeader: { marginLeft: -theme.gap(1) },
sectionRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
width: "100%",
paddingBottom: theme.gap(0.5),
paddingHorizontal: theme.gap(1),
},
disabledCheckButton: { color: theme.colors.text.secondary },
logoutButtonStyle: { flex: 1 },
}));
+173
View File
@@ -0,0 +1,173 @@
/*
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
* Copyright (C) 2023
*
* Meelo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Meelo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { store } from "@/state/store";
import {
Rubik_300Light,
Rubik_400Regular,
Rubik_500Medium,
Rubik_600SemiBold,
Rubik_700Bold,
Rubik_800ExtraBold,
Rubik_900Black,
} from "@expo-google-fonts/rubik";
import { useFonts } from "expo-font";
import { Stack } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import i18next from "i18next";
import { Provider } from "jotai";
import { useEffect, useMemo, useState } from "react";
import { initReactI18next } from "react-i18next";
import ToastManager from "toastify-react-native";
import "intl-pluralrules";
import { DefaultQueryOptions } from "@/api/query";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { UnistylesRuntime } from "react-native-unistyles";
import type { ToastConfigParams } from "toastify-react-native/utils/interfaces";
import { BackgroundGradient } from "~/components/background-gradient";
import { useColorScheme } from "~/hooks/color-scheme";
import { Toast as MeeloToast } from "~/primitives/toast";
import { colorSchemePreference } from "~/state/color-scheme";
import { languagePreference } from "~/state/lang";
import { appThemes } from "~/theme";
import resources from "../../../../translations";
SplashScreen.preventAutoHideAsync();
SplashScreen.setOptions({
duration: 1000,
fade: true,
});
export default function RootLayout() {
const [loaded, error] = useFonts({
Rubik_300Light,
Rubik_400Regular,
Rubik_500Medium,
Rubik_600SemiBold,
Rubik_700Bold,
Rubik_800ExtraBold,
Rubik_900Black,
});
const colorSchemePref = store.get(colorSchemePreference);
const rnColorScheme = useColorScheme();
const actualColorScheme = useMemo(() => {
if (colorSchemePref === "system") {
return rnColorScheme ?? "light";
}
return colorSchemePref;
}, [rnColorScheme, colorSchemePref]);
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: DefaultQueryOptions,
},
}),
);
useEffect(() => {
i18next.use(initReactI18next).init({
interpolation: {
escapeValue: false,
},
returnEmptyString: false,
fallbackLng: "en",
lng: store.get(languagePreference),
resources,
});
});
useEffect(() => {
if (loaded || error) {
SplashScreen.hideAsync();
}
}, [loaded, error]);
if (!loaded) {
return null;
}
return (
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<ColorSchemeProvider>
<KeyboardProvider>
<Stack
screenOptions={{
animation: "none",
headerShown: false,
contentStyle: {
flex: 1,
},
statusBarStyle:
actualColorScheme === "light"
? "dark"
: "light",
}}
>
<Stack.Screen
name="(protected)"
options={{
contentStyle: {
backgroundColor: "transparent",
},
}}
/>
<Stack.Screen
name="auth"
options={{
contentStyle: {
backgroundColor: (actualColorScheme ===
"dark"
? appThemes.dark
: appThemes.light
).colors.background,
},
}}
/>
</Stack>
</KeyboardProvider>
<BackgroundGradient />
</ColorSchemeProvider>
</Provider>
<ToastManager
theme={actualColorScheme}
custom={MeeloToast}
config={{
success: (p: ToastConfigParams) => (
<MeeloToast {...p} variant={"success"} />
),
error: (p: ToastConfigParams) => (
<MeeloToast {...p} variant={"error"} />
),
}}
/>
</QueryClientProvider>
);
}
const ColorSchemeProvider = ({ children }: any) => {
const colorScheme = useColorScheme();
useEffect(() => {
UnistylesRuntime.setTheme(colorScheme);
}, [colorScheme]);
return children;
};
+318
View File
@@ -0,0 +1,318 @@
/*
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
* Copyright (C) 2023
*
* Meelo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Meelo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { ErrorIcon } from "@/ui/icons";
import { router } from "expo-router";
import { useSetAtom } from "jotai";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
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 { getAPI_ } from "~/api";
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";
const styles = StyleSheet.create((theme, rt) => ({
root: {
display: "flex",
flexDirection: "column",
alignItems: "center",
marginBottom: theme.gap(10),
justifyContent: "space-evenly",
},
// 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,
display: "flex",
flexDirection: "column",
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",
alignItems: "center",
gap: 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<"login" | "signup">("login");
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 === "signup") {
Promise.race([
timeout(3000),
api.register({
username: data.username,
password: data.password,
}),
])
.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);
router.replace("/");
})
.catch((e) => {
setErrorMessage(e.message ?? e.toString());
setLoading(false);
});
}
};
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={onChange}
error={errors.url?.message}
value={value}
/>
)}
/>
<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)}
title={t(
formType === "login"
? "auth.loginButton"
: "auth.signupButton",
)}
/>
{errorMsg && (
<View style={styles.errorContainer}>
<ErrorIcon style={styles.errorMsg} />
<Text style={styles.errorMsg}>{errorMsg}</Text>
</View>
)}
<Divider h withInsets />
<Button
variant="outlined"
onPress={() => {
setFormType(
formType === "login" ? "signup" : "login",
);
setErrorMessage(undefined);
}}
title={t(
formType === "login"
? "auth.signup"
: "auth.signin",
)}
/>
</View>
</KeyboardAwareScrollView>
<KeyboardToolbar />
</>
);
}
@@ -0,0 +1,49 @@
import type { ArtistWithRelations } from "@/models/artist";
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { Illustration } from "./illustration";
import { LoadableText } from "./loadable_text";
type Props = { artist: ArtistWithRelations<"illustration"> | undefined };
// TODO Handle wrap when the artist name is a single word and is larger than view
export const ArtistHeader = ({ artist }: Props) => {
return (
<View style={styles.root}>
<View style={styles.avatar}>
<Illustration
illustration={artist?.illustration}
quality="medium"
variant="circle"
/>
</View>
<View style={styles.text}>
<LoadableText
content={artist?.name}
skeletonWidth={10}
numberOfLines={2}
variant="h2"
/>
</View>
</View>
);
};
const styles = StyleSheet.create((theme) => ({
root: {
width: "100%",
flexDirection: "row",
alignItems: "center",
padding: theme.gap(2),
gap: theme.gap(3),
display: "flex",
},
avatar: {
flex: 1,
aspectRatio: 1,
},
text: {
flex: { xs: 2, sm: 3, md: 5, xl: 12 },
},
}));
@@ -0,0 +1,188 @@
import type { InfiniteQuery } from "@/api/query";
import type Illustration from "@/models/illustration";
import type { IllustratedResource } from "@/models/illustration";
import type Resource from "@/models/resource";
import { generateArray } from "@/utils/gen-list";
import {
Canvas,
RadialGradient,
Rect,
type RectProps,
vec,
} from "@shopify/react-native-skia";
import { useFocusEffect } from "expo-router";
import { atom, useAtom, useAtomValue } from "jotai";
import { useEffect, useMemo } from "react";
import { View } from "react-native";
import {
useDerivedValue,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useInfiniteQuery } from "~/api";
import { accessTokenAtom } from "~/state/user";
export const keyIllustrationAtom = atom<Illustration | null>(null);
export const useSetKeyIllustration = (keyItem?: IllustratedResource) => {
const [keyIllustration, setKeyIllustration] = useAtom(keyIllustrationAtom);
useFocusEffect(() => {
if (
keyItem?.illustration &&
keyItem.illustration.url !== keyIllustration?.url
)
setKeyIllustration(keyItem.illustration);
});
};
export const useSetKeyIllustrationFromInfiniteQuery = <
T extends Resource,
T1 extends IllustratedResource,
>(
query: InfiniteQuery<T, T1>,
) => {
const { data } = useInfiniteQuery(() => query);
const [keyIllustration, setKeyIllustration] = useAtom(keyIllustrationAtom);
useFocusEffect(() => {
const firstItem = data?.pages
.at(0)
?.items.find((item) => item.illustration !== null);
if (
firstItem?.illustration &&
firstItem.illustration.url !== keyIllustration?.url
)
setKeyIllustration(firstItem.illustration);
});
};
const transitionDuration = 300;
export const BackgroundGradient = () => {
const accessToken = useAtomValue(accessTokenAtom);
const keyIllustration = useAtomValue(keyIllustrationAtom);
const currentColor1 = useSharedValue("transparent");
const currentColor2 = useSharedValue("transparent");
const currentColor3 = useSharedValue("transparent");
const currentColor4 = useSharedValue("transparent");
const currentColor5 = useSharedValue("transparent");
useEffect(() => {
if (!accessToken) {
currentColor1.value = "transparent";
currentColor2.value = "transparent";
currentColor3.value = "transparent";
currentColor4.value = "transparent";
currentColor5.value = "transparent";
}
}, [accessToken]);
useEffect(() => {
if (keyIllustration) {
currentColor1.value = withTiming(keyIllustration.colors[0], {
duration: transitionDuration,
});
currentColor2.value = withTiming(keyIllustration.colors[1], {
duration: transitionDuration,
});
currentColor3.value = withTiming(keyIllustration.colors[2], {
duration: transitionDuration,
});
currentColor4.value = withTiming(keyIllustration.colors[3], {
duration: transitionDuration,
});
currentColor5.value = withTiming(keyIllustration.colors[4], {
duration: transitionDuration,
});
}
}, [keyIllustration]);
const gradientColor1 = useDerivedValue(() => {
return [currentColor1.value, "transparent"];
}, []);
const gradientColor2 = useDerivedValue(() => {
return [currentColor2.value, "transparent"];
}, []);
const gradientColor3 = useDerivedValue(() => {
return [currentColor3.value, "transparent"];
}, []);
const gradientColor4 = useDerivedValue(() => {
return [currentColor4.value, "transparent"];
}, []);
const gradientColor5 = useDerivedValue(() => {
return [currentColor5.value, "transparent"];
}, []);
const gradientColors = useMemo(
() => [
gradientColor1,
gradientColor2,
gradientColor3,
gradientColor4,
gradientColor5,
],
[],
);
return (
<View style={styles.root}>
<StyledCanvas style={styles.canvas}>
{generateArray(5, null).map((_, index) => {
const cxRatio =
index === 0 || index === 3
? 0.1
: index === 4
? 0
: 0.9;
const cyRatio = index < 2 ? 0.9 : index < 4 ? 0.1 : 1;
return (
<FullScreenRect key={index}>
<GradientEllipse
uniProps={(_, rt) => ({
c: vec(
rt.screen.width * cxRatio,
rt.screen.height * cyRatio,
),
})}
colors={gradientColors[index]}
/>
</FullScreenRect>
);
})}
</StyledCanvas>
</View>
);
};
const GradientEllipse = withUnistyles(RadialGradient, (_, rt) => {
const r = Math.max(rt.screen.width, rt.screen.height) * 0.55;
return {
r,
};
});
const FullScreenRect = withUnistyles(
Rect,
(_theme, rt) =>
({
x: 0,
y: 0,
width: rt.screen.width,
height: rt.screen.height,
}) as RectProps,
);
const StyledCanvas = withUnistyles(Canvas);
const styles = StyleSheet.create((theme) => ({
root: {
position: "absolute",
left: 0,
zIndex: -1,
right: 0,
top: 0,
height: "100%",
backgroundColor: theme.colors.background,
},
canvas: {
flex: 1,
opacity: theme.name === "light" ? 0.5 : 0.4,
},
}));
@@ -0,0 +1,12 @@
import { BlurView as BV } from "expo-blur";
import { withUnistyles } from "react-native-unistyles";
export const BlurView = withUnistyles(
BV,
(theme) =>
({
tint: theme.name === "light" ? "light" : "dark",
experimentalBlurMethod: "dimezisBlurView",
intensity: 80,
}) as const,
);
@@ -0,0 +1,98 @@
import { CheckIcon, type Icon as IconType } from "@/ui/icons";
import { type ComponentProps, useMemo } from "react";
import { View } from "react-native";
import SelectDropdown from "react-native-select-dropdown";
import { StyleSheet } from "react-native-unistyles";
import { Button } from "~/primitives/button";
import { Divider } from "~/primitives/divider";
import { Icon } from "~/primitives/icon";
import { Text } from "~/primitives/text";
type Props<T> = {
values: readonly T[];
isSelected: (item: T) => boolean;
onSelect: (item: T) => void;
formatItem: (item: T) => string;
checkIcon?: IconType;
buttonProps: Omit<
ComponentProps<typeof Button>,
"propagateToParent" | "onPress"
>;
};
export const Dropdown = <T,>({
values,
onSelect,
formatItem,
buttonProps,
isSelected,
checkIcon,
}: Props<T>) => {
const notReadonlyArray = useMemo(() => [...values], [values]);
return (
<SelectDropdown
data={notReadonlyArray}
onSelect={onSelect}
dropdownStyle={styles.dropdownContainer}
renderItem={(item: T) => (
<View style={styles.dropdownItem}>
<View style={styles.dropdownItemRow}>
<View style={styles.dropdownItemIconContainer}>
{isSelected(item) ? (
<Icon
icon={checkIcon ?? CheckIcon}
style={styles.dropdownItemIcon}
/>
) : undefined}
</View>
<Text
style={styles.dropdownItemLabel}
content={formatItem(item)}
/>
</View>
<Divider h />
</View>
)}
statusBarTranslucent
dropdownOverlayColor="transparent"
renderButton={() => (
<View>
<Button {...buttonProps} propagateToParent />
</View>
)}
/>
);
};
const styles = StyleSheet.create((theme) => ({
dropdownContainer: {
borderRadius: theme.borderRadius,
//TODO Can overflow
width: 150,
gap: theme.gap(2),
},
dropdownItem: { backgroundColor: theme.colors.background },
dropdownItemRow: {
display: "flex",
flexDirection: "row",
alignItems: "center",
gap: theme.gap(1),
padding: theme.gap(1),
},
dropdownItemIconContainer: {
flex: 1,
display: "flex",
justifyContent: "center",
alignItems: "center",
},
dropdownItemIcon: {
size: theme.fontSize.rem(1),
} as any,
dropdownItemLabel: {
flex: 5,
display: "flex",
alignItems: "flex-start",
},
}));
@@ -0,0 +1,32 @@
import { EmptyStateIcon, type Icon } from "@/ui/icons";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Text } from "~/primitives/text";
type Props = Partial<{ icon: Icon; text: TranslationKey }>;
export const EmptyState = ({ icon, text }: Props) => {
const { t } = useTranslation();
const IconComponent = withUnistyles(icon ?? EmptyStateIcon, (theme) => ({
color: theme.colors.text.secondary,
}));
return (
<View style={styles.root}>
<IconComponent />
<Text
style={styles.text}
content={t(text ?? "emptyState.default")}
/>
</View>
);
};
const styles = StyleSheet.create((theme) => ({
root: {
flex: 1,
justifyContent: "space-evenly",
alignItems: "center",
},
text: { color: theme.colors.text.secondary, ...theme.fontStyles.medium },
}));
@@ -0,0 +1,209 @@
import type {
AlbumExternalMetadata,
ArtistExternalMetadata,
ExternalMetadataSource,
} from "@/models/external-metadata";
import { ExpandLessIcon, ExpandMoreIcon } from "@/ui/icons";
import { generateArray } from "@/utils/gen-list";
import { Image } from "expo-image";
import { openBrowserAsync } from "expo-web-browser";
import { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ScrollView, View, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useAPI } from "~/api";
import { Icon } from "~/primitives/icon";
import { Pressable } from "~/primitives/pressable";
import { Text } from "~/primitives/text";
import { TextSkeleton } from "~/primitives/text";
import { LoadableText } from "./loadable_text";
import { SectionHeader } from "./section-header";
type Props = {
externalMetadata:
| AlbumExternalMetadata
| ArtistExternalMetadata
| undefined;
style?: ViewStyle;
};
const DescriptionLineCount = 10;
// Generic section for external metadata.
export const ExternalMetadataDescriptionSection = ({
externalMetadata,
style,
}: Props) => {
const { t } = useTranslation();
const heading = useMemo(() => t("browsing.sections.about"), [t]);
// If null, no need to be able to expand.
// If boolean, reflect whether the line clamp is enabled
const [isExpanded, expand] = useState<boolean | null>(null);
if (externalMetadata?.description === null) {
return null;
}
return (
<View style={[styles.root, style]}>
<SectionHeader
skeletonWidth={heading.length}
content={externalMetadata === undefined ? undefined : heading}
trailing={
isExpanded !== null ? (
<Pressable
onPress={() => expand((expanded) => !expanded)}
>
<Icon
icon={
isExpanded ? ExpandLessIcon : ExpandMoreIcon
}
/>
</Pressable>
) : undefined
}
/>
<View style={styles.descriptionContainer}>
{externalMetadata?.description === undefined ? (
<>
{generateArray(DescriptionLineCount).map((_, idx) => (
<TextSkeleton
key={idx}
width={"100%"}
variant="body"
/>
))}
</>
) : (
externalMetadata.description !== null && (
<Text
content={externalMetadata.description}
variant="body"
style={styles.descriptionText}
numberOfLines={
isExpanded ? undefined : DescriptionLineCount
}
onTextLayout={(e) => {
if (
e.nativeEvent.lines.length >
DescriptionLineCount
) {
expand(false);
}
}}
/>
)
)}
</View>
</View>
);
};
// Generic section for external metadata.
// Will render empty view if no sources
export const ExternalMetadataSourcesSection = ({
externalMetadata,
style,
}: Props) => {
const { t } = useTranslation();
const api = useAPI();
const heading = useMemo(() => t("models.externalLink_plural"), [t]);
const scrollRef = useRef<ScrollView>(null);
if (externalMetadata && externalMetadata.sources.length === 0) {
return null;
}
return (
<View style={[styles.root, style]}>
<SectionHeader
skeletonWidth={heading.length}
onPress={() =>
scrollRef.current?.scrollTo({ x: 0, animated: true })
}
content={externalMetadata === undefined ? undefined : heading}
/>
<ScrollView
ref={scrollRef}
horizontal
contentContainerStyle={styles.externalLinks}
>
{(externalMetadata?.sources ?? generateArray(2)).map(
(source: ExternalMetadataSource | undefined, idx) => (
<Pressable
onPress={() =>
source && openBrowserAsync(source.url)
}
key={source?.providerId ?? idx}
style={styles.externalLink}
>
{source ? (
<Image
contentFit="contain"
source={{
uri: source
? api.getIllustrationURL(
source.providerIcon,
"original",
)
: undefined,
headers: {
Authorization: `Bearer ${api.accessToken}`,
},
}}
style={styles.externalLinkIcon}
/>
) : (
<View
style={[
styles.externalLinkIcon,
styles.externalLinkIconPlaceholder,
]}
/>
)}
<LoadableText
content={source?.providerName}
variant="body"
skeletonWidth={10}
/>
</Pressable>
),
)}
</ScrollView>
</View>
);
};
const styles = StyleSheet.create((theme) => ({
root: {
width: "100%",
flex: 1,
},
externalLinks: {
gap: theme.gap(2),
paddingHorizontal: theme.gap(1.5),
marginBottom: theme.gap(1),
},
externalLinkIcon: {
aspectRatio: 1,
height: "100%",
},
externalLinkIconPlaceholder: {
backgroundColor: theme.colors.skeleton,
borderRadius: theme.borderRadius,
},
externalLink: {
borderWidth: 1,
gap: theme.gap(1),
borderColor: theme.colors.text.secondary,
borderRadius: theme.borderRadius,
padding: theme.gap(1),
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
},
descriptionText: {
lineHeight: theme.gap(2.5),
},
descriptionContainer: {
flex: 1,
paddingHorizontal: theme.gap(1.5),
},
}));
@@ -0,0 +1,235 @@
/*
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
* Copyright (C) 2023
*
* Meelo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Meelo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import type IllustrationModel from "@/models/illustration";
import type { IllustrationQuality } from "@/models/illustration";
import type { Icon } from "@/ui/icons";
import { useEffect, useMemo, useState } from "react";
import { View, type ViewStyle } from "react-native";
import { Blurhash } from "react-native-blurhash";
import Image from "react-native-fast-image";
import { useAnimatedStyle, useSharedValue } from "react-native-reanimated";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useAPI } from "~/api";
type Props = {
illustration: IllustrationModel | undefined | null;
fallbackIcon?: Icon;
quality: IllustrationQuality;
normalizedThumbnail?: true;
variant?: "fill" | "circle" | "center";
// If true, the blurhash will not be ecoded/displayed.
// Instead it will use the color from the illustration model as a place holder
// To enhance performance in infinite lists
simpleColorPlaceholder?: true;
style?: ViewStyle;
};
export const Illustration = ({
illustration,
fallbackIcon,
quality,
variant,
simpleColorPlaceholder,
normalizedThumbnail,
style,
}: Props) => {
const api = useAPI();
const innerAspectRatio = useMemo(
() =>
normalizedThumbnail
? 16 / 9
: variant === "fill" || variant === "circle"
? 1
: (illustration?.aspectRatio ?? 1),
[illustration],
);
styles.useVariants({
imageType: innerAspectRatio > 1 ? "wide" : "tall",
shape: variant === "circle" ? "circle" : undefined,
align: variant === "center" ? "center" : "bottom",
normalizedThumbnail: normalizedThumbnail ?? false,
});
const [loadStatus, setLoadStatus] = useState<"done" | "loading" | "error">(
"loading",
);
const imageOpacity = useSharedValue(0);
const blurhashOpacity = useSharedValue(0);
const fallbackOpacity = useSharedValue(0);
const imageOpacityStyle = useAnimatedStyle(() => ({
opacity: imageOpacity.value,
}));
const blurhashOpacityStyle = useAnimatedStyle(() => ({
opacity: blurhashOpacity.value,
}));
const fallbackOpacityStyle = useAnimatedStyle(() => ({
opacity: fallbackOpacity.value,
}));
const FallbackIcon = withUnistyles(
fallbackIcon ?? View,
(theme) =>
({
color: theme.colors.text.secondary,
}) as any,
);
useEffect(() => {
// Whatever the new illustration props is,
// we need to reset the blurhash and the image
blurhashOpacity.value = 0;
imageOpacity.value = 0;
if (illustration === null) {
fallbackOpacity.value = 1;
} else {
fallbackOpacity.value = 0;
}
setLoadStatus("loading");
}, [illustration]);
return (
<View style={[styles.outerContainer, style]}>
<View
style={[
{
aspectRatio: innerAspectRatio,
},
styles.innerContainer(
simpleColorPlaceholder
? illustration?.colors.at(0)
: undefined,
),
]}
>
{illustration && loadStatus !== "error" && (
<>
{!simpleColorPlaceholder && loadStatus !== "done" && (
<View style={[styles.slot]}>
<Blurhash
decodeAsync
style={[
blurhashOpacityStyle,
styles.slotContent,
styles.blurhash,
]}
onLoadEnd={() => {
blurhashOpacity.value = 1;
}}
blurhash={illustration.blurhash}
decodeWidth={16}
decodeHeight={16}
/>
</View>
)}
<View style={[imageOpacityStyle, styles.slot]}>
<Image
style={[styles.slotContent]}
onLoad={() => {
imageOpacity.value = 1;
setLoadStatus("done");
}}
resizeMode={
variant === "circle" || normalizedThumbnail
? "cover"
: "contain"
}
onError={() => {
setLoadStatus("error");
}}
source={{
headers: {
Authorization: `Bearer ${api.accessToken}`,
},
uri: api.getIllustrationURL(
illustration.url,
quality,
),
}}
/>
</View>
</>
)}
{(!illustration || loadStatus === "error") && (
<View
style={[
fallbackOpacityStyle,
styles.fallbackContainer,
styles.slot,
]}
>
<FallbackIcon />
</View>
)}
</View>
</View>
);
};
const styles = StyleSheet.create((theme) => ({
outerContainer: {
overflow: "hidden",
flex: 1,
alignItems: "center",
variants: {
normalizedThumbnail: {
true: { aspectRatio: 16 / 9 },
false: { aspectRatio: 1 },
},
align: {
center: { justifyContent: "center" },
bottom: {
justifyContent: "flex-end",
},
},
},
},
innerContainer: (illustrationColor: string | undefined) => ({
backgroundColor: illustrationColor ?? theme.colors.skeleton,
borderRadius: theme.borderRadius,
variants: {
imageType: {
tall: { height: "100%", width: undefined },
wide: { height: undefined, width: "100%" },
},
shape: {
circle: { borderRadius: 99999, overflow: "hidden" },
default: {},
},
},
}),
blurhash: {
backgroundColor: theme.colors.skeleton,
},
slot: {
width: "100%",
height: "100%",
position: "absolute",
bottom: 0,
overflow: "hidden",
borderRadius: theme.borderRadius,
},
slotContent: {
flex: 1,
},
fallbackContainer: {
flex: 1,
display: "flex",
justifyContent: "center",
alignItems: "center",
},
}));
@@ -0,0 +1,119 @@
import type { FilterControl } from "@/infinite-controls/filters/control";
import type { LayoutControl } from "@/infinite-controls/layout";
import type { SortControl } from "@/infinite-controls/sort";
import { AscIcon, DescIcon, GridIcon, ListIcon } from "@/ui/icons";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Dropdown } from "~/components/dropdown";
import { Button } from "~/primitives/button";
//TODO Actions
//TODO Height of buttons is not consistent
type Props<SortingKey extends string> = {
layout?: LayoutControl;
sort?: SortControl<SortingKey>;
filters?: FilterControl<any>[];
};
export const Controls = <S extends string>({
layout,
sort,
filters,
}: 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),
}),
);
return (
<View style={styles.root}>
{filters?.map((filter, idx) => (
<Dropdown
key={idx}
buttonProps={{
title: t(filter.buttonLabel),
icon: filter.buttonIcon as any,
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,
);
}
}}
/>
))}
{sort && (
<Dropdown
values={sort.sortingKeys}
formatItem={(item) => t(sort.formatItem(item))}
buttonProps={{
title: t(sort.formatItem(sort.selected.sort)),
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
icon={layout.layout === "list" ? GridIcon : ListIcon}
containerStyle={styles.button}
onPress={() => {
layout.onUpdate({
layout: layout.layout === "list" ? "grid" : "list",
itemSize: layout.itemSize,
});
}}
/>
)}
</View>
);
};
const styles = StyleSheet.create((theme) => ({
root: {
display: "flex",
flexDirection: "row",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "center",
gap: theme.gap(1),
},
button: { elevation: 10 },
}));
@@ -0,0 +1,102 @@
import { getLibraries } from "@/api/queries";
import { toTanStackInfiniteQuery } from "@/api/query";
import {
useTypeFilterControl as useTypeFilterControlBase,
useTypeFiltersControl as useTypeFiltersControlBase,
} from "@/infinite-controls/filters/resource-type";
import {
useLibraryFilterControl as useLibraryFilterControlBase,
useLibraryFiltersControl as useLibraryFiltersControlBase,
} from "@/infinite-controls/filters/library";
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 { useAPI } from "~/api";
const useLibraryFilterControl = () => {
const { t } = useTranslation();
const api = useAPI();
const query = toTanStackInfiniteQuery(api, getLibraries);
const librariesQuery = useReactInfiniteQuery({
...query,
throwOnError: false,
});
useEffect(() => {
if (librariesQuery.isError) {
Toast.error(t("toasts.library.loadFail"));
}
}, [librariesQuery.isError]);
const libraries = librariesQuery.data?.pages.at(0)?.items;
return useLibraryFilterControlBase({
hook: () => null,
onUpdate: () => {},
libraries,
});
};
const useLibraryFiltersControl = () => {
const { t } = useTranslation();
const api = useAPI();
const query = toTanStackInfiniteQuery(api, getLibraries);
const librariesQuery = useReactInfiniteQuery({
...query,
throwOnError: false,
});
useEffect(() => {
if (librariesQuery.isError) {
Toast.error(t("toasts.library.loadFail"));
}
}, [librariesQuery.isError]);
const libraries = librariesQuery.data?.pages.at(0)?.items;
return useLibraryFiltersControlBase({
hook: () => [],
onUpdate: () => {},
libraries,
});
};
const useTypeFiltersControl = <FilterKey extends string>({
types: filterKeys,
translate,
}: {
types: readonly FilterKey[];
translate: (s: FilterKey) => TranslationKey;
}) => {
return useTypeFiltersControlBase({
types: filterKeys,
translate,
hook: () => {
const { type } = useLocalSearchParams<{ type?: FilterKey }>();
return type ? [type] : [];
},
onUpdate: () => {},
});
};
const useTypeFilterControl = <FilterKey extends string>({
types,
translate,
}: {
types: readonly FilterKey[];
translate: (s: FilterKey) => TranslationKey;
}) => {
return useTypeFilterControlBase({
types,
translate,
hook: () => {
const { type } = useLocalSearchParams<{ type?: FilterKey }>();
return type ?? null;
},
onUpdate: () => {},
});
};
export {
useTypeFiltersControl,
useTypeFilterControl,
useLibraryFilterControl,
useLibraryFiltersControl,
};
@@ -16,24 +16,23 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useConfirm } from "material-ui-confirm";
import { Translator } from "../i18n/i18n";
import { useLayoutControl as useLayoutControlBase } from "@/infinite-controls/layout";
import type { LayoutOption } from "@/models/layout";
const confirmDownloadAction = (
confirm: ReturnType<typeof useConfirm>,
downloadUrl: string,
t: Translator,
) => {
confirm({
title: t("warning"),
description: t("downloadWarning"),
confirmationText: t("download"),
confirmationButtonProps: {
color: "error",
variant: "outlined",
href: downloadUrl,
// Hook to get Layout data to pass to Controls
export const useLayoutControl = ({
defaultLayout,
enableToggle,
}: {
defaultLayout: LayoutOption;
enableToggle: boolean;
}) => {
return useLayoutControlBase({
hook: () => {
return null;
},
defaultLayout,
enableToggle,
onUpdate: () => {},
});
};
export default confirmDownloadAction;
@@ -0,0 +1,41 @@
/*
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
* Copyright (C) 2023
*
* Meelo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Meelo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useSortControl as useSortControlBase } from "@/infinite-controls/sort";
import { useLocalSearchParams } from "expo-router";
import type { Sorting } from "~/utils/sorting";
export const useSortControl = <SortingKey extends string>({
sortingKeys,
translate,
}: {
sortingKeys: readonly SortingKey[];
translate: (s: SortingKey) => TranslationKey;
}) => {
return useSortControlBase({
hook: () => {
let { sort, order } = useLocalSearchParams<Sorting<SortingKey>>();
sort ??= sortingKeys[0];
order ??= "asc";
return { sort, order };
},
sortingKeys,
translate,
onUpdate: () => {},
});
};
@@ -0,0 +1,208 @@
import type { InfiniteQuery } from "@/api/query";
import type { IllustratedResource } from "@/models/illustration";
import type { LayoutOption } from "@/models/layout";
import type Resource from "@/models/resource";
import { generateArray } from "@/utils/gen-list";
import { type ContentStyle, FlashList } from "@shopify/flash-list";
import type React from "react";
import { type ComponentProps, useMemo, useState } from "react";
import { View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useInfiniteQuery } from "~/api";
import { useRootViewStyle } from "~/hooks/root-view-style";
import { Divider } from "~/primitives/divider";
import { breakpoints } from "~/theme";
import { useSetKeyIllustrationFromInfiniteQuery } from "../background-gradient";
import { EmptyState } from "../empty-state";
import { Controls } from "./controls/component";
//TODO Grid: The breakpoint's type from rt does not seem to use the type defined in the theme
//It should be typesafe
//TODO Button to scroll back up
// Notes:
// the navigation header being positioned using 'absolute'
// We need to rely on the root view style hook to get its height
//
// The controls (libraries, types, etc.) are positioned absolutely, with padding to avoid overlap with header
//
// The scroll view itself has a top padding where the controls should end up being layed out in
type Props<T, T1, Sort extends string> = {
query: InfiniteQuery<T, T1>;
layout: LayoutOption;
header?: React.ReactElement;
controls: Omit<ComponentProps<typeof Controls<Sort>>, "style">;
render: (item: T1 | undefined) => React.ReactElement;
ignoreTabBar?: true;
};
export const InfiniteView = <
T extends Resource,
T1 extends IllustratedResource,
Sort extends string,
>(
props: Props<T, T1, Sort>,
) => {
styles.useVariants({ layout: props.layout });
const { paddingBottom, paddingTop } = useRootViewStyle();
const queryRes = useInfiniteQuery(() => props.query);
const [controlsHeight, setControlsHeight] = useState(null as number | null);
const firstPage = queryRes.data?.pages.at(0)?.items;
const itemList = useMemo(() => {
const itemCount = queryRes.items?.length ?? 0;
const trailingSkeletons =
(queryRes.isFetching && !itemCount) || queryRes.isFetchingNextPage
? 1
: 0;
return [
...(queryRes.items ?? []),
...generateArray(trailingSkeletons, undefined),
];
}, [queryRes.items, queryRes.isFetching, queryRes.isFetchingNextPage]);
const ScrollView = props.layout === "list" ? List : Grid;
useSetKeyIllustrationFromInfiniteQuery(props.query);
return (
<View style={[styles.rootStyle]}>
{props.header && (
<View style={[{ paddingTop }, styles.optionalHeader]}>
{props.header}
<Divider h />
</View>
)}
<View style={styles.body}>
<View
style={styles.controls(props.header ? 0 : paddingTop)}
onLayout={(e) =>
setControlsHeight(e.nativeEvent.layout.height)
}
>
<Controls {...props.controls} />
</View>
{firstPage?.length === 0 ? (
<View
style={[
styles.emptyState,
styles.scrollViewTopPadding(controlsHeight),
]}
>
<EmptyState />
</View>
) : (
<ScrollView
data={itemList}
refreshing={queryRes.isRefetching}
contentContainerStyle={
[
styles.scrollView,
{ paddingBottom },
] as ContentStyle
}
onRefresh={() => queryRes.refetch()}
onEndReachedThreshold={0.5}
onEndReached={() => queryRes.fetchNextPage()}
ListHeaderComponent={
<View
style={styles.scrollViewTopPadding(
controlsHeight,
)}
/>
}
renderItem={({ item }) => {
return (
<View style={styles.itemContainer}>
{props.render(item as T1 | undefined)}
</View>
);
}}
{...(props.layout === "grid"
? {
uniProps: (theme, rt) => ({
keyExtractor: (_, idx) => {
const item =
queryRes.items?.at(idx);
const columnCount =
// @ts-expect-error
theme.layout.grid.columnCount[
rt.breakpoint!
];
if (!item) {
return `skeleton-${idx}-cc:${columnCount}`;
}
return `item-${item.id}-cc:${columnCount}`;
},
}),
}
: {
keyExtractor: (_, idx) => {
const item = queryRes.items?.at(idx);
if (!item) {
return `skeleton-${idx}`;
}
return `item-${item.id}`;
},
})}
/>
)}
</View>
</View>
);
};
const Grid = withUnistyles(FlashList, (theme, rt) => {
// @ts-expect-error
const colCount = theme.layout.grid.columnCount[rt.breakpoint!];
return {
onEndReachedThreshold: rt.screen.height,
estimatedItemSize: rt.screen.width / colCount,
numColumns: colCount,
};
});
const List = withUnistyles(FlashList, (_, rt) => ({
numColumns: 1,
onEndReachedThreshold: rt.screen.height,
estimatedItemSize: 70, // TODO
ItemSeparatorComponent: () => <Divider h withInsets />,
}));
const styles = StyleSheet.create((theme) => ({
rootStyle: {
flex: 1,
position: "relative",
height: "100%",
maxWidth: breakpoints.xl,
width: "100%",
},
body: { flex: 1 },
optionalHeader: {},
emptyState: { height: 300 },
controls: (paddingTop: number) => ({
paddingBottom: theme.gap(1.5),
paddingTop: theme.gap(1.5) + paddingTop,
paddingHorizontal: theme.gap(1),
position: "absolute",
zIndex: 1,
width: "100%",
}),
scrollView: {
paddingHorizontal: theme.gap(1),
},
//approximate so that the list start below controls
//putting the actual controls in the ListHeaderComponent causes the dropdown to be misplaced
scrollViewTopPadding: (controlsHeight: number | null) => ({
paddingTop: controlsHeight == null ? theme.gap(8.25) : controlsHeight,
}),
itemContainer: {
variants: {
layout: {
// TODO Gap between columns?
grid: { width: "100%" },
list: {},
},
},
},
}));
@@ -0,0 +1,105 @@
import type Illustration from "@/models/illustration";
import { type Href, useRouter } from "expo-router";
import type { ComponentProps } from "react";
import { Pressable, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import type { RequireAtLeastOne } from "type-fest";
import { Illustration as IllustrationComponent } from "~/components/illustration";
import { LoadableText } from "../loadable_text";
type Props = {
title: string | undefined;
subtitle: string | null | undefined;
illustration: Illustration | null | undefined;
illustrationProps?: Omit<
ComponentProps<typeof IllustrationComponent>,
"illustration" | "quality"
>;
} & RequireAtLeastOne<{ href: Href | null; onPress: (() => void) | null }>;
//TODO Ripple or visual feedback on press
export const ListItem = ({
title,
subtitle,
illustration,
illustrationProps,
href,
onPress,
}: Props) => {
const router = useRouter();
styles.useVariants({
normalizedThumbnail: illustrationProps?.normalizedThumbnail ?? false,
});
return (
<Pressable
onPress={() => {
onPress?.();
if (href) {
router.push(href);
}
}}
style={[styles.root]}
>
<View style={styles.illustration}>
<IllustrationComponent
illustration={illustration}
{...illustrationProps}
variant={illustrationProps?.variant ?? "center"}
quality="low"
/>
</View>
<View style={styles.textContainer}>
<LoadableText
content={title}
skeletonWidth={15}
variant="h6"
numberOfLines={1}
/>
{subtitle !== null && (
<LoadableText
content={subtitle}
skeletonWidth={10}
variant="body"
numberOfLines={1}
/>
)}
</View>
</Pressable>
);
};
const styles = StyleSheet.create((theme) => ({
root: {
display: "flex",
flexDirection: "row",
borderRadius: theme.borderRadius,
overflow: "hidden",
width: "100%",
justifyContent: "flex-start",
gap: theme.gap(2),
padding: theme.gap(1),
},
illustration: {
variants: {
normalizedThumbnail: {
false: {
aspectRatio: 1,
width: 56,
},
true: {
marginTop: theme.gap(0.5),
marginBottom: theme.gap(0.5),
aspectRatio: 16 / 9,
width: 80,
},
},
},
},
textContainer: {
display: "flex",
flex: 1,
justifyContent: "center",
gap: theme.gap(1),
},
}));
@@ -0,0 +1,81 @@
import type { AlbumWithRelations } from "@/models/album";
import { getYear } from "@/utils/date";
import { type ComponentProps, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ListItem } from "../list-item";
import { Tile } from "../tile";
type Props = {
album: AlbumWithRelations<"artist" | "illustration"> | undefined;
subtitle: "artistName" | "year";
illustrationProps?: ComponentProps<typeof Tile>["illustrationProps"];
onPress?: () => void;
formatSubtitle?: (s: string) => string;
};
export const AlbumItem = ({
album,
illustrationProps,
subtitle,
formatSubtitle,
onPress,
}: Props) => {
const formattedSubtitle = useFormattedSubtitle({
subtitle,
formatSubtitle,
album,
});
return (
<ListItem
title={album?.name}
subtitle={formattedSubtitle}
href={album?.masterId ? `/releases/${album.masterId}` : null}
onPress={onPress}
illustration={album?.illustration}
illustrationProps={illustrationProps}
/>
);
};
export const AlbumTile = ({
album,
illustrationProps,
subtitle,
formatSubtitle,
}: Props) => {
const formattedSubtitle = useFormattedSubtitle({
subtitle,
formatSubtitle,
album,
});
return (
<Tile
illustration={album?.illustration}
illustrationProps={illustrationProps}
title={album?.name}
href={album?.masterId ? `/releases/${album.masterId}` : null}
subtitle={formattedSubtitle}
/>
);
};
const useFormattedSubtitle = ({
formatSubtitle,
album,
subtitle,
}: Pick<Props, "formatSubtitle" | "album" | "subtitle">) => {
const { t } = useTranslation();
return useMemo(() => {
const f = formatSubtitle ?? ((e: string) => e);
if (album === undefined) {
return undefined;
}
if (subtitle === "year") {
return f(getYear(album.releaseDate)?.toString() ?? "");
}
if (subtitle === "artistName") {
return f(album.artist?.name ?? t("compilationArtistLabel"));
}
return null;
}, [subtitle, formatSubtitle, album]);
};
@@ -0,0 +1,36 @@
import type { ArtistWithRelations } from "@/models/artist";
import type { ComponentProps } from "react";
import { ListItem } from "../list-item";
import { Tile } from "../tile";
type Props = {
artist: ArtistWithRelations<"illustration"> | undefined;
illustrationProps?: ComponentProps<typeof Tile>["illustrationProps"];
onPress?: () => void;
};
export const ArtistTile = ({ artist, illustrationProps, onPress }: Props) => {
return (
<Tile
illustration={artist?.illustration}
title={artist?.name}
subtitle={null}
onPress={onPress}
href={artist ? `/artists/${artist.id}` : null}
illustrationProps={{ ...illustrationProps, variant: "circle" }}
/>
);
};
export const ArtistItem = ({ artist, illustrationProps, onPress }: Props) => {
return (
<ListItem
title={artist?.name}
subtitle={null}
href={artist ? `/artists/${artist.id}` : null}
onPress={onPress}
illustration={artist?.illustration}
illustrationProps={{ ...illustrationProps, variant: "circle" }}
/>
);
};
@@ -0,0 +1,38 @@
import type { ReleaseWithRelations } from "@/models/release";
import { getYear } from "@/utils/date";
import { type ComponentProps, useMemo } from "react";
import { Tile } from "../tile";
const ReleaseTile = (props: {
release: ReleaseWithRelations<"illustration"> | undefined;
illustrationProps?: ComponentProps<typeof Tile>["illustrationProps"];
}) => {
const subtitle = useMemo(() => {
if (!props.release) {
return undefined;
}
const extensions = props.release.extensions;
const yearFormat = props.release
? (getYear(props.release.releaseDate)?.toString() ?? "")
: "";
if (extensions.length > 0 && yearFormat.length > 0) {
return [extensions[0], yearFormat].join(" - ");
}
if (extensions.length > 0) {
return extensions[0];
}
return yearFormat;
}, [props.release]);
return (
<Tile
title={props.release?.name}
subtitle={subtitle}
href={props.release ? `/releases/${props.release.id}` : null}
illustration={props.release?.illustration}
illustrationProps={props.illustrationProps}
/>
);
};
export default ReleaseTile;
@@ -0,0 +1,61 @@
import type { SearchResult } from "@/models/search";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { AlbumItem } from "./album";
import { ArtistItem } from "./artist";
import { SongItem } from "./song";
import { VideoItem } from "./video";
export const SearchResultItem = ({
searchResult,
onPress,
}: { searchResult: SearchResult | undefined; onPress: () => void }) => {
const { t } = useTranslation();
if (!searchResult || searchResult.song) {
return (
<SongItem
song={searchResult?.song}
illustrationProps={{ simpleColorPlaceholder: true }}
subtitle="artists"
onPress={onPress}
formatSubtitle={(s) => `${t("models.song")} • ${s}`}
/>
);
}
if (searchResult.album) {
return (
<AlbumItem
album={searchResult.album}
illustrationProps={{ simpleColorPlaceholder: true }}
subtitle="artistName"
onPress={onPress}
formatSubtitle={(s) => `${t("models.album")} • ${s}`}
/>
);
}
if (searchResult.artist) {
return (
<ArtistItem
artist={searchResult.artist}
illustrationProps={{ simpleColorPlaceholder: true }}
onPress={onPress}
/>
);
}
if (searchResult.video) {
return (
<VideoItem
video={searchResult.video}
illustrationProps={{ simpleColorPlaceholder: true }}
onPress={onPress}
subtitle="artistName"
formatSubtitle={(s) => `${t("models.video")} • ${s}`}
/>
);
}
// biome-ignore lint/suspicious/noConsole: <explanation>
console.error("Unexpected search result");
// biome-ignore lint/suspicious/noConsole: <explanation>
console.error(searchResult);
return <View />;
};
@@ -0,0 +1,47 @@
import type { SongWithRelations } from "@/models/song";
import formatArtists from "@/utils/format-artists";
import { type ComponentProps, useMemo } from "react";
import { ListItem } from "../list-item";
type Props = {
song:
| SongWithRelations<"illustration" | "artist" | "featuring">
| undefined;
illustrationProps: ComponentProps<typeof ListItem>["illustrationProps"];
subtitle: "artists" | null | undefined;
formatSubtitle?: (s: string) => string;
onPress?: () => void;
};
export const SongItem = ({
song,
illustrationProps,
subtitle,
formatSubtitle,
onPress,
}: Props) => {
const formattedSubtitle = useMemo(() => {
const f = formatSubtitle ?? ((e: string) => e);
if (subtitle === null) {
return null;
}
if (song === undefined) {
return undefined;
}
if (subtitle === "artists") {
return f(formatArtists(song.artist, song.featuring));
}
return null;
}, [subtitle, formatSubtitle, song]);
return (
<ListItem
title={song?.name}
subtitle={formattedSubtitle}
onPress={() => {
onPress?.();
}} // TODO Launch playback
illustration={song?.illustration}
illustrationProps={illustrationProps}
/>
);
};
@@ -0,0 +1,66 @@
import type { VideoWithRelations } from "@/models/video";
import formatDuration from "@/utils/format-duration";
import { type ComponentProps, useMemo } from "react";
import { ListItem } from "../list-item";
import { Tile } from "../tile";
type Props = {
illustrationProps?: ComponentProps<typeof Tile>["illustrationProps"];
formatSubtitle?: (s: string) => string;
onPress?: () => void;
} & (
| {
subtitle: "duration";
video: VideoWithRelations<"illustration" | "master"> | undefined;
}
| {
subtitle: "artistName";
video: VideoWithRelations<"artist" | "illustration"> | undefined;
}
);
export const VideoTile = (props: Props) => {
const formattedSubtitle = useFormattedSubtitle(props);
return (
<Tile
illustration={props.video?.illustration}
illustrationProps={props.illustrationProps}
title={props.video?.name}
onPress={() => {
props.onPress?.();
}} // TODO Launch playback
subtitle={formattedSubtitle}
/>
);
};
export const VideoItem = (props: Props) => {
const formattedSubtitle = useFormattedSubtitle(props);
return (
<ListItem
title={props.video?.name}
subtitle={formattedSubtitle}
onPress={() => {
props.onPress?.();
}} // TODO Launch playback
illustration={props.video?.illustration}
illustrationProps={props.illustrationProps}
/>
);
};
const useFormattedSubtitle = ({ formatSubtitle, video, subtitle }: Props) => {
return useMemo(() => {
const f = formatSubtitle ?? ((e: string) => e);
if (video === undefined) {
return undefined;
}
if (subtitle === "duration") {
return f(formatDuration(video.master.duration));
}
if (subtitle === "artistName") {
return f(video.artist.name);
}
return null;
}, [subtitle, formatSubtitle, video]);
};
@@ -0,0 +1,105 @@
import type IllustrationModel from "@/models/illustration";
import { type Href, useRouter } from "expo-router";
import type { ComponentProps } from "react";
import { Pressable, View, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import type { RequireAtLeastOne } from "type-fest";
import { Illustration } from "~/components/illustration";
import { LoadableText } from "~/components/loadable_text";
const styles = StyleSheet.create((theme) => ({
container: {
padding: theme.gap(0.5),
height: "auto",
width: "100%",
borderRadius: theme.borderRadius,
overflow: "hidden",
},
imageContainer: {
variants: {
normalizedThumbnail: {
true: { aspectRatio: 16 / 9 },
false: { aspectRatio: 1 },
},
},
},
textColumn: {
display: "flex",
flexDirection: "column",
paddingHorizontal: theme.gap(0.5),
paddingVertical: theme.gap(1),
gap: theme.gap(0.5),
variants: {
normalizedThumbnail: {
true: {},
false: {},
},
hasSubtitle: {
true: { alignItems: "flex-start" },
false: { alignItems: "center" },
},
},
},
}));
type Props = {
illustration: IllustrationModel | null | undefined;
illustrationProps?: Omit<
ComponentProps<typeof Illustration>,
"illustration" | "quality"
>;
title: string | undefined;
subtitle: string | undefined | null;
containerStyle?: ViewStyle;
} & RequireAtLeastOne<{ href: Href | null; onPress: (() => void) | null }>;
export const Tile = ({
illustration,
title,
subtitle,
href,
onPress,
...props
}: Props) => {
styles.useVariants({
hasSubtitle: subtitle !== null,
normalizedThumbnail:
props.illustrationProps?.normalizedThumbnail ?? false,
});
const router = useRouter();
return (
<Pressable
onPress={() => {
onPress?.();
if (href) {
router.push(href);
}
}}
style={[styles.container, props.containerStyle]}
>
<View style={styles.imageContainer}>
<Illustration
illustration={illustration}
{...props.illustrationProps}
quality="medium"
/>
</View>
<View style={[styles.textColumn]}>
<LoadableText
variant="h6"
numberOfLines={1}
skeletonWidth={10}
content={title}
/>
{subtitle !== null && (
<LoadableText
variant="body"
skeletonWidth={8}
numberOfLines={1}
content={subtitle}
/>
)}
</View>
</Pressable>
);
};
@@ -0,0 +1,41 @@
/*
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
* Copyright (C) 2023
*
* Meelo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Meelo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import type { ComponentProps } from "react";
import { View } from "react-native";
import { Text, TextSkeleton } from "~/primitives/text";
type Props = Omit<ComponentProps<typeof Text>, "content" | "children"> & {
content: string | undefined;
skeletonWidth: ComponentProps<typeof TextSkeleton>["width"];
};
export const LoadableText = ({ content, ...props }: Props) => {
return (
<View>
{content === undefined ? (
<TextSkeleton
variant={props.variant}
width={props.skeletonWidth}
/>
) : (
<Text {...props}>{content}</Text>
)}
</View>
);
};
@@ -0,0 +1,63 @@
/*
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
* Copyright (C) 2023
*
* Meelo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Meelo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Image } from "expo-image";
import type { ImageProps } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
const styles = StyleSheet.create((_theme) => ({
image: {
flex: 1,
width: "100%",
},
icon: { aspectRatio: 1, height: "100%" },
}));
const BannerBase = withUnistyles(
Image,
(theme) =>
({
priority: "high",
contentFit: "contain",
source:
theme.name === "dark"
? require("../../assets/banner1_white.png")
: require("../../assets/banner1_black.png"),
}) as const,
);
export const Banner = (props: Pick<ImageProps, "style">) => {
return <BannerBase style={[styles.image, props.style]} />;
};
const IconBase = withUnistyles(
Image,
(theme) =>
({
priority: "high",
contentFit: "contain",
source:
theme.name === "light"
? require("../../assets/icon-black.png")
: require("../../assets/icon-white.png"),
}) as const,
);
export const Icon = (props: Pick<ImageProps, "style">) => {
return <IconBase style={[styles.icon, props.style]} />;
};
@@ -0,0 +1,44 @@
import { StarIcon } from "@/ui/icons";
import { generateArray } from "@/utils/gen-list";
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { Icon } from "~/primitives/icon";
import { TextSkeleton } from "~/primitives/text";
export const Rating = ({
rating,
}: {
// Out of 100
rating: number | undefined;
}) => {
return (
<View style={styles.root}>
{rating ? (
generateArray(5, undefined).map((_, idx) => (
<Icon
key={idx}
icon={StarIcon}
style={styles.star(rating ?? 0, idx)}
/>
))
) : (
<TextSkeleton width={11} />
)}
</View>
);
};
const styles = StyleSheet.create((theme) => ({
root: {
flexDirection: "row",
gap: theme.gap(0.25),
justifyContent: "center",
},
star: (rating: number, starIndex: number) => ({
size: theme.fontSize.rem(1),
color:
Math.ceil(rating / 20) >= starIndex + 1
? theme.colors.text.primary
: theme.colors.text.secondary,
}),
}));
+132
View File
@@ -0,0 +1,132 @@
import type Resource from "@/models/resource";
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 { StyleSheet, withUnistyles } from "react-native-unistyles";
import { EmptyState } from "~/components/empty-state";
import { SectionHeader } from "./section-header";
type Props<T> = {
items?: (T | undefined)[];
header?: string;
style?: ViewStyle;
seeMore?: Href;
render: (item: T | undefined) => React.ReactElement;
onEndReached?: () => void;
hideIfEmpty?: true;
};
export const Row = <T extends Resource>({ items, ...props }: Props<T>) => {
const itemList = useMemo(() => {
return items ?? [undefined];
}, [items]);
return <RowBase {...props} items={itemList} />;
};
const RowBase = <T,>({
items,
header,
hideIfEmpty,
style,
render,
onEndReached,
seeMore,
}: Props<T>) => {
const flatListRef = createRef<FlatList<unknown>>();
return (
(!hideIfEmpty || (items && items.length > 0)) && (
<View style={[styles.root, style]}>
{header && (
<SectionHeader
seeMore={seeMore}
onPress={() =>
flatListRef.current?.scrollToIndex({
animated: true,
index: 0,
})
}
skeletonWidth={header.length}
content={
items === undefined || items[0] === undefined
? undefined
: header
}
/>
)}
{items?.length !== 0 ? (
<ResponsiveFlatList
horizontal
snapToAlignment="start"
decelerationRate={"normal"}
ref={flatListRef}
data={items}
onEndReached={onEndReached}
contentContainerStyle={styles.row}
renderItem={({ item, index }) => {
return (
<View style={[styles.item(index)]}>
{render(item as T | undefined)}
</View>
);
}}
/>
) : (
<View style={styles.emptyState}>
<EmptyState />
</View>
)}
</View>
)
);
};
const styles = StyleSheet.create((theme, rt) => ({
root: { display: "flex", alignItems: "flex-start", width: "100%" },
emptyState: {
aspectRatio: 2.5, // TODO this an approximate, would be nice to compute this correctly
width: "100%",
},
row: { gap: theme.gap(0.5), paddingRight: theme.gap(1) },
item: (itemIndex: number) => ({
marginLeft: itemIndex === 0 ? theme.gap(1) : 0,
// @ts-expect-error
width: rt.screen.width / theme.layout.grid.columnCount[rt.breakpoint!],
}),
}));
const ResponsiveFlatList = withUnistyles(FlatList, (theme, rt) => ({
// @ts-expect-error
initialNumToRender: theme.layout.grid.columnCount[rt.breakpoint!],
snapToInterval:
// TODO The interval is not correct,
// the further we scroll the more the snap position is shifted to the left
theme.gap(0.5) +
// @ts-expect-error
rt.screen.width / theme.layout.grid.columnCount[rt.breakpoint!],
}));
// export const InfiniteRow = <T0 extends Resource, T extends Resource>({
// query,
// ...props
// }: Props<T0, T>) => {
// const { items, isFetching, isFetchingNextPage, fetchNextPage } =
// useInfiniteQuery(() => query);
//
// const itemList = useMemo(() => {
// const itemCount = items?.length ?? 0;
// if ((isFetching && !itemCount) || isFetchingNextPage) {
// return [...(items ?? []), undefined];
// }
// return items ?? [undefined];
// }, [items, isFetching, isFetchingNextPage]);
// return (
// <RowBase
// {...props}
// items={itemList}
// onEndReached={() => fetchNextPage()}
// />
// );
// };
//
@@ -0,0 +1,71 @@
import {
ScrollView,
type ScrollViewProps,
View,
type ViewProps,
type ViewStyle,
} from "react-native";
import { useRootViewStyle } from "~/hooks/root-view-style";
// ScrollView that adds padding at the top and bottom to avoid header + tabbar
export const SafeScrollView = (
props: Omit<ScrollViewProps, "contentContainerStyle"> & {
contentContainerStyle?: ViewStyle[];
},
) => {
const { paddingTop, ...rootStyle } = useRootViewStyle();
return (
<ScrollView
{...props}
contentContainerStyle={[
...(props.contentContainerStyle ?? []),
rootStyle,
{
paddingTop: mergePaddingTop(
paddingTop,
props.contentContainerStyle,
),
},
]}
/>
);
};
// View that adds padding at the top and bottom to avoid header + tabbar
export const SafeView = (
props: Omit<ViewProps, "style"> & {
style?: ViewStyle[];
},
) => {
const { paddingTop, ...rootStyle } = useRootViewStyle();
return (
<View
{...props}
style={[
...(props.style ?? []),
rootStyle,
{
paddingTop: mergePaddingTop(paddingTop, props.style),
},
]}
/>
);
};
const mergePaddingTop = (
paddingTop: number,
styles: ViewStyle[] | undefined,
) => {
return (
paddingTop +
(styles
?.map(
(s) =>
((s.padding ??
s.paddingVertical ??
s.paddingTop) as number) || 0,
)
?.reduce((p, s) => p + s) ?? 0)
);
};
@@ -0,0 +1,64 @@
import { MoreIcon } from "@/ui/icons";
import { type Href, useRouter } from "expo-router";
import type { ComponentProps } from "react";
import { useTranslation } from "react-i18next";
import { View, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { Button } from "~/primitives/button";
import { Pressable } from "~/primitives/pressable";
import { LoadableText } from "./loadable_text";
type Props = {
onPress?: () => void;
style?: ViewStyle;
seeMore?: Href;
trailing?: React.ReactElement;
} & Omit<ComponentProps<typeof LoadableText>, "variant">;
//TODO Avoid text to be too large
export const SectionHeader = ({
onPress,
style,
seeMore,
trailing,
...textProps
}: Props) => {
const { t } = useTranslation();
const router = useRouter();
return (
<View style={[styles.root, style]}>
<Pressable onPress={() => onPress?.()} disabled={!onPress}>
<LoadableText {...textProps} variant="h4" numberOfLines={1} />
</Pressable>
{trailing ||
(seeMore !== undefined && (
<Button
containerStyle={styles.button(
textProps.content === undefined,
)}
size="small"
title={t("browsing.seeAll")}
icon={MoreIcon}
iconPosition="right"
onPress={() => router.push(seeMore)}
/>
))}
</View>
);
};
const styles = StyleSheet.create((theme) => ({
root: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
width: "100%",
paddingHorizontal: theme.gap(1),
paddingRight: theme.gap(2),
marginBottom: theme.gap(1),
},
button: (isLoading: boolean) => ({
opacity: isLoading ? 0 : 1,
}),
}));
@@ -0,0 +1,133 @@
import type { SongWithRelations } from "@/models/song";
import { generateArray } from "@/utils/gen-list";
import type { Href } from "expo-router";
import { type ComponentProps, Fragment, createRef, useMemo } from "react";
import { ScrollView, View, type ViewStyle } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { ListItem } from "~/components/item/list-item";
import { SongItem } from "~/components/item/resource/song";
import { SectionHeader } from "~/components/section-header";
import { Divider } from "~/primitives/divider";
import { breakpoints } from "~/theme";
type Song = SongWithRelations<
"artist" | "featuring" | "master" | "illustration"
>;
type Props = {
songs: Song[] | undefined;
style?: ViewStyle;
seeMore?: Href;
header?: string;
hideIfEmpty?: true;
illustrationProps?: ComponentProps<typeof ListItem>["illustrationProps"];
subtitle: null | ((song: Song) => "artists" | null);
};
const ItemsPerColumn = 4;
export const SongGrid = ({
songs,
subtitle,
header,
illustrationProps,
hideIfEmpty,
seeMore,
...props
}: Props) => {
const scrollViewRef = createRef<ScrollView>();
const chunks = useMemo(() => {
if (songs === undefined) {
return [generateArray(ItemsPerColumn, undefined)];
}
const res: Song[][] = [];
for (let i = 0; i < songs.length; i += ItemsPerColumn) {
res.push(songs.slice(i, i + ItemsPerColumn));
}
return res;
}, [songs]);
return (
(songs === undefined ||
(!hideIfEmpty && songs.length === 0) ||
songs.length > 0) && (
<View style={[styles.root, props.style]}>
{header && (
<SectionHeader
seeMore={seeMore}
onPress={() =>
scrollViewRef.current?.scrollTo({
x: 0,
animated: true,
})
}
skeletonWidth={header.length}
content={songs === undefined ? undefined : header}
/>
)}
<SnappyScrollView
horizontal
snapToAlignment="start"
decelerationRate={"normal"}
ref={scrollViewRef}
style={styles.scrollView}
>
{chunks.map((chunk, chunkIdx) => (
<View
key={chunkIdx}
style={styles.column(
chunkIdx,
chunkIdx === chunks.length - 1,
)}
>
{chunk.map((item, idx) => (
<Fragment
key={
item?.id.toString() ?? `skeleton-${idx}`
}
>
<SongItem
song={item}
subtitle={
subtitle === null
? null
: item
? subtitle(item)
: undefined
}
illustrationProps={illustrationProps}
/>
{idx !== chunk.length - 1 && (
<Divider h withInsets />
)}
</Fragment>
))}
</View>
))}
</SnappyScrollView>
</View>
)
);
};
const ColumnWidthRatio = 0.9;
const styles = StyleSheet.create((theme, rt) => ({
root: { display: "flex", alignItems: "flex-start" },
scrollView: {},
column: (idx: number, isLast: boolean) => ({
paddingRight: isLast ? theme.gap(4) : theme.gap(1),
width: rt.screen.width * (!isLast ? ColumnWidthRatio : 1),
maxWidth: breakpoints.sm * ColumnWidthRatio,
marginLeft: idx === 0 && !isLast ? theme.gap(1) : 0,
// Preventing single-col grids to be scrollable
paddingLeft: idx === 0 && isLast ? theme.gap(1) : 0,
}),
}));
const SnappyScrollView = withUnistyles(ScrollView, (_, rt) => ({
//TODO Check on other screen sizes
snapToInterval:
(rt.screen.width >= breakpoints.sm ? breakpoints.sm : rt.screen.width) *
ColumnWidthRatio,
}));
@@ -0,0 +1,16 @@
import { useAtomValue } from "jotai";
import { useMemo } from "react";
import { useColorScheme as useRNColorScheme } from "react-native";
import { colorSchemePreference } from "~/state/color-scheme";
export const useColorScheme = () => {
const colorSchemePref = useAtomValue(colorSchemePreference);
const rnColorScheme = useRNColorScheme();
const actualColorScheme = useMemo(() => {
if (colorSchemePref === "system") {
return rnColorScheme ?? "light";
}
return colorSchemePref;
}, [rnColorScheme, colorSchemePref]);
return actualColorScheme;
};
Loaded 100 of 821 files, more files were not shown because too many files have changed in this diff. Show more