Rockbox open source high quality audio player as a Music Player Daemon
mpris rockbox mpd libadwaita audio rust zig deno
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #39 from tsirysndr/feat/track-favourites

webui: implement 'favourites' tracks/albums

authored by

Tsiry Sandratraina and committed by
GitHub
bf1df998 8f6ad61b

+1854 -88
+4
Cargo.lock
··· 5686 5686 "anyhow", 5687 5687 "async-graphql", 5688 5688 "async-graphql-actix-web", 5689 + "chrono", 5690 + "cuid", 5689 5691 "futures", 5690 5692 "futures-channel", 5691 5693 "futures-util", ··· 5725 5727 version = "0.1.0" 5726 5728 dependencies = [ 5727 5729 "anyhow", 5730 + "chrono", 5731 + "cuid", 5728 5732 "futures", 5729 5733 "owo-colors 5.0.0", 5730 5734 "prost 0.13.2",
+2
crates/graphql/Cargo.toml
··· 10 10 anyhow = "1.0.87" 11 11 async-graphql = "7.0.9" 12 12 async-graphql-actix-web = "7.0.9" 13 + chrono = {version = "0.4.38", features = ["serde"]} 14 + cuid = "1.3.3" 13 15 futures = "0.3.30" 14 16 futures-channel = "0.3.31" 15 17 futures-util = "0.3.31"
+61 -1
crates/graphql/src/schema/library.rs
··· 1 1 use async_graphql::*; 2 - use rockbox_library::repo; 2 + use rockbox_library::{entity::favourites::Favourites, repo}; 3 3 use sqlx::{Pool, Sqlite}; 4 4 5 5 use crate::schema::objects::track::Track; ··· 59 59 let pool = ctx.data::<Pool<Sqlite>>()?; 60 60 let results = repo::track::find(pool.clone(), &id).await?; 61 61 Ok(results.map(Into::into)) 62 + } 63 + 64 + async fn liked_tracks(&self, ctx: &Context<'_>) -> Result<Vec<Track>, Error> { 65 + let pool = ctx.data::<Pool<Sqlite>>()?; 66 + let results = repo::favourites::all_tracks(pool.clone()).await?; 67 + Ok(results.into_iter().map(Into::into).collect()) 68 + } 69 + 70 + async fn liked_albums(&self, ctx: &Context<'_>) -> Result<Vec<Album>, Error> { 71 + let pool = ctx.data::<Pool<Sqlite>>()?; 72 + let results = repo::favourites::all_albums(pool.clone()).await?; 73 + Ok(results.into_iter().map(Into::into).collect()) 74 + } 75 + } 76 + 77 + #[derive(Default)] 78 + pub struct LibraryMutation; 79 + 80 + #[Object] 81 + impl LibraryMutation { 82 + async fn like_track(&self, ctx: &Context<'_>, id: String) -> Result<i32, Error> { 83 + let pool = ctx.data::<Pool<Sqlite>>()?; 84 + repo::favourites::save( 85 + pool.clone(), 86 + Favourites { 87 + id: cuid::cuid1()?, 88 + track_id: Some(id), 89 + created_at: chrono::Utc::now(), 90 + album_id: None, 91 + }, 92 + ) 93 + .await?; 94 + Ok(0) 95 + } 96 + 97 + async fn like_album(&self, ctx: &Context<'_>, id: String) -> Result<i32, Error> { 98 + let pool = ctx.data::<Pool<Sqlite>>()?; 99 + repo::favourites::save( 100 + pool.clone(), 101 + Favourites { 102 + id: cuid::cuid1()?, 103 + album_id: Some(id), 104 + created_at: chrono::Utc::now(), 105 + track_id: None, 106 + }, 107 + ) 108 + .await?; 109 + Ok(0) 110 + } 111 + 112 + async fn unlike_track(&self, ctx: &Context<'_>, id: String) -> Result<i32, Error> { 113 + let pool = ctx.data::<Pool<Sqlite>>()?; 114 + repo::favourites::delete(pool.clone(), &id).await?; 115 + Ok(0) 116 + } 117 + 118 + async fn unlike_album(&self, ctx: &Context<'_>, id: String) -> Result<i32, Error> { 119 + let pool = ctx.data::<Pool<Sqlite>>()?; 120 + repo::favourites::delete(pool.clone(), &id).await?; 121 + Ok(0) 62 122 } 63 123 }
+7 -2
crates/graphql/src/schema/mod.rs
··· 1 1 use async_graphql::{MergedObject, MergedSubscription}; 2 2 use browse::BrowseQuery; 3 - use library::LibraryQuery; 3 + use library::{LibraryMutation, LibraryQuery}; 4 4 use playback::{PlaybackMutation, PlaybackQuery, PlaybackSubscription}; 5 5 use playlist::{PlaylistMutation, PlaylistQuery, PlaylistSubscription}; 6 6 use settings::SettingsQuery; ··· 29 29 ); 30 30 31 31 #[derive(MergedObject, Default)] 32 - pub struct Mutation(PlaybackMutation, PlaylistMutation, SoundMutation); 32 + pub struct Mutation( 33 + PlaybackMutation, 34 + PlaylistMutation, 35 + SoundMutation, 36 + LibraryMutation, 37 + ); 33 38 34 39 #[derive(MergedSubscription, Default)] 35 40 pub struct Subscription(PlaybackSubscription, PlaylistSubscription);
+1 -1
crates/library/migrations/20240923093823_create_tables.sql
··· 76 76 77 77 CREATE TABLE IF NOT EXISTS favourites ( 78 78 id VARCHAR(255) PRIMARY KEY, 79 - track_id VARCHAR(255) NOT NULL, 79 + track_id VARCHAR(255) DEFAULT NULL, 80 80 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP 81 81 ); 82 82
+2
crates/library/migrations/20241020125757_add-album_id-column.sql
··· 1 + -- Add migration script here 2 + ALTER TABLE favourites ADD COLUMN album_id VARCHAR(255) DEFAULT NULL;
+8 -3
crates/library/src/entity/favourites.rs
··· 1 - #[derive(sqlx::FromRow, Default)] 1 + use chrono::{DateTime, Utc}; 2 + use serde::{Deserialize, Serialize}; 3 + 4 + #[derive(sqlx::FromRow, Default, Debug, Clone, Serialize, Deserialize)] 2 5 pub struct Favourites { 3 6 pub id: String, 4 - pub track_id: String, 5 - pub created_at: i64, 7 + pub track_id: Option<String>, 8 + pub album_id: Option<String>, 9 + #[serde(with = "chrono::serde::ts_seconds")] 10 + pub created_at: DateTime<Utc>, 6 11 }
+11
crates/library/src/lib.rs
··· 32 32 Ok(_) => {} 33 33 Err(_) => println!("artist_id column already exists"), 34 34 } 35 + 36 + match pool 37 + .execute(include_str!( 38 + "../migrations/20241020125757_add-album_id-column.sql" 39 + )) 40 + .await 41 + { 42 + Ok(_) => {} 43 + Err(_) => println!("album_id column already exists"), 44 + } 45 + 35 46 sqlx::query("PRAGMA journal_mode=WAL") 36 47 .execute(&pool) 37 48 .await?;
+86 -3
crates/library/src/repo/favourites.rs
··· 1 - use crate::entity::favourites::Favourites; 1 + use crate::entity::{album::Album, favourites::Favourites, track::Track}; 2 2 use sqlx::{Pool, Sqlite}; 3 3 4 - pub async fn save(pool: Pool<Sqlite>, favourite: Favourites) {} 4 + pub async fn save(pool: Pool<Sqlite>, favourite: Favourites) -> Result<(), sqlx::Error> { 5 + if favourite.track_id.is_none() && favourite.album_id.is_none() { 6 + return Err(sqlx::Error::RowNotFound); 7 + } 8 + 9 + let results = sqlx::query( 10 + r#" 11 + SELECT * FROM favourites WHERE track_id = $1 OR album_id = $2 12 + "#, 13 + ) 14 + .bind(&favourite.track_id) 15 + .bind(&favourite.album_id) 16 + .fetch_optional(&pool) 17 + .await?; 18 + 19 + if results.is_some() { 20 + return Ok(()); 21 + } 22 + 23 + sqlx::query( 24 + r#" 25 + INSERT INTO favourites ( 26 + id, 27 + track_id, 28 + album_id, 29 + created_at 30 + ) 31 + VALUES ($1, $2, $3, $4) 32 + "#, 33 + ) 34 + .bind(&favourite.id) 35 + .bind(&favourite.track_id) 36 + .bind(&favourite.album_id) 37 + .bind(&favourite.created_at) 38 + .execute(&pool) 39 + .await?; 40 + Ok(()) 41 + } 42 + 43 + pub async fn all_tracks(pool: Pool<Sqlite>) -> Result<Vec<Track>, sqlx::Error> { 44 + match sqlx::query_as::<_, Track>( 45 + r#" 46 + SELECT * FROM favourites LEFT JOIN track ON favourites.track_id = track.id WHERE favourites.track_id IS NOT NULL 47 + ORDER BY created_at DESC 48 + "#, 49 + ) 50 + .fetch_all(&pool) 51 + .await 52 + { 53 + Ok(favourites) => Ok(favourites), 54 + Err(e) => { 55 + eprintln!("Error fetching favourites: {:?}", e); 56 + Err(e) 57 + } 58 + } 59 + } 5 60 6 - pub async fn all(pool: Pool<Sqlite>) {} 61 + pub async fn all_albums(pool: Pool<Sqlite>) -> Result<Vec<Album>, sqlx::Error> { 62 + match sqlx::query_as::<_, Album>( 63 + r#" 64 + SELECT * FROM favourites LEFT JOIN album ON favourites.album_id = album.id WHERE favourites.album_id IS NOT NULL ORDER BY created_at DESC 65 + "#, 66 + ) 67 + .fetch_all(&pool) 68 + .await 69 + { 70 + Ok(favourites) => Ok(favourites), 71 + Err(e) => { 72 + eprintln!("Error fetching favourites: {:?}", e); 73 + Err(e) 74 + } 75 + } 76 + } 77 + 78 + pub async fn delete(pool: Pool<Sqlite>, id: &str) -> Result<(), sqlx::Error> { 79 + sqlx::query( 80 + r#" 81 + DELETE FROM favourites WHERE track_id = $1 OR album_id = $2 82 + "#, 83 + ) 84 + .bind(id) 85 + .bind(id) 86 + .execute(&pool) 87 + .await?; 88 + Ok(()) 89 + }
+2
crates/rpc/Cargo.toml
··· 5 5 6 6 [dependencies] 7 7 anyhow = "1.0.89" 8 + chrono = {version = "0.4.38", features = ["serde"]} 9 + cuid = "1.3.3" 8 10 futures = "0.3.30" 9 11 owo-colors = "5.0.0" 10 12 prost = "0.13.2"
+42
crates/rpc/proto/rockbox/v1alpha1/library.proto
··· 91 91 repeated Track tracks = 1; 92 92 } 93 93 94 + message LikeTrackRequest { 95 + string id = 1; 96 + } 97 + 98 + message LikeTrackResponse {} 99 + 100 + message LikeAlbumRequest { 101 + string id = 1; 102 + } 103 + 104 + message LikeAlbumResponse {} 105 + 106 + message UnlikeTrackRequest { 107 + string id = 1; 108 + } 109 + 110 + message UnlikeTrackResponse {} 111 + 112 + message UnlikeAlbumRequest { 113 + string id = 1; 114 + } 115 + 116 + message UnlikeAlbumResponse {} 117 + 118 + message GetLikedTracksRequest {} 119 + 120 + message GetLikedTracksResponse { 121 + repeated Track tracks = 1; 122 + } 123 + 124 + message GetLikedAlbumsRequest {} 125 + 126 + message GetLikedAlbumsResponse { 127 + repeated Album albums = 1; 128 + } 129 + 94 130 service LibraryService { 95 131 rpc GetAlbums(GetAlbumsRequest) returns (GetAlbumsResponse); 96 132 rpc GetArtists(GetArtistsRequest) returns (GetArtistsResponse); ··· 98 134 rpc GetAlbum(GetAlbumRequest) returns (GetAlbumResponse); 99 135 rpc GetArtist(GetArtistRequest) returns (GetArtistResponse); 100 136 rpc GetTrack(GetTrackRequest) returns (GetTrackResponse); 137 + rpc LikeTrack(LikeTrackRequest) returns (LikeTrackResponse); 138 + rpc UnlikeTrack(UnlikeTrackRequest) returns (UnlikeTrackResponse); 139 + rpc LikeAlbum(LikeAlbumRequest) returns (LikeAlbumResponse); 140 + rpc UnlikeAlbum(UnlikeAlbumRequest) returns (UnlikeAlbumResponse); 141 + rpc GetLikedTracks(GetLikedTracksRequest) returns (GetLikedTracksResponse); 142 + rpc GetLikedAlbums(GetLikedAlbumsRequest) returns (GetLikedAlbumsResponse); 101 143 }
+514
crates/rpc/src/api/rockbox.v1alpha1.rs
··· 459 459 #[prost(message, repeated, tag = "1")] 460 460 pub tracks: ::prost::alloc::vec::Vec<Track>, 461 461 } 462 + #[derive(Clone, PartialEq, ::prost::Message)] 463 + pub struct LikeTrackRequest { 464 + #[prost(string, tag = "1")] 465 + pub id: ::prost::alloc::string::String, 466 + } 467 + #[derive(Clone, Copy, PartialEq, ::prost::Message)] 468 + pub struct LikeTrackResponse {} 469 + #[derive(Clone, PartialEq, ::prost::Message)] 470 + pub struct LikeAlbumRequest { 471 + #[prost(string, tag = "1")] 472 + pub id: ::prost::alloc::string::String, 473 + } 474 + #[derive(Clone, Copy, PartialEq, ::prost::Message)] 475 + pub struct LikeAlbumResponse {} 476 + #[derive(Clone, PartialEq, ::prost::Message)] 477 + pub struct UnlikeTrackRequest { 478 + #[prost(string, tag = "1")] 479 + pub id: ::prost::alloc::string::String, 480 + } 481 + #[derive(Clone, Copy, PartialEq, ::prost::Message)] 482 + pub struct UnlikeTrackResponse {} 483 + #[derive(Clone, PartialEq, ::prost::Message)] 484 + pub struct UnlikeAlbumRequest { 485 + #[prost(string, tag = "1")] 486 + pub id: ::prost::alloc::string::String, 487 + } 488 + #[derive(Clone, Copy, PartialEq, ::prost::Message)] 489 + pub struct UnlikeAlbumResponse {} 490 + #[derive(Clone, Copy, PartialEq, ::prost::Message)] 491 + pub struct GetLikedTracksRequest {} 492 + #[derive(Clone, PartialEq, ::prost::Message)] 493 + pub struct GetLikedTracksResponse { 494 + #[prost(message, repeated, tag = "1")] 495 + pub tracks: ::prost::alloc::vec::Vec<Track>, 496 + } 497 + #[derive(Clone, Copy, PartialEq, ::prost::Message)] 498 + pub struct GetLikedAlbumsRequest {} 499 + #[derive(Clone, PartialEq, ::prost::Message)] 500 + pub struct GetLikedAlbumsResponse { 501 + #[prost(message, repeated, tag = "1")] 502 + pub albums: ::prost::alloc::vec::Vec<Album>, 503 + } 462 504 /// Generated client implementations. 463 505 pub mod library_service_client { 464 506 #![allow(unused_variables, dead_code, missing_docs, clippy::let_unit_value)] ··· 696 738 .insert(GrpcMethod::new("rockbox.v1alpha1.LibraryService", "GetTrack")); 697 739 self.inner.unary(req, path, codec).await 698 740 } 741 + pub async fn like_track( 742 + &mut self, 743 + request: impl tonic::IntoRequest<super::LikeTrackRequest>, 744 + ) -> std::result::Result< 745 + tonic::Response<super::LikeTrackResponse>, 746 + tonic::Status, 747 + > { 748 + self.inner 749 + .ready() 750 + .await 751 + .map_err(|e| { 752 + tonic::Status::new( 753 + tonic::Code::Unknown, 754 + format!("Service was not ready: {}", e.into()), 755 + ) 756 + })?; 757 + let codec = tonic::codec::ProstCodec::default(); 758 + let path = http::uri::PathAndQuery::from_static( 759 + "/rockbox.v1alpha1.LibraryService/LikeTrack", 760 + ); 761 + let mut req = request.into_request(); 762 + req.extensions_mut() 763 + .insert(GrpcMethod::new("rockbox.v1alpha1.LibraryService", "LikeTrack")); 764 + self.inner.unary(req, path, codec).await 765 + } 766 + pub async fn unlike_track( 767 + &mut self, 768 + request: impl tonic::IntoRequest<super::UnlikeTrackRequest>, 769 + ) -> std::result::Result< 770 + tonic::Response<super::UnlikeTrackResponse>, 771 + tonic::Status, 772 + > { 773 + self.inner 774 + .ready() 775 + .await 776 + .map_err(|e| { 777 + tonic::Status::new( 778 + tonic::Code::Unknown, 779 + format!("Service was not ready: {}", e.into()), 780 + ) 781 + })?; 782 + let codec = tonic::codec::ProstCodec::default(); 783 + let path = http::uri::PathAndQuery::from_static( 784 + "/rockbox.v1alpha1.LibraryService/UnlikeTrack", 785 + ); 786 + let mut req = request.into_request(); 787 + req.extensions_mut() 788 + .insert( 789 + GrpcMethod::new("rockbox.v1alpha1.LibraryService", "UnlikeTrack"), 790 + ); 791 + self.inner.unary(req, path, codec).await 792 + } 793 + pub async fn like_album( 794 + &mut self, 795 + request: impl tonic::IntoRequest<super::LikeAlbumRequest>, 796 + ) -> std::result::Result< 797 + tonic::Response<super::LikeAlbumResponse>, 798 + tonic::Status, 799 + > { 800 + self.inner 801 + .ready() 802 + .await 803 + .map_err(|e| { 804 + tonic::Status::new( 805 + tonic::Code::Unknown, 806 + format!("Service was not ready: {}", e.into()), 807 + ) 808 + })?; 809 + let codec = tonic::codec::ProstCodec::default(); 810 + let path = http::uri::PathAndQuery::from_static( 811 + "/rockbox.v1alpha1.LibraryService/LikeAlbum", 812 + ); 813 + let mut req = request.into_request(); 814 + req.extensions_mut() 815 + .insert(GrpcMethod::new("rockbox.v1alpha1.LibraryService", "LikeAlbum")); 816 + self.inner.unary(req, path, codec).await 817 + } 818 + pub async fn unlike_album( 819 + &mut self, 820 + request: impl tonic::IntoRequest<super::UnlikeAlbumRequest>, 821 + ) -> std::result::Result< 822 + tonic::Response<super::UnlikeAlbumResponse>, 823 + tonic::Status, 824 + > { 825 + self.inner 826 + .ready() 827 + .await 828 + .map_err(|e| { 829 + tonic::Status::new( 830 + tonic::Code::Unknown, 831 + format!("Service was not ready: {}", e.into()), 832 + ) 833 + })?; 834 + let codec = tonic::codec::ProstCodec::default(); 835 + let path = http::uri::PathAndQuery::from_static( 836 + "/rockbox.v1alpha1.LibraryService/UnlikeAlbum", 837 + ); 838 + let mut req = request.into_request(); 839 + req.extensions_mut() 840 + .insert( 841 + GrpcMethod::new("rockbox.v1alpha1.LibraryService", "UnlikeAlbum"), 842 + ); 843 + self.inner.unary(req, path, codec).await 844 + } 845 + pub async fn get_liked_tracks( 846 + &mut self, 847 + request: impl tonic::IntoRequest<super::GetLikedTracksRequest>, 848 + ) -> std::result::Result< 849 + tonic::Response<super::GetLikedTracksResponse>, 850 + tonic::Status, 851 + > { 852 + self.inner 853 + .ready() 854 + .await 855 + .map_err(|e| { 856 + tonic::Status::new( 857 + tonic::Code::Unknown, 858 + format!("Service was not ready: {}", e.into()), 859 + ) 860 + })?; 861 + let codec = tonic::codec::ProstCodec::default(); 862 + let path = http::uri::PathAndQuery::from_static( 863 + "/rockbox.v1alpha1.LibraryService/GetLikedTracks", 864 + ); 865 + let mut req = request.into_request(); 866 + req.extensions_mut() 867 + .insert( 868 + GrpcMethod::new("rockbox.v1alpha1.LibraryService", "GetLikedTracks"), 869 + ); 870 + self.inner.unary(req, path, codec).await 871 + } 872 + pub async fn get_liked_albums( 873 + &mut self, 874 + request: impl tonic::IntoRequest<super::GetLikedAlbumsRequest>, 875 + ) -> std::result::Result< 876 + tonic::Response<super::GetLikedAlbumsResponse>, 877 + tonic::Status, 878 + > { 879 + self.inner 880 + .ready() 881 + .await 882 + .map_err(|e| { 883 + tonic::Status::new( 884 + tonic::Code::Unknown, 885 + format!("Service was not ready: {}", e.into()), 886 + ) 887 + })?; 888 + let codec = tonic::codec::ProstCodec::default(); 889 + let path = http::uri::PathAndQuery::from_static( 890 + "/rockbox.v1alpha1.LibraryService/GetLikedAlbums", 891 + ); 892 + let mut req = request.into_request(); 893 + req.extensions_mut() 894 + .insert( 895 + GrpcMethod::new("rockbox.v1alpha1.LibraryService", "GetLikedAlbums"), 896 + ); 897 + self.inner.unary(req, path, codec).await 898 + } 699 899 } 700 900 } 701 901 /// Generated server implementations. ··· 745 945 request: tonic::Request<super::GetTrackRequest>, 746 946 ) -> std::result::Result< 747 947 tonic::Response<super::GetTrackResponse>, 948 + tonic::Status, 949 + >; 950 + async fn like_track( 951 + &self, 952 + request: tonic::Request<super::LikeTrackRequest>, 953 + ) -> std::result::Result< 954 + tonic::Response<super::LikeTrackResponse>, 955 + tonic::Status, 956 + >; 957 + async fn unlike_track( 958 + &self, 959 + request: tonic::Request<super::UnlikeTrackRequest>, 960 + ) -> std::result::Result< 961 + tonic::Response<super::UnlikeTrackResponse>, 962 + tonic::Status, 963 + >; 964 + async fn like_album( 965 + &self, 966 + request: tonic::Request<super::LikeAlbumRequest>, 967 + ) -> std::result::Result< 968 + tonic::Response<super::LikeAlbumResponse>, 969 + tonic::Status, 970 + >; 971 + async fn unlike_album( 972 + &self, 973 + request: tonic::Request<super::UnlikeAlbumRequest>, 974 + ) -> std::result::Result< 975 + tonic::Response<super::UnlikeAlbumResponse>, 976 + tonic::Status, 977 + >; 978 + async fn get_liked_tracks( 979 + &self, 980 + request: tonic::Request<super::GetLikedTracksRequest>, 981 + ) -> std::result::Result< 982 + tonic::Response<super::GetLikedTracksResponse>, 983 + tonic::Status, 984 + >; 985 + async fn get_liked_albums( 986 + &self, 987 + request: tonic::Request<super::GetLikedAlbumsRequest>, 988 + ) -> std::result::Result< 989 + tonic::Response<super::GetLikedAlbumsResponse>, 748 990 tonic::Status, 749 991 >; 750 992 } ··· 1079 1321 let inner = self.inner.clone(); 1080 1322 let fut = async move { 1081 1323 let method = GetTrackSvc(inner); 1324 + let codec = tonic::codec::ProstCodec::default(); 1325 + let mut grpc = tonic::server::Grpc::new(codec) 1326 + .apply_compression_config( 1327 + accept_compression_encodings, 1328 + send_compression_encodings, 1329 + ) 1330 + .apply_max_message_size_config( 1331 + max_decoding_message_size, 1332 + max_encoding_message_size, 1333 + ); 1334 + let res = grpc.unary(method, req).await; 1335 + Ok(res) 1336 + }; 1337 + Box::pin(fut) 1338 + } 1339 + "/rockbox.v1alpha1.LibraryService/LikeTrack" => { 1340 + #[allow(non_camel_case_types)] 1341 + struct LikeTrackSvc<T: LibraryService>(pub Arc<T>); 1342 + impl< 1343 + T: LibraryService, 1344 + > tonic::server::UnaryService<super::LikeTrackRequest> 1345 + for LikeTrackSvc<T> { 1346 + type Response = super::LikeTrackResponse; 1347 + type Future = BoxFuture< 1348 + tonic::Response<Self::Response>, 1349 + tonic::Status, 1350 + >; 1351 + fn call( 1352 + &mut self, 1353 + request: tonic::Request<super::LikeTrackRequest>, 1354 + ) -> Self::Future { 1355 + let inner = Arc::clone(&self.0); 1356 + let fut = async move { 1357 + <T as LibraryService>::like_track(&inner, request).await 1358 + }; 1359 + Box::pin(fut) 1360 + } 1361 + } 1362 + let accept_compression_encodings = self.accept_compression_encodings; 1363 + let send_compression_encodings = self.send_compression_encodings; 1364 + let max_decoding_message_size = self.max_decoding_message_size; 1365 + let max_encoding_message_size = self.max_encoding_message_size; 1366 + let inner = self.inner.clone(); 1367 + let fut = async move { 1368 + let method = LikeTrackSvc(inner); 1369 + let codec = tonic::codec::ProstCodec::default(); 1370 + let mut grpc = tonic::server::Grpc::new(codec) 1371 + .apply_compression_config( 1372 + accept_compression_encodings, 1373 + send_compression_encodings, 1374 + ) 1375 + .apply_max_message_size_config( 1376 + max_decoding_message_size, 1377 + max_encoding_message_size, 1378 + ); 1379 + let res = grpc.unary(method, req).await; 1380 + Ok(res) 1381 + }; 1382 + Box::pin(fut) 1383 + } 1384 + "/rockbox.v1alpha1.LibraryService/UnlikeTrack" => { 1385 + #[allow(non_camel_case_types)] 1386 + struct UnlikeTrackSvc<T: LibraryService>(pub Arc<T>); 1387 + impl< 1388 + T: LibraryService, 1389 + > tonic::server::UnaryService<super::UnlikeTrackRequest> 1390 + for UnlikeTrackSvc<T> { 1391 + type Response = super::UnlikeTrackResponse; 1392 + type Future = BoxFuture< 1393 + tonic::Response<Self::Response>, 1394 + tonic::Status, 1395 + >; 1396 + fn call( 1397 + &mut self, 1398 + request: tonic::Request<super::UnlikeTrackRequest>, 1399 + ) -> Self::Future { 1400 + let inner = Arc::clone(&self.0); 1401 + let fut = async move { 1402 + <T as LibraryService>::unlike_track(&inner, request).await 1403 + }; 1404 + Box::pin(fut) 1405 + } 1406 + } 1407 + let accept_compression_encodings = self.accept_compression_encodings; 1408 + let send_compression_encodings = self.send_compression_encodings; 1409 + let max_decoding_message_size = self.max_decoding_message_size; 1410 + let max_encoding_message_size = self.max_encoding_message_size; 1411 + let inner = self.inner.clone(); 1412 + let fut = async move { 1413 + let method = UnlikeTrackSvc(inner); 1414 + let codec = tonic::codec::ProstCodec::default(); 1415 + let mut grpc = tonic::server::Grpc::new(codec) 1416 + .apply_compression_config( 1417 + accept_compression_encodings, 1418 + send_compression_encodings, 1419 + ) 1420 + .apply_max_message_size_config( 1421 + max_decoding_message_size, 1422 + max_encoding_message_size, 1423 + ); 1424 + let res = grpc.unary(method, req).await; 1425 + Ok(res) 1426 + }; 1427 + Box::pin(fut) 1428 + } 1429 + "/rockbox.v1alpha1.LibraryService/LikeAlbum" => { 1430 + #[allow(non_camel_case_types)] 1431 + struct LikeAlbumSvc<T: LibraryService>(pub Arc<T>); 1432 + impl< 1433 + T: LibraryService, 1434 + > tonic::server::UnaryService<super::LikeAlbumRequest> 1435 + for LikeAlbumSvc<T> { 1436 + type Response = super::LikeAlbumResponse; 1437 + type Future = BoxFuture< 1438 + tonic::Response<Self::Response>, 1439 + tonic::Status, 1440 + >; 1441 + fn call( 1442 + &mut self, 1443 + request: tonic::Request<super::LikeAlbumRequest>, 1444 + ) -> Self::Future { 1445 + let inner = Arc::clone(&self.0); 1446 + let fut = async move { 1447 + <T as LibraryService>::like_album(&inner, request).await 1448 + }; 1449 + Box::pin(fut) 1450 + } 1451 + } 1452 + let accept_compression_encodings = self.accept_compression_encodings; 1453 + let send_compression_encodings = self.send_compression_encodings; 1454 + let max_decoding_message_size = self.max_decoding_message_size; 1455 + let max_encoding_message_size = self.max_encoding_message_size; 1456 + let inner = self.inner.clone(); 1457 + let fut = async move { 1458 + let method = LikeAlbumSvc(inner); 1459 + let codec = tonic::codec::ProstCodec::default(); 1460 + let mut grpc = tonic::server::Grpc::new(codec) 1461 + .apply_compression_config( 1462 + accept_compression_encodings, 1463 + send_compression_encodings, 1464 + ) 1465 + .apply_max_message_size_config( 1466 + max_decoding_message_size, 1467 + max_encoding_message_size, 1468 + ); 1469 + let res = grpc.unary(method, req).await; 1470 + Ok(res) 1471 + }; 1472 + Box::pin(fut) 1473 + } 1474 + "/rockbox.v1alpha1.LibraryService/UnlikeAlbum" => { 1475 + #[allow(non_camel_case_types)] 1476 + struct UnlikeAlbumSvc<T: LibraryService>(pub Arc<T>); 1477 + impl< 1478 + T: LibraryService, 1479 + > tonic::server::UnaryService<super::UnlikeAlbumRequest> 1480 + for UnlikeAlbumSvc<T> { 1481 + type Response = super::UnlikeAlbumResponse; 1482 + type Future = BoxFuture< 1483 + tonic::Response<Self::Response>, 1484 + tonic::Status, 1485 + >; 1486 + fn call( 1487 + &mut self, 1488 + request: tonic::Request<super::UnlikeAlbumRequest>, 1489 + ) -> Self::Future { 1490 + let inner = Arc::clone(&self.0); 1491 + let fut = async move { 1492 + <T as LibraryService>::unlike_album(&inner, request).await 1493 + }; 1494 + Box::pin(fut) 1495 + } 1496 + } 1497 + let accept_compression_encodings = self.accept_compression_encodings; 1498 + let send_compression_encodings = self.send_compression_encodings; 1499 + let max_decoding_message_size = self.max_decoding_message_size; 1500 + let max_encoding_message_size = self.max_encoding_message_size; 1501 + let inner = self.inner.clone(); 1502 + let fut = async move { 1503 + let method = UnlikeAlbumSvc(inner); 1504 + let codec = tonic::codec::ProstCodec::default(); 1505 + let mut grpc = tonic::server::Grpc::new(codec) 1506 + .apply_compression_config( 1507 + accept_compression_encodings, 1508 + send_compression_encodings, 1509 + ) 1510 + .apply_max_message_size_config( 1511 + max_decoding_message_size, 1512 + max_encoding_message_size, 1513 + ); 1514 + let res = grpc.unary(method, req).await; 1515 + Ok(res) 1516 + }; 1517 + Box::pin(fut) 1518 + } 1519 + "/rockbox.v1alpha1.LibraryService/GetLikedTracks" => { 1520 + #[allow(non_camel_case_types)] 1521 + struct GetLikedTracksSvc<T: LibraryService>(pub Arc<T>); 1522 + impl< 1523 + T: LibraryService, 1524 + > tonic::server::UnaryService<super::GetLikedTracksRequest> 1525 + for GetLikedTracksSvc<T> { 1526 + type Response = super::GetLikedTracksResponse; 1527 + type Future = BoxFuture< 1528 + tonic::Response<Self::Response>, 1529 + tonic::Status, 1530 + >; 1531 + fn call( 1532 + &mut self, 1533 + request: tonic::Request<super::GetLikedTracksRequest>, 1534 + ) -> Self::Future { 1535 + let inner = Arc::clone(&self.0); 1536 + let fut = async move { 1537 + <T as LibraryService>::get_liked_tracks(&inner, request) 1538 + .await 1539 + }; 1540 + Box::pin(fut) 1541 + } 1542 + } 1543 + let accept_compression_encodings = self.accept_compression_encodings; 1544 + let send_compression_encodings = self.send_compression_encodings; 1545 + let max_decoding_message_size = self.max_decoding_message_size; 1546 + let max_encoding_message_size = self.max_encoding_message_size; 1547 + let inner = self.inner.clone(); 1548 + let fut = async move { 1549 + let method = GetLikedTracksSvc(inner); 1550 + let codec = tonic::codec::ProstCodec::default(); 1551 + let mut grpc = tonic::server::Grpc::new(codec) 1552 + .apply_compression_config( 1553 + accept_compression_encodings, 1554 + send_compression_encodings, 1555 + ) 1556 + .apply_max_message_size_config( 1557 + max_decoding_message_size, 1558 + max_encoding_message_size, 1559 + ); 1560 + let res = grpc.unary(method, req).await; 1561 + Ok(res) 1562 + }; 1563 + Box::pin(fut) 1564 + } 1565 + "/rockbox.v1alpha1.LibraryService/GetLikedAlbums" => { 1566 + #[allow(non_camel_case_types)] 1567 + struct GetLikedAlbumsSvc<T: LibraryService>(pub Arc<T>); 1568 + impl< 1569 + T: LibraryService, 1570 + > tonic::server::UnaryService<super::GetLikedAlbumsRequest> 1571 + for GetLikedAlbumsSvc<T> { 1572 + type Response = super::GetLikedAlbumsResponse; 1573 + type Future = BoxFuture< 1574 + tonic::Response<Self::Response>, 1575 + tonic::Status, 1576 + >; 1577 + fn call( 1578 + &mut self, 1579 + request: tonic::Request<super::GetLikedAlbumsRequest>, 1580 + ) -> Self::Future { 1581 + let inner = Arc::clone(&self.0); 1582 + let fut = async move { 1583 + <T as LibraryService>::get_liked_albums(&inner, request) 1584 + .await 1585 + }; 1586 + Box::pin(fut) 1587 + } 1588 + } 1589 + let accept_compression_encodings = self.accept_compression_encodings; 1590 + let send_compression_encodings = self.send_compression_encodings; 1591 + let max_decoding_message_size = self.max_decoding_message_size; 1592 + let max_encoding_message_size = self.max_encoding_message_size; 1593 + let inner = self.inner.clone(); 1594 + let fut = async move { 1595 + let method = GetLikedAlbumsSvc(inner); 1082 1596 let codec = tonic::codec::ProstCodec::default(); 1083 1597 let mut grpc = tonic::server::Grpc::new(codec) 1084 1598 .apply_compression_config(
crates/rpc/src/api/rockbox_descriptor.bin

This is a binary file and will not be displayed.

+89 -2
crates/rpc/src/library.rs
··· 1 - use rockbox_library::repo; 1 + use rockbox_library::{entity::favourites::Favourites, repo}; 2 2 use sqlx::Sqlite; 3 3 4 4 use crate::api::rockbox::v1alpha1::{ 5 5 library_service_server::LibraryService, Album, Artist, GetAlbumRequest, GetAlbumResponse, 6 6 GetAlbumsRequest, GetAlbumsResponse, GetArtistRequest, GetArtistResponse, GetArtistsRequest, 7 - GetArtistsResponse, GetTrackRequest, GetTrackResponse, GetTracksRequest, GetTracksResponse, 7 + GetArtistsResponse, GetLikedAlbumsRequest, GetLikedAlbumsResponse, GetLikedTracksRequest, 8 + GetLikedTracksResponse, GetTrackRequest, GetTrackResponse, GetTracksRequest, GetTracksResponse, 9 + LikeAlbumRequest, LikeAlbumResponse, LikeTrackRequest, LikeTrackResponse, UnlikeAlbumRequest, 10 + UnlikeAlbumResponse, UnlikeTrackRequest, UnlikeTrackResponse, 8 11 }; 9 12 10 13 pub struct Library { ··· 110 113 .map_err(|e| tonic::Status::internal(e.to_string()))?; 111 114 Ok(tonic::Response::new(GetTrackResponse { 112 115 track: track.map(|t| t.into()), 116 + })) 117 + } 118 + 119 + async fn like_track( 120 + &self, 121 + request: tonic::Request<LikeTrackRequest>, 122 + ) -> Result<tonic::Response<LikeTrackResponse>, tonic::Status> { 123 + let params = request.into_inner(); 124 + repo::favourites::save( 125 + self.pool.clone(), 126 + Favourites { 127 + id: cuid::cuid1().map_err(|e| tonic::Status::internal(e.to_string()))?, 128 + track_id: Some(params.id), 129 + created_at: chrono::Utc::now(), 130 + album_id: None, 131 + }, 132 + ) 133 + .await 134 + .map_err(|e| tonic::Status::internal(e.to_string()))?; 135 + Ok(tonic::Response::new(LikeTrackResponse {})) 136 + } 137 + 138 + async fn like_album( 139 + &self, 140 + request: tonic::Request<LikeAlbumRequest>, 141 + ) -> Result<tonic::Response<LikeAlbumResponse>, tonic::Status> { 142 + let params = request.into_inner(); 143 + repo::favourites::save( 144 + self.pool.clone(), 145 + Favourites { 146 + id: cuid::cuid1().map_err(|e| tonic::Status::internal(e.to_string()))?, 147 + track_id: None, 148 + created_at: chrono::Utc::now(), 149 + album_id: Some(params.id), 150 + }, 151 + ) 152 + .await 153 + .map_err(|e| tonic::Status::internal(e.to_string()))?; 154 + Ok(tonic::Response::new(LikeAlbumResponse {})) 155 + } 156 + 157 + async fn unlike_track( 158 + &self, 159 + request: tonic::Request<UnlikeTrackRequest>, 160 + ) -> Result<tonic::Response<UnlikeTrackResponse>, tonic::Status> { 161 + let params = request.into_inner(); 162 + repo::favourites::delete(self.pool.clone(), &params.id) 163 + .await 164 + .map_err(|e| tonic::Status::internal(e.to_string()))?; 165 + Ok(tonic::Response::new(UnlikeTrackResponse {})) 166 + } 167 + 168 + async fn unlike_album( 169 + &self, 170 + request: tonic::Request<UnlikeAlbumRequest>, 171 + ) -> Result<tonic::Response<UnlikeAlbumResponse>, tonic::Status> { 172 + let params = request.into_inner(); 173 + repo::favourites::delete(self.pool.clone(), &params.id) 174 + .await 175 + .map_err(|e| tonic::Status::internal(e.to_string()))?; 176 + Ok(tonic::Response::new(UnlikeAlbumResponse {})) 177 + } 178 + 179 + async fn get_liked_tracks( 180 + &self, 181 + _request: tonic::Request<GetLikedTracksRequest>, 182 + ) -> Result<tonic::Response<GetLikedTracksResponse>, tonic::Status> { 183 + let tracks = repo::favourites::all_tracks(self.pool.clone()) 184 + .await 185 + .map_err(|e| tonic::Status::internal(e.to_string()))?; 186 + Ok(tonic::Response::new(GetLikedTracksResponse { 187 + tracks: tracks.into_iter().map(|t| t.into()).collect(), 188 + })) 189 + } 190 + 191 + async fn get_liked_albums( 192 + &self, 193 + _request: tonic::Request<GetLikedAlbumsRequest>, 194 + ) -> Result<tonic::Response<GetLikedAlbumsResponse>, tonic::Status> { 195 + let albums = repo::favourites::all_albums(self.pool.clone()) 196 + .await 197 + .map_err(|e| tonic::Status::internal(e.to_string()))?; 198 + Ok(tonic::Response::new(GetLikedAlbumsResponse { 199 + albums: albums.into_iter().map(|a| a.into()).collect(), 113 200 })) 114 201 } 115 202 }
+180
webui/rockbox/graphql.schema.json
··· 980 980 "deprecationReason": null 981 981 }, 982 982 { 983 + "name": "likeAlbum", 984 + "description": null, 985 + "args": [ 986 + { 987 + "name": "id", 988 + "description": null, 989 + "type": { 990 + "kind": "NON_NULL", 991 + "name": null, 992 + "ofType": { 993 + "kind": "SCALAR", 994 + "name": "String", 995 + "ofType": null 996 + } 997 + }, 998 + "defaultValue": null, 999 + "isDeprecated": false, 1000 + "deprecationReason": null 1001 + } 1002 + ], 1003 + "type": { 1004 + "kind": "NON_NULL", 1005 + "name": null, 1006 + "ofType": { 1007 + "kind": "SCALAR", 1008 + "name": "Int", 1009 + "ofType": null 1010 + } 1011 + }, 1012 + "isDeprecated": false, 1013 + "deprecationReason": null 1014 + }, 1015 + { 1016 + "name": "likeTrack", 1017 + "description": null, 1018 + "args": [ 1019 + { 1020 + "name": "id", 1021 + "description": null, 1022 + "type": { 1023 + "kind": "NON_NULL", 1024 + "name": null, 1025 + "ofType": { 1026 + "kind": "SCALAR", 1027 + "name": "String", 1028 + "ofType": null 1029 + } 1030 + }, 1031 + "defaultValue": null, 1032 + "isDeprecated": false, 1033 + "deprecationReason": null 1034 + } 1035 + ], 1036 + "type": { 1037 + "kind": "NON_NULL", 1038 + "name": null, 1039 + "ofType": { 1040 + "kind": "SCALAR", 1041 + "name": "Int", 1042 + "ofType": null 1043 + } 1044 + }, 1045 + "isDeprecated": false, 1046 + "deprecationReason": null 1047 + }, 1048 + { 983 1049 "name": "next", 984 1050 "description": null, 985 1051 "args": [], ··· 1699 1765 }, 1700 1766 "isDeprecated": false, 1701 1767 "deprecationReason": null 1768 + }, 1769 + { 1770 + "name": "unlikeAlbum", 1771 + "description": null, 1772 + "args": [ 1773 + { 1774 + "name": "id", 1775 + "description": null, 1776 + "type": { 1777 + "kind": "NON_NULL", 1778 + "name": null, 1779 + "ofType": { 1780 + "kind": "SCALAR", 1781 + "name": "String", 1782 + "ofType": null 1783 + } 1784 + }, 1785 + "defaultValue": null, 1786 + "isDeprecated": false, 1787 + "deprecationReason": null 1788 + } 1789 + ], 1790 + "type": { 1791 + "kind": "NON_NULL", 1792 + "name": null, 1793 + "ofType": { 1794 + "kind": "SCALAR", 1795 + "name": "Int", 1796 + "ofType": null 1797 + } 1798 + }, 1799 + "isDeprecated": false, 1800 + "deprecationReason": null 1801 + }, 1802 + { 1803 + "name": "unlikeTrack", 1804 + "description": null, 1805 + "args": [ 1806 + { 1807 + "name": "id", 1808 + "description": null, 1809 + "type": { 1810 + "kind": "NON_NULL", 1811 + "name": null, 1812 + "ofType": { 1813 + "kind": "SCALAR", 1814 + "name": "String", 1815 + "ofType": null 1816 + } 1817 + }, 1818 + "defaultValue": null, 1819 + "isDeprecated": false, 1820 + "deprecationReason": null 1821 + } 1822 + ], 1823 + "type": { 1824 + "kind": "NON_NULL", 1825 + "name": null, 1826 + "ofType": { 1827 + "kind": "SCALAR", 1828 + "name": "Int", 1829 + "ofType": null 1830 + } 1831 + }, 1832 + "isDeprecated": false, 1833 + "deprecationReason": null 1702 1834 } 1703 1835 ], 1704 1836 "inputFields": null, ··· 2099 2231 "kind": "OBJECT", 2100 2232 "name": "SystemStatus", 2101 2233 "ofType": null 2234 + } 2235 + }, 2236 + "isDeprecated": false, 2237 + "deprecationReason": null 2238 + }, 2239 + { 2240 + "name": "likedAlbums", 2241 + "description": null, 2242 + "args": [], 2243 + "type": { 2244 + "kind": "NON_NULL", 2245 + "name": null, 2246 + "ofType": { 2247 + "kind": "LIST", 2248 + "name": null, 2249 + "ofType": { 2250 + "kind": "NON_NULL", 2251 + "name": null, 2252 + "ofType": { 2253 + "kind": "OBJECT", 2254 + "name": "Album", 2255 + "ofType": null 2256 + } 2257 + } 2258 + } 2259 + }, 2260 + "isDeprecated": false, 2261 + "deprecationReason": null 2262 + }, 2263 + { 2264 + "name": "likedTracks", 2265 + "description": null, 2266 + "args": [], 2267 + "type": { 2268 + "kind": "NON_NULL", 2269 + "name": null, 2270 + "ofType": { 2271 + "kind": "LIST", 2272 + "name": null, 2273 + "ofType": { 2274 + "kind": "NON_NULL", 2275 + "name": null, 2276 + "ofType": { 2277 + "kind": "OBJECT", 2278 + "name": "Track", 2279 + "ofType": null 2280 + } 2281 + } 2102 2282 } 2103 2283 }, 2104 2284 "isDeprecated": false,
+12 -3
webui/rockbox/src/Components/Album/Album.tsx
··· 15 15 import ContextMenu from "./ContextMenu"; 16 16 import HeartOutline from "../Icons/HeartOutline"; 17 17 import AlbumArt from "../../Assets/albumart.svg"; 18 + import Heart from "../Icons/Heart"; 18 19 19 20 export type AlbumProps = { 20 21 album: any; 21 22 onPlay: (album: any) => void; 22 23 onLike: (album: any) => void; 23 24 onUnLike: (album: any) => void; 25 + liked?: boolean; 24 26 }; 25 27 26 28 const Album: FC<AlbumProps> = (props) => { ··· 43 45 <Play small color="#000" /> 44 46 </div> 45 47 <ContextMenu item={props.album} /> 46 - <FloatingButton onClick={() => props.onLike(props.album)}> 47 - <HeartOutline color="#fff" size={20} /> 48 - </FloatingButton> 48 + {!props.liked && ( 49 + <FloatingButton onClick={() => props.onLike(props.album)}> 50 + <HeartOutline color="#fff" size={20} /> 51 + </FloatingButton> 52 + )} 53 + {props.liked && ( 54 + <FloatingButton onClick={() => props.onUnLike(props.album)}> 55 + <Heart color="#fe09a3" size={20} /> 56 + </FloatingButton> 57 + )} 49 58 </AlbumFooterMenu> 50 59 </Hover> 51 60 <Link to={`/albums/${props.album.id}`}>
+38 -3
webui/rockbox/src/Components/Album/AlbumWithData.tsx
··· 1 1 /* eslint-disable @typescript-eslint/no-explicit-any */ 2 2 import { FC } from "react"; 3 3 import Album from "./Album"; 4 - import { usePlayAlbumMutation } from "../../Hooks/GraphQL"; 4 + import { 5 + useLikeAlbumMutation, 6 + usePlayAlbumMutation, 7 + useUnlikeAlbumMutation, 8 + } from "../../Hooks/GraphQL"; 9 + import { useRecoilState } from "recoil"; 10 + import { likesState } from "../Likes/LikesState"; 5 11 6 12 export type AlbumWithDataProps = { 7 13 album: any; 8 14 }; 9 15 10 16 const AlbumWithData: FC<AlbumWithDataProps> = ({ album }) => { 17 + const [likes, setLikes] = useRecoilState(likesState); 11 18 const [playAlbum] = usePlayAlbumMutation(); 19 + const [likeAlbum] = useLikeAlbumMutation(); 20 + const [unlikeAlbum] = useUnlikeAlbumMutation(); 12 21 13 22 const onPlay = ({ id: albumId }: any) => { 14 23 playAlbum({ ··· 17 26 }, 18 27 }); 19 28 }; 29 + 30 + const onLike = ({ id: albumId }: any) => { 31 + setLikes({ 32 + ...likes, 33 + [albumId]: true, 34 + }); 35 + likeAlbum({ 36 + variables: { 37 + albumId, 38 + }, 39 + }); 40 + }; 41 + 42 + const onUnlike = ({ id: albumId }: any) => { 43 + setLikes({ 44 + ...likes, 45 + [albumId]: false, 46 + }); 47 + unlikeAlbum({ 48 + variables: { 49 + albumId, 50 + }, 51 + }); 52 + }; 53 + 20 54 return ( 21 55 <Album 22 56 album={album} 23 - onLike={() => {}} 57 + onLike={onLike} 24 58 onPlay={onPlay} 25 - onUnLike={() => {}} 59 + onUnLike={onUnlike} 60 + liked={likes[album.id]} 26 61 /> 27 62 ); 28 63 };
-2
webui/rockbox/src/Components/AlbumDetails/AlbumDetails.stories.tsx
··· 22 22 onGoBack: fn(), 23 23 onPlayAll: fn(), 24 24 onShuffleAll: fn(), 25 - onLike: fn(), 26 - onUnlike: fn(), 27 25 tracks, 28 26 album: { 29 27 id: "1",
-4
webui/rockbox/src/Components/AlbumDetails/AlbumDetails.test.tsx
··· 11 11 describe("AlbumDetails", () => { 12 12 it("should render", () => { 13 13 const onGoBack = vi.fn(); 14 - const onLike = vi.fn(); 15 14 const onPlayAll = vi.fn(); 16 15 const onShuffleAll = vi.fn(); 17 - const onUnlike = vi.fn(); 18 16 const { container } = render( 19 17 <MemoryRouter initialEntries={["/"]}> 20 18 <MockedProvider mocks={mocks}> ··· 22 20 <RecoilRoot> 23 21 <AlbumDetails 24 22 onGoBack={onGoBack} 25 - onLike={onLike} 26 23 onPlayAll={onPlayAll} 27 24 onShuffleAll={onShuffleAll} 28 - onUnlike={onUnlike} 29 25 tracks={tracks} 30 26 album={{ 31 27 id: "1",
+1 -2
webui/rockbox/src/Components/AlbumDetails/AlbumDetails.tsx
··· 36 36 onPlayAll: () => void; 37 37 onShuffleAll: () => void; 38 38 onGoBack: () => void; 39 - onLike: (track: string) => void; 40 - onUnlike: (track: string) => void; 41 39 tracks: Track[]; 42 40 album?: Album | null; 43 41 volumes: Track[][]; ··· 107 105 > 108 106 <ContextMenu 109 107 track={{ 108 + id: info.row.original.id, 110 109 title: info.row.original.title, 111 110 artist: info.row.original.artist, 112 111 time: info.row.original.time,
-2
webui/rockbox/src/Components/AlbumDetails/AlbumDetailsWithData.tsx
··· 94 94 return ( 95 95 <AlbumDetails 96 96 onGoBack={() => navigate(-1)} 97 - onLike={() => {}} 98 97 onPlayAll={() => onPlayAll(false)} 99 98 onShuffleAll={() => onPlayAll(true)} 100 - onUnlike={() => {}} 101 99 // eslint-disable-next-line @typescript-eslint/no-explicit-any 102 100 tracks={tracks as any[]} 103 101 // eslint-disable-next-line @typescript-eslint/no-explicit-any
+1 -1
webui/rockbox/src/Components/ArtistDetails/ArtistDetails.stories.tsx
··· 21 21 args: { 22 22 name: "Daft Punk", 23 23 tracks, 24 - albums, 24 + albums: albums.map((x) => ({ ...x, cover: x.albumArt })), 25 25 onPlayAll: fn(), 26 26 onShuffleAll: fn(), 27 27 onPlayAlbum: fn(),
+1
webui/rockbox/src/Components/ArtistDetails/ArtistDetails.tsx
··· 112 112 <ButtonGroup style={{ justifyContent: "flex-end", alignItems: "center" }}> 113 113 <ContextMenu 114 114 track={{ 115 + id: info.row.original.id, 115 116 title: info.row.original.title, 116 117 artist: info.row.original.artist, 117 118 time: info.row.original.time,
+2
webui/rockbox/src/Components/ContextMenu/ContextMenu.stories.tsx
··· 30 30 onAddTrackToPlaylist: fn(), 31 31 onPlayLast: fn(), 32 32 onAddShuffled: fn(), 33 + onLike: fn(), 34 + onUnlike: fn(), 33 35 recentPlaylists: [], 34 36 }, 35 37 };
+2
webui/rockbox/src/Components/ContextMenu/ContextMenu.test.tsx
··· 20 20 onAddTrackToPlaylist={vi.fn()} 21 21 onPlayLast={vi.fn()} 22 22 onAddShuffled={vi.fn()} 23 + onLike={vi.fn()} 24 + onUnlike={vi.fn()} 23 25 recentPlaylists={[]} 24 26 /> 25 27 </Providers>
+11 -3
webui/rockbox/src/Components/ContextMenu/ContextMenu.tsx
··· 30 30 onAddTrackToPlaylist: (playlistId: string, trackId: string) => void; 31 31 onPlayLast: (path: string) => void; 32 32 onAddShuffled: (path: string) => void; 33 + onLike: (trackId: string) => void; 34 + onUnlike: (trackId: string) => void; 33 35 recentPlaylists: any[]; 34 36 }; 35 37 ··· 41 43 onPlayLast, 42 44 onAddTrackToPlaylist, 43 45 onAddShuffled, 46 + onLike, 47 + onUnlike, 44 48 recentPlaylists, 45 49 }) => { 46 50 const theme = useTheme(); ··· 196 200 </StatefulPopover> 197 201 <Separator /> 198 202 {liked && ( 199 - <Icon> 200 - <Heart height={24} width={24} color={theme.colors.icon} /> 203 + <Icon onClick={() => onUnlike(track.id)}> 204 + <Heart height={24} width={24} color={"#fe09a3"} /> 201 205 </Icon> 202 206 )} 203 207 {!liked && ( 204 - <Icon> 208 + <Icon 209 + onClick={() => { 210 + onLike(track.id); 211 + }} 212 + > 205 213 <HeartOutline height={24} width={24} color={theme.colors.icon} /> 206 214 </Icon> 207 215 )}
+43 -1
webui/rockbox/src/Components/ContextMenu/ContextMenuWithData.tsx
··· 1 1 import { FC } from "react"; 2 2 import ContextMenu from "./ContextMenu"; 3 - import { useInsertTracksMutation } from "../../Hooks/GraphQL"; 3 + import { 4 + useGetLikedTracksQuery, 5 + useInsertTracksMutation, 6 + useLikeTrackMutation, 7 + useUnlikeTrackMutation, 8 + } from "../../Hooks/GraphQL"; 4 9 import { 5 10 PLAYLIST_INSERT_FIRST, 6 11 PLAYLIST_INSERT_LAST, 7 12 PLAYLIST_INSERT_SHUFFLED, 8 13 } from "../../Types/playlist"; 14 + import { useRecoilState } from "recoil"; 15 + import { likesState } from "../Likes/LikesState"; 9 16 10 17 export type ContextMenuWithDataProps = { 11 18 // eslint-disable-next-line @typescript-eslint/no-explicit-any ··· 13 20 }; 14 21 15 22 const ContextMenuWithData: FC<ContextMenuWithDataProps> = ({ track }) => { 23 + const [likes, setLikes] = useRecoilState(likesState); 24 + const { refetch } = useGetLikedTracksQuery({ 25 + fetchPolicy: "network-only", 26 + }); 16 27 const [insertTracks] = useInsertTracksMutation(); 28 + const [likeTrack] = useLikeTrackMutation(); 29 + const [unlikeTrack] = useUnlikeTrackMutation(); 17 30 18 31 const onPlayNext = (path: string) => { 19 32 insertTracks({ ··· 42 55 }); 43 56 }; 44 57 58 + const onLike = async (trackId: string) => { 59 + setLikes({ 60 + ...likes, 61 + [trackId]: true, 62 + }); 63 + await likeTrack({ 64 + variables: { 65 + trackId, 66 + }, 67 + }); 68 + await refetch(); 69 + }; 70 + 71 + const onUnlike = async (trackId: string) => { 72 + setLikes({ 73 + ...likes, 74 + [trackId]: false, 75 + }); 76 + await unlikeTrack({ 77 + variables: { 78 + trackId, 79 + }, 80 + }); 81 + await refetch(); 82 + }; 83 + 45 84 return ( 46 85 <ContextMenu 47 86 track={track} ··· 50 89 onAddTrackToPlaylist={() => {}} 51 90 onPlayLast={onPlayLast} 52 91 onAddShuffled={onAddShuffled} 92 + onLike={onLike} 93 + onUnlike={onUnlike} 53 94 recentPlaylists={[]} 95 + liked={likes[track.id]} 54 96 /> 55 97 ); 56 98 };
+45
webui/rockbox/src/Components/ControlBar/ControlBarWithData.tsx
··· 3 3 import { 4 4 useCurrentlyPlayingSongSubscription, 5 5 useGetCurrentTrackQuery, 6 + useGetLikedAlbumsQuery, 7 + useGetLikedTracksQuery, 6 8 useGetPlaybackStatusQuery, 7 9 useNextMutation, 8 10 usePauseMutation, ··· 16 18 import { controlBarState } from "./ControlBarState"; 17 19 import { usePlayQueue } from "../../Hooks/usePlayQueue"; 18 20 import { useResumePlaylist } from "../../Hooks/useResumePlaylist"; 21 + import { likesState } from "../Likes/LikesState"; 19 22 20 23 const ControlBarWithData: FC = () => { 21 24 const [{ nowPlaying, locked, resumeIndex }, setControlBarState] = ··· 32 35 const { data: playbackStatus } = usePlaybackStatusSubscription(); 33 36 const { previousTracks, nextTracks } = usePlayQueue(); 34 37 const { resumePlaylistTrack } = useResumePlaylist(); 38 + 39 + const [likes, setLikes] = useRecoilState(likesState); 40 + const { data: likedTracksData, loading: likedTracksLoading } = 41 + useGetLikedTracksQuery({ 42 + fetchPolicy: "network-only", 43 + }); 44 + const { data: likedAlbumsData, loading: likedAlbumsLoading } = 45 + useGetLikedAlbumsQuery({ 46 + fetchPolicy: "network-only", 47 + }); 48 + 49 + useEffect(() => { 50 + if ( 51 + !likedTracksData || 52 + likedTracksLoading || 53 + !likedAlbumsData || 54 + likedAlbumsLoading 55 + ) { 56 + return; 57 + } 58 + 59 + const updatedLikes: Record<string, boolean> = { 60 + ...likes, 61 + }; 62 + 63 + likedTracksData.likedTracks.forEach((x) => { 64 + updatedLikes[x.id!] = true; 65 + }); 66 + 67 + likedAlbumsData.likedAlbums.forEach((x) => { 68 + updatedLikes[x.id!] = true; 69 + }); 70 + 71 + setLikes(updatedLikes); 72 + 73 + // eslint-disable-next-line react-hooks/exhaustive-deps 74 + }, [ 75 + likedTracksData, 76 + likedTracksLoading, 77 + likedAlbumsData, 78 + likedAlbumsLoading, 79 + ]); 35 80 36 81 const setNowPlaying = (nowPlaying: CurrentTrack) => { 37 82 setControlBarState((state) => ({
+4 -4
webui/rockbox/src/Components/ControlBar/PlayQueue/PlayQueue.tsx
··· 118 118 }} 119 119 > 120 120 {tracks[virtualItem.index].cover && ( 121 - <div className="album-cover-container"> 121 + <div className="album-cover-container queue"> 122 122 <AlbumCover src={tracks[virtualItem.index].cover!} /> 123 123 <div 124 124 onClick={() => _onPlayTrackAt(virtualItem.index)} 125 - className="floating-play" 125 + className="floating-play queue" 126 126 > 127 127 <Play 128 128 size={16} ··· 132 132 </div> 133 133 )} 134 134 {!tracks[virtualItem.index].cover && ( 135 - <div className="album-cover-container"> 135 + <div className="album-cover-container queue"> 136 136 <AlbumCoverAlt> 137 137 <TrackIcon width={28} height={28} color="#a4a3a3" /> 138 138 </AlbumCoverAlt> 139 139 <div 140 140 onClick={() => _onPlayTrackAt(virtualItem.index)} 141 - className="floating-play" 141 + className="floating-play queue" 142 142 > 143 143 <Play 144 144 size={16}
+4 -3
webui/rockbox/src/Components/ControlBar/PlayQueue/styles.css
··· 1 - .album-cover-container { 1 + .album-cover-container.queue { 2 2 height: 64px; 3 3 justify-content: center; 4 4 align-items: center; 5 5 display: flex; 6 6 position: relative; 7 + margin-left: 10px; 7 8 } 8 9 9 - .album-cover-container .floating-play { 10 + .album-cover-container.queue .floating-play { 10 11 display: none; 11 12 position: absolute; 12 - left: 19px; 13 + left: 10px; 13 14 top: 17px; 14 15 } 15 16
-2
webui/rockbox/src/Components/ControlBar/PlayQueue/styles.tsx
··· 41 41 height: 64px; 42 42 align-items: center; 43 43 padding-left: 16px; 44 - padding-right: 16px; 45 44 cursor: pointer; 46 45 &:hover { 47 46 background-color: ${({ theme }) => theme.colors.hover}; ··· 89 88 display: flex; 90 89 justify-content: center; 91 90 align-items: center; 92 - margin-right: 18px; 93 91 ${({ current }) => `opacity: ${current ? 0 : 1};`} 94 92 `; 95 93
+182 -4
webui/rockbox/src/Components/Likes/Likes.tsx
··· 1 1 /* eslint-disable @typescript-eslint/no-explicit-any */ 2 - import { FC } from "react"; 2 + import { FC, useRef } from "react"; 3 + import { createColumnHelper } from "@tanstack/react-table"; 3 4 import Sidebar from "../Sidebar"; 4 5 import ControlBar from "../ControlBar"; 5 - import { Container, ContentWrapper, Title } from "./styles"; 6 6 import MainView from "../MainView"; 7 + import { 8 + AlbumCover, 9 + AlbumCoverAlt, 10 + ButtonGroup, 11 + Container, 12 + ContentWrapper, 13 + FilterContainer, 14 + Link, 15 + Title, 16 + } from "./styles"; 17 + import { Track } from "../../Types/track"; 18 + import Table from "../VirtualizedTable"; 19 + import Filter from "../Filter"; 20 + import TrackIcon from "../Icons/Track"; 21 + import { Play } from "@styled-icons/ionicons-sharp"; 22 + import "./styles.css"; 23 + import ContextMenu from "../ContextMenu"; 7 24 8 - const Likes: FC = () => { 25 + const columnHelper = createColumnHelper<Track>(); 26 + 27 + export type TracksProps = { 28 + tracks: Track[]; 29 + onPlayTrack: (id: string) => void; 30 + }; 31 + 32 + const Likes: FC<TracksProps> = (props) => { 33 + const containerRef = useRef<HTMLDivElement>(null); 34 + const columns = [ 35 + columnHelper.accessor("trackNumber", { 36 + header: "#", 37 + size: 20, 38 + cell: (info) => ( 39 + <div 40 + style={{ 41 + display: "flex", 42 + alignItems: "center", 43 + justifyContent: "center", 44 + marginLeft: 5, 45 + marginRight: 5, 46 + }} 47 + > 48 + {info.getValue()} 49 + </div> 50 + ), 51 + }), 52 + columnHelper.accessor("albumArt", { 53 + header: "Title", 54 + size: 54, 55 + cell: (info) => ( 56 + <> 57 + {info.getValue() && ( 58 + <div className="album-cover-container"> 59 + <AlbumCover 60 + src={info.getValue()!} 61 + alt="album art" 62 + effect="blur" 63 + /> 64 + <div 65 + onClick={() => props.onPlayTrack(info.row.original.id)} 66 + className="floating-play" 67 + > 68 + <Play size={16} color={info.getValue() ? "#fff" : "#000"} /> 69 + </div> 70 + </div> 71 + )} 72 + {!info.getValue() && ( 73 + <div className="album-cover-container"> 74 + <AlbumCoverAlt> 75 + <TrackIcon width={28} height={28} color="#a4a3a3" /> 76 + </AlbumCoverAlt> 77 + <div 78 + onClick={() => props.onPlayTrack(info.row.original.id)} 79 + className="floating-play" 80 + > 81 + <Play size={16} color={info.getValue() ? "#fff" : "#000"} /> 82 + </div> 83 + </div> 84 + )} 85 + </> 86 + ), 87 + }), 88 + columnHelper.accessor("title", { 89 + header: "", 90 + cell: (info) => ( 91 + <div 92 + style={{ 93 + minWidth: 150, 94 + maxWidth: "calc((100vw - 240px - 230px) / 3)", 95 + fontSize: 14, 96 + textOverflow: "ellipsis", 97 + overflow: "hidden", 98 + whiteSpace: "nowrap", 99 + cursor: "pointer", 100 + color: "#000", 101 + }} 102 + > 103 + {info.getValue()} 104 + </div> 105 + ), 106 + }), 107 + columnHelper.accessor("artist", { 108 + header: "Artist", 109 + cell: (info) => ( 110 + <div 111 + style={{ 112 + minWidth: 150, 113 + maxWidth: 170, 114 + fontSize: 14, 115 + textOverflow: "ellipsis", 116 + overflow: "hidden", 117 + whiteSpace: "nowrap", 118 + cursor: "pointer", 119 + color: "#000", 120 + }} 121 + > 122 + <Link to={`/artists/${info.row.original.artistId}`}> 123 + {info.getValue()} 124 + </Link> 125 + </div> 126 + ), 127 + }), 128 + columnHelper.accessor("album", { 129 + header: "Album", 130 + cell: (info) => ( 131 + <div 132 + style={{ 133 + minWidth: 150, 134 + maxWidth: "calc((100vw - 240px - 230px) / 3)", 135 + fontSize: 14, 136 + textOverflow: "ellipsis", 137 + overflow: "hidden", 138 + whiteSpace: "nowrap", 139 + cursor: "pointer", 140 + color: "#000", 141 + }} 142 + > 143 + <Link to={`/albums/${info.row.original.albumId}`}> 144 + {info.getValue()} 145 + </Link> 146 + </div> 147 + ), 148 + }), 149 + columnHelper.accessor("time", { 150 + header: "Time", 151 + size: 50, 152 + cell: (info) => info.getValue(), 153 + }), 154 + columnHelper.accessor("id", { 155 + header: "", 156 + size: 100, 157 + cell: (info) => ( 158 + <ButtonGroup 159 + style={{ justifyContent: "flex-end", alignItems: "center" }} 160 + > 161 + <ContextMenu 162 + track={{ 163 + id: info.row.original.id, 164 + title: info.row.original.title, 165 + artist: info.row.original.artist, 166 + time: info.row.original.time, 167 + cover: info.row.original.albumArt, 168 + path: info.row.original.path, 169 + }} 170 + /> 171 + </ButtonGroup> 172 + ), 173 + }), 174 + ]; 9 175 return ( 10 176 <Container> 11 177 <Sidebar active="likes" /> 12 178 <MainView> 13 179 <ControlBar /> 14 - <ContentWrapper> 180 + <ContentWrapper ref={containerRef}> 15 181 <Title>Likes</Title> 182 + <FilterContainer> 183 + <Filter placeholder="Search song" onChange={() => {}} /> 184 + </FilterContainer> 185 + <div style={{ marginBottom: 60 }}> 186 + {props.tracks.length > 0 && ( 187 + <Table 188 + columns={columns as any} 189 + tracks={props.tracks} 190 + containerRef={containerRef} 191 + /> 192 + )} 193 + </div> 16 194 </ContentWrapper> 17 195 </MainView> 18 196 </Container>
+12
webui/rockbox/src/Components/Likes/LikesState.ts
··· 1 + import { atom } from "recoil"; 2 + import { Track } from "../../Types/track"; 3 + 4 + export const likesState = atom<Record<string, boolean>>({ 5 + key: "likes", 6 + default: {}, 7 + }); 8 + 9 + export const likedTracks = atom<Track[]>({ 10 + key: "likedTracks", 11 + default: [], 12 + });
+49 -2
webui/rockbox/src/Components/Likes/LikesWithData.tsx
··· 1 - import { FC } from "react"; 1 + import { FC, useEffect } from "react"; 2 2 import Likes from "./Likes"; 3 + import { useGetLikedTracksQuery } from "../../Hooks/GraphQL"; 4 + import { useTimeFormat } from "../../Hooks/useFormat"; 5 + import { useRecoilState } from "recoil"; 6 + import { likedTracks, likesState } from "./LikesState"; 3 7 4 8 const LikesWithData: FC = () => { 5 - return <Likes />; 9 + const [likes, setLikes] = useRecoilState(likesState); 10 + const { data, loading } = useGetLikedTracksQuery({ 11 + fetchPolicy: "network-only", 12 + }); 13 + const [tracks, setTracks] = useRecoilState(likedTracks); 14 + const { formatTime } = useTimeFormat(); 15 + 16 + useEffect(() => { 17 + if (!data || loading) { 18 + return; 19 + } 20 + 21 + const updatedLikes: Record<string, boolean> = { 22 + ...likes, 23 + }; 24 + data.likedTracks.forEach((x) => { 25 + updatedLikes[x.id!] = true; 26 + }); 27 + setLikes(updatedLikes); 28 + 29 + setTracks( 30 + data.likedTracks.map((x, i) => ({ 31 + id: x.id!, 32 + trackNumber: i + 1, 33 + title: x.title, 34 + artist: x.artist, 35 + album: x.album, 36 + time: formatTime(x.length), 37 + albumArt: x.albumArt 38 + ? `http://localhost:6062/covers/${x.albumArt}` 39 + : undefined, 40 + albumId: x.albumId, 41 + artistId: x.artistId, 42 + path: x.path, 43 + })) 44 + ); 45 + // eslint-disable-next-line react-hooks/exhaustive-deps 46 + }, [data, loading]); 47 + 48 + const onPlayTrack = (trackId: string) => { 49 + console.log(">>", trackId); 50 + }; 51 + 52 + return <Likes tracks={tracks} onPlayTrack={onPlayTrack} />; 6 53 }; 7 54 8 55 export default LikesWithData;
+29
webui/rockbox/src/Components/Likes/styles.css
··· 1 + table { 2 + border-spacing: 0; 3 + } 4 + 5 + .album-cover-container { 6 + height: 48px; 7 + width: 48px; 8 + justify-content: center; 9 + align-items: center; 10 + display: flex; 11 + position: relative; 12 + cursor: pointer; 13 + margin-right: 10px; 14 + } 15 + 16 + .album-cover-container .floating-play { 17 + display: none; 18 + position: absolute; 19 + left: 17px; 20 + top: 10px; 21 + } 22 + 23 + .album-cover-container:hover .floating-play { 24 + display: block; 25 + } 26 + 27 + .album-cover-container:hover img { 28 + opacity: 0.4; 29 + }
+28 -34
webui/rockbox/src/Components/Likes/styles.tsx
··· 1 1 import styled from "@emotion/styled"; 2 - import { Link } from "react-router-dom"; 2 + import { LazyLoadImage } from "react-lazy-load-image-component"; 3 + import { Link as RouterLink } from "react-router-dom"; 3 4 4 5 export const Container = styled.div` 5 6 display: flex; ··· 47 48 48 49 export const ContentWrapper = styled.div` 49 50 overflow-y: auto; 50 - height: calc(100vh - 100px); 51 + height: calc(100vh - 60px); 51 52 padding-left: 20px; 52 53 padding-right: 20px; 54 + position: relative; 53 55 `; 54 56 55 - export const AlbumCover = styled.img` 57 + export const AlbumCover = styled(LazyLoadImage)` 56 58 height: 48px; 57 59 width: 48px; 58 60 `; 59 61 60 - export const Directory = styled(Link)` 61 - color: #000; 62 - margin-left: 10px; 63 - text-decoration: none; 64 - font-family: RockfordSansRegular; 65 - width: calc(100vw - 500px); 66 - max-width: calc(100vw - 500px); 67 - text-overflow: ellipsis; 68 - overflow: hidden; 69 - white-space: nowrap; 70 - display: block; 71 - &:hover { 72 - text-decoration: underline; 73 - } 62 + export const AlbumCoverAlt = styled.div<{ current?: boolean }>` 63 + height: 48px; 64 + width: 48px; 65 + border-radius: 4px; 66 + cursor: pointer; 67 + background-color: ${(props) => props.theme.colors.cover}; 68 + display: flex; 69 + justify-content: center; 70 + align-items: center; 71 + ${({ current }) => `opacity: ${current ? 0 : 1};`} 74 72 `; 75 73 76 - export const AudioFile = styled.div` 77 - color: #000; 78 - margin-left: 10px; 79 - text-decoration: none; 80 - font-family: RockfordSansRegular; 81 - width: calc(100vw - 500px); 82 - max-width: calc(100vw - 500px); 83 - text-overflow: ellipsis; 84 - overflow: hidden; 85 - white-space: nowrap; 86 - display: block; 87 - cursor: pointer; 88 - &:hover { 89 - text-decoration: underline; 90 - } 74 + export const FilterContainer = styled.div` 75 + margin-top: 30px; 76 + margin-bottom: 40px; 91 77 `; 92 78 93 79 export const BackButton = styled.button` ··· 98 84 justify-content: center; 99 85 height: 30px; 100 86 width: 30px; 101 - left: 20px; 102 87 border-radius: 15px; 103 88 background-color: #f7f7f8; 104 - margin-top: 45px; 89 + margin-top: 26px; 105 90 margin-bottom: 46px; 106 91 position: absolute; 107 92 z-index: 1; 108 93 `; 94 + 95 + export const Link = styled(RouterLink)` 96 + color: #000; 97 + text-decoration: none; 98 + font-family: RockfordSansRegular; 99 + &:hover { 100 + text-decoration: underline; 101 + } 102 + `;
+1
webui/rockbox/src/Components/Tracks/Tracks.tsx
··· 160 160 > 161 161 <ContextMenu 162 162 track={{ 163 + id: info.row.original.id, 163 164 title: info.row.original.title, 164 165 artist: info.row.original.artist, 165 166 time: info.row.original.time,
+2 -2
webui/rockbox/src/Components/Tracks/__snapshots__/Tracks.test.tsx.snap
··· 425 425 style="margin-bottom: 60px;" 426 426 > 427 427 <div 428 - style="height: 96px;" 428 + style="height: 96px; margin-bottom: 100px;" 429 429 > 430 430 <table 431 431 style="width: 100%;" 432 432 > 433 433 <thead> 434 434 <tr 435 - style="height: 36px; color: rgba(0, 0, 0, 0.54);" 435 + style="height: 48px; color: rgba(0, 0, 0, 0.54);" 436 436 > 437 437 <th 438 438 style="text-align: left; width: 20px;"
+13 -4
webui/rockbox/src/Components/VirtualizedTable/VirtualizedTable.tsx
··· 22 22 tracks, 23 23 containerRef, 24 24 }) => { 25 - // eslint-disable-next-line @typescript-eslint/no-unused-vars 26 - const [data] = useState([...tracks]); 25 + const [data, setData] = useState([...tracks]); 26 + 27 + useEffect(() => { 28 + setData([...tracks]); 29 + }, [tracks]); 30 + 27 31 const table = useReactTable({ 28 32 // eslint-disable-next-line @typescript-eslint/no-explicit-any 29 33 data: data as any, ··· 53 57 }, []); 54 58 55 59 return ( 56 - <div style={{ height: `${rowVirtualizer.getTotalSize()}px` }}> 60 + <div 61 + style={{ 62 + height: `${rowVirtualizer.getTotalSize()}px`, 63 + marginBottom: 100, 64 + }} 65 + > 57 66 <table style={{ width: "100%" }}> 58 67 <thead> 59 68 {table.getHeaderGroups().map((headerGroup) => ( 60 69 <tr 61 70 key={headerGroup.id} 62 - style={{ height: 36, color: "rgba(0, 0, 0, 0.54)" }} 71 + style={{ height: 48, color: "rgba(0, 0, 0, 0.54)" }} 63 72 > 64 73 {headerGroup.headers.map((header) => ( 65 74 <th
+25
webui/rockbox/src/GraphQL/Library/Mutation.ts
··· 1 + import { gql } from "@apollo/client"; 2 + 3 + export const LIKE_TRACK = gql` 4 + mutation LikeTrack($trackId: String!) { 5 + likeTrack(id: $trackId) 6 + } 7 + `; 8 + 9 + export const UNLIKE_TRACK = gql` 10 + mutation UnlikeTrack($trackId: String!) { 11 + unlikeTrack(id: $trackId) 12 + } 13 + `; 14 + 15 + export const LIKE_ALBUM = gql` 16 + mutation LikeAlbum($albumId: String!) { 17 + likeAlbum(id: $albumId) 18 + } 19 + `; 20 + 21 + export const UNLIKE_ALBUM = gql` 22 + mutation UnlikeAlbum($albumId: String!) { 23 + unlikeAlbum(id: $albumId) 24 + } 25 + `;
+45
webui/rockbox/src/GraphQL/Library/Query.ts
··· 112 112 } 113 113 } 114 114 `; 115 + 116 + export const GET_LIKED_TRACKS = gql` 117 + query GetLikedTracks { 118 + likedTracks { 119 + id 120 + tracknum 121 + title 122 + artist 123 + album 124 + discnum 125 + albumArtist 126 + artistId 127 + albumId 128 + albumArt 129 + path 130 + length 131 + } 132 + } 133 + `; 134 + 135 + export const GET_LIKED_ALBUMS = gql` 136 + query GetLikedAlbums { 137 + likedAlbums { 138 + id 139 + title 140 + artist 141 + albumArt 142 + year 143 + yearString 144 + artistId 145 + md5 146 + tracks { 147 + id 148 + title 149 + artist 150 + album 151 + albumArtist 152 + artistId 153 + albumId 154 + path 155 + length 156 + } 157 + } 158 + } 159 + `;
+295
webui/rockbox/src/Hooks/GraphQL.tsx
··· 82 82 insertPlaylist: Scalars['String']['output']; 83 83 insertTracks: Scalars['Int']['output']; 84 84 keyclickClick: Scalars['String']['output']; 85 + likeAlbum: Scalars['Int']['output']; 86 + likeTrack: Scalars['Int']['output']; 85 87 next: Scalars['Int']['output']; 86 88 pause: Scalars['Int']['output']; 87 89 pcmbufFade: Scalars['String']['output']; ··· 110 112 soundSet: Scalars['String']['output']; 111 113 soundUnit: Scalars['String']['output']; 112 114 systemSoundPlay: Scalars['String']['output']; 115 + unlikeAlbum: Scalars['Int']['output']; 116 + unlikeTrack: Scalars['Int']['output']; 113 117 }; 114 118 115 119 ··· 151 155 }; 152 156 153 157 158 + export type MutationLikeAlbumArgs = { 159 + id: Scalars['String']['input']; 160 + }; 161 + 162 + 163 + export type MutationLikeTrackArgs = { 164 + id: Scalars['String']['input']; 165 + }; 166 + 167 + 154 168 export type MutationPlayArgs = { 155 169 elapsed: Scalars['Int']['input']; 156 170 offset: Scalars['Int']['input']; ··· 204 218 startIndex?: InputMaybe<Scalars['Int']['input']>; 205 219 }; 206 220 221 + 222 + export type MutationUnlikeAlbumArgs = { 223 + id: Scalars['String']['input']; 224 + }; 225 + 226 + 227 + export type MutationUnlikeTrackArgs = { 228 + id: Scalars['String']['input']; 229 + }; 230 + 207 231 export type Playlist = { 208 232 __typename?: 'Playlist'; 209 233 amount: Scalars['Int']['output']; ··· 231 255 getTrackInfo: Scalars['String']['output']; 232 256 globalSettings: UserSettings; 233 257 globalStatus: SystemStatus; 258 + likedAlbums: Array<Album>; 259 + likedTracks: Array<Track>; 234 260 nextTrack?: Maybe<Track>; 235 261 playlistAmount: Scalars['Int']['output']; 236 262 playlistGetCurrent: Playlist; ··· 530 556 531 557 export type GetEntriesQuery = { __typename?: 'Query', treeGetEntries: Array<{ __typename?: 'Entry', name: string, attr: number, timeWrite: number }> }; 532 558 559 + export type LikeTrackMutationVariables = Exact<{ 560 + trackId: Scalars['String']['input']; 561 + }>; 562 + 563 + 564 + export type LikeTrackMutation = { __typename?: 'Mutation', likeTrack: number }; 565 + 566 + export type UnlikeTrackMutationVariables = Exact<{ 567 + trackId: Scalars['String']['input']; 568 + }>; 569 + 570 + 571 + export type UnlikeTrackMutation = { __typename?: 'Mutation', unlikeTrack: number }; 572 + 573 + export type LikeAlbumMutationVariables = Exact<{ 574 + albumId: Scalars['String']['input']; 575 + }>; 576 + 577 + 578 + export type LikeAlbumMutation = { __typename?: 'Mutation', likeAlbum: number }; 579 + 580 + export type UnlikeAlbumMutationVariables = Exact<{ 581 + albumId: Scalars['String']['input']; 582 + }>; 583 + 584 + 585 + export type UnlikeAlbumMutation = { __typename?: 'Mutation', unlikeAlbum: number }; 586 + 533 587 export type GetAlbumsQueryVariables = Exact<{ [key: string]: never; }>; 534 588 535 589 ··· 558 612 559 613 560 614 export type GetAlbumQuery = { __typename?: 'Query', album?: { __typename?: 'Album', id: string, title: string, artist: string, albumArt?: string | null, year: number, yearString: string, artistId: string, md5: string, tracks: Array<{ __typename?: 'Track', id?: string | null, title: string, tracknum: number, artist: string, album: string, discnum: number, albumArtist: string, artistId?: string | null, albumId?: string | null, path: string, length: number }> } | null }; 615 + 616 + export type GetLikedTracksQueryVariables = Exact<{ [key: string]: never; }>; 617 + 618 + 619 + export type GetLikedTracksQuery = { __typename?: 'Query', likedTracks: Array<{ __typename?: 'Track', id?: string | null, tracknum: number, title: string, artist: string, album: string, discnum: number, albumArtist: string, artistId?: string | null, albumId?: string | null, albumArt?: string | null, path: string, length: number }> }; 620 + 621 + export type GetLikedAlbumsQueryVariables = Exact<{ [key: string]: never; }>; 622 + 623 + 624 + export type GetLikedAlbumsQuery = { __typename?: 'Query', likedAlbums: Array<{ __typename?: 'Album', id: string, title: string, artist: string, albumArt?: string | null, year: number, yearString: string, artistId: string, md5: string, tracks: Array<{ __typename?: 'Track', id?: string | null, title: string, artist: string, album: string, albumArtist: string, artistId?: string | null, albumId?: string | null, path: string, length: number }> }> }; 561 625 562 626 export type PlayMutationVariables = Exact<{ 563 627 elapsed: Scalars['Int']['input']; ··· 771 835 export type GetEntriesLazyQueryHookResult = ReturnType<typeof useGetEntriesLazyQuery>; 772 836 export type GetEntriesSuspenseQueryHookResult = ReturnType<typeof useGetEntriesSuspenseQuery>; 773 837 export type GetEntriesQueryResult = Apollo.QueryResult<GetEntriesQuery, GetEntriesQueryVariables>; 838 + export const LikeTrackDocument = gql` 839 + mutation LikeTrack($trackId: String!) { 840 + likeTrack(id: $trackId) 841 + } 842 + `; 843 + export type LikeTrackMutationFn = Apollo.MutationFunction<LikeTrackMutation, LikeTrackMutationVariables>; 844 + 845 + /** 846 + * __useLikeTrackMutation__ 847 + * 848 + * To run a mutation, you first call `useLikeTrackMutation` within a React component and pass it any options that fit your needs. 849 + * When your component renders, `useLikeTrackMutation` returns a tuple that includes: 850 + * - A mutate function that you can call at any time to execute the mutation 851 + * - An object with fields that represent the current status of the mutation's execution 852 + * 853 + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; 854 + * 855 + * @example 856 + * const [likeTrackMutation, { data, loading, error }] = useLikeTrackMutation({ 857 + * variables: { 858 + * trackId: // value for 'trackId' 859 + * }, 860 + * }); 861 + */ 862 + export function useLikeTrackMutation(baseOptions?: Apollo.MutationHookOptions<LikeTrackMutation, LikeTrackMutationVariables>) { 863 + const options = {...defaultOptions, ...baseOptions} 864 + return Apollo.useMutation<LikeTrackMutation, LikeTrackMutationVariables>(LikeTrackDocument, options); 865 + } 866 + export type LikeTrackMutationHookResult = ReturnType<typeof useLikeTrackMutation>; 867 + export type LikeTrackMutationResult = Apollo.MutationResult<LikeTrackMutation>; 868 + export type LikeTrackMutationOptions = Apollo.BaseMutationOptions<LikeTrackMutation, LikeTrackMutationVariables>; 869 + export const UnlikeTrackDocument = gql` 870 + mutation UnlikeTrack($trackId: String!) { 871 + unlikeTrack(id: $trackId) 872 + } 873 + `; 874 + export type UnlikeTrackMutationFn = Apollo.MutationFunction<UnlikeTrackMutation, UnlikeTrackMutationVariables>; 875 + 876 + /** 877 + * __useUnlikeTrackMutation__ 878 + * 879 + * To run a mutation, you first call `useUnlikeTrackMutation` within a React component and pass it any options that fit your needs. 880 + * When your component renders, `useUnlikeTrackMutation` returns a tuple that includes: 881 + * - A mutate function that you can call at any time to execute the mutation 882 + * - An object with fields that represent the current status of the mutation's execution 883 + * 884 + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; 885 + * 886 + * @example 887 + * const [unlikeTrackMutation, { data, loading, error }] = useUnlikeTrackMutation({ 888 + * variables: { 889 + * trackId: // value for 'trackId' 890 + * }, 891 + * }); 892 + */ 893 + export function useUnlikeTrackMutation(baseOptions?: Apollo.MutationHookOptions<UnlikeTrackMutation, UnlikeTrackMutationVariables>) { 894 + const options = {...defaultOptions, ...baseOptions} 895 + return Apollo.useMutation<UnlikeTrackMutation, UnlikeTrackMutationVariables>(UnlikeTrackDocument, options); 896 + } 897 + export type UnlikeTrackMutationHookResult = ReturnType<typeof useUnlikeTrackMutation>; 898 + export type UnlikeTrackMutationResult = Apollo.MutationResult<UnlikeTrackMutation>; 899 + export type UnlikeTrackMutationOptions = Apollo.BaseMutationOptions<UnlikeTrackMutation, UnlikeTrackMutationVariables>; 900 + export const LikeAlbumDocument = gql` 901 + mutation LikeAlbum($albumId: String!) { 902 + likeAlbum(id: $albumId) 903 + } 904 + `; 905 + export type LikeAlbumMutationFn = Apollo.MutationFunction<LikeAlbumMutation, LikeAlbumMutationVariables>; 906 + 907 + /** 908 + * __useLikeAlbumMutation__ 909 + * 910 + * To run a mutation, you first call `useLikeAlbumMutation` within a React component and pass it any options that fit your needs. 911 + * When your component renders, `useLikeAlbumMutation` returns a tuple that includes: 912 + * - A mutate function that you can call at any time to execute the mutation 913 + * - An object with fields that represent the current status of the mutation's execution 914 + * 915 + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; 916 + * 917 + * @example 918 + * const [likeAlbumMutation, { data, loading, error }] = useLikeAlbumMutation({ 919 + * variables: { 920 + * albumId: // value for 'albumId' 921 + * }, 922 + * }); 923 + */ 924 + export function useLikeAlbumMutation(baseOptions?: Apollo.MutationHookOptions<LikeAlbumMutation, LikeAlbumMutationVariables>) { 925 + const options = {...defaultOptions, ...baseOptions} 926 + return Apollo.useMutation<LikeAlbumMutation, LikeAlbumMutationVariables>(LikeAlbumDocument, options); 927 + } 928 + export type LikeAlbumMutationHookResult = ReturnType<typeof useLikeAlbumMutation>; 929 + export type LikeAlbumMutationResult = Apollo.MutationResult<LikeAlbumMutation>; 930 + export type LikeAlbumMutationOptions = Apollo.BaseMutationOptions<LikeAlbumMutation, LikeAlbumMutationVariables>; 931 + export const UnlikeAlbumDocument = gql` 932 + mutation UnlikeAlbum($albumId: String!) { 933 + unlikeAlbum(id: $albumId) 934 + } 935 + `; 936 + export type UnlikeAlbumMutationFn = Apollo.MutationFunction<UnlikeAlbumMutation, UnlikeAlbumMutationVariables>; 937 + 938 + /** 939 + * __useUnlikeAlbumMutation__ 940 + * 941 + * To run a mutation, you first call `useUnlikeAlbumMutation` within a React component and pass it any options that fit your needs. 942 + * When your component renders, `useUnlikeAlbumMutation` returns a tuple that includes: 943 + * - A mutate function that you can call at any time to execute the mutation 944 + * - An object with fields that represent the current status of the mutation's execution 945 + * 946 + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; 947 + * 948 + * @example 949 + * const [unlikeAlbumMutation, { data, loading, error }] = useUnlikeAlbumMutation({ 950 + * variables: { 951 + * albumId: // value for 'albumId' 952 + * }, 953 + * }); 954 + */ 955 + export function useUnlikeAlbumMutation(baseOptions?: Apollo.MutationHookOptions<UnlikeAlbumMutation, UnlikeAlbumMutationVariables>) { 956 + const options = {...defaultOptions, ...baseOptions} 957 + return Apollo.useMutation<UnlikeAlbumMutation, UnlikeAlbumMutationVariables>(UnlikeAlbumDocument, options); 958 + } 959 + export type UnlikeAlbumMutationHookResult = ReturnType<typeof useUnlikeAlbumMutation>; 960 + export type UnlikeAlbumMutationResult = Apollo.MutationResult<UnlikeAlbumMutation>; 961 + export type UnlikeAlbumMutationOptions = Apollo.BaseMutationOptions<UnlikeAlbumMutation, UnlikeAlbumMutationVariables>; 774 962 export const GetAlbumsDocument = gql` 775 963 query GetAlbums { 776 964 albums { ··· 1041 1229 export type GetAlbumLazyQueryHookResult = ReturnType<typeof useGetAlbumLazyQuery>; 1042 1230 export type GetAlbumSuspenseQueryHookResult = ReturnType<typeof useGetAlbumSuspenseQuery>; 1043 1231 export type GetAlbumQueryResult = Apollo.QueryResult<GetAlbumQuery, GetAlbumQueryVariables>; 1232 + export const GetLikedTracksDocument = gql` 1233 + query GetLikedTracks { 1234 + likedTracks { 1235 + id 1236 + tracknum 1237 + title 1238 + artist 1239 + album 1240 + discnum 1241 + albumArtist 1242 + artistId 1243 + albumId 1244 + albumArt 1245 + path 1246 + length 1247 + } 1248 + } 1249 + `; 1250 + 1251 + /** 1252 + * __useGetLikedTracksQuery__ 1253 + * 1254 + * To run a query within a React component, call `useGetLikedTracksQuery` and pass it any options that fit your needs. 1255 + * When your component renders, `useGetLikedTracksQuery` returns an object from Apollo Client that contains loading, error, and data properties 1256 + * you can use to render your UI. 1257 + * 1258 + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; 1259 + * 1260 + * @example 1261 + * const { data, loading, error } = useGetLikedTracksQuery({ 1262 + * variables: { 1263 + * }, 1264 + * }); 1265 + */ 1266 + export function useGetLikedTracksQuery(baseOptions?: Apollo.QueryHookOptions<GetLikedTracksQuery, GetLikedTracksQueryVariables>) { 1267 + const options = {...defaultOptions, ...baseOptions} 1268 + return Apollo.useQuery<GetLikedTracksQuery, GetLikedTracksQueryVariables>(GetLikedTracksDocument, options); 1269 + } 1270 + export function useGetLikedTracksLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetLikedTracksQuery, GetLikedTracksQueryVariables>) { 1271 + const options = {...defaultOptions, ...baseOptions} 1272 + return Apollo.useLazyQuery<GetLikedTracksQuery, GetLikedTracksQueryVariables>(GetLikedTracksDocument, options); 1273 + } 1274 + export function useGetLikedTracksSuspenseQuery(baseOptions?: Apollo.SkipToken | Apollo.SuspenseQueryHookOptions<GetLikedTracksQuery, GetLikedTracksQueryVariables>) { 1275 + const options = baseOptions === Apollo.skipToken ? baseOptions : {...defaultOptions, ...baseOptions} 1276 + return Apollo.useSuspenseQuery<GetLikedTracksQuery, GetLikedTracksQueryVariables>(GetLikedTracksDocument, options); 1277 + } 1278 + export type GetLikedTracksQueryHookResult = ReturnType<typeof useGetLikedTracksQuery>; 1279 + export type GetLikedTracksLazyQueryHookResult = ReturnType<typeof useGetLikedTracksLazyQuery>; 1280 + export type GetLikedTracksSuspenseQueryHookResult = ReturnType<typeof useGetLikedTracksSuspenseQuery>; 1281 + export type GetLikedTracksQueryResult = Apollo.QueryResult<GetLikedTracksQuery, GetLikedTracksQueryVariables>; 1282 + export const GetLikedAlbumsDocument = gql` 1283 + query GetLikedAlbums { 1284 + likedAlbums { 1285 + id 1286 + title 1287 + artist 1288 + albumArt 1289 + year 1290 + yearString 1291 + artistId 1292 + md5 1293 + tracks { 1294 + id 1295 + title 1296 + artist 1297 + album 1298 + albumArtist 1299 + artistId 1300 + albumId 1301 + path 1302 + length 1303 + } 1304 + } 1305 + } 1306 + `; 1307 + 1308 + /** 1309 + * __useGetLikedAlbumsQuery__ 1310 + * 1311 + * To run a query within a React component, call `useGetLikedAlbumsQuery` and pass it any options that fit your needs. 1312 + * When your component renders, `useGetLikedAlbumsQuery` returns an object from Apollo Client that contains loading, error, and data properties 1313 + * you can use to render your UI. 1314 + * 1315 + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; 1316 + * 1317 + * @example 1318 + * const { data, loading, error } = useGetLikedAlbumsQuery({ 1319 + * variables: { 1320 + * }, 1321 + * }); 1322 + */ 1323 + export function useGetLikedAlbumsQuery(baseOptions?: Apollo.QueryHookOptions<GetLikedAlbumsQuery, GetLikedAlbumsQueryVariables>) { 1324 + const options = {...defaultOptions, ...baseOptions} 1325 + return Apollo.useQuery<GetLikedAlbumsQuery, GetLikedAlbumsQueryVariables>(GetLikedAlbumsDocument, options); 1326 + } 1327 + export function useGetLikedAlbumsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetLikedAlbumsQuery, GetLikedAlbumsQueryVariables>) { 1328 + const options = {...defaultOptions, ...baseOptions} 1329 + return Apollo.useLazyQuery<GetLikedAlbumsQuery, GetLikedAlbumsQueryVariables>(GetLikedAlbumsDocument, options); 1330 + } 1331 + export function useGetLikedAlbumsSuspenseQuery(baseOptions?: Apollo.SkipToken | Apollo.SuspenseQueryHookOptions<GetLikedAlbumsQuery, GetLikedAlbumsQueryVariables>) { 1332 + const options = baseOptions === Apollo.skipToken ? baseOptions : {...defaultOptions, ...baseOptions} 1333 + return Apollo.useSuspenseQuery<GetLikedAlbumsQuery, GetLikedAlbumsQueryVariables>(GetLikedAlbumsDocument, options); 1334 + } 1335 + export type GetLikedAlbumsQueryHookResult = ReturnType<typeof useGetLikedAlbumsQuery>; 1336 + export type GetLikedAlbumsLazyQueryHookResult = ReturnType<typeof useGetLikedAlbumsLazyQuery>; 1337 + export type GetLikedAlbumsSuspenseQueryHookResult = ReturnType<typeof useGetLikedAlbumsSuspenseQuery>; 1338 + export type GetLikedAlbumsQueryResult = Apollo.QueryResult<GetLikedAlbumsQuery, GetLikedAlbumsQueryVariables>; 1044 1339 export const PlayDocument = gql` 1045 1340 mutation Play($elapsed: Int!, $offset: Int!) { 1046 1341 play(elapsed: $elapsed, offset: $offset)