Compare commits
8 Commits
31-use-mod
...
25-create-
Author | SHA1 | Date | |
---|---|---|---|
585367f715 | |||
8a37caecbb | |||
4681c3ff22 | |||
9c1c8a6a1e | |||
48f74ae26f | |||
39bc660478 | |||
464596cd11 | |||
d8e7ffd963 |
17
.env.example
17
.env.example
@ -1,17 +0,0 @@
|
|||||||
# 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
|
|
@ -2,25 +2,17 @@
|
|||||||
build:
|
build:
|
||||||
needs: []
|
needs: []
|
||||||
image: $CI_REGISTRY/libretunes/ops/docker-leptos:latest
|
image: $CI_REGISTRY/libretunes/ops/docker-leptos:latest
|
||||||
variables:
|
|
||||||
RUSTFLAGS: "-D warnings"
|
|
||||||
script:
|
script:
|
||||||
- cargo-leptos build
|
- 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
|
# Build the docker image and push it to the registry
|
||||||
docker-build:
|
docker-build:
|
||||||
needs: ["build"]
|
needs: ["build"]
|
||||||
extends: .docker
|
image: docker:latest
|
||||||
script:
|
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 .
|
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
|
||||||
# If running on the default branch, tag as latest
|
# If running on the default branch, tag as latest
|
||||||
- if [ "$CI_COMMIT_BRANCH" == "$CI_DEFAULT_BRANCH" ]; then docker tag
|
- if [ "$CI_COMMIT_BRANCH" == "$CI_DEFAULT_BRANCH" ]; then docker tag
|
||||||
@ -52,50 +44,35 @@ cargo-doc:
|
|||||||
paths:
|
paths:
|
||||||
- target/doc
|
- 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 the review environment
|
||||||
start-review:
|
start-review:
|
||||||
extends: .docker
|
extends: .argocd
|
||||||
rules:
|
rules:
|
||||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||||
when: manual
|
when: manual
|
||||||
script:
|
script:
|
||||||
- apk add curl openssl
|
- argocd app sync argocd/libretunes-review-${CI_COMMIT_SHORT_SHA}
|
||||||
- cd cicd
|
- argocd app wait argocd/libretunes-review-${CI_COMMIT_SHORT_SHA}
|
||||||
- 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:
|
environment:
|
||||||
name: review/$CI_COMMIT_SHORT_SHA
|
name: review/$CI_COMMIT_SHORT_SHA
|
||||||
url: https://review-$CI_COMMIT_SHORT_SHA.libretunes.xyz
|
url: https://review-$CI_COMMIT_SHORT_SHA.libretunes.mregirouard.com
|
||||||
on_stop: stop-review
|
on_stop: stop-review
|
||||||
auto_stop_in: 1 week
|
|
||||||
|
|
||||||
# Stop the review environment
|
# Stop the review environment
|
||||||
stop-review:
|
stop-review:
|
||||||
needs: ["start-review"]
|
needs: ["start-review"]
|
||||||
extends: .docker
|
extends: .argocd
|
||||||
rules:
|
rules:
|
||||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||||
when: manual
|
when: manual
|
||||||
allow_failure: true
|
allow_failure: true
|
||||||
script:
|
script:
|
||||||
- apk add jq curl
|
- argocd app delete argocd/libretunes-review-${CI_COMMIT_SHORT_SHA} --cascade
|
||||||
- ./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:
|
environment:
|
||||||
name: review/$CI_COMMIT_SHORT_SHA
|
name: review/$CI_COMMIT_SHORT_SHA
|
||||||
action: stop
|
action: stop
|
||||||
|
36
Cargo.lock
generated
36
Cargo.lock
generated
@ -52,7 +52,7 @@ dependencies = [
|
|||||||
"actix-rt",
|
"actix-rt",
|
||||||
"actix-service",
|
"actix-service",
|
||||||
"actix-utils",
|
"actix-utils",
|
||||||
"ahash 0.8.11",
|
"ahash 0.8.6",
|
||||||
"base64 0.21.5",
|
"base64 0.21.5",
|
||||||
"bitflags 2.4.1",
|
"bitflags 2.4.1",
|
||||||
"brotli",
|
"brotli",
|
||||||
@ -201,7 +201,7 @@ dependencies = [
|
|||||||
"actix-service",
|
"actix-service",
|
||||||
"actix-utils",
|
"actix-utils",
|
||||||
"actix-web-codegen",
|
"actix-web-codegen",
|
||||||
"ahash 0.8.11",
|
"ahash 0.8.6",
|
||||||
"bytes",
|
"bytes",
|
||||||
"bytestring",
|
"bytestring",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
@ -290,9 +290,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ahash"
|
name = "ahash"
|
||||||
version = "0.7.8"
|
version = "0.7.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9"
|
checksum = "5a824f2aa7e75a0c98c5a504fceb80649e9c35265d44525b5f94de4771a395cd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom",
|
"getrandom",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
@ -301,9 +301,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ahash"
|
name = "ahash"
|
||||||
version = "0.8.11"
|
version = "0.8.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011"
|
checksum = "91429305e9f0a25f6205c5b8e0d2db09e0708a7a6df0f42212bb56c32c8ac97a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"getrandom",
|
"getrandom",
|
||||||
@ -1193,7 +1193,7 @@ version = "0.12.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash 0.7.8",
|
"ahash 0.7.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1208,7 +1208,7 @@ version = "0.14.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604"
|
checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash 0.8.11",
|
"ahash 0.8.6",
|
||||||
"allocator-api2",
|
"allocator-api2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -3114,9 +3114,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen"
|
name = "wasm-bindgen"
|
||||||
version = "0.2.92"
|
version = "0.2.89"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8"
|
checksum = "0ed0d4f68a3015cc185aff4db9506a015f4b96f95303897bfa23f846db54064e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"wasm-bindgen-macro",
|
"wasm-bindgen-macro",
|
||||||
@ -3124,9 +3124,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-backend"
|
name = "wasm-bindgen-backend"
|
||||||
version = "0.2.92"
|
version = "0.2.89"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da"
|
checksum = "1b56f625e64f3a1084ded111c4d5f477df9f8c92df113852fa5a374dbda78826"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bumpalo",
|
"bumpalo",
|
||||||
"log",
|
"log",
|
||||||
@ -3151,9 +3151,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro"
|
name = "wasm-bindgen-macro"
|
||||||
version = "0.2.92"
|
version = "0.2.89"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726"
|
checksum = "0162dbf37223cd2afce98f3d0785506dcb8d266223983e4b5b525859e6e182b2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"quote",
|
"quote",
|
||||||
"wasm-bindgen-macro-support",
|
"wasm-bindgen-macro-support",
|
||||||
@ -3161,9 +3161,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro-support"
|
name = "wasm-bindgen-macro-support"
|
||||||
version = "0.2.92"
|
version = "0.2.89"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7"
|
checksum = "f0eb82fcb7930ae6219a7ecfd55b217f5f0893484b7a13022ebb2b2bf20b5283"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@ -3174,9 +3174,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-shared"
|
name = "wasm-bindgen-shared"
|
||||||
version = "0.2.92"
|
version = "0.2.89"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96"
|
checksum = "7ab9b36309365056cd639da3134bf87fa8f3d86008abf99e612384a6eecd459f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web-sys"
|
name = "web-sys"
|
||||||
|
@ -17,7 +17,7 @@ leptos = { version = "0.5", features = ["nightly"] }
|
|||||||
leptos_meta = { version = "0.5", features = ["nightly"] }
|
leptos_meta = { version = "0.5", features = ["nightly"] }
|
||||||
leptos_actix = { version = "0.5", optional = true }
|
leptos_actix = { version = "0.5", optional = true }
|
||||||
leptos_router = { version = "0.5", features = ["nightly"] }
|
leptos_router = { version = "0.5", features = ["nightly"] }
|
||||||
wasm-bindgen = "=0.2.92"
|
wasm-bindgen = "=0.2.89"
|
||||||
leptos_icons = { version = "0.1.0", default_features = false, features = [
|
leptos_icons = { version = "0.1.0", default_features = false, features = [
|
||||||
"BsPlayFill",
|
"BsPlayFill",
|
||||||
"BsPauseFill",
|
"BsPauseFill",
|
||||||
@ -27,7 +27,8 @@ leptos_icons = { version = "0.1.0", default_features = false, features = [
|
|||||||
"CgTrash",
|
"CgTrash",
|
||||||
"IoReturnUpBackSharp",
|
"IoReturnUpBackSharp",
|
||||||
"AiEyeFilled",
|
"AiEyeFilled",
|
||||||
"AiEyeInvisibleFilled"
|
"AiEyeInvisibleFilled",
|
||||||
|
"BsThreeDotsVertical",
|
||||||
] }
|
] }
|
||||||
dotenv = { version = "0.15.0", optional = true }
|
dotenv = { version = "0.15.0", optional = true }
|
||||||
diesel = { version = "2.1.4", features = ["postgres", "r2d2", "time"], optional = true }
|
diesel = { version = "2.1.4", features = ["postgres", "r2d2", "time"], optional = true }
|
||||||
|
2
LICENSE
2
LICENSE
@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2023-2024 The LibreTunes Authors
|
Copyright (c) 2022 henrik
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
@ -1,22 +0,0 @@
|
|||||||
#!/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
|
|
||||||
}' \
|
|
@ -1,19 +0,0 @@
|
|||||||
#!/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
|
|
@ -1,55 +0,0 @@
|
|||||||
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:
|
|
@ -1,22 +0,0 @@
|
|||||||
#!/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"
|
|
@ -1,36 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
@ -1,2 +0,0 @@
|
|||||||
pub mod songs;
|
|
||||||
pub mod albums;
|
|
@ -1,59 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
10
src/app.rs
10
src/app.rs
@ -1,6 +1,7 @@
|
|||||||
use crate::playbar::PlayBar;
|
use crate::playbar::PlayBar;
|
||||||
use crate::playstatus::PlayStatus;
|
use crate::playstatus::PlayStatus;
|
||||||
use crate::queue::Queue;
|
use crate::queue::Queue;
|
||||||
|
use crate::searchbar::SearchBar;
|
||||||
use leptos::*;
|
use leptos::*;
|
||||||
use leptos_meta::*;
|
use leptos_meta::*;
|
||||||
use leptos_router::*;
|
use leptos_router::*;
|
||||||
@ -37,12 +38,15 @@ pub fn App() -> impl IntoView {
|
|||||||
/// Renders the home page of your application.
|
/// Renders the home page of your application.
|
||||||
#[component]
|
#[component]
|
||||||
fn HomePage() -> impl IntoView {
|
fn HomePage() -> impl IntoView {
|
||||||
let play_status = PlayStatus::default();
|
let mut play_status = PlayStatus::default();
|
||||||
let play_status = create_rw_signal(play_status);
|
let play_status = create_rw_signal(play_status);
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<PlayBar status=play_status/>
|
<div class="home">
|
||||||
<Queue status=play_status/>
|
<PlayBar status=play_status/>
|
||||||
|
<Queue status=play_status/>
|
||||||
|
<SearchBar status=play_status/>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
use cfg_if::cfg_if;
|
use cfg_if::cfg_if;
|
||||||
|
use leptos::logging::log;
|
||||||
|
|
||||||
cfg_if! {
|
cfg_if! {
|
||||||
if #[cfg(feature = "ssr")] {
|
if #[cfg(feature = "ssr")] {
|
||||||
use leptos::logging::log;
|
|
||||||
|
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
use std::env;
|
use std::env;
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
pub mod app;
|
pub mod app;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod songdata;
|
||||||
pub mod playstatus;
|
pub mod playstatus;
|
||||||
pub mod playbar;
|
pub mod playbar;
|
||||||
pub mod database;
|
pub mod database;
|
||||||
@ -7,9 +8,9 @@ pub mod queue;
|
|||||||
pub mod song;
|
pub mod song;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod pages;
|
pub mod pages;
|
||||||
pub mod api;
|
|
||||||
pub mod users;
|
pub mod users;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
|
pub mod searchbar;
|
||||||
use cfg_if::cfg_if;
|
use cfg_if::cfg_if;
|
||||||
|
|
||||||
cfg_if! {
|
cfg_if! {
|
||||||
@ -26,6 +27,7 @@ if #[cfg(feature = "hydrate")] {
|
|||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub fn hydrate() {
|
pub fn hydrate() {
|
||||||
use app::*;
|
use app::*;
|
||||||
|
use leptos::*;
|
||||||
|
|
||||||
console_error_panic_hook::set_once();
|
console_error_panic_hook::set_once();
|
||||||
|
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
use std::time::SystemTime;
|
use std::time::SystemTime;
|
||||||
|
use std::error::Error;
|
||||||
use time::Date;
|
use time::Date;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@ -8,7 +9,6 @@ cfg_if! {
|
|||||||
if #[cfg(feature = "ssr")] {
|
if #[cfg(feature = "ssr")] {
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use crate::database::PgPooledConn;
|
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", derive(Queryable, Selectable, Insertable))]
|
||||||
#[cfg_attr(feature = "ssr", diesel(table_name = crate::schema::songs))]
|
#[cfg_attr(feature = "ssr", diesel(table_name = crate::schema::songs))]
|
||||||
#[cfg_attr(feature = "ssr", diesel(check_for_backend(diesel::pg::Pg)))]
|
#[cfg_attr(feature = "ssr", diesel(check_for_backend(diesel::pg::Pg)))]
|
||||||
#[derive(Serialize, Deserialize, Clone)]
|
#[derive(Serialize, Deserialize)]
|
||||||
pub struct Song {
|
pub struct Song {
|
||||||
/// A unique id for the song
|
/// A unique id for the song
|
||||||
#[cfg_attr(feature = "ssr", diesel(deserialize_as = i32))]
|
#[cfg_attr(feature = "ssr", diesel(deserialize_as = i32))]
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
use crate::auth::signup;
|
use crate::auth::signup;
|
||||||
use crate::models::User;
|
use crate::models::User;
|
||||||
|
use leptos::ev::input;
|
||||||
use leptos::leptos_dom::*;
|
use leptos::leptos_dom::*;
|
||||||
use leptos::*;
|
use leptos::*;
|
||||||
use leptos_icons::AiIcon::*;
|
use leptos_icons::AiIcon::*;
|
||||||
@ -14,6 +15,8 @@ pub fn Signup() -> impl IntoView {
|
|||||||
|
|
||||||
let (show_password, set_show_password) = create_signal(false);
|
let (show_password, set_show_password) = create_signal(false);
|
||||||
|
|
||||||
|
let navigate = leptos_router::use_navigate();
|
||||||
|
|
||||||
let toggle_password = move |_| {
|
let toggle_password = move |_| {
|
||||||
set_show_password.update(|show_password| *show_password = !*show_password);
|
set_show_password.update(|show_password| *show_password = !*show_password);
|
||||||
log!("showing password");
|
log!("showing password");
|
||||||
|
126
src/playbar.rs
126
src/playbar.rs
@ -1,6 +1,6 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::playstatus::PlayStatus;
|
use crate::playstatus::PlayStatus;
|
||||||
use crate::api::songs::get_artists;
|
|
||||||
use crate::api::albums::get_album;
|
|
||||||
use leptos::ev::MouseEvent;
|
use leptos::ev::MouseEvent;
|
||||||
use leptos::html::{Audio, Div};
|
use leptos::html::{Audio, Div};
|
||||||
use leptos::leptos_dom::*;
|
use leptos::leptos_dom::*;
|
||||||
@ -113,6 +113,26 @@ 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
|
/// The play, pause, and skip buttons
|
||||||
#[component]
|
#[component]
|
||||||
fn PlayControls(status: RwSignal<PlayStatus>) -> impl IntoView {
|
fn PlayControls(status: RwSignal<PlayStatus>) -> impl IntoView {
|
||||||
@ -133,8 +153,10 @@ fn PlayControls(status: RwSignal<PlayStatus>) -> impl IntoView {
|
|||||||
status.update(|status| last_played_song = status.history.pop_back());
|
status.update(|status| last_played_song = status.history.pop_back());
|
||||||
|
|
||||||
if let Some(last_played_song) = last_played_song {
|
if let Some(last_played_song) = last_played_song {
|
||||||
// Push the popped song to the front of the queue, and set status to playing
|
// Push the popped song to the front of the queue, and play it
|
||||||
|
let next_src = last_played_song.song_path.clone();
|
||||||
status.update(|status| status.queue.push_front(last_played_song));
|
status.update(|status| status.queue.push_front(last_played_song));
|
||||||
|
set_play_src(status, next_src);
|
||||||
set_playing(status, true);
|
set_playing(status, true);
|
||||||
} else {
|
} else {
|
||||||
warn!("Unable to skip back: No previous song");
|
warn!("Unable to skip back: No previous song");
|
||||||
@ -225,46 +247,26 @@ fn PlayDuration(elapsed_secs: MaybeSignal<i64>, total_secs: MaybeSignal<i64>) ->
|
|||||||
fn MediaInfo(status: RwSignal<PlayStatus>) -> impl IntoView {
|
fn MediaInfo(status: RwSignal<PlayStatus>) -> impl IntoView {
|
||||||
let name = Signal::derive(move || {
|
let name = Signal::derive(move || {
|
||||||
status.with(|status| {
|
status.with(|status| {
|
||||||
status.queue.front().map_or("No media playing".into(), |song| song.title.clone())
|
status.queue.front().map_or("No media playing".into(), |song| song.name.clone())
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
let song_id = Signal::derive(move || {
|
let artist = Signal::derive(move || {
|
||||||
status.with(|status| {
|
status.with(|status| {
|
||||||
status.queue.front().map_or(None, |song| song.id)
|
status.queue.front().map_or("".into(), |song| song.artist.clone())
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
let song_artists_resource = create_resource(song_id, move |song_id| async move {
|
let album = Signal::derive(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.with(|status| {
|
||||||
status.queue.front().map_or(None, |song| song.album_id)
|
status.queue.front().map_or("".into(), |song| song.album.clone())
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
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 || {
|
let image = Signal::derive(move || {
|
||||||
status.with(|status| {
|
status.with(|status| {
|
||||||
// TODO Use some default / unknown image?
|
// TODO Use some default / unknown image?
|
||||||
status.queue.front().map_or("".into(), |song| song.image_path.clone().unwrap_or("".into()))
|
status.queue.front().map_or("".into(), |song| song.image_path.clone())
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -273,29 +275,8 @@ fn MediaInfo(status: RwSignal<PlayStatus>) -> impl IntoView {
|
|||||||
<img class="media-info-img" align="left" src={image}/>
|
<img class="media-info-img" align="left" src={image}/>
|
||||||
<div class="media-info-text">
|
<div class="media-info-text">
|
||||||
{name}
|
{name}
|
||||||
<br/>
|
<br/>
|
||||||
<Suspense
|
{artist} - {album}
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@ -367,36 +348,9 @@ fn QueueToggle(status: RwSignal<PlayStatus>) -> impl IntoView {
|
|||||||
/// The main play bar component, containing the progress bar, media info, play controls, and play duration
|
/// The main play bar component, containing the progress bar, media info, play controls, and play duration
|
||||||
#[component]
|
#[component]
|
||||||
pub fn PlayBar(status: RwSignal<PlayStatus>) -> impl IntoView {
|
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
|
// 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| {
|
let _arrow_key_handle = window_event_listener(ev::keydown, move |e: ev::KeyboardEvent| {
|
||||||
if e.key() == "ArrowRight" {
|
if e.key() == "ArrowRight" && status.with_untracked(|status| status.search_active) == false {
|
||||||
e.prevent_default();
|
e.prevent_default();
|
||||||
log!("Right arrow key pressed, skipping forward by {} seconds", ARROW_KEY_SKIP_TIME);
|
log!("Right arrow key pressed, skipping forward by {} seconds", ARROW_KEY_SKIP_TIME);
|
||||||
|
|
||||||
@ -409,7 +363,7 @@ pub fn PlayBar(status: RwSignal<PlayStatus>) -> impl IntoView {
|
|||||||
error!("Unable to skip forward: Unable to get current duration");
|
error!("Unable to skip forward: Unable to get current duration");
|
||||||
}
|
}
|
||||||
|
|
||||||
} else if e.key() == "ArrowLeft" {
|
} else if e.key() == "ArrowLeft" && status.with_untracked(|status| status.search_active) == false {
|
||||||
e.prevent_default();
|
e.prevent_default();
|
||||||
log!("Left arrow key pressed, skipping backward by {} seconds", ARROW_KEY_SKIP_TIME);
|
log!("Left arrow key pressed, skipping backward by {} seconds", ARROW_KEY_SKIP_TIME);
|
||||||
|
|
||||||
@ -426,7 +380,7 @@ pub fn PlayBar(status: RwSignal<PlayStatus>) -> impl IntoView {
|
|||||||
|
|
||||||
// Listen for space bar presses to play/pause
|
// Listen for space bar presses to play/pause
|
||||||
let _space_bar_handle = window_event_listener(ev::keypress, move |e: ev::KeyboardEvent| {
|
let _space_bar_handle = window_event_listener(ev::keypress, move |e: ev::KeyboardEvent| {
|
||||||
if e.key() == " " {
|
if e.key() == " " && status.with_untracked(|status| status.search_active) == false {
|
||||||
e.prevent_default();
|
e.prevent_default();
|
||||||
log!("Space bar pressed, toggling play/pause");
|
log!("Space bar pressed, toggling play/pause");
|
||||||
|
|
||||||
@ -450,11 +404,11 @@ pub fn PlayBar(status: RwSignal<PlayStatus>) -> impl IntoView {
|
|||||||
status.with_untracked(|status| {
|
status.with_untracked(|status| {
|
||||||
// Start playing the first song in the queue, if available
|
// Start playing the first song in the queue, if available
|
||||||
if let Some(song) = status.queue.front() {
|
if let Some(song) = status.queue.front() {
|
||||||
log!("Starting playing with song: {}", song.title);
|
log!("Starting playing with song: {}", song.name);
|
||||||
|
|
||||||
// Don't use the set_play_src / set_playing helper function
|
// Don't use the set_play_src / set_playing helper function
|
||||||
// here because we already have access to the audio element
|
// here because we already have access to the audio element
|
||||||
audio.set_src(&song.storage_path);
|
audio.set_src(&song.song_path);
|
||||||
|
|
||||||
if let Err(e) = audio.play() {
|
if let Err(e) = audio.play() {
|
||||||
error!("Error playing audio on load: {:?}", e);
|
error!("Error playing audio on load: {:?}", e);
|
||||||
@ -503,7 +457,7 @@ pub fn PlayBar(status: RwSignal<PlayStatus>) -> impl IntoView {
|
|||||||
let prev_song = status.queue.pop_front();
|
let prev_song = status.queue.pop_front();
|
||||||
|
|
||||||
if let Some(prev_song) = prev_song {
|
if let Some(prev_song) = prev_song {
|
||||||
log!("Adding song to history: {}", prev_song.title);
|
log!("Adding song to history: {}", prev_song.name);
|
||||||
status.history.push_back(prev_song);
|
status.history.push_back(prev_song);
|
||||||
} else {
|
} else {
|
||||||
log!("Queue empty, no previous song to add to history");
|
log!("Queue empty, no previous song to add to history");
|
||||||
@ -512,7 +466,7 @@ pub fn PlayBar(status: RwSignal<PlayStatus>) -> impl IntoView {
|
|||||||
|
|
||||||
// Get the next song to play, if available
|
// Get the next song to play, if available
|
||||||
let next_src = status.with_untracked(|status| {
|
let next_src = status.with_untracked(|status| {
|
||||||
status.queue.front().map(|song| song.storage_path.clone())
|
status.queue.front().map(|song| song.song_path.clone())
|
||||||
});
|
});
|
||||||
|
|
||||||
if let Some(audio) = audio_ref.get() {
|
if let Some(audio) = audio_ref.get() {
|
||||||
|
@ -3,7 +3,7 @@ use leptos::NodeRef;
|
|||||||
use leptos::html::Audio;
|
use leptos::html::Audio;
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
use crate::models::Song;
|
use crate::songdata::SongData;
|
||||||
|
|
||||||
/// Represents the global state of the audio player feature of LibreTunes
|
/// Represents the global state of the audio player feature of LibreTunes
|
||||||
pub struct PlayStatus {
|
pub struct PlayStatus {
|
||||||
@ -11,12 +11,14 @@ pub struct PlayStatus {
|
|||||||
pub playing: bool,
|
pub playing: bool,
|
||||||
/// Whether or not the queue is open
|
/// Whether or not the queue is open
|
||||||
pub queue_open: bool,
|
pub queue_open: bool,
|
||||||
|
/// Whether or not the search bar is active (useful for knowing when spacebar to play/pause, etc should be disabled)
|
||||||
|
pub search_active: bool,
|
||||||
/// A reference to the HTML audio element
|
/// A reference to the HTML audio element
|
||||||
pub audio_player: Option<NodeRef<Audio>>,
|
pub audio_player: Option<NodeRef<Audio>>,
|
||||||
/// A queue of songs that have been played, ordered from oldest to newest
|
/// A queue of songs that have been played, ordered from oldest to newest
|
||||||
pub history: VecDeque<Song>,
|
pub history: VecDeque<SongData>,
|
||||||
/// A queue of songs that have yet to be played, ordered from next up to last
|
/// A queue of songs that have yet to be played, ordered from next up to last
|
||||||
pub queue: VecDeque<Song>,
|
pub queue: VecDeque<SongData>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PlayStatus {
|
impl PlayStatus {
|
||||||
@ -56,6 +58,7 @@ impl Default for PlayStatus {
|
|||||||
Self {
|
Self {
|
||||||
playing: false,
|
playing: false,
|
||||||
queue_open: false,
|
queue_open: false,
|
||||||
|
search_active: false,
|
||||||
audio_player: None,
|
audio_player: None,
|
||||||
history: VecDeque::new(),
|
history: VecDeque::new(),
|
||||||
queue: VecDeque::new(),
|
queue: VecDeque::new(),
|
||||||
|
@ -99,7 +99,7 @@ pub fn Queue(status: RwSignal<PlayStatus>) -> impl IntoView {
|
|||||||
on:dragenter=move |e: DragEvent| on_drag_enter(e, index)
|
on:dragenter=move |e: DragEvent| on_drag_enter(e, index)
|
||||||
on:dragover=on_drag_over
|
on:dragover=on_drag_over
|
||||||
>
|
>
|
||||||
<Song song_id_arg=song.id song_image_path=song.image_path.clone().unwrap_or("".to_string()) song_title=song.title.clone() />
|
<Song song_image_path=song.image_path.clone() song_title=song.name.clone() song_artist=song.artist.clone() />
|
||||||
<Show
|
<Show
|
||||||
when=move || index != 0
|
when=move || index != 0
|
||||||
fallback=|| view!{
|
fallback=|| view!{
|
||||||
|
@ -39,10 +39,11 @@ if #[cfg(feature = "ssr")] {
|
|||||||
/// # Returns
|
/// # Returns
|
||||||
/// A Result containing a vector of albums if the search was successful, or an error if the search failed
|
/// A Result containing a vector of albums if the search was successful, or an error if the search failed
|
||||||
#[server(endpoint = "search_albums")]
|
#[server(endpoint = "search_albums")]
|
||||||
pub async fn search_albums(query: String, limit: i64) -> Result<Vec<Album>, ServerFnError> {
|
pub async fn search_albums(query: String, limit: i64) -> Result<Vec<(Album, f32)>, ServerFnError> {
|
||||||
use crate::schema::albums::dsl::*;
|
use crate::schema::albums::dsl::*;
|
||||||
|
|
||||||
Ok(albums
|
Ok(albums
|
||||||
|
.select((albums::all_columns(), trgm_distance(title, query.clone())))
|
||||||
.filter(trgm_similar(title, query.clone()))
|
.filter(trgm_similar(title, query.clone()))
|
||||||
.order_by(trgm_distance(title, query))
|
.order_by(trgm_distance(title, query))
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
@ -58,10 +59,11 @@ pub async fn search_albums(query: String, limit: i64) -> Result<Vec<Album>, Serv
|
|||||||
/// # Returns
|
/// # Returns
|
||||||
/// A Result containing a vector of artists if the search was successful, or an error if the search failed
|
/// A Result containing a vector of artists if the search was successful, or an error if the search failed
|
||||||
#[server(endpoint = "search_artists")]
|
#[server(endpoint = "search_artists")]
|
||||||
pub async fn search_artists(query: String, limit: i64) -> Result<Vec<Artist>, ServerFnError> {
|
pub async fn search_artists(query: String, limit: i64) -> Result<Vec<(Artist, f32)>, ServerFnError> {
|
||||||
use crate::schema::artists::dsl::*;
|
use crate::schema::artists::dsl::*;
|
||||||
|
|
||||||
Ok(artists
|
Ok(artists
|
||||||
|
.select((artists::all_columns(), trgm_distance(name, query.clone())))
|
||||||
.filter(trgm_similar(name, query.clone()))
|
.filter(trgm_similar(name, query.clone()))
|
||||||
.order_by(trgm_distance(name, query))
|
.order_by(trgm_distance(name, query))
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
@ -77,10 +79,11 @@ pub async fn search_artists(query: String, limit: i64) -> Result<Vec<Artist>, Se
|
|||||||
/// # Returns
|
/// # Returns
|
||||||
/// A Result containing a vector of songs if the search was successful, or an error if the search failed
|
/// A Result containing a vector of songs if the search was successful, or an error if the search failed
|
||||||
#[server(endpoint = "search_songs")]
|
#[server(endpoint = "search_songs")]
|
||||||
pub async fn search_songs(query: String, limit: i64) -> Result<Vec<Song>, ServerFnError> {
|
pub async fn search_songs(query: String, limit: i64) -> Result<Vec<(Song, f32)>, ServerFnError> {
|
||||||
use crate::schema::songs::dsl::*;
|
use crate::schema::songs::dsl::*;
|
||||||
|
|
||||||
Ok(songs
|
Ok(songs
|
||||||
|
.select((songs::all_columns(), trgm_distance(title, query.clone())))
|
||||||
.filter(trgm_similar(title, query.clone()))
|
.filter(trgm_similar(title, query.clone()))
|
||||||
.order_by(trgm_distance(title, query))
|
.order_by(trgm_distance(title, query))
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
@ -95,9 +98,10 @@ pub async fn search_songs(query: String, limit: i64) -> Result<Vec<Song>, Server
|
|||||||
/// `limit` - The maximum number of results to return for each type
|
/// `limit` - The maximum number of results to return for each type
|
||||||
///
|
///
|
||||||
/// # Returns
|
/// # Returns
|
||||||
/// A Result containing a tuple of vectors of albums, artists, and songs if the search was successful,
|
/// A Result containing a tuple of vectors of albums, artists, and songs,
|
||||||
|
/// along with respective similarity scores, if the search was successful.
|
||||||
#[server(endpoint = "search")]
|
#[server(endpoint = "search")]
|
||||||
pub async fn search(query: String, limit: i64) -> Result<(Vec<Album>, Vec<Artist>, Vec<Song>), ServerFnError> {
|
pub async fn search(query: String, limit: i64) -> Result<(Vec<(Album, f32)>, Vec<(Artist, f32)>, Vec<(Song, f32)>), ServerFnError> {
|
||||||
let albums = search_albums(query.clone(), limit);
|
let albums = search_albums(query.clone(), limit);
|
||||||
let artists = search_artists(query.clone(), limit);
|
let artists = search_artists(query.clone(), limit);
|
||||||
let songs = search_songs(query.clone(), limit);
|
let songs = search_songs(query.clone(), limit);
|
||||||
|
154
src/searchbar.rs
Normal file
154
src/searchbar.rs
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
use crate::search::search;
|
||||||
|
use crate::playstatus::PlayStatus;
|
||||||
|
use crate::song::Song;
|
||||||
|
use crate::models::Album;
|
||||||
|
use crate::models::Artist;
|
||||||
|
use crate::models::Song;
|
||||||
|
use leptos::*;
|
||||||
|
use leptos::ev::*;
|
||||||
|
use leptos::leptos_dom::*;
|
||||||
|
use leptos_icons::*;
|
||||||
|
use leptos_icons::BsIcon::*;
|
||||||
|
|
||||||
|
const OPTIONS_BTN_SIZE: &str = "2.5rem";
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn SearchBar(status: RwSignal<PlayStatus>) -> impl IntoView {
|
||||||
|
let search_query = create_rw_signal(String::new());
|
||||||
|
let search_results = create_rw_signal((Vec::<(Album, f32)>::new(), Vec::<(Artist, f32)>::new(), Vec::<(Song, f32)>::new()));
|
||||||
|
let search_limit = 10;
|
||||||
|
|
||||||
|
let on_input = move |e: Event| {
|
||||||
|
search_query.set(event_target_value(&e));
|
||||||
|
|
||||||
|
log!("Search Query: {:?}", search_query.get_untracked());
|
||||||
|
|
||||||
|
if search_query.get_untracked().len() < 3 {
|
||||||
|
search_results.set((Vec::<(Album, f32)>::new(), Vec::<(Artist, f32)>::new(), Vec::<(Song, f32)>::new()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
spawn_local(async move {
|
||||||
|
log!("Searching for: {:?}", search_query.get_untracked());
|
||||||
|
let results = search(search_query.get_untracked(), search_limit).await;
|
||||||
|
match results {
|
||||||
|
Ok((albums, artists, songs)) => {
|
||||||
|
search_results.set((albums, artists, songs));
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
log!("Error searching: {:?}", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_disabled = move |_e: FocusEvent| {
|
||||||
|
|
||||||
|
log!("Search Bar Disabled");
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_enabled = move |_e: FocusEvent| {
|
||||||
|
status.update(|status| {
|
||||||
|
status.search_active = true;
|
||||||
|
});
|
||||||
|
log!("Search Bar Enabled");
|
||||||
|
};
|
||||||
|
|
||||||
|
let prevent_focus = move |e: MouseEvent| {
|
||||||
|
e.prevent_default();
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="search-container">
|
||||||
|
<div class="search-bar">
|
||||||
|
<input type="search" placeholder="Search" on:input=on_input on:blur=on_disabled on:focus=on_enabled/>
|
||||||
|
</div>
|
||||||
|
<div class="search-results">
|
||||||
|
<ul class="search-results-list">
|
||||||
|
{
|
||||||
|
move || search_results.with(|(albums, artists, songs)| -> Vec<_> {
|
||||||
|
let mut album_index = 0;
|
||||||
|
let mut artist_index = 0;
|
||||||
|
let mut song_index = 0;
|
||||||
|
let mut views = Vec::new();
|
||||||
|
while album_index < albums.len() || artist_index < artists.len() || song_index < songs.len() {
|
||||||
|
const RM_BTN_SIZE: &str = "2.5rem";
|
||||||
|
let album_score = if album_index < albums.len() { albums[album_index].1 } else { f32::MAX };
|
||||||
|
let artist_score = if artist_index < artists.len() { artists[artist_index].1 } else { f32::MAX };
|
||||||
|
let song_score = if song_index < songs.len() { songs[song_index].1 } else { f32::MAX };
|
||||||
|
if artist_score <= album_score && artist_score <= song_score {
|
||||||
|
let artist = &artists[artist_index].0;
|
||||||
|
artist_index += 1;
|
||||||
|
views.push(view! {
|
||||||
|
<li class="search-result">
|
||||||
|
<div class="result-container">
|
||||||
|
<div class="search-result-artist">
|
||||||
|
{artist.name.clone()}
|
||||||
|
</div>
|
||||||
|
<div class="right-side-result">
|
||||||
|
<div class="search-item-type">
|
||||||
|
"(Artist)"
|
||||||
|
</div>
|
||||||
|
<button class="search-result-options" on:mousedown=prevent_focus>
|
||||||
|
<Icon class="search-result-options-icon" width=OPTIONS_BTN_SIZE height=OPTIONS_BTN_SIZE icon=Icon::from(BsThreeDotsVertical) />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if album_score <= artist_score && album_score <= song_score {
|
||||||
|
let album = &albums[album_index].0;
|
||||||
|
album_index += 1;
|
||||||
|
views.push(view! {
|
||||||
|
<li class="search-result">
|
||||||
|
<div class="result-container">
|
||||||
|
<div class="search-result-album">
|
||||||
|
{album.title.clone()}
|
||||||
|
{match album.release_date {
|
||||||
|
Some(date) => format!(" ({})", date),
|
||||||
|
None => "".to_string()
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div class="right-side-result">
|
||||||
|
<div class="search-item-type">
|
||||||
|
"(Album)"
|
||||||
|
</div>
|
||||||
|
<button class="search-result-options" on:mousedown=prevent_focus>
|
||||||
|
<Icon class="search-result-options-icon" width=OPTIONS_BTN_SIZE height=OPTIONS_BTN_SIZE icon=Icon::from(BsThreeDotsVertical) />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if song_score <= artist_score && song_score <= album_score {
|
||||||
|
let song = &songs[song_index].0;
|
||||||
|
song_index += 1;
|
||||||
|
views.push(view! {
|
||||||
|
<li class="search-result">
|
||||||
|
<div class="result-container">
|
||||||
|
<Song song_image_path=match song.image_path.clone() {
|
||||||
|
Some(path) => path,
|
||||||
|
None => "".to_string()
|
||||||
|
} song_title=song.title.clone() song_artist="".to_string() />
|
||||||
|
<div class="right-side-result">
|
||||||
|
<div class="search-item-type">
|
||||||
|
"(Song)"
|
||||||
|
</div>
|
||||||
|
<button class="search-result-options" on:mousedown=prevent_focus>
|
||||||
|
<Icon class="search-result-options-icon" width=OPTIONS_BTN_SIZE height=OPTIONS_BTN_SIZE icon=Icon::from(BsThreeDotsVertical) />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
views
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
36
src/song.rs
36
src/song.rs
@ -1,41 +1,13 @@
|
|||||||
use leptos::*;
|
use leptos::*;
|
||||||
use leptos::logging::*;
|
|
||||||
use crate::api::songs::get_artists;
|
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Song(song_id_arg: Option<i32>, song_image_path: String, song_title: String) -> impl IntoView {
|
pub fn Song(song_image_path: String, song_title: String, song_artist: 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!{
|
view!{
|
||||||
<div class="queue-song">
|
<div class="song">
|
||||||
<img src={song_image_path} alt={song_title.clone()} />
|
<img src={song_image_path} alt={song_title.clone()} />
|
||||||
<div class="queue-song-info">
|
<div class="song-info">
|
||||||
<h3>{song_title}</h3>
|
<h3>{song_title}</h3>
|
||||||
<Suspense
|
<p>{song_artist}</p>
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
16
src/songdata.rs
Normal file
16
src/songdata.rs
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
/// 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,
|
||||||
|
}
|
@ -3,6 +3,8 @@
|
|||||||
@import 'queue.scss';
|
@import 'queue.scss';
|
||||||
@import 'login.scss';
|
@import 'login.scss';
|
||||||
@import 'signup.scss';
|
@import 'signup.scss';
|
||||||
|
@import 'song.scss';
|
||||||
|
@import 'searchbar.scss';
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: sans-serif;
|
font-family: sans-serif;
|
||||||
@ -11,3 +13,8 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.home {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
@ -5,10 +5,12 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
width: 400px;
|
width: 400px;
|
||||||
height: calc(100% - 78.9px); /* Adjust height to fit the queue */
|
height: calc(100% - 87px); /* Adjust height to fit the queue */
|
||||||
background-color: #424242; /* Queue background color */
|
background-color: #424242; /* Queue background color */
|
||||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
|
||||||
overflow-y: auto; /* Add scroll bar when queue is too long */
|
overflow-y: auto; /* Add scroll bar when queue is too long */
|
||||||
|
margin: 5px;
|
||||||
|
border-radius: 5px;
|
||||||
|
|
||||||
.queue-header {
|
.queue-header {
|
||||||
background-color: #333; /* Header background color */
|
background-color: #333; /* Header background color */
|
||||||
@ -29,36 +31,6 @@
|
|||||||
padding-right: 10px;
|
padding-right: 10px;
|
||||||
padding-top: 5px;
|
padding-top: 5px;
|
||||||
padding-bottom: 5px;
|
padding-bottom: 5px;
|
||||||
|
|
||||||
.queue-song {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 10px;
|
|
||||||
border-bottom: 1px solid #ccc; /* Separator line color */
|
|
||||||
|
|
||||||
img {
|
|
||||||
max-width: 50px; /* Adjust maximum width for images */
|
|
||||||
margin-right: 10px; /* Add spacing between image and text */
|
|
||||||
border-radius: 5px; /* Add border radius to image */
|
|
||||||
}
|
|
||||||
|
|
||||||
.queue-song-info {
|
|
||||||
h3 {
|
|
||||||
margin: 0; /* Remove default margin for heading */
|
|
||||||
color: #fff; /* Adjust text color for song */
|
|
||||||
}
|
|
||||||
|
|
||||||
.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 */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
button {
|
||||||
background: none;
|
background: none;
|
||||||
|
99
style/searchbar.scss
Normal file
99
style/searchbar.scss
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
@import 'theme.scss';
|
||||||
|
|
||||||
|
.search-container {
|
||||||
|
display: flex;
|
||||||
|
margin: 5px auto;
|
||||||
|
margin-left: 282px;
|
||||||
|
border-radius: 5px;
|
||||||
|
height: 100%;
|
||||||
|
width: calc(100% - 690px);
|
||||||
|
background-color: $search-background-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
background-color: transparent;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: left;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar input[type="search"] {
|
||||||
|
background-color: $search-bar-input-background-color;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
color: $search-bar-input-color;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 16px;
|
||||||
|
&::placeholder {
|
||||||
|
color: rgb(61, 61, 61);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results {
|
||||||
|
background-color: $search-background-color;
|
||||||
|
border-bottom-left-radius: 5px;
|
||||||
|
border-bottom-right-radius: 5px;
|
||||||
|
display: flex;
|
||||||
|
margin-top: 55px;
|
||||||
|
height: calc(100% - 143px);
|
||||||
|
width: calc(100% - 690px);
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
li {
|
||||||
|
width: calc(100% - 20px);
|
||||||
|
padding: 10px;
|
||||||
|
border-bottom: 1px solid $search-highlight-color;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: white;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
&:hover {
|
||||||
|
background-color: $search-highlight-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
border-top: 2px solid $search-highlight-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-side-result {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
.search-item-type {
|
||||||
|
color: $search-item-type-color;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-options {
|
||||||
|
background-color: transparent;
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $search-options-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
color: $search-options-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
22
style/song.scss
Normal file
22
style/song.scss
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
.song {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 50px; /* Adjust maximum width for images */
|
||||||
|
margin-right: 10px; /* Add spacing between image and text */
|
||||||
|
border-radius: 5px; /* Add border radius to image */
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-info {
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #fff; /* Adjust text color for song */
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: #aaa; /* Adjust text color for artist */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -7,6 +7,12 @@ $play-bar-background-color: #212121;
|
|||||||
$play-grad-start: #0a0533;
|
$play-grad-start: #0a0533;
|
||||||
$play-grad-end: $accent-color;
|
$play-grad-end: $accent-color;
|
||||||
$queue-background-color: $play-bar-background-color;
|
$queue-background-color: $play-bar-background-color;
|
||||||
|
$search-background-color: $play-bar-background-color;
|
||||||
|
$search-bar-input-background-color: gray;
|
||||||
|
$search-bar-input-color: black;
|
||||||
|
$search-highlight-color: #333;
|
||||||
|
$search-item-type-color: #666;
|
||||||
|
$search-options-color: #666;
|
||||||
|
|
||||||
$auth-inputs: #796dd4;
|
$auth-inputs: #796dd4;
|
||||||
$auth-containers: white;
|
$auth-containers: white;
|
||||||
|
Reference in New Issue
Block a user