Compare commits

...

31 Commits

Author SHA1 Message Date
2fffc16c82 Update ahash to fix build 2024-04-16 16:47:01 -04:00
08fcba5495 Fix wasm bindgen version issue 2024-04-16 16:27:43 -04:00
076d3a2865 Update styling of song in queue to adjust for blank space in suspense of artists 2024-04-12 23:47:36 -04:00
13d6d07e8e Fix handling of artists resource in song component 2024-04-12 23:46:37 -04:00
cf07ec2982 Fix handling of album and song resources in MediaInfo 2024-04-12 23:45:14 -04:00
aa76a068d6 Stop using set_player_src, instead, memo and effect to set src of audio element 2024-04-12 23:43:52 -04:00
2037122dc0 Add clone trait to Song model 2024-04-12 23:40:10 -04:00
9327ec19f5 Add server function to get song from song id 2024-04-12 23:38:35 -04:00
ece6d19fc3 Remove SongData struct 2024-04-05 23:51:17 -04:00
633000062c Modify queue, playbar, and playstatus to work with Song model struct instead of old SongData 2024-04-05 23:48:24 -04:00
082e6b9269 Add albums server functions to fetch album from album id 2024-04-05 23:47:08 -04:00
ec33b09fa9 update Song component to work for model version of Song struct 2024-04-05 23:46:24 -04:00
e1d3bb4099 Add songs server endpoint to get artists using song id 2024-04-05 23:43:44 -04:00
6bed5e5c55 Merge branch '28-make-ci-build-fail-on-warnings' into 'main'
Make CI build fail on warnings

Closes #28

See merge request libretunes/libretunes!17
2024-03-30 23:26:37 -04:00
93ea42b727 Fix warnings 2024-03-29 21:24:01 -04:00
6cef7ed36d Set deny warnings Rust flag 2024-03-29 21:18:48 -04:00
827d88ac94 Merge branch '13-create-example-environment-variable-file' into 'main'
Add example environment variable file

Closes #13

See merge request libretunes/libretunes!15
2024-03-29 21:10:27 -04:00
fa17a11557 Merge branch '25-fix-incorrect-copyright-line-in-license' into 'main'
Fix incorrect copyright line in license

Closes #25

See merge request libretunes/libretunes!16
2024-03-29 21:03:56 -04:00
f2b68fd8e3 Add example environment variable file 2024-03-29 20:25:16 -04:00
d09caba44c Update copyright year to include 2023 in LICENSE file 2024-03-29 20:18:11 -04:00
5a62f93ba8 Update copyright year and author in LICENSE file 2024-03-29 20:14:50 -04:00
7b6630447f Merge branch '27-switch-to-new-ci-system' into 'main'
Switch to new CI system

Closes #27

See merge request libretunes/libretunes!14
2024-03-29 20:04:33 -04:00
a783fb4e62 Generate random hex value for Postgres password instead of base64 2024-03-29 19:48:29 -04:00
6fc66336b7 Fix review environment URL 2024-03-29 19:27:11 -04:00
27ef4589bf Fix tunnel config script argument order 2024-03-29 17:08:24 -04:00
c736695cea Add export command for environment variables 2024-03-29 16:53:32 -04:00
eb56957a27 Automatically stop review environment after 1 week 2024-03-29 16:53:32 -04:00
0ed4de8bfd Deploy using Docker compose and Cloudflare for review environments 2024-03-26 16:39:14 -04:00
fd4d823cf5 Add misc CICD scripts and compose file 2024-03-26 16:36:50 -04:00
5e2b41da5d Create Docker base template 2024-03-26 16:19:31 -04:00
126ee5c366 Use Docker runner and dind service for Docker build 2024-03-22 18:13:49 -04:00
23 changed files with 418 additions and 104 deletions

17
.env.example Normal file
View File

@ -0,0 +1,17 @@
# Example environment variable file
# Copy this to .env or manually set the environment variables
# Redis URL -- Used for storing session data
REDIS_URL=redis://localhost:6379
# PostgreSQL URL -- Used for storing data
# Option 1: Specify the URL directly
DATABASE_URL=postgresql://libretunes:password@localhost:5432/libretunes
# Option 2: Specify the individual components
# Must specify at least POSTGRES_HOST
# POSTGRES_USER=libretunes
# POSTGRES_PASSWORD=password
# POSTGRES_HOST=localhost
# POSTGRES_PORT=5432
# POSTGRES_DB=libretunes

View File

@ -2,17 +2,25 @@
build:
needs: []
image: $CI_REGISTRY/libretunes/ops/docker-leptos:latest
variables:
RUSTFLAGS: "-D warnings"
script:
- cargo-leptos build
.docker:
image: docker:latest
services:
- docker:dind
tags:
- docker
before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
# Build the docker image and push it to the registry
docker-build:
needs: ["build"]
image: docker:latest
extends: .docker
script:
- /usr/local/bin/dockerd-entrypoint.sh &
- while ! docker info; do echo "Waiting for Docker to become available..."; sleep 1; done
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
# If running on the default branch, tag as latest
- if [ "$CI_COMMIT_BRANCH" == "$CI_DEFAULT_BRANCH" ]; then docker tag
@ -44,35 +52,50 @@ cargo-doc:
paths:
- target/doc
.argocd:
image: argoproj/argocd:v2.6.15
before_script:
- argocd login ${ARGOCD_SERVER} --username ${ARGOCD_USERNAME} --password ${ARGOCD_PASSWORD} --grpc-web
# Start the review environment
start-review:
extends: .argocd
extends: .docker
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
when: manual
script:
- argocd app sync argocd/libretunes-review-${CI_COMMIT_SHORT_SHA}
- argocd app wait argocd/libretunes-review-${CI_COMMIT_SHORT_SHA}
- apk add curl openssl
- cd cicd
- echo "$CLOUDFLARE_TUNNEL_AUTH_JSON" > tunnel-auth.json
- ./add-dns.sh $CLOUDFLARE_ZONE_ID review-$CI_COMMIT_SHORT_SHA libretunes-auto-review $CLOUDFLARE_API_TOKEN $CLOUDFLARE_TUNNEL_ID
- ./create-tunnel-config.sh http://libretunes:3000 review-$CI_COMMIT_SHORT_SHA.libretunes.xyz $CLOUDFLARE_TUNNEL_ID
- export COMPOSE_PROJECT_NAME=review-$CI_COMMIT_SHORT_SHA
- export POSTGRES_PASSWORD=$(openssl rand -hex 16)
- export LIBRETUNES_VERSION=$CI_COMMIT_SHORT_SHA
- docker compose --file docker-compose-cicd.yml pull
- docker compose --file docker-compose-cicd.yml create
- export CONFIG_VOL_NAME=review-${CI_COMMIT_SHORT_SHA}_cloudflared-config
- export TMP_CONTAINER_NAME=$(docker run --rm -d -v $CONFIG_VOL_NAME:/data busybox sh -c "sleep infinity")
- docker cp tunnel-auth.json $TMP_CONTAINER_NAME:/data/auth.json
- docker cp cloudflared-tunnel-config.yml $TMP_CONTAINER_NAME:/data/config.yml
- docker stop $TMP_CONTAINER_NAME
- docker compose --file docker-compose-cicd.yml up -d
environment:
name: review/$CI_COMMIT_SHORT_SHA
url: https://review-$CI_COMMIT_SHORT_SHA.libretunes.mregirouard.com
url: https://review-$CI_COMMIT_SHORT_SHA.libretunes.xyz
on_stop: stop-review
auto_stop_in: 1 week
# Stop the review environment
stop-review:
needs: ["start-review"]
extends: .argocd
extends: .docker
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
when: manual
allow_failure: true
script:
- argocd app delete argocd/libretunes-review-${CI_COMMIT_SHORT_SHA} --cascade
- apk add jq curl
- ./cicd/remove-dns.sh $CLOUDFLARE_ZONE_ID review-$CI_COMMIT_SHORT_SHA.libretunes.xyz libretunes-auto-review $CLOUDFLARE_API_TOKEN
- export COMPOSE_PROJECT_NAME=review-$CI_COMMIT_SHORT_SHA
- export LIBRETUNES_VERSION=$CI_COMMIT_SHORT_SHA
- docker compose --file cicd/docker-compose-cicd.yml down
- docker compose --file cicd/docker-compose-cicd.yml rm -f -v
environment:
name: review/$CI_COMMIT_SHORT_SHA
action: stop

36
Cargo.lock generated
View File

@ -52,7 +52,7 @@ dependencies = [
"actix-rt",
"actix-service",
"actix-utils",
"ahash 0.8.6",
"ahash 0.8.11",
"base64 0.21.5",
"bitflags 2.4.1",
"brotli",
@ -201,7 +201,7 @@ dependencies = [
"actix-service",
"actix-utils",
"actix-web-codegen",
"ahash 0.8.6",
"ahash 0.8.11",
"bytes",
"bytestring",
"cfg-if",
@ -290,9 +290,9 @@ dependencies = [
[[package]]
name = "ahash"
version = "0.7.7"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a824f2aa7e75a0c98c5a504fceb80649e9c35265d44525b5f94de4771a395cd"
checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9"
dependencies = [
"getrandom",
"once_cell",
@ -301,9 +301,9 @@ dependencies = [
[[package]]
name = "ahash"
version = "0.8.6"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91429305e9f0a25f6205c5b8e0d2db09e0708a7a6df0f42212bb56c32c8ac97a"
checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011"
dependencies = [
"cfg-if",
"getrandom",
@ -1193,7 +1193,7 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
dependencies = [
"ahash 0.7.7",
"ahash 0.7.8",
]
[[package]]
@ -1208,7 +1208,7 @@ version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604"
dependencies = [
"ahash 0.8.6",
"ahash 0.8.11",
"allocator-api2",
]
@ -3114,9 +3114,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]]
name = "wasm-bindgen"
version = "0.2.89"
version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ed0d4f68a3015cc185aff4db9506a015f4b96f95303897bfa23f846db54064e"
checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8"
dependencies = [
"cfg-if",
"wasm-bindgen-macro",
@ -3124,9 +3124,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-backend"
version = "0.2.89"
version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b56f625e64f3a1084ded111c4d5f477df9f8c92df113852fa5a374dbda78826"
checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da"
dependencies = [
"bumpalo",
"log",
@ -3151,9 +3151,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.89"
version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0162dbf37223cd2afce98f3d0785506dcb8d266223983e4b5b525859e6e182b2"
checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@ -3161,9 +3161,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.89"
version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0eb82fcb7930ae6219a7ecfd55b217f5f0893484b7a13022ebb2b2bf20b5283"
checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7"
dependencies = [
"proc-macro2",
"quote",
@ -3174,9 +3174,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.89"
version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ab9b36309365056cd639da3134bf87fa8f3d86008abf99e612384a6eecd459f"
checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96"
[[package]]
name = "web-sys"

View File

@ -17,7 +17,7 @@ leptos = { version = "0.5", features = ["nightly"] }
leptos_meta = { version = "0.5", features = ["nightly"] }
leptos_actix = { version = "0.5", optional = true }
leptos_router = { version = "0.5", features = ["nightly"] }
wasm-bindgen = "=0.2.89"
wasm-bindgen = "=0.2.92"
leptos_icons = { version = "0.1.0", default_features = false, features = [
"BsPlayFill",
"BsPauseFill",

View File

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2022 henrik
Copyright (c) 2023-2024 The LibreTunes Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

22
cicd/add-dns.sh Executable file
View File

@ -0,0 +1,22 @@
#!/bin/sh
set -e
ZONE_ID=$1
RECORD_NAME=$2
RECORD_COMMENT=$3
API_TOKEN=$4
TUNNEL_ID=$5
curl --request POST --silent \
--url https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records \
--header 'Content-Type: application/json' \
--header "Authorization: Bearer $API_TOKEN" \
--data '{
"content": "'$TUNNEL_ID'.cfargotunnel.com",
"name": "'$RECORD_NAME'",
"comment": "'$RECORD_COMMENT'",
"proxied": true,
"type": "CNAME",
"ttl": 1
}' \

19
cicd/create-tunnel-config.sh Executable file
View File

@ -0,0 +1,19 @@
#!/bin/sh
set -e
SERVICE=$1
HOSTNAME=$2
TUNNEL_ID=$3
echo "Creating tunnel config for $HOSTNAME"
cat <<EOF > cloudflared-tunnel-config.yml
tunnel: $TUNNEL_ID
credentials-file: /etc/cloudflared/auth.json
ingress:
- hostname: $HOSTNAME
service: $SERVICE
- service: http_status:404
EOF

View File

@ -0,0 +1,55 @@
version: '3'
services:
cloudflare:
image: cloudflare/cloudflared:latest
command: tunnel run
volumes:
- cloudflared-config:/etc/cloudflared:ro
libretunes:
image: registry.mregirouard.com/libretunes/libretunes:${LIBRETUNES_VERSION}
environment:
REDIS_URL: redis://redis:6379
POSTGRES_HOST: postgres
POSTGRES_USER: libretunes
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: libretunes
volumes:
- libretunes-audio:/site/audio
depends_on:
- redis
- postgres
restart: always
redis:
image: redis:latest
volumes:
- libretunes-redis:/data
restart: always
healthcheck:
test: ["CMD-SHELL", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
postgres:
image: postgres:latest
environment:
POSTGRES_USER: libretunes
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: libretunes
volumes:
- libretunes-postgres:/var/lib/postgresql/data
restart: always
healthcheck:
test: ["CMD-SHELL", "pg_isready -U libretunes"]
interval: 10s
timeout: 5s
retries: 5
volumes:
cloudflared-config:
libretunes-audio:
libretunes-redis:
libretunes-postgres:

22
cicd/remove-dns.sh Executable file
View File

@ -0,0 +1,22 @@
#!/bin/sh
set -e
ZONE_ID=$1
RECORD_NAME=$2
RECORD_COMMENT=$3
API_TOKEN=$4
RECORD_ID=$(
curl --request GET --silent \
--url "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records?name=$RECORD_NAME&comment=$RECORD_COMMENT" \
--header "Content-Type: application/json" \
--header "Authorization: Bearer $API_TOKEN" \
| jq -r '.result[0].id')
echo "Deleting DNS record ID $RECORD_ID"
curl --request DELETE --silent \
--url "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/$RECORD_ID" \
--header "Content-Type: application/json" \
--header "Authorization: Bearer $API_TOKEN"

36
src/api/albums.rs Normal file
View File

@ -0,0 +1,36 @@
use leptos::*;
use crate::models::Album;
use cfg_if::cfg_if;
cfg_if! {
if #[cfg(feature = "ssr")] {
use crate::database::get_db_conn;
use diesel::prelude::*;
}
}
/// Gets the Album associated with an album id
///
/// # Arguments
/// `album_id_arg` The id of the Album to get the album for
///
/// # Returns
/// A Result containing an Album if the operation was successful, or an error if the operation failed
#[server(endpoint = "albums/get-album")]
pub async fn get_album(album_id_arg: Option<i32>) -> Result<Album, ServerFnError> {
use crate::schema::albums::dsl::*;
let my_id = album_id_arg.ok_or(ServerFnError::ServerError("Album id must be present (Some) to get Album".to_string()))?;
let mut my_album_vec: Vec<Album> = albums
.filter(id.eq(my_id))
.limit(1)
.load(&mut get_db_conn())?;
let my_album = my_album_vec.pop().ok_or(ServerFnError::ServerError("Album not found".to_string()))?;
Ok(my_album)
}

2
src/api/mod.rs Normal file
View File

@ -0,0 +1,2 @@
pub mod songs;
pub mod albums;

59
src/api/songs.rs Normal file
View File

@ -0,0 +1,59 @@
use leptos::*;
use crate::models::Artist;
use crate::models::Song;
use cfg_if::cfg_if;
cfg_if! {
if #[cfg(feature = "ssr")] {
use crate::database::get_db_conn;
use diesel::prelude::*;
}
}
/// Gets a Vector of Artists associated with a Song
///
/// # Arguments
/// `song_id_arg` - The id of the Song to get the Artists for
///
/// # Returns
/// A Result containing a Vector of Artists if the operation was successful, or an error if the operation failed
#[server(endpoint = "songs/get-artists")]
pub async fn get_artists(song_id_arg: Option<i32>) -> Result<Vec<Artist>, ServerFnError> {
use crate::schema::artists::dsl::*;
use crate::schema::song_artists::dsl::*;
let my_id = song_id_arg.ok_or(ServerFnError::ServerError("Song id must be present (Some) to get artists".to_string()))?;
let my_artists = artists
.inner_join(song_artists)
.filter(song_id.eq(my_id))
.select(artists::all_columns())
.load(&mut get_db_conn())?;
Ok(my_artists)
}
/// Gets the song associated with a song id
///
/// # Arguments
/// `song_id_arg` - The id of the Song to get the song for
///
/// # Returns
/// A Result containing a Song if the operation was successful, or an error if the operation failed
#[server(endpoint = "songs/get-song")]
pub async fn get_song(song_id_arg: Option<i32>) -> Result<Song, ServerFnError> {
use crate::schema::songs::dsl::*;
let my_id = song_id_arg.ok_or(ServerFnError::ServerError("Song id must be present (Some) to get Song".to_string()))?;
let mut my_song_vec: Vec<Song> = songs
.filter(id.eq(my_id))
.limit(1)
.load(&mut get_db_conn())?;
let my_song = my_song_vec.pop().ok_or(ServerFnError::ServerError("Song not found".to_string()))?;
Ok(my_song)
}

View File

@ -37,7 +37,7 @@ pub fn App() -> impl IntoView {
/// Renders the home page of your application.
#[component]
fn HomePage() -> impl IntoView {
let mut play_status = PlayStatus::default();
let play_status = PlayStatus::default();
let play_status = create_rw_signal(play_status);
view! {

View File

@ -1,8 +1,8 @@
use cfg_if::cfg_if;
use leptos::logging::log;
cfg_if! {
if #[cfg(feature = "ssr")] {
use leptos::logging::log;
use lazy_static::lazy_static;
use std::env;

View File

@ -1,6 +1,5 @@
pub mod app;
pub mod auth;
pub mod songdata;
pub mod playstatus;
pub mod playbar;
pub mod database;
@ -8,6 +7,7 @@ pub mod queue;
pub mod song;
pub mod models;
pub mod pages;
pub mod api;
pub mod users;
pub mod search;
use cfg_if::cfg_if;
@ -26,7 +26,6 @@ if #[cfg(feature = "hydrate")] {
#[wasm_bindgen]
pub fn hydrate() {
use app::*;
use leptos::*;
console_error_panic_hook::set_once();

View File

@ -1,5 +1,4 @@
use std::time::SystemTime;
use std::error::Error;
use time::Date;
use serde::{Deserialize, Serialize};
@ -9,6 +8,7 @@ cfg_if! {
if #[cfg(feature = "ssr")] {
use diesel::prelude::*;
use crate::database::PgPooledConn;
use std::error::Error;
}
}
@ -239,7 +239,7 @@ impl Album {
#[cfg_attr(feature = "ssr", derive(Queryable, Selectable, Insertable))]
#[cfg_attr(feature = "ssr", diesel(table_name = crate::schema::songs))]
#[cfg_attr(feature = "ssr", diesel(check_for_backend(diesel::pg::Pg)))]
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, Clone)]
pub struct Song {
/// A unique id for the song
#[cfg_attr(feature = "ssr", diesel(deserialize_as = i32))]

View File

@ -1,6 +1,5 @@
use crate::auth::signup;
use crate::models::User;
use leptos::ev::input;
use leptos::leptos_dom::*;
use leptos::*;
use leptos_icons::AiIcon::*;
@ -15,8 +14,6 @@ pub fn Signup() -> impl IntoView {
let (show_password, set_show_password) = create_signal(false);
let navigate = leptos_router::use_navigate();
let toggle_password = move |_| {
set_show_password.update(|show_password| *show_password = !*show_password);
log!("showing password");

View File

@ -1,6 +1,6 @@
use std::time::Duration;
use crate::playstatus::PlayStatus;
use crate::api::songs::get_artists;
use crate::api::albums::get_album;
use leptos::ev::MouseEvent;
use leptos::html::{Audio, Div};
use leptos::leptos_dom::*;
@ -113,26 +113,6 @@ fn toggle_queue(status: impl SignalUpdate<Value = PlayStatus>) {
}
/// Set the source of the audio player
///
/// Logs an error if the audio element is not available
///
///
/// # Arguments
/// * `status` - The `PlayStatus` to get the audio element from, as a signal
/// * `src` - The source to set the audio player to
///
fn set_play_src(status: impl SignalUpdate<Value = PlayStatus>, src: String) {
status.update(|status| {
if let Some(audio) = status.get_audio() {
audio.set_src(&src);
log!("Player set src to: {}", src);
} else {
error!("Unable to set src: Audio element not available");
}
});
}
/// The play, pause, and skip buttons
#[component]
fn PlayControls(status: RwSignal<PlayStatus>) -> impl IntoView {
@ -153,10 +133,8 @@ fn PlayControls(status: RwSignal<PlayStatus>) -> impl IntoView {
status.update(|status| last_played_song = status.history.pop_back());
if let Some(last_played_song) = last_played_song {
// Push the popped song to the front of the queue, and play it
let next_src = last_played_song.song_path.clone();
// Push the popped song to the front of the queue, and set status to playing
status.update(|status| status.queue.push_front(last_played_song));
set_play_src(status, next_src);
set_playing(status, true);
} else {
warn!("Unable to skip back: No previous song");
@ -247,26 +225,46 @@ fn PlayDuration(elapsed_secs: MaybeSignal<i64>, total_secs: MaybeSignal<i64>) ->
fn MediaInfo(status: RwSignal<PlayStatus>) -> impl IntoView {
let name = Signal::derive(move || {
status.with(|status| {
status.queue.front().map_or("No media playing".into(), |song| song.name.clone())
status.queue.front().map_or("No media playing".into(), |song| song.title.clone())
})
});
let artist = Signal::derive(move || {
let song_id = Signal::derive(move || {
status.with(|status| {
status.queue.front().map_or("".into(), |song| song.artist.clone())
status.queue.front().map_or(None, |song| song.id)
})
});
let album = Signal::derive(move || {
let song_artists_resource = create_resource(song_id, move |song_id| async move {
if let Some(song_id) = song_id {
let artists_vec = get_artists(Some(song_id)).await.map_or(Vec::new(), |artists| artists);
// convert the vec of artists to a string of artists separated by commas
let artists_string = artists_vec.iter().map(|artist| artist.name.clone()).collect::<Vec<String>>().join(", ");
artists_string
} else {
"".into()
}
});
let album_id = Signal::derive(move || {
status.with(|status| {
status.queue.front().map_or("".into(), |song| song.album.clone())
status.queue.front().map_or(None, |song| song.album_id)
})
});
let album_resource = create_resource(album_id, move |album_id| async move {
// get the album name attribute or return ""
if let Some(album_id) = album_id {
get_album(Some(album_id)).await.map_or("".into(), |album| album.title)
} else {
"".into()
}
});
let image = Signal::derive(move || {
status.with(|status| {
// TODO Use some default / unknown image?
status.queue.front().map_or("".into(), |song| song.image_path.clone())
status.queue.front().map_or("".into(), |song| song.image_path.clone().unwrap_or("".into()))
})
});
@ -276,7 +274,28 @@ fn MediaInfo(status: RwSignal<PlayStatus>) -> impl IntoView {
<div class="media-info-text">
{name}
<br/>
{artist} - {album}
<Suspense
fallback=move || {
view! {}
}
>
{move || {
song_artists_resource.get().map_or(view!{{}""}, |artists_string| view! {
{artists_string}" - "
})
}}
</Suspense>
<Suspense
fallback=move || {
view! {}
}
>
{move || {
album_resource.get().map_or(view!{{}""}, |album_name| view! {
""{album_name}
})
}}
</Suspense>
</div>
</div>
}
@ -348,6 +367,33 @@ fn QueueToggle(status: RwSignal<PlayStatus>) -> impl IntoView {
/// The main play bar component, containing the progress bar, media info, play controls, and play duration
#[component]
pub fn PlayBar(status: RwSignal<PlayStatus>) -> impl IntoView {
// Set the source of the audio player to the first song in the queue
let current_song_path = create_memo(
move |_| {
status.with(|status| {
status.queue.front().map(|song| song.storage_path.clone())
})
}
);
create_effect(move |_| {
current_song_path.with(|current_song_path| {
status.with_untracked(|status| {
if let Some(audio) = status.get_audio() {
if let Some(song_path) = current_song_path {
audio.set_src(&song_path);
log!("Player set src to: {}", song_path);
} else {
// We are treating this as a non-fatal error because the queue could be empty or finished
warn!("Unable to set src: No song in queue");
}
} else {
error!("Unable to set src: Audio element not available");
}
});
});
});
// Listen for key down events -- arrow keys don't seem to trigger key press events
let _arrow_key_handle = window_event_listener(ev::keydown, move |e: ev::KeyboardEvent| {
if e.key() == "ArrowRight" {
@ -404,11 +450,11 @@ pub fn PlayBar(status: RwSignal<PlayStatus>) -> impl IntoView {
status.with_untracked(|status| {
// Start playing the first song in the queue, if available
if let Some(song) = status.queue.front() {
log!("Starting playing with song: {}", song.name);
log!("Starting playing with song: {}", song.title);
// Don't use the set_play_src / set_playing helper function
// here because we already have access to the audio element
audio.set_src(&song.song_path);
audio.set_src(&song.storage_path);
if let Err(e) = audio.play() {
error!("Error playing audio on load: {:?}", e);
@ -457,7 +503,7 @@ pub fn PlayBar(status: RwSignal<PlayStatus>) -> impl IntoView {
let prev_song = status.queue.pop_front();
if let Some(prev_song) = prev_song {
log!("Adding song to history: {}", prev_song.name);
log!("Adding song to history: {}", prev_song.title);
status.history.push_back(prev_song);
} else {
log!("Queue empty, no previous song to add to history");
@ -466,7 +512,7 @@ pub fn PlayBar(status: RwSignal<PlayStatus>) -> impl IntoView {
// Get the next song to play, if available
let next_src = status.with_untracked(|status| {
status.queue.front().map(|song| song.song_path.clone())
status.queue.front().map(|song| song.storage_path.clone())
});
if let Some(audio) = audio_ref.get() {

View File

@ -3,7 +3,7 @@ use leptos::NodeRef;
use leptos::html::Audio;
use std::collections::VecDeque;
use crate::songdata::SongData;
use crate::models::Song;
/// Represents the global state of the audio player feature of LibreTunes
pub struct PlayStatus {
@ -14,9 +14,9 @@ pub struct PlayStatus {
/// A reference to the HTML audio element
pub audio_player: Option<NodeRef<Audio>>,
/// A queue of songs that have been played, ordered from oldest to newest
pub history: VecDeque<SongData>,
pub history: VecDeque<Song>,
/// A queue of songs that have yet to be played, ordered from next up to last
pub queue: VecDeque<SongData>,
pub queue: VecDeque<Song>,
}
impl PlayStatus {

View File

@ -99,7 +99,7 @@ pub fn Queue(status: RwSignal<PlayStatus>) -> impl IntoView {
on:dragenter=move |e: DragEvent| on_drag_enter(e, index)
on:dragover=on_drag_over
>
<Song song_image_path=song.image_path.clone() song_title=song.name.clone() song_artist=song.artist.clone() />
<Song song_id_arg=song.id song_image_path=song.image_path.clone().unwrap_or("".to_string()) song_title=song.title.clone() />
<Show
when=move || index != 0
fallback=|| view!{

View File

@ -1,13 +1,41 @@
use leptos::*;
use leptos::logging::*;
use crate::api::songs::get_artists;
#[component]
pub fn Song(song_image_path: String, song_title: String, song_artist: String) -> impl IntoView {
pub fn Song(song_id_arg: Option<i32>, song_image_path: String, song_title: String) -> impl IntoView {
let song_id = Signal::derive(move || song_id_arg);
let song_artists_resource = create_resource(song_id, move |song_id| async move {
let artists_vec = get_artists(song_id).await.unwrap_or_else(|_| {
warn!("Error when searching for artists for song");
Vec::new()
});
// convert the vec of artists to a string of artists separated by commas
let artists_string = artists_vec.iter().map(|artist| artist.name.clone()).collect::<Vec<String>>().join(", ");
artists_string
});
view!{
<div class="queue-song">
<img src={song_image_path} alt={song_title.clone()} />
<div class="queue-song-info">
<h3>{song_title}</h3>
<p>{song_artist}</p>
<Suspense
fallback=move || view! {<p class="fallback-artists">""</p>}
>
{move || {
song_artists_resource.get().map(|artists_string| {
if artists_string.is_empty() {
view! {<p class="fallback-artists">""</p>}
}
else {
view! {<p class="artists">{artists_string}</p>}
}
})
}}
</Suspense>
</div>
</div>
}

View File

@ -1,16 +0,0 @@
/// Holds information about a song
#[derive(Debug, Clone)]
pub struct SongData {
/// Song name
pub name: String,
/// Song artist
pub artist: String,
/// Song album
pub album: String,
/// Path to song file, relative to the root of the web server.
/// For example, `"/assets/audio/Song.mp3"`
pub song_path: String,
/// Path to song image, relative to the root of the web server.
/// For example, `"/assets/images/Song.jpg"`
pub image_path: String,
}

View File

@ -48,7 +48,12 @@
color: #fff; /* Adjust text color for song */
}
p {
.fallback-artists {
margin: 14px 0 0 0; /* Adjust margin for blank space to align text */
}
.artists {
font-size: 14px;
margin: 0; /* Remove default margin for paragraph */
color: #aaa; /* Adjust text color for artist */
}