Add music sharing and mobile player polish
Add track, release, and queue sharing with post-login redirects; support shared playlist links and highlighted shared tracks. Add local synchronized playback for jams, constrain HTTP metrics to known routes, and refine mobile player controls/layout.
This commit is contained in:
+38
@@ -37,6 +37,7 @@ impl Role {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const SESSION_USER_ID: &str = "user_id";
|
const SESSION_USER_ID: &str = "user_id";
|
||||||
|
const SESSION_POST_LOGIN_REDIRECT: &str = "post_login_redirect";
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct AuthenticatedUser {
|
pub struct AuthenticatedUser {
|
||||||
@@ -103,6 +104,43 @@ pub async fn login(session: &Session, user_id: i64) -> cot::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn remember_post_login_redirect(session: &Session, location: &str) -> cot::Result<()> {
|
||||||
|
if let Some(location) = safe_internal_redirect(location) {
|
||||||
|
session
|
||||||
|
.insert(SESSION_POST_LOGIN_REDIRECT, location)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_post_login_redirect(session: &Session) -> cot::Result<Option<String>> {
|
||||||
|
let location: Option<String> = session
|
||||||
|
.get(SESSION_POST_LOGIN_REDIRECT)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
Ok(location.and_then(|value| safe_internal_redirect(&value)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn clear_post_login_redirect(session: &Session) -> cot::Result<()> {
|
||||||
|
let _: Option<String> = session
|
||||||
|
.remove(SESSION_POST_LOGIN_REDIRECT)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn safe_internal_redirect(location: &str) -> Option<String> {
|
||||||
|
let location = location.trim();
|
||||||
|
if !location.starts_with('/') || location.starts_with("//") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if location.bytes().any(|b| matches!(b, b'\r' | b'\n')) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(location.chars().take(2048).collect())
|
||||||
|
}
|
||||||
|
|
||||||
/// Flush (destroy) the session.
|
/// Flush (destroy) the session.
|
||||||
pub async fn logout(session: &Session) -> cot::Result<()> {
|
pub async fn logout(session: &Session) -> cot::Result<()> {
|
||||||
session
|
session
|
||||||
|
|||||||
@@ -358,6 +358,11 @@ translations! {
|
|||||||
player_add_to_queue: "Add to queue" , "Добавить в очередь";
|
player_add_to_queue: "Add to queue" , "Добавить в очередь";
|
||||||
player_add_to_end_queue: "Add to end of queue" , "Добавить в конец очереди";
|
player_add_to_end_queue: "Add to end of queue" , "Добавить в конец очереди";
|
||||||
player_play_next: "Play next" , "Играть следующим";
|
player_play_next: "Play next" , "Играть следующим";
|
||||||
|
player_share: "Share" , "Поделиться";
|
||||||
|
player_share_track: "Share track" , "Поделиться треком";
|
||||||
|
player_share_queue: "Share queue" , "Поделиться очередью";
|
||||||
|
player_shared_playlist: "Shared playlist" , "Общий плейлист";
|
||||||
|
player_jam_play_on_this_device: "Play on this device" , "Играть на этом устройстве";
|
||||||
player_queue: "Queue" , "Очередь";
|
player_queue: "Queue" , "Очередь";
|
||||||
player_next: "Next" , "Далее";
|
player_next: "Next" , "Далее";
|
||||||
player_previous: "Previous" , "Назад";
|
player_previous: "Previous" , "Назад";
|
||||||
|
|||||||
+137
-13
@@ -29,7 +29,7 @@ use cot::form::{Form, FormResult};
|
|||||||
use cot::html::Html;
|
use cot::html::Html;
|
||||||
use cot::middleware::SessionMiddleware;
|
use cot::middleware::SessionMiddleware;
|
||||||
use cot::project::RegisterAppsContext;
|
use cot::project::RegisterAppsContext;
|
||||||
use cot::request::extractors::{RequestForm, UrlQuery};
|
use cot::request::extractors::{Path, RequestForm, UrlQuery};
|
||||||
use cot::response::IntoResponse;
|
use cot::response::IntoResponse;
|
||||||
use cot::router::method::get;
|
use cot::router::method::get;
|
||||||
use cot::router::{Route, Router};
|
use cot::router::{Route, Router};
|
||||||
@@ -63,15 +63,55 @@ fn build_registry() -> Arc<JobRegistry> {
|
|||||||
// Handlers
|
// Handlers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async fn index(session: Session, db: Database, i18n: I18n) -> cot::Result<cot::response::Response> {
|
#[derive(Deserialize)]
|
||||||
|
struct IndexQuery {
|
||||||
|
track: Option<i64>,
|
||||||
|
release: Option<i64>,
|
||||||
|
playlist_share: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn index(
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
i18n: I18n,
|
||||||
|
UrlQuery(query): UrlQuery<IndexQuery>,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
let _user = match auth::get_session_user(&session, &db).await {
|
let _user = match auth::get_session_user(&session, &db).await {
|
||||||
Some(u) => u,
|
Some(u) => u,
|
||||||
None => return Ok(auth::redirect("/login")),
|
None => {
|
||||||
|
if let Some(location) = share_query_redirect(&query) {
|
||||||
|
auth::remember_post_login_redirect(&session, &location).await?;
|
||||||
|
}
|
||||||
|
return Ok(auth::redirect("/login"));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let template = player::PlayerPageTemplate { t: i18n.t };
|
let template = player::PlayerPageTemplate { t: i18n.t };
|
||||||
Html::new(template.render()?).into_response()
|
Html::new(template.render()?).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn share_query_redirect(query: &IndexQuery) -> Option<String> {
|
||||||
|
if let Some(track_id) = query.track.filter(|id| *id > 0) {
|
||||||
|
return Some(format!("/?track={track_id}"));
|
||||||
|
}
|
||||||
|
if let Some(release_id) = query.release.filter(|id| *id > 0) {
|
||||||
|
return Some(format!("/?release={release_id}"));
|
||||||
|
}
|
||||||
|
let token = query.playlist_share.as_deref()?.trim();
|
||||||
|
if is_share_token(token) {
|
||||||
|
Some(format!("/?playlist_share={token}"))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_share_token(token: &str) -> bool {
|
||||||
|
!token.is_empty()
|
||||||
|
&& token.len() <= 64
|
||||||
|
&& token
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_'))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct SetLangQuery {
|
struct SetLangQuery {
|
||||||
lang: String,
|
lang: String,
|
||||||
@@ -131,6 +171,21 @@ struct LoginForm {
|
|||||||
password: String,
|
password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct LoginQuery {
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct SharePathId {
|
||||||
|
id: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct SharePathToken {
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Logout
|
// Logout
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -168,6 +223,58 @@ async fn metrics_handler(
|
|||||||
.expect("valid response"))
|
.expect("valid response"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn share_track_handler(
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
Path(path): Path<SharePathId>,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let location = if path.id > 0 {
|
||||||
|
format!("/?track={}", path.id)
|
||||||
|
} else {
|
||||||
|
"/".to_string()
|
||||||
|
};
|
||||||
|
if auth::get_session_user(&session, &db).await.is_none() {
|
||||||
|
auth::remember_post_login_redirect(&session, &location).await?;
|
||||||
|
return Ok(auth::redirect("/login"));
|
||||||
|
}
|
||||||
|
Ok(auth::redirect(&location))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn share_release_handler(
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
Path(path): Path<SharePathId>,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let location = if path.id > 0 {
|
||||||
|
format!("/?release={}", path.id)
|
||||||
|
} else {
|
||||||
|
"/".to_string()
|
||||||
|
};
|
||||||
|
if auth::get_session_user(&session, &db).await.is_none() {
|
||||||
|
auth::remember_post_login_redirect(&session, &location).await?;
|
||||||
|
return Ok(auth::redirect("/login"));
|
||||||
|
}
|
||||||
|
Ok(auth::redirect(&location))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn share_playlist_handler(
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
Path(path): Path<SharePathToken>,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let token = path.token.trim();
|
||||||
|
let location = if is_share_token(token) {
|
||||||
|
format!("/?playlist_share={token}")
|
||||||
|
} else {
|
||||||
|
"/".to_string()
|
||||||
|
};
|
||||||
|
if auth::get_session_user(&session, &db).await.is_none() {
|
||||||
|
auth::remember_post_login_redirect(&session, &location).await?;
|
||||||
|
return Ok(auth::redirect("/login"));
|
||||||
|
}
|
||||||
|
Ok(auth::redirect(&location))
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// App
|
// App
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -196,11 +303,26 @@ impl App for FuruApp {
|
|||||||
),
|
),
|
||||||
Route::with_handler_and_name(
|
Route::with_handler_and_name(
|
||||||
"/",
|
"/",
|
||||||
|session: Session, db: Database, i18n: I18n| async move {
|
|session: Session, db: Database, i18n: I18n, query: UrlQuery<IndexQuery>| async move {
|
||||||
index(session, db, i18n).await
|
index(session, db, i18n, query).await
|
||||||
},
|
},
|
||||||
"index",
|
"index",
|
||||||
),
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/share/track/{id}",
|
||||||
|
get(share_track_handler),
|
||||||
|
"share_track",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/share/release/{id}",
|
||||||
|
get(share_release_handler),
|
||||||
|
"share_release",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/share/playlist/{token}",
|
||||||
|
get(share_playlist_handler),
|
||||||
|
"share_playlist",
|
||||||
|
),
|
||||||
Route::with_handler_and_name(
|
Route::with_handler_and_name(
|
||||||
"/metrics",
|
"/metrics",
|
||||||
get({
|
get({
|
||||||
@@ -218,14 +340,15 @@ impl App for FuruApp {
|
|||||||
"/login",
|
"/login",
|
||||||
get({
|
get({
|
||||||
let config = Arc::clone(&self.config);
|
let config = Arc::clone(&self.config);
|
||||||
move |i18n: I18n, db: Database| {
|
move |i18n: I18n, db: Database, query: UrlQuery<LoginQuery>| {
|
||||||
let config = Arc::clone(&config);
|
let config = Arc::clone(&config);
|
||||||
async move {
|
async move {
|
||||||
// No users at all → redirect to first-run setup
|
// No users at all → redirect to first-run setup
|
||||||
if User::count_all(&db).await.unwrap_or(0) == 0 {
|
if User::count_all(&db).await.unwrap_or(0) == 0 {
|
||||||
return Ok(auth::redirect("/admin/setup"));
|
return Ok(auth::redirect("/admin/setup"));
|
||||||
}
|
}
|
||||||
login_page_handler(i18n, &config, db, String::new())
|
let message = query.0.error.unwrap_or_default();
|
||||||
|
login_page_handler(i18n, &config, db, message)
|
||||||
.await?
|
.await?
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
@@ -263,23 +386,24 @@ impl App for FuruApp {
|
|||||||
match hash.verify(&password) {
|
match hash.verify(&password) {
|
||||||
PasswordVerificationResult::Ok
|
PasswordVerificationResult::Ok
|
||||||
| PasswordVerificationResult::OkObsolete(_) => {
|
| PasswordVerificationResult::OkObsolete(_) => {
|
||||||
|
let redirect_to =
|
||||||
|
auth::get_post_login_redirect(&session)
|
||||||
|
.await?
|
||||||
|
.unwrap_or_else(|| "/".to_string());
|
||||||
auth::login(&session, user.id_val()).await?;
|
auth::login(&session, user.id_val()).await?;
|
||||||
|
auth::clear_post_login_redirect(&session).await?;
|
||||||
metrics::record_auth_attempt(
|
metrics::record_auth_attempt(
|
||||||
"password", "success", "ok",
|
"password", "success", "ok",
|
||||||
);
|
);
|
||||||
metrics::record_session_created("password");
|
metrics::record_session_created("password");
|
||||||
return Ok(auth::redirect("/"));
|
return Ok(auth::redirect(&redirect_to));
|
||||||
}
|
}
|
||||||
PasswordVerificationResult::Invalid => {}
|
PasswordVerificationResult::Invalid => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
metrics::record_auth_attempt(
|
metrics::record_auth_attempt("password", "failure", "bad_credentials");
|
||||||
"password",
|
|
||||||
"failure",
|
|
||||||
"bad_credentials",
|
|
||||||
);
|
|
||||||
let msg = i18n.t.login_invalid.to_owned();
|
let msg = i18n.t.login_invalid.to_owned();
|
||||||
login_page_handler(i18n, &config, db, msg)
|
login_page_handler(i18n, &config, db, msg)
|
||||||
.await?
|
.await?
|
||||||
|
|||||||
+309
-50
@@ -6,11 +6,11 @@ use std::sync::{LazyLock, Mutex};
|
|||||||
use std::task::{Context, Poll};
|
use std::task::{Context, Poll};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use cot::http::header::CONTENT_LENGTH;
|
use cot::Error;
|
||||||
use cot::http::Method;
|
use cot::http::Method;
|
||||||
|
use cot::http::header::CONTENT_LENGTH;
|
||||||
use cot::request::Request;
|
use cot::request::Request;
|
||||||
use cot::response::Response;
|
use cot::response::Response;
|
||||||
use cot::Error;
|
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use tower::{Layer, Service};
|
use tower::{Layer, Service};
|
||||||
|
|
||||||
@@ -80,14 +80,15 @@ where
|
|||||||
|
|
||||||
fn call(&mut self, request: Request) -> Self::Future {
|
fn call(&mut self, request: Request) -> Self::Future {
|
||||||
let method = request.method().clone();
|
let method = request.method().clone();
|
||||||
let route = normalize_route(request.uri().path());
|
let route = known_http_route(request.uri().path()).map(str::to_owned);
|
||||||
let request_bytes = request
|
let request_bytes = request
|
||||||
.headers()
|
.headers()
|
||||||
.get(CONTENT_LENGTH)
|
.get(CONTENT_LENGTH)
|
||||||
.and_then(|value| value.to_str().ok())
|
.and_then(|value| value.to_str().ok())
|
||||||
.and_then(|value| value.parse::<f64>().ok())
|
.and_then(|value| value.parse::<f64>().ok())
|
||||||
.unwrap_or(0.0);
|
.unwrap_or(0.0);
|
||||||
let labels = http_labels(&method, &route, "in_flight");
|
if let Some(route) = &route {
|
||||||
|
let labels = http_labels(&method, route, "in_flight");
|
||||||
REGISTRY.inc_gauge("furumusic_http_in_flight_requests", labels, 1.0);
|
REGISTRY.inc_gauge("furumusic_http_in_flight_requests", labels, 1.0);
|
||||||
REGISTRY.inc_counter(
|
REGISTRY.inc_counter(
|
||||||
"furumusic_http_request_body_bytes_total",
|
"furumusic_http_request_body_bytes_total",
|
||||||
@@ -97,11 +98,15 @@ where
|
|||||||
],
|
],
|
||||||
request_bytes,
|
request_bytes,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
let fut = self.inner.call(request);
|
let fut = self.inner.call(request);
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
let result = fut.await;
|
let result = fut.await;
|
||||||
|
let Some(route) = route else {
|
||||||
|
return result;
|
||||||
|
};
|
||||||
let elapsed = start.elapsed().as_secs_f64();
|
let elapsed = start.elapsed().as_secs_f64();
|
||||||
REGISTRY.inc_gauge(
|
REGISTRY.inc_gauge(
|
||||||
"furumusic_http_in_flight_requests",
|
"furumusic_http_in_flight_requests",
|
||||||
@@ -236,8 +241,17 @@ pub fn record_agent_discover_run(outcome: &'static str, duration: Duration) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn record_agent_discover_files(seen: u64, queued: u64, skipped_hash: u64, skipped_existing: u64) {
|
pub fn record_agent_discover_files(
|
||||||
REGISTRY.inc_counter("furumusic_agent_discover_files_seen_total", Vec::new(), seen as f64);
|
seen: u64,
|
||||||
|
queued: u64,
|
||||||
|
skipped_hash: u64,
|
||||||
|
skipped_existing: u64,
|
||||||
|
) {
|
||||||
|
REGISTRY.inc_counter(
|
||||||
|
"furumusic_agent_discover_files_seen_total",
|
||||||
|
Vec::new(),
|
||||||
|
seen as f64,
|
||||||
|
);
|
||||||
REGISTRY.inc_counter(
|
REGISTRY.inc_counter(
|
||||||
"furumusic_agent_discover_files_queued_total",
|
"furumusic_agent_discover_files_queued_total",
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
@@ -340,18 +354,12 @@ pub fn record_agent_llm(
|
|||||||
let model = normalize_model_label(model);
|
let model = normalize_model_label(model);
|
||||||
REGISTRY.inc_counter(
|
REGISTRY.inc_counter(
|
||||||
"furumusic_agent_llm_requests_total",
|
"furumusic_agent_llm_requests_total",
|
||||||
vec![
|
vec![("model", model.clone()), ("outcome", outcome.to_owned())],
|
||||||
("model", model.clone()),
|
|
||||||
("outcome", outcome.to_owned()),
|
|
||||||
],
|
|
||||||
1.0,
|
1.0,
|
||||||
);
|
);
|
||||||
REGISTRY.observe_histogram(
|
REGISTRY.observe_histogram(
|
||||||
"furumusic_agent_llm_duration_seconds",
|
"furumusic_agent_llm_duration_seconds",
|
||||||
vec![
|
vec![("model", model.clone()), ("outcome", outcome.to_owned())],
|
||||||
("model", model.clone()),
|
|
||||||
("outcome", outcome.to_owned()),
|
|
||||||
],
|
|
||||||
duration.as_secs_f64(),
|
duration.as_secs_f64(),
|
||||||
JOB_BUCKETS,
|
JOB_BUCKETS,
|
||||||
);
|
);
|
||||||
@@ -362,10 +370,7 @@ pub fn record_agent_llm(
|
|||||||
);
|
);
|
||||||
REGISTRY.inc_counter(
|
REGISTRY.inc_counter(
|
||||||
"furumusic_agent_llm_tokens_total",
|
"furumusic_agent_llm_tokens_total",
|
||||||
vec![
|
vec![("model", model.clone()), ("type", "completion".to_owned())],
|
||||||
("model", model.clone()),
|
|
||||||
("type", "completion".to_owned()),
|
|
||||||
],
|
|
||||||
completion_tokens as f64,
|
completion_tokens as f64,
|
||||||
);
|
);
|
||||||
REGISTRY.observe_histogram(
|
REGISTRY.observe_histogram(
|
||||||
@@ -400,7 +405,12 @@ pub fn record_agent_llm_parse_failure(model: &str) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn record_agent_rag(kind: &'static str, outcome: &'static str, duration: Duration, results: usize) {
|
pub fn record_agent_rag(
|
||||||
|
kind: &'static str,
|
||||||
|
outcome: &'static str,
|
||||||
|
duration: Duration,
|
||||||
|
results: usize,
|
||||||
|
) {
|
||||||
REGISTRY.inc_counter(
|
REGISTRY.inc_counter(
|
||||||
"furumusic_agent_rag_queries_total",
|
"furumusic_agent_rag_queries_total",
|
||||||
vec![("kind", kind.to_owned()), ("outcome", outcome.to_owned())],
|
vec![("kind", kind.to_owned()), ("outcome", outcome.to_owned())],
|
||||||
@@ -423,7 +433,10 @@ pub fn record_agent_rag(kind: &'static str, outcome: &'static str, duration: Dur
|
|||||||
pub fn record_agent_cover_lookup(source: &'static str, outcome: &'static str, bytes: usize) {
|
pub fn record_agent_cover_lookup(source: &'static str, outcome: &'static str, bytes: usize) {
|
||||||
REGISTRY.inc_counter(
|
REGISTRY.inc_counter(
|
||||||
"furumusic_agent_cover_lookup_total",
|
"furumusic_agent_cover_lookup_total",
|
||||||
vec![("source", source.to_owned()), ("outcome", outcome.to_owned())],
|
vec![
|
||||||
|
("source", source.to_owned()),
|
||||||
|
("outcome", outcome.to_owned()),
|
||||||
|
],
|
||||||
1.0,
|
1.0,
|
||||||
);
|
);
|
||||||
REGISTRY.inc_counter(
|
REGISTRY.inc_counter(
|
||||||
@@ -433,7 +446,11 @@ pub fn record_agent_cover_lookup(source: &'static str, outcome: &'static str, by
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn record_agent_cover_variant(variant: &'static str, outcome: &'static str, duration: Duration) {
|
pub fn record_agent_cover_variant(
|
||||||
|
variant: &'static str,
|
||||||
|
outcome: &'static str,
|
||||||
|
duration: Duration,
|
||||||
|
) {
|
||||||
REGISTRY.inc_counter(
|
REGISTRY.inc_counter(
|
||||||
"furumusic_agent_cover_variant_generation_total",
|
"furumusic_agent_cover_variant_generation_total",
|
||||||
vec![
|
vec![
|
||||||
@@ -489,7 +506,12 @@ pub fn record_torrent_download(outcome: &'static str, selected_bytes: u64, durat
|
|||||||
|
|
||||||
pub async fn render(pool: &PgPool, config: &AppConfig) -> String {
|
pub async fn render(pool: &PgPool, config: &AppConfig) -> String {
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
emit_static_gauge(&mut out, "furumusic_build_info", &[("version", env!("CARGO_PKG_VERSION"))], 1.0);
|
emit_static_gauge(
|
||||||
|
&mut out,
|
||||||
|
"furumusic_build_info",
|
||||||
|
&[("version", env!("CARGO_PKG_VERSION"))],
|
||||||
|
1.0,
|
||||||
|
);
|
||||||
render_active_users(&mut out);
|
render_active_users(&mut out);
|
||||||
render_storage(&mut out, config);
|
render_storage(&mut out, config);
|
||||||
render_db_metrics(&mut out, pool).await;
|
render_db_metrics(&mut out, pool).await;
|
||||||
@@ -538,11 +560,42 @@ fn render_storage(out: &mut String, config: &AppConfig) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn render_db_metrics(out: &mut String, pool: &PgPool) {
|
async fn render_db_metrics(out: &mut String, pool: &PgPool) {
|
||||||
render_group_counts(out, pool, "furumusic_users_total", "SELECT role::text AS label, COUNT(*) AS count FROM furumusic__user GROUP BY role", "role").await;
|
render_group_counts(
|
||||||
render_single_count(out, pool, "furumusic_library_tracks_total", "SELECT COUNT(*) FROM furumusic__track").await;
|
out,
|
||||||
render_single_count(out, pool, "furumusic_library_releases_total", "SELECT COUNT(*) FROM furumusic__release").await;
|
pool,
|
||||||
render_single_count(out, pool, "furumusic_library_artists_total", "SELECT COUNT(*) FROM furumusic__artist").await;
|
"furumusic_users_total",
|
||||||
render_single_count(out, pool, "furumusic_library_playlists_total", "SELECT COUNT(*) FROM furumusic__playlist").await;
|
"SELECT role::text AS label, COUNT(*) AS count FROM furumusic__user GROUP BY role",
|
||||||
|
"role",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
render_single_count(
|
||||||
|
out,
|
||||||
|
pool,
|
||||||
|
"furumusic_library_tracks_total",
|
||||||
|
"SELECT COUNT(*) FROM furumusic__track",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
render_single_count(
|
||||||
|
out,
|
||||||
|
pool,
|
||||||
|
"furumusic_library_releases_total",
|
||||||
|
"SELECT COUNT(*) FROM furumusic__release",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
render_single_count(
|
||||||
|
out,
|
||||||
|
pool,
|
||||||
|
"furumusic_library_artists_total",
|
||||||
|
"SELECT COUNT(*) FROM furumusic__artist",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
render_single_count(
|
||||||
|
out,
|
||||||
|
pool,
|
||||||
|
"furumusic_library_playlists_total",
|
||||||
|
"SELECT COUNT(*) FROM furumusic__playlist",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
render_group_counts(out, pool, "furumusic_media_files_total", "SELECT file_type::text AS label, COUNT(*) AS count FROM furumusic__media_file GROUP BY file_type", "type").await;
|
render_group_counts(out, pool, "furumusic_media_files_total", "SELECT file_type::text AS label, COUNT(*) AS count FROM furumusic__media_file GROUP BY file_type", "type").await;
|
||||||
render_group_sums(out, pool, "furumusic_media_file_bytes_total", "SELECT file_type::text AS label, COALESCE(SUM(file_size_bytes), 0)::bigint AS value FROM furumusic__media_file GROUP BY file_type", "type").await;
|
render_group_sums(out, pool, "furumusic_media_file_bytes_total", "SELECT file_type::text AS label, COALESCE(SUM(file_size_bytes), 0)::bigint AS value FROM furumusic__media_file GROUP BY file_type", "type").await;
|
||||||
render_group_counts(out, pool, "furumusic_agent_reviews_total", "SELECT status::text AS label, COUNT(*) AS count FROM furumusic__pending_review GROUP BY status", "status").await;
|
render_group_counts(out, pool, "furumusic_agent_reviews_total", "SELECT status::text AS label, COUNT(*) AS count FROM furumusic__pending_review GROUP BY status", "status").await;
|
||||||
@@ -550,7 +603,13 @@ async fn render_db_metrics(out: &mut String, pool: &PgPool) {
|
|||||||
render_group_counts(out, pool, "furumusic_scheduler_job_running", "SELECT job_name::text AS label, COUNT(*) AS count FROM furumusic__job_run WHERE status = 'running' GROUP BY job_name", "job").await;
|
render_group_counts(out, pool, "furumusic_scheduler_job_running", "SELECT job_name::text AS label, COUNT(*) AS count FROM furumusic__job_run WHERE status = 'running' GROUP BY job_name", "job").await;
|
||||||
render_group_sums(out, pool, "furumusic_scheduler_job_enabled", "SELECT name::text AS label, (CASE WHEN enabled THEN 1 ELSE 0 END)::bigint AS value FROM furumusic__scheduled_job", "job").await;
|
render_group_sums(out, pool, "furumusic_scheduler_job_enabled", "SELECT name::text AS label, (CASE WHEN enabled THEN 1 ELSE 0 END)::bigint AS value FROM furumusic__scheduled_job", "job").await;
|
||||||
render_group_counts(out, pool, "furumusic_torrent_sessions_total", "SELECT status::text AS label, COUNT(*) AS count FROM furumusic__torrent_session GROUP BY status", "status").await;
|
render_group_counts(out, pool, "furumusic_torrent_sessions_total", "SELECT status::text AS label, COUNT(*) AS count FROM furumusic__torrent_session GROUP BY status", "status").await;
|
||||||
render_single_count(out, pool, "furumusic_play_history_total", "SELECT COUNT(*) FROM furumusic__play_history").await;
|
render_single_count(
|
||||||
|
out,
|
||||||
|
pool,
|
||||||
|
"furumusic_play_history_total",
|
||||||
|
"SELECT COUNT(*) FROM furumusic__play_history",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn render_single_count(out: &mut String, pool: &PgPool, metric: &'static str, sql: &str) {
|
async fn render_single_count(out: &mut String, pool: &PgPool, metric: &'static str, sql: &str) {
|
||||||
@@ -566,7 +625,10 @@ async fn render_group_counts(
|
|||||||
sql: &str,
|
sql: &str,
|
||||||
label_name: &'static str,
|
label_name: &'static str,
|
||||||
) {
|
) {
|
||||||
if let Ok(rows) = sqlx::query_as::<_, (String, i64)>(sql).fetch_all(pool).await {
|
if let Ok(rows) = sqlx::query_as::<_, (String, i64)>(sql)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
for (label, count) in rows {
|
for (label, count) in rows {
|
||||||
emit_static_gauge(out, metric, &[(label_name, label.as_str())], count as f64);
|
emit_static_gauge(out, metric, &[(label_name, label.as_str())], count as f64);
|
||||||
}
|
}
|
||||||
@@ -580,7 +642,10 @@ async fn render_group_sums(
|
|||||||
sql: &str,
|
sql: &str,
|
||||||
label_name: &'static str,
|
label_name: &'static str,
|
||||||
) {
|
) {
|
||||||
if let Ok(rows) = sqlx::query_as::<_, (String, i64)>(sql).fetch_all(pool).await {
|
if let Ok(rows) = sqlx::query_as::<_, (String, i64)>(sql)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
for (label, value) in rows {
|
for (label, value) in rows {
|
||||||
emit_static_gauge(out, metric, &[(label_name, label.as_str())], value as f64);
|
emit_static_gauge(out, metric, &[(label_name, label.as_str())], value as f64);
|
||||||
}
|
}
|
||||||
@@ -641,7 +706,12 @@ impl Registry {
|
|||||||
for (bucket, count) in state.buckets.iter().zip(state.counts.iter()) {
|
for (bucket, count) in state.buckets.iter().zip(state.counts.iter()) {
|
||||||
let mut labels = key.labels.clone();
|
let mut labels = key.labels.clone();
|
||||||
labels.push(("le", bucket.to_string()));
|
labels.push(("le", bucket.to_string()));
|
||||||
emit_metric(&mut out, &format!("{}_bucket", key.name), &labels, *count as f64);
|
emit_metric(
|
||||||
|
&mut out,
|
||||||
|
&format!("{}_bucket", key.name),
|
||||||
|
&labels,
|
||||||
|
*count as f64,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let mut inf_labels = key.labels.clone();
|
let mut inf_labels = key.labels.clone();
|
||||||
inf_labels.push(("le", "+Inf".to_owned()));
|
inf_labels.push(("le", "+Inf".to_owned()));
|
||||||
@@ -683,31 +753,220 @@ fn http_labels(method: &Method, route: &str, status: &str) -> Vec<(&'static str,
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_route(path: &str) -> String {
|
fn known_http_route(path: &str) -> Option<&'static str> {
|
||||||
let mut route = String::with_capacity(path.len());
|
let path = canonicalize_http_path(path);
|
||||||
for segment in path.split('/') {
|
KNOWN_HTTP_ROUTES
|
||||||
if segment.is_empty() {
|
.iter()
|
||||||
continue;
|
.copied()
|
||||||
|
.find(|pattern| route_pattern_matches(pattern, &path))
|
||||||
}
|
}
|
||||||
route.push('/');
|
|
||||||
if segment.parse::<i64>().is_ok() || looks_like_uuid(segment) {
|
fn canonicalize_http_path(path: &str) -> String {
|
||||||
route.push_str("{id}");
|
let without_trailing = path.trim_end_matches('/');
|
||||||
} else {
|
if without_trailing.is_empty() {
|
||||||
route.push_str(segment);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if route.is_empty() {
|
|
||||||
"/".to_owned()
|
"/".to_owned()
|
||||||
} else {
|
} else {
|
||||||
route
|
without_trailing.to_owned()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn looks_like_uuid(value: &str) -> bool {
|
fn route_pattern_matches(pattern: &str, path: &str) -> bool {
|
||||||
value.len() == 36
|
if pattern == "/" {
|
||||||
&& value
|
return path == "/";
|
||||||
.chars()
|
}
|
||||||
.all(|ch| ch.is_ascii_hexdigit() || ch == '-')
|
|
||||||
|
let mut pattern_segments = pattern.trim_start_matches('/').split('/');
|
||||||
|
let mut path_segments = path.trim_start_matches('/').split('/');
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match (pattern_segments.next(), path_segments.next()) {
|
||||||
|
(None, None) => return true,
|
||||||
|
(Some(pattern_segment), Some(path_segment)) => {
|
||||||
|
if path_segment.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if is_route_param(pattern_segment) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if pattern_segment != path_segment {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => return false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_route_param(segment: &str) -> bool {
|
||||||
|
segment.starts_with('{') && segment.ends_with('}')
|
||||||
|
}
|
||||||
|
|
||||||
|
const KNOWN_HTTP_ROUTES: &[&str] = &[
|
||||||
|
// Keep this allowlist in sync with Cot route declarations. Unknown paths are
|
||||||
|
// intentionally skipped so bot traffic cannot create high-cardinality labels.
|
||||||
|
"/",
|
||||||
|
"/admin",
|
||||||
|
"/swagger",
|
||||||
|
"/swagger/openapi.json",
|
||||||
|
"/share/track/{id}",
|
||||||
|
"/share/release/{id}",
|
||||||
|
"/share/playlist/{token}",
|
||||||
|
"/metrics",
|
||||||
|
"/login",
|
||||||
|
"/logout",
|
||||||
|
"/set-lang",
|
||||||
|
"/auth/oidc/start",
|
||||||
|
"/auth/oidc/callback",
|
||||||
|
"/api/me",
|
||||||
|
"/admin/setup",
|
||||||
|
"/admin/v2",
|
||||||
|
"/admin/v2/api/dashboard",
|
||||||
|
"/admin/v2/api/reviews",
|
||||||
|
"/admin/v2/api/reviews/bulk",
|
||||||
|
"/admin/v2/api/users",
|
||||||
|
"/admin/v2/api/users/{id}",
|
||||||
|
"/admin/v2/api/reviews/{id}/approve",
|
||||||
|
"/admin/v2/api/jobs",
|
||||||
|
"/admin/v2/api/jobs/metadata_backfill/run-options",
|
||||||
|
"/admin/v2/api/jobs/artwork_backfill/run-options",
|
||||||
|
"/admin/v2/api/jobs/{name}/run",
|
||||||
|
"/admin/v2/api/settings",
|
||||||
|
"/admin/v2/api/settings/probe",
|
||||||
|
"/admin/v2/api/jobs/{name}/toggle",
|
||||||
|
"/admin/v2/api/jobs/{name}/runs",
|
||||||
|
"/admin/v2/api/jobs/{name}/runs/{run_id}",
|
||||||
|
"/admin/v2/api/library",
|
||||||
|
"/admin/v2/api/library/item",
|
||||||
|
"/admin/v2/api/library/item/detail",
|
||||||
|
"/admin/v2/api/library/item/image",
|
||||||
|
"/admin/v2/api/library/item/upload-image",
|
||||||
|
"/admin/v2/api/library/bulk",
|
||||||
|
"/admin/debug",
|
||||||
|
"/admin/settings",
|
||||||
|
"/admin/settings/probe",
|
||||||
|
"/admin/users",
|
||||||
|
"/admin/users/new",
|
||||||
|
"/admin/users/{id}/edit",
|
||||||
|
"/admin/users/{id}/delete",
|
||||||
|
"/admin/artists",
|
||||||
|
"/admin/artists/new",
|
||||||
|
"/admin/artists/{id}/edit",
|
||||||
|
"/admin/artists/{id}/delete",
|
||||||
|
"/admin/artists/{id}/available-covers",
|
||||||
|
"/admin/artists/{id}/set-image",
|
||||||
|
"/admin/artists/{id}/upload-image",
|
||||||
|
"/admin/releases",
|
||||||
|
"/admin/releases/new",
|
||||||
|
"/admin/releases/{id}/edit",
|
||||||
|
"/admin/releases/{id}/delete",
|
||||||
|
"/admin/media-files",
|
||||||
|
"/admin/media-files/{id}/delete",
|
||||||
|
"/admin/jobs",
|
||||||
|
"/admin/jobs/metadata_backfill/run-options",
|
||||||
|
"/admin/jobs/{name}/run",
|
||||||
|
"/admin/jobs/{name}/toggle",
|
||||||
|
"/admin/jobs/{name}/cron",
|
||||||
|
"/admin/jobs/{name}/runs/{run_id}",
|
||||||
|
"/admin/jobs/{name}",
|
||||||
|
"/admin/reviews/clear",
|
||||||
|
"/admin/reviews/bulk",
|
||||||
|
"/admin/reviews",
|
||||||
|
"/admin/reviews/{id}",
|
||||||
|
"/admin/reviews/{id}/approve",
|
||||||
|
"/admin/reviews/{id}/reject",
|
||||||
|
"/admin/reviews/{id}/requeue",
|
||||||
|
"/api/player/me",
|
||||||
|
"/api/player/lastfm/status",
|
||||||
|
"/api/player/lastfm/connect",
|
||||||
|
"/api/player/lastfm/callback",
|
||||||
|
"/api/player/lastfm/disconnect",
|
||||||
|
"/api/player/lastfm/now-playing",
|
||||||
|
"/api/player/lastfm/scrobble",
|
||||||
|
"/api/player/agent-queue",
|
||||||
|
"/api/player/torrents",
|
||||||
|
"/api/player/torrents/session/{id}",
|
||||||
|
"/api/player/torrents/preview",
|
||||||
|
"/api/player/uploads/local",
|
||||||
|
"/api/player/uploads/tracks",
|
||||||
|
"/api/player/uploads/tracks/{track_id}",
|
||||||
|
"/api/player/uploads/bulk-tracks",
|
||||||
|
"/api/player/uploads/releases/{id}",
|
||||||
|
"/api/player/uploads/reviews/{id}",
|
||||||
|
"/api/player/uploads/reviews/{id}/approve",
|
||||||
|
"/api/player/torrents/{id}/start",
|
||||||
|
"/api/player/torrents/{id}/pause",
|
||||||
|
"/api/player/torrents/{id}/status",
|
||||||
|
"/api/player/artists",
|
||||||
|
"/api/player/artists/{id}",
|
||||||
|
"/api/player/releases/{id}",
|
||||||
|
"/api/player/radio/{kind}/{id}",
|
||||||
|
"/api/player/playlists",
|
||||||
|
"/api/player/share-playlist",
|
||||||
|
"/api/player/share-playlist/{id}",
|
||||||
|
"/api/player/playlists/{id}",
|
||||||
|
"/api/player/playlists/{id}/tracks",
|
||||||
|
"/api/player/likes",
|
||||||
|
"/api/player/likes/toggle/{track_id}",
|
||||||
|
"/api/player/likes/release/{id}",
|
||||||
|
"/api/player/follows",
|
||||||
|
"/api/player/follows/toggle/{id}",
|
||||||
|
"/api/player/stream/{track_id}",
|
||||||
|
"/api/player/cover/{media_file_id}/{variant}",
|
||||||
|
"/api/player/cover/{media_file_id}",
|
||||||
|
"/api/player/devices/heartbeat",
|
||||||
|
"/api/player/devices/poll",
|
||||||
|
"/api/player/devices/active",
|
||||||
|
"/api/player/devices/command",
|
||||||
|
"/api/player/jams/users",
|
||||||
|
"/api/player/jams",
|
||||||
|
"/api/player/jams/join",
|
||||||
|
"/api/player/jams/invite",
|
||||||
|
"/api/player/jams/leave",
|
||||||
|
"/api/player/state",
|
||||||
|
"/api/player/history",
|
||||||
|
"/api/player/search",
|
||||||
|
"/api/player/tracks-by-ids",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::known_http_route;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn known_http_route_matches_declared_dynamic_routes() {
|
||||||
|
assert_eq!(
|
||||||
|
known_http_route("/api/player/stream/42"),
|
||||||
|
Some("/api/player/stream/{track_id}")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
known_http_route("/admin/jobs/metadata_backfill/runs/123"),
|
||||||
|
Some("/admin/jobs/{name}/runs/{run_id}")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
known_http_route("/share/playlist/abcDEF123"),
|
||||||
|
Some("/share/playlist/{token}")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
known_http_route("/share/release/42"),
|
||||||
|
Some("/share/release/{id}")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn known_http_route_skips_unknown_bot_paths() {
|
||||||
|
assert_eq!(known_http_route("/wp-login.php"), None);
|
||||||
|
assert_eq!(
|
||||||
|
known_http_route("/api/player/not-a-real-endpoint/123"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(known_http_route("/static/random-bot-path.js"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn known_http_route_uses_stable_canonical_labels() {
|
||||||
|
assert_eq!(known_http_route("/admin/"), Some("/admin"));
|
||||||
|
assert_eq!(known_http_route("/login/"), Some("/login"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_model_label(value: &str) -> String {
|
fn normalize_model_label(value: &str) -> String {
|
||||||
|
|||||||
@@ -1910,6 +1910,47 @@ pub mod db_migrations {
|
|||||||
&[Operation::custom(create_external_metadata_ids).build()];
|
&[Operation::custom(create_external_metadata_ids).build()];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- M0037: Shared playlist snapshots ------------------------------------
|
||||||
|
|
||||||
|
#[cot::db::migrations::migration_op]
|
||||||
|
async fn create_playlist_share_links(
|
||||||
|
ctx: migrations::MigrationContext<'_>,
|
||||||
|
) -> cot::db::Result<()> {
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__playlist_share_link (
|
||||||
|
token VARCHAR(64) PRIMARY KEY,
|
||||||
|
creator_user_id BIGINT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
track_ids_json TEXT NOT NULL,
|
||||||
|
created_at VARCHAR(32) NOT NULL
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_playlist_share_link_creator
|
||||||
|
ON furumusic__playlist_share_link (creator_user_id, created_at DESC)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0037CreatePlaylistShareLinks;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0037CreatePlaylistShareLinks {
|
||||||
|
const APP_NAME: &'static str = "furumusic";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0037_create_playlist_share_links";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0036_create_external_metadata_ids",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] =
|
||||||
|
&[Operation::custom(create_playlist_share_links).build()];
|
||||||
|
}
|
||||||
|
|
||||||
pub const MIGRATIONS: &[&SyncDynMigration] = &[
|
pub const MIGRATIONS: &[&SyncDynMigration] = &[
|
||||||
&M0006CreateMediaFile,
|
&M0006CreateMediaFile,
|
||||||
&M0007CreateArtist,
|
&M0007CreateArtist,
|
||||||
@@ -1937,5 +1978,6 @@ pub mod db_migrations {
|
|||||||
&M0034CreateArtworkLookupState,
|
&M0034CreateArtworkLookupState,
|
||||||
&M0035CreateEntityGenreTags,
|
&M0035CreateEntityGenreTags,
|
||||||
&M0036CreateExternalMetadataIds,
|
&M0036CreateExternalMetadataIds,
|
||||||
|
&M0037CreatePlaylistShareLinks,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-1
@@ -430,8 +430,13 @@ pub async fn oidc_callback_handler(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let redirect_to = auth::get_post_login_redirect(&session)
|
||||||
|
.await?
|
||||||
|
.unwrap_or_else(|| "/".to_string());
|
||||||
|
|
||||||
// Log the user in.
|
// Log the user in.
|
||||||
auth::login(&session, user.id_val()).await?;
|
auth::login(&session, user.id_val()).await?;
|
||||||
|
auth::clear_post_login_redirect(&session).await?;
|
||||||
crate::metrics::record_auth_attempt("oidc", "success", "ok");
|
crate::metrics::record_auth_attempt("oidc", "success", "ok");
|
||||||
crate::metrics::record_session_created("oidc");
|
crate::metrics::record_session_created("oidc");
|
||||||
|
|
||||||
@@ -453,7 +458,7 @@ pub async fn oidc_callback_handler(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
Ok(auth::redirect("/"))
|
Ok(auth::redirect(&redirect_to))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -289,6 +289,19 @@ pub(super) struct PlaylistDetail {
|
|||||||
pub(super) tracks: Vec<TrackItem>,
|
pub(super) tracks: Vec<TrackItem>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct ShareLinkResponse {
|
||||||
|
pub(super) token: String,
|
||||||
|
pub(super) url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct PlaylistShareDetail {
|
||||||
|
pub(super) token: String,
|
||||||
|
pub(super) title: String,
|
||||||
|
pub(super) tracks: Vec<TrackItem>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
pub(super) struct SearchResults {
|
pub(super) struct SearchResults {
|
||||||
pub(super) artists: Vec<ArtistCard>,
|
pub(super) artists: Vec<ArtistCard>,
|
||||||
|
|||||||
+165
-2
@@ -3533,13 +3533,127 @@ async fn load_track_items_by_ids(pool: &sqlx::PgPool, ids: &[i64]) -> cot::Resul
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
let mut track_map: HashMap<i64, TrackItem> = build_track_items(tracks, pool)
|
let track_map: HashMap<i64, TrackItem> = build_track_items(tracks, pool)
|
||||||
.await?
|
.await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|track| (track.id, track))
|
.map(|track| (track.id, track))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Ok(ids.iter().filter_map(|id| track_map.remove(id)).collect())
|
Ok(ids
|
||||||
|
.iter()
|
||||||
|
.filter_map(|id| track_map.get(id).cloned())
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /api/player/share-playlist
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn create_playlist_share_handler(
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
Json(body): Json<CreatePlaylistShareRequest>,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let Some(user) = auth::get_session_user(&session, &db).await else {
|
||||||
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let ids: Vec<i64> = body
|
||||||
|
.track_ids
|
||||||
|
.into_iter()
|
||||||
|
.filter(|id| *id > 0)
|
||||||
|
.take(500)
|
||||||
|
.collect();
|
||||||
|
if ids.is_empty() {
|
||||||
|
return Ok(json_error(StatusCode::BAD_REQUEST, "playlist is empty"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let tracks = load_track_items_by_ids(pool, &ids).await?;
|
||||||
|
if tracks.is_empty() {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"playlist has no visible tracks",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let visible_ids: Vec<i64> = tracks.iter().map(|track| track.id).collect();
|
||||||
|
let track_ids_json =
|
||||||
|
serde_json::to_string(&visible_ids).map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
let title = body
|
||||||
|
.title
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or("Shared queue")
|
||||||
|
.chars()
|
||||||
|
.take(160)
|
||||||
|
.collect::<String>();
|
||||||
|
let token = uuid::Uuid::new_v4().simple().to_string();
|
||||||
|
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"INSERT INTO furumusic__playlist_share_link
|
||||||
|
(token, creator_user_id, title, track_ids_json, created_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)"#,
|
||||||
|
)
|
||||||
|
.bind(&token)
|
||||||
|
.bind(user.id)
|
||||||
|
.bind(&title)
|
||||||
|
.bind(&track_ids_json)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Json(ShareLinkResponse {
|
||||||
|
token: token.clone(),
|
||||||
|
url: format!("/share/playlist/{token}"),
|
||||||
|
})
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /api/player/share-playlist/{id}
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn playlist_share_detail_handler(
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
path: Path<PathStringId>,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let Some(_user) = auth::get_session_user(&session, &db).await else {
|
||||||
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let token = path.0.id.trim().to_string();
|
||||||
|
let share: Option<(String, String)> = sqlx::query_as(
|
||||||
|
r#"SELECT title::text, track_ids_json::text
|
||||||
|
FROM furumusic__playlist_share_link
|
||||||
|
WHERE token = $1"#,
|
||||||
|
)
|
||||||
|
.bind(&token)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let Some((title, track_ids_json)) = share else {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"shared playlist not found",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
let ids: Vec<i64> = serde_json::from_str(&track_ids_json).unwrap_or_default();
|
||||||
|
let tracks = load_track_items_by_ids(pool, &ids).await?;
|
||||||
|
|
||||||
|
Json(PlaylistShareDetail {
|
||||||
|
token,
|
||||||
|
title,
|
||||||
|
tracks,
|
||||||
|
})
|
||||||
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the virtual "Likes" playlist for a given user.
|
/// Return the virtual "Likes" playlist for a given user.
|
||||||
@@ -6840,6 +6954,55 @@ impl App for PlayerApp {
|
|||||||
}),
|
}),
|
||||||
"player_playlists",
|
"player_playlists",
|
||||||
),
|
),
|
||||||
|
// -- Shared playlist snapshots --
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/share-playlist",
|
||||||
|
post({
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
move |session: Session, db: Database, json: Json<CreatePlaylistShareRequest>| {
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
async move {
|
||||||
|
let pg_pool = pool
|
||||||
|
.get_or_init(|| async {
|
||||||
|
sqlx::postgres::PgPoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect(&pool_config.database_url)
|
||||||
|
.await
|
||||||
|
.expect("player pool")
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
create_playlist_share_handler(session, db, pg_pool, json).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
"player_share_playlist",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/share-playlist/{id}",
|
||||||
|
get({
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
move |session: Session, db: Database, path: Path<PathStringId>| {
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
async move {
|
||||||
|
let pg_pool = pool
|
||||||
|
.get_or_init(|| async {
|
||||||
|
sqlx::postgres::PgPoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect(&pool_config.database_url)
|
||||||
|
.await
|
||||||
|
.expect("player pool")
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
playlist_share_detail_handler(session, db, pg_pool, path).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
"player_share_playlist_detail",
|
||||||
|
),
|
||||||
// -- Playlist detail (get, update, delete) --
|
// -- Playlist detail (get, update, delete) --
|
||||||
Route::with_handler_and_name(
|
Route::with_handler_and_name(
|
||||||
"/playlists/{id}",
|
"/playlists/{id}",
|
||||||
|
|||||||
@@ -45,6 +45,12 @@ pub(super) struct RemoveTrackRequest {
|
|||||||
pub(super) track_id: i64,
|
pub(super) track_id: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct CreatePlaylistShareRequest {
|
||||||
|
pub(super) track_ids: Vec<i64>,
|
||||||
|
pub(super) title: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(super) struct PaginationQuery {
|
pub(super) struct PaginationQuery {
|
||||||
pub(super) page: Option<i32>,
|
pub(super) page: Option<i32>,
|
||||||
|
|||||||
@@ -722,6 +722,9 @@
|
|||||||
<button class="track-action-btn queue-insert-btn queue-end-btn" @click.stop="$store.queue.addToEnd([item.track])" title="{{ t.player_add_to_queue }}">
|
<button class="track-action-btn queue-insert-btn queue-end-btn" @click.stop="$store.queue.addToEnd([item.track])" title="{{ t.player_add_to_queue }}">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h14M5 18h7"/><path d="M17 15l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h14M5 18h7"/><path d="M17 15l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="track-action-btn track-share-btn" @click.stop="$store.sharing.copyTrack(item.track || item.track_id, $event.currentTarget)" title="{{ t.player_share_track }}" aria-label="{{ t.player_share_track }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 10.6l6.8-3.9M8.6 13.4l6.8 3.9"/></svg>
|
||||||
|
</button>
|
||||||
<button class="track-action-btn playlist-add-btn" @click.stop="$store.playlists.showPicker([item.track_id])" title="{{ t.player_add_to_playlist }}">
|
<button class="track-action-btn playlist-add-btn" @click.stop="$store.playlists.showPicker([item.track_id])" title="{{ t.player_add_to_playlist }}">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ const T = {
|
|||||||
lastfmDisconnectFailed: "{{ t.player_lastfm_disconnect_failed }}",
|
lastfmDisconnectFailed: "{{ t.player_lastfm_disconnect_failed }}",
|
||||||
startRadio: "{{ t.player_start_radio }}",
|
startRadio: "{{ t.player_start_radio }}",
|
||||||
radioFailed: "{{ t.player_radio_failed }}",
|
radioFailed: "{{ t.player_radio_failed }}",
|
||||||
|
share: "{{ t.player_share }}",
|
||||||
|
shareTrack: "{{ t.player_share_track }}",
|
||||||
|
shareQueue: "{{ t.player_share_queue }}",
|
||||||
|
sharedPlaylist: "{{ t.player_shared_playlist }}",
|
||||||
connectionLost: "{{ t.player_connection_lost }}",
|
connectionLost: "{{ t.player_connection_lost }}",
|
||||||
connectionLostDetail: "{{ t.player_connection_lost_detail }}",
|
connectionLostDetail: "{{ t.player_connection_lost_detail }}",
|
||||||
activeDevice: "{{ t.player_active_device }}",
|
activeDevice: "{{ t.player_active_device }}",
|
||||||
@@ -285,6 +289,204 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Alpine.store('sharing', {
|
||||||
|
_handledInitialLink: false,
|
||||||
|
sharedTrackId: null,
|
||||||
|
|
||||||
|
absoluteUrl(path) {
|
||||||
|
return new URL(path, window.location.origin).href;
|
||||||
|
},
|
||||||
|
|
||||||
|
async copyText(text) {
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard?.writeText && window.isSecureContext) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
textarea.value = text;
|
||||||
|
textarea.setAttribute('readonly', '');
|
||||||
|
textarea.style.position = 'fixed';
|
||||||
|
textarea.style.left = '-9999px';
|
||||||
|
textarea.style.top = '0';
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
let ok = false;
|
||||||
|
try {
|
||||||
|
ok = document.execCommand('copy');
|
||||||
|
} catch {}
|
||||||
|
textarea.remove();
|
||||||
|
return ok;
|
||||||
|
},
|
||||||
|
|
||||||
|
async copyUrl(path, trigger = null) {
|
||||||
|
const copied = await this.copyText(this.absoluteUrl(path));
|
||||||
|
this.flashTrigger(trigger, copied);
|
||||||
|
return copied;
|
||||||
|
},
|
||||||
|
|
||||||
|
copyTrack(track, trigger = null) {
|
||||||
|
const id = Number(track?.id || track);
|
||||||
|
if (!id) return;
|
||||||
|
this.copyUrl(`/share/track/${id}`, trigger);
|
||||||
|
},
|
||||||
|
|
||||||
|
async copyQueue(trigger = null) {
|
||||||
|
const queue = Alpine.store('queue');
|
||||||
|
return this.copyTracks(queue?.tracks || [], T.sharedPlaylist, trigger);
|
||||||
|
},
|
||||||
|
|
||||||
|
async copyRelease(release, trigger = null) {
|
||||||
|
const id = Number(release?.id || release || 0);
|
||||||
|
if (!id) return;
|
||||||
|
return this.copyUrl(`/share/release/${id}`, trigger);
|
||||||
|
},
|
||||||
|
|
||||||
|
async copyTracks(tracks, title = T.sharedPlaylist, trigger = null) {
|
||||||
|
const trackIds = (tracks || []).map(track => Number(track.id)).filter(Boolean);
|
||||||
|
if (!trackIds.length) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/player/share-playlist', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ track_ids: trackIds, title }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('share failed');
|
||||||
|
const data = await res.json();
|
||||||
|
await this.copyUrl(data.url || `/share/playlist/${data.token}`, trigger);
|
||||||
|
} catch (err) {
|
||||||
|
this.flashTrigger(trigger, false);
|
||||||
|
console.warn(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
flashTrigger(trigger, copied) {
|
||||||
|
if (!trigger) return;
|
||||||
|
const className = copied ? 'share-copy-flash' : 'share-copy-failed';
|
||||||
|
trigger.classList.remove('share-copy-flash', 'share-copy-failed');
|
||||||
|
void trigger.offsetWidth;
|
||||||
|
trigger.classList.add(className);
|
||||||
|
window.setTimeout(() => trigger.classList.remove(className), 720);
|
||||||
|
},
|
||||||
|
|
||||||
|
markSharedTrack(trackId) {
|
||||||
|
const id = Number(trackId || 0);
|
||||||
|
this.sharedTrackId = id > 0 ? id : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
isSharedTrack(track) {
|
||||||
|
const id = Number(track?.id || track || 0);
|
||||||
|
return id > 0 && id === Number(this.sharedTrackId || 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
focusSharedTrack(trackId) {
|
||||||
|
const run = () => this.scrollSharedTrackIntoView(trackId);
|
||||||
|
requestAnimationFrame(run);
|
||||||
|
setTimeout(run, 120);
|
||||||
|
setTimeout(run, 360);
|
||||||
|
setTimeout(run, 720);
|
||||||
|
},
|
||||||
|
|
||||||
|
scrollSharedTrackIntoView(trackId) {
|
||||||
|
const id = Number(trackId || 0);
|
||||||
|
if (!id) return false;
|
||||||
|
const row = document.querySelector(`#center-scroll [data-shared-track-id="${id}"]`);
|
||||||
|
const container = document.getElementById('center-scroll');
|
||||||
|
if (!row || !container) return false;
|
||||||
|
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
const rowRect = row.getBoundingClientRect();
|
||||||
|
const top = container.scrollTop
|
||||||
|
+ rowRect.top
|
||||||
|
- containerRect.top
|
||||||
|
- ((container.clientHeight - rowRect.height) / 2);
|
||||||
|
container.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
async handleInitialShareLinks() {
|
||||||
|
if (this._handledInitialLink) return;
|
||||||
|
this._handledInitialLink = true;
|
||||||
|
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const trackId = Number(params.get('track') || 0);
|
||||||
|
const releaseId = Number(params.get('release') || 0);
|
||||||
|
const playlistToken = (params.get('playlist_share') || '').trim();
|
||||||
|
|
||||||
|
if (trackId > 0) {
|
||||||
|
await this.openSharedTrack(trackId);
|
||||||
|
this._clearShareQuery();
|
||||||
|
} else if (releaseId > 0) {
|
||||||
|
await this.openSharedRelease(releaseId);
|
||||||
|
this._clearShareQuery();
|
||||||
|
} else if (playlistToken) {
|
||||||
|
await this.openSharedPlaylist(playlistToken);
|
||||||
|
this._clearShareQuery();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async openSharedTrack(trackId) {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/player/tracks-by-ids', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ids: [Number(trackId)] }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('track load failed');
|
||||||
|
const tracks = await res.json();
|
||||||
|
const track = Array.isArray(tracks) ? tracks[0] : null;
|
||||||
|
if (!track) return;
|
||||||
|
this.markSharedTrack(track.id);
|
||||||
|
if (track.release_id) {
|
||||||
|
await Alpine.store('library').openRelease(track.release_id, { focusSharedTrackId: track.id });
|
||||||
|
}
|
||||||
|
const queue = Alpine.store('queue');
|
||||||
|
queue.tracks = [track];
|
||||||
|
queue.currentIndex = 0;
|
||||||
|
Alpine.store('player')._playLocal(track, { paused: true });
|
||||||
|
this.focusSharedTrack(track.id);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async openSharedRelease(releaseId) {
|
||||||
|
const id = Number(releaseId || 0);
|
||||||
|
if (!id) return;
|
||||||
|
this.markSharedTrack(null);
|
||||||
|
await Alpine.store('library').openRelease(id);
|
||||||
|
},
|
||||||
|
|
||||||
|
async openSharedPlaylist(token) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/player/share-playlist/${encodeURIComponent(token)}`);
|
||||||
|
if (!res.ok) throw new Error('playlist load failed');
|
||||||
|
const data = await res.json();
|
||||||
|
const tracks = Array.isArray(data.tracks) ? data.tracks : [];
|
||||||
|
if (!tracks.length) return;
|
||||||
|
const queue = Alpine.store('queue');
|
||||||
|
queue.tracks = tracks;
|
||||||
|
queue.currentIndex = 0;
|
||||||
|
Alpine.store('player')._playLocal(tracks[0], { paused: true });
|
||||||
|
Alpine.store('library').showSharedPlaylist(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_clearShareQuery() {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.delete('track');
|
||||||
|
url.searchParams.delete('release');
|
||||||
|
url.searchParams.delete('playlist_share');
|
||||||
|
const query = url.searchParams.toString();
|
||||||
|
const next = `${url.pathname}${query ? '?' + query : ''}${window.location.hash}`;
|
||||||
|
history.replaceState({}, '', next);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// User store
|
// User store
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -510,11 +712,18 @@ document.addEventListener('alpine:init', () => {
|
|||||||
_playbackStartedAt: null,
|
_playbackStartedAt: null,
|
||||||
_listenedSeconds: 0,
|
_listenedSeconds: 0,
|
||||||
_lastAudioTime: 0,
|
_lastAudioTime: 0,
|
||||||
|
_localSourceTrackId: null,
|
||||||
_remoteExecuting: false,
|
_remoteExecuting: false,
|
||||||
_remoteStateBaseTime: 0,
|
_remoteStateBaseTime: 0,
|
||||||
_remoteStateReceivedAt: 0,
|
_remoteStateReceivedAt: 0,
|
||||||
_remoteStateTimer: null,
|
_remoteStateTimer: null,
|
||||||
|
|
||||||
|
_hasInitialShareLink() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
return Number(params.get('track') || 0) > 0
|
||||||
|
|| (params.get('playlist_share') || '').trim().length > 0;
|
||||||
|
},
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
audio.volume = this.volume;
|
audio.volume = this.volume;
|
||||||
|
|
||||||
@@ -528,6 +737,10 @@ document.addEventListener('alpine:init', () => {
|
|||||||
audio.addEventListener('ended', () => {
|
audio.addEventListener('ended', () => {
|
||||||
this._trackListenedDelta();
|
this._trackListenedDelta();
|
||||||
this._recordHistory(true);
|
this._recordHistory(true);
|
||||||
|
if (Alpine.store('devices')?.shouldPlayJamLocally()) {
|
||||||
|
this.isPlaying = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.next();
|
this.next();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -555,7 +768,9 @@ document.addEventListener('alpine:init', () => {
|
|||||||
}, 250);
|
}, 250);
|
||||||
|
|
||||||
// Restore state
|
// Restore state
|
||||||
|
if (!this._hasInitialShareLink()) {
|
||||||
this._restoreState();
|
this._restoreState();
|
||||||
|
}
|
||||||
|
|
||||||
// Save state on page unload
|
// Save state on page unload
|
||||||
window.addEventListener('beforeunload', () => {
|
window.addEventListener('beforeunload', () => {
|
||||||
@@ -567,6 +782,12 @@ document.addEventListener('alpine:init', () => {
|
|||||||
if (!track) return;
|
if (!track) return;
|
||||||
if (this._shouldSendRemote()) {
|
if (this._shouldSendRemote()) {
|
||||||
this._mirrorRemoteTrack(track, true, 0);
|
this._mirrorRemoteTrack(track, true, 0);
|
||||||
|
if (this._shouldMirrorRemoteLocally()) {
|
||||||
|
this._playLocal(track, {
|
||||||
|
position_seconds: 0,
|
||||||
|
paused: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
this._sendRemote('play_track', this._remotePlaybackPayload(track, {
|
this._sendRemote('play_track', this._remotePlaybackPayload(track, {
|
||||||
position_seconds: 0,
|
position_seconds: 0,
|
||||||
paused: false,
|
paused: false,
|
||||||
@@ -583,6 +804,12 @@ document.addEventListener('alpine:init', () => {
|
|||||||
const track = queue.tracks[idx];
|
const track = queue.tracks[idx];
|
||||||
if (this._shouldSendRemote()) {
|
if (this._shouldSendRemote()) {
|
||||||
this._mirrorRemoteTrack(track, true, 0);
|
this._mirrorRemoteTrack(track, true, 0);
|
||||||
|
if (this._shouldMirrorRemoteLocally()) {
|
||||||
|
this._playLocal(track, {
|
||||||
|
position_seconds: 0,
|
||||||
|
paused: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
this._sendRemote('play_from_index', this._remotePlaybackPayload(track, {
|
this._sendRemote('play_from_index', this._remotePlaybackPayload(track, {
|
||||||
index: idx,
|
index: idx,
|
||||||
position_seconds: 0,
|
position_seconds: 0,
|
||||||
@@ -595,6 +822,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
_playLocal(track, options = {}) {
|
_playLocal(track, options = {}) {
|
||||||
this.currentTrack = track;
|
this.currentTrack = track;
|
||||||
|
this._localSourceTrackId = track.id;
|
||||||
this._historyRecorded = false;
|
this._historyRecorded = false;
|
||||||
this._resetPlaybackTracking();
|
this._resetPlaybackTracking();
|
||||||
audio.src = track.stream_url;
|
audio.src = track.stream_url;
|
||||||
@@ -626,6 +854,9 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.isPlaying = false;
|
this.isPlaying = false;
|
||||||
this._remoteStateBaseTime = this.currentTime;
|
this._remoteStateBaseTime = this.currentTime;
|
||||||
this._remoteStateReceivedAt = Date.now();
|
this._remoteStateReceivedAt = Date.now();
|
||||||
|
if (this._shouldMirrorRemoteLocally()) {
|
||||||
|
this._pauseLocal();
|
||||||
|
}
|
||||||
this._sendRemote('pause');
|
this._sendRemote('pause');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -637,6 +868,9 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.isPlaying = true;
|
this.isPlaying = true;
|
||||||
this._remoteStateBaseTime = this.currentTime;
|
this._remoteStateBaseTime = this.currentTime;
|
||||||
this._remoteStateReceivedAt = Date.now();
|
this._remoteStateReceivedAt = Date.now();
|
||||||
|
if (this._shouldMirrorRemoteLocally()) {
|
||||||
|
audio.play().catch(() => {});
|
||||||
|
}
|
||||||
this._sendRemote('resume');
|
this._sendRemote('resume');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -656,6 +890,10 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.progress = this.duration > 0 ? (this.currentTime / this.duration) * 100 : 0;
|
this.progress = this.duration > 0 ? (this.currentTime / this.duration) * 100 : 0;
|
||||||
this._remoteStateBaseTime = nextTime;
|
this._remoteStateBaseTime = nextTime;
|
||||||
this._remoteStateReceivedAt = Date.now();
|
this._remoteStateReceivedAt = Date.now();
|
||||||
|
if (this._shouldMirrorRemoteLocally() && this._localSourceTrackId === this.currentTrack?.id) {
|
||||||
|
audio.currentTime = nextTime;
|
||||||
|
this._lastAudioTime = audio.currentTime || 0;
|
||||||
|
}
|
||||||
this._sendRemote('seek', { time: nextTime });
|
this._sendRemote('seek', { time: nextTime });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -673,13 +911,35 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
seekFromClick(event) {
|
seekFromClick(event) {
|
||||||
const bar = event.currentTarget;
|
const bar = event.currentTarget;
|
||||||
|
this._setProgressFromClientX(event.clientX, bar);
|
||||||
|
},
|
||||||
|
|
||||||
|
_setProgressFromClientX(clientX, bar) {
|
||||||
const rect = bar.getBoundingClientRect();
|
const rect = bar.getBoundingClientRect();
|
||||||
const pct = (event.clientX - rect.left) / rect.width;
|
const pct = rect.width > 0 ? Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) : 0;
|
||||||
if (this.duration > 0) {
|
if (this.duration > 0) {
|
||||||
this.seek(pct * this.duration);
|
this.seek(pct * this.duration);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
startProgressDrag(event) {
|
||||||
|
const bar = event.currentTarget;
|
||||||
|
this._setProgressFromClientX(event.clientX, bar);
|
||||||
|
bar.setPointerCapture?.(event.pointerId);
|
||||||
|
|
||||||
|
const move = (e) => {
|
||||||
|
this._setProgressFromClientX(e.clientX, bar);
|
||||||
|
};
|
||||||
|
const stop = () => {
|
||||||
|
window.removeEventListener('pointermove', move);
|
||||||
|
window.removeEventListener('pointerup', stop);
|
||||||
|
window.removeEventListener('pointercancel', stop);
|
||||||
|
};
|
||||||
|
window.addEventListener('pointermove', move);
|
||||||
|
window.addEventListener('pointerup', stop);
|
||||||
|
window.addEventListener('pointercancel', stop);
|
||||||
|
},
|
||||||
|
|
||||||
next() {
|
next() {
|
||||||
if (this._shouldSendRemote()) {
|
if (this._shouldSendRemote()) {
|
||||||
this._sendRemote('next', {
|
this._sendRemote('next', {
|
||||||
@@ -742,7 +1002,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
setVolume(v) {
|
setVolume(v) {
|
||||||
this._setVolumeLocal(v);
|
this._setVolumeLocal(v);
|
||||||
if (this._shouldSendRemote()) {
|
if (this._shouldSendRemote() && !this._shouldMirrorRemoteLocally()) {
|
||||||
this._sendRemote('set_volume', { volume: this.volume });
|
this._sendRemote('set_volume', { volume: this.volume });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -832,6 +1092,10 @@ document.addEventListener('alpine:init', () => {
|
|||||||
return !!devices && !this._remoteExecuting && !devices.isActive();
|
return !!devices && !this._remoteExecuting && !devices.isActive();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
_shouldMirrorRemoteLocally() {
|
||||||
|
return !!Alpine.store('devices')?.shouldPlayJamLocally();
|
||||||
|
},
|
||||||
|
|
||||||
_sendRemote(command, payload = {}) {
|
_sendRemote(command, payload = {}) {
|
||||||
const devices = Alpine.store('devices');
|
const devices = Alpine.store('devices');
|
||||||
if (!devices) return false;
|
if (!devices) return false;
|
||||||
@@ -906,8 +1170,57 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this._updateMediaSession();
|
this._updateMediaSession();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
_syncLocalPlaybackState(state) {
|
||||||
|
if (!state) return;
|
||||||
|
const queue = Alpine.store('queue');
|
||||||
|
const tracks = Array.isArray(state.tracks) ? state.tracks.filter(Boolean) : [];
|
||||||
|
if (queue && tracks.length > 0) {
|
||||||
|
queue.tracks = queue._tracksWithJamDefaults(tracks);
|
||||||
|
queue.currentIndex = Math.max(0, Math.min(Number(state.index || 0), queue.tracks.length - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
const track = state.track || queue?.tracks?.[queue.currentIndex] || null;
|
||||||
|
if (!track) return;
|
||||||
|
|
||||||
|
const desiredTime = Math.max(0, Number(state.position_seconds || 0));
|
||||||
|
const duration = Number(state.duration_seconds || track.duration_seconds || this.duration || 0);
|
||||||
|
this.currentTrack = track;
|
||||||
|
this.shuffle = !!state.shuffle;
|
||||||
|
this.repeatMode = state.repeat_mode || 'off';
|
||||||
|
this.duration = duration;
|
||||||
|
this._remoteStateBaseTime = desiredTime;
|
||||||
|
this._remoteStateReceivedAt = Date.now();
|
||||||
|
|
||||||
|
if (this._localSourceTrackId !== track.id) {
|
||||||
|
this._playLocal(track, {
|
||||||
|
position_seconds: desiredTime,
|
||||||
|
paused: !!state.paused,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const drift = Math.abs((audio.currentTime || 0) - desiredTime);
|
||||||
|
const driftLimit = state.paused ? 0.08 : 0.75;
|
||||||
|
if (drift > driftLimit) {
|
||||||
|
audio.currentTime = desiredTime;
|
||||||
|
this._lastAudioTime = audio.currentTime || 0;
|
||||||
|
}
|
||||||
|
if (state.paused) {
|
||||||
|
if (!audio.paused) audio.pause();
|
||||||
|
this.isPlaying = false;
|
||||||
|
} else if (audio.paused) {
|
||||||
|
audio.play().catch(() => {});
|
||||||
|
this.isPlaying = true;
|
||||||
|
} else {
|
||||||
|
this.isPlaying = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentTime = audio.currentTime || desiredTime;
|
||||||
|
this.progress = duration > 0 ? (this.currentTime / duration) * 100 : 0;
|
||||||
|
this._updateMediaSession();
|
||||||
|
},
|
||||||
|
|
||||||
_tickRemoteProgress(force = false) {
|
_tickRemoteProgress(force = false) {
|
||||||
if (this._isLocalPlaybackDevice() || !this.currentTrack) return;
|
if (this._isLocalPlaybackDevice() || this._shouldMirrorRemoteLocally() || !this.currentTrack) return;
|
||||||
if (!force && !this.isPlaying) return;
|
if (!force && !this.isPlaying) return;
|
||||||
let nextTime = Number(this._remoteStateBaseTime || 0);
|
let nextTime = Number(this._remoteStateBaseTime || 0);
|
||||||
if (this.isPlaying && this._remoteStateReceivedAt > 0) {
|
if (this.isPlaying && this._remoteStateReceivedAt > 0) {
|
||||||
@@ -1046,6 +1359,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
: tracks[idx];
|
: tracks[idx];
|
||||||
if (currentTrack) {
|
if (currentTrack) {
|
||||||
this.currentTrack = currentTrack;
|
this.currentTrack = currentTrack;
|
||||||
|
this._localSourceTrackId = currentTrack.id;
|
||||||
this._historyRecorded = false;
|
this._historyRecorded = false;
|
||||||
this._resetPlaybackTracking();
|
this._resetPlaybackTracking();
|
||||||
audio.src = currentTrack.stream_url;
|
audio.src = currentTrack.stream_url;
|
||||||
@@ -1177,6 +1491,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
jamUsers: [],
|
jamUsers: [],
|
||||||
jamSelectedUsers: [],
|
jamSelectedUsers: [],
|
||||||
jamSearching: false,
|
jamSearching: false,
|
||||||
|
jamLocalPlayback: false,
|
||||||
remoteHintVisible: false,
|
remoteHintVisible: false,
|
||||||
remoteHintDeviceName: '',
|
remoteHintDeviceName: '',
|
||||||
_remoteHintShown: false,
|
_remoteHintShown: false,
|
||||||
@@ -1184,6 +1499,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
_pollTimer: null,
|
_pollTimer: null,
|
||||||
_jamSearchTimer: null,
|
_jamSearchTimer: null,
|
||||||
_stateRefreshTick: 0,
|
_stateRefreshTick: 0,
|
||||||
|
_lastPlaybackState: null,
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this.id = this._ensureId();
|
this.id = this._ensureId();
|
||||||
@@ -1239,6 +1555,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
});
|
});
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
if (data.playback_state) this._lastPlaybackState = data.playback_state;
|
||||||
this._apply(data);
|
this._apply(data);
|
||||||
|
|
||||||
const player = Alpine.store('player');
|
const player = Alpine.store('player');
|
||||||
@@ -1247,7 +1564,11 @@ document.addEventListener('alpine:init', () => {
|
|||||||
}
|
}
|
||||||
if (player && !this.isActive()) {
|
if (player && !this.isActive()) {
|
||||||
if (data.playback_state) {
|
if (data.playback_state) {
|
||||||
|
if (this.shouldPlayJamLocally()) {
|
||||||
|
player._syncLocalPlaybackState(data.playback_state);
|
||||||
|
} else {
|
||||||
player._applyRemotePlaybackState(data.playback_state);
|
player._applyRemotePlaybackState(data.playback_state);
|
||||||
|
}
|
||||||
} else if (++this._stateRefreshTick % 8 === 0) {
|
} else if (++this._stateRefreshTick % 8 === 0) {
|
||||||
player._restoreState();
|
player._restoreState();
|
||||||
}
|
}
|
||||||
@@ -1257,9 +1578,11 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
_apply(data) {
|
_apply(data) {
|
||||||
const wasActive = this.isActive();
|
const wasActive = this.isActive();
|
||||||
|
const previousJamId = this.currentJamId;
|
||||||
this.activeDeviceId = data.active_device_id || null;
|
this.activeDeviceId = data.active_device_id || null;
|
||||||
this.devices = Array.isArray(data.devices) ? data.devices : [];
|
this.devices = Array.isArray(data.devices) ? data.devices : [];
|
||||||
this.jams = Array.isArray(data.jams) ? data.jams : [];
|
this.jams = Array.isArray(data.jams) ? data.jams : [];
|
||||||
|
if (data.playback_state) this._lastPlaybackState = data.playback_state;
|
||||||
if (data.current_jam_id) {
|
if (data.current_jam_id) {
|
||||||
this.currentJamId = data.current_jam_id;
|
this.currentJamId = data.current_jam_id;
|
||||||
sessionStorage.setItem('furu_player_jam_id', this.currentJamId);
|
sessionStorage.setItem('furu_player_jam_id', this.currentJamId);
|
||||||
@@ -1267,6 +1590,9 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.currentJamId = null;
|
this.currentJamId = null;
|
||||||
sessionStorage.removeItem('furu_player_jam_id');
|
sessionStorage.removeItem('furu_player_jam_id');
|
||||||
}
|
}
|
||||||
|
if (previousJamId !== this.currentJamId || !this.canPlayJamLocally()) {
|
||||||
|
this._setJamLocalPlayback(false, { pauseLocal: true });
|
||||||
|
}
|
||||||
if (wasActive && !this.isActive()) {
|
if (wasActive && !this.isActive()) {
|
||||||
Alpine.store('player')?._pauseLocal();
|
Alpine.store('player')?._pauseLocal();
|
||||||
}
|
}
|
||||||
@@ -1350,6 +1676,41 @@ document.addEventListener('alpine:init', () => {
|
|||||||
return !!jam && !jam.is_owner;
|
return !!jam && !jam.is_owner;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
canPlayJamLocally() {
|
||||||
|
const jam = this.selectedJam();
|
||||||
|
return !!jam && jam.is_member && !jam.is_owner && jam.host_device_online;
|
||||||
|
},
|
||||||
|
|
||||||
|
shouldPlayJamLocally() {
|
||||||
|
return this.jamLocalPlayback && this.canPlayJamLocally();
|
||||||
|
},
|
||||||
|
|
||||||
|
setJamLocalPlayback(enabled) {
|
||||||
|
this._setJamLocalPlayback(enabled, { pauseLocal: true });
|
||||||
|
if (!this.jamLocalPlayback) return;
|
||||||
|
|
||||||
|
const player = Alpine.store('player');
|
||||||
|
if (player && this._lastPlaybackState) {
|
||||||
|
player._syncLocalPlaybackState(this._lastPlaybackState);
|
||||||
|
} else if (player?.currentTrack) {
|
||||||
|
player._syncLocalPlaybackState(player._remotePlaybackPayload(player.currentTrack, {
|
||||||
|
position_seconds: player.currentTime || 0,
|
||||||
|
duration_seconds: player.duration || 0,
|
||||||
|
paused: !player.isPlaying,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
this.poll();
|
||||||
|
},
|
||||||
|
|
||||||
|
_setJamLocalPlayback(enabled, options = {}) {
|
||||||
|
const next = !!enabled && this.canPlayJamLocally();
|
||||||
|
const wasEnabled = this.jamLocalPlayback;
|
||||||
|
this.jamLocalPlayback = next;
|
||||||
|
if (wasEnabled && !next && options.pauseLocal) {
|
||||||
|
Alpine.store('player')?._pauseLocal();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
isActive() {
|
isActive() {
|
||||||
if (this.isControllingRemoteJam()) return false;
|
if (this.isControllingRemoteJam()) return false;
|
||||||
return !this.activeDeviceId || this.activeDeviceId === this.id;
|
return !this.activeDeviceId || this.activeDeviceId === this.id;
|
||||||
@@ -1555,6 +1916,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
this.currentJamId = jam.id;
|
this.currentJamId = jam.id;
|
||||||
sessionStorage.setItem('furu_player_jam_id', jam.id);
|
sessionStorage.setItem('furu_player_jam_id', jam.id);
|
||||||
|
if (data.playback_state) this._lastPlaybackState = data.playback_state;
|
||||||
this._apply(data);
|
this._apply(data);
|
||||||
Alpine.store('queue')?._ensureCurrentJamAttribution();
|
Alpine.store('queue')?._ensureCurrentJamAttribution();
|
||||||
this.open = false;
|
this.open = false;
|
||||||
@@ -1588,6 +1950,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.currentJamId = null;
|
this.currentJamId = null;
|
||||||
this.jamPanelOpen = false;
|
this.jamPanelOpen = false;
|
||||||
this.jamPanelJamId = null;
|
this.jamPanelJamId = null;
|
||||||
|
this._setJamLocalPlayback(false, { pauseLocal: true });
|
||||||
sessionStorage.removeItem('furu_player_jam_id');
|
sessionStorage.removeItem('furu_player_jam_id');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1881,6 +2244,9 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
// Navigate to initial hash (if any)
|
// Navigate to initial hash (if any)
|
||||||
this._navigateFromHash({ fromHash: true, restoreScroll: true });
|
this._navigateFromHash({ fromHash: true, restoreScroll: true });
|
||||||
|
this.$nextTick(() => {
|
||||||
|
Alpine.store('sharing')?.handleInitialShareLinks();
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
_setHash(hash) {
|
_setHash(hash) {
|
||||||
@@ -1957,7 +2323,11 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
_afterNavigation(options = {}) {
|
_afterNavigation(options = {}) {
|
||||||
if (options.restoreScroll) {
|
if (options.focusSharedTrackId) {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
Alpine.store('sharing')?.focusSharedTrack(options.focusSharedTrackId);
|
||||||
|
});
|
||||||
|
} else if (options.restoreScroll) {
|
||||||
this._restoreScrollPosition(this._activeHash);
|
this._restoreScrollPosition(this._activeHash);
|
||||||
} else {
|
} else {
|
||||||
this.$nextTick(() => { this._scrollToTop(); });
|
this.$nextTick(() => { this._scrollToTop(); });
|
||||||
@@ -2331,6 +2701,28 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this._afterNavigation(options);
|
this._afterNavigation(options);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
showSharedPlaylist(share, options = {}) {
|
||||||
|
this._saveScrollPosition(this._activeHash);
|
||||||
|
this.searchQuery = '';
|
||||||
|
this.searchResults = null;
|
||||||
|
this.currentArtist = null;
|
||||||
|
this.currentRelease = null;
|
||||||
|
this.currentPlaylist = {
|
||||||
|
id: 0,
|
||||||
|
title: share?.title || T.sharedPlaylist,
|
||||||
|
description: null,
|
||||||
|
is_own: false,
|
||||||
|
owner_name: null,
|
||||||
|
is_public: false,
|
||||||
|
is_saved: false,
|
||||||
|
kind: 'shared',
|
||||||
|
tracks: Array.isArray(share?.tracks) ? share.tracks : [],
|
||||||
|
};
|
||||||
|
this.view = 'playlist_detail';
|
||||||
|
this._previousView = 'artists';
|
||||||
|
this._afterNavigation(options);
|
||||||
|
},
|
||||||
|
|
||||||
async playRelease(releaseId) {
|
async playRelease(releaseId) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/player/releases/${releaseId}`);
|
const res = await fetch(`/api/player/releases/${releaseId}`);
|
||||||
|
|||||||
+50
-16
@@ -411,17 +411,6 @@
|
|||||||
<template x-if="!artist.image_url">
|
<template x-if="!artist.image_url">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
|
||||||
</template>
|
</template>
|
||||||
<button class="artist-follow-card-btn"
|
|
||||||
:class="{ followed: $store.follows.has(artist.id) }"
|
|
||||||
@click.stop="$store.follows.toggle(artist.id)"
|
|
||||||
:title="$store.follows.has(artist.id) ? '{{ t.player_unfollow_artist }}' : '{{ t.player_follow_artist }}'">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M16 21v-2a4 4 0 00-4-4H6a4 4 0 00-4 4v2"/>
|
|
||||||
<circle cx="9" cy="7" r="4"/>
|
|
||||||
<path x-show="!$store.follows.has(artist.id)" d="M19 8v6M16 11h6"/>
|
|
||||||
<path x-show="$store.follows.has(artist.id)" d="M16 11l2 2 4-5"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="search-artist-name" x-text="artist.name"></div>
|
<div class="search-artist-name" x-text="artist.name"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -504,6 +493,9 @@
|
|||||||
<button class="track-action-btn queue-insert-btn queue-end-btn" @click.stop="$store.queue.addToEnd([track])" title="{{ t.player_add_to_queue }}">
|
<button class="track-action-btn queue-insert-btn queue-end-btn" @click.stop="$store.queue.addToEnd([track])" title="{{ t.player_add_to_queue }}">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h14M5 18h7"/><path d="M17 15l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h14M5 18h7"/><path d="M17 15l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="track-action-btn track-share-btn" @click.stop="$store.sharing.copyTrack(track, $event.currentTarget)" title="{{ t.player_share_track }}" aria-label="{{ t.player_share_track }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 10.6l6.8-3.9M8.6 13.4l6.8 3.9"/></svg>
|
||||||
|
</button>
|
||||||
<button class="track-action-btn playlist-add-btn" @click.stop="$store.playlists.showPicker([track.id])" title="{{ t.player_add_to_playlist }}">
|
<button class="track-action-btn playlist-add-btn" @click.stop="$store.playlists.showPicker([track.id])" title="{{ t.player_add_to_playlist }}">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -725,6 +717,9 @@
|
|||||||
<button class="track-action-btn queue-insert-btn queue-end-btn" @click.stop="$store.queue.addToEnd([track])" title="{{ t.player_add_to_queue }}">
|
<button class="track-action-btn queue-insert-btn queue-end-btn" @click.stop="$store.queue.addToEnd([track])" title="{{ t.player_add_to_queue }}">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h14M5 18h7"/><path d="M17 15l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h14M5 18h7"/><path d="M17 15l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="track-action-btn track-share-btn" @click.stop="$store.sharing.copyTrack(track, $event.currentTarget)" title="{{ t.player_share_track }}" aria-label="{{ t.player_share_track }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 10.6l6.8-3.9M8.6 13.4l6.8 3.9"/></svg>
|
||||||
|
</button>
|
||||||
<button class="track-action-btn playlist-add-btn" @click.stop="$store.playlists.showPicker([track.id])" title="{{ t.player_add_to_playlist }}">
|
<button class="track-action-btn playlist-add-btn" @click.stop="$store.playlists.showPicker([track.id])" title="{{ t.player_add_to_playlist }}">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -780,6 +775,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="release-year" x-text="$store.library.currentRelease.year || ''"></div>
|
<div class="release-year" x-text="$store.library.currentRelease.year || ''"></div>
|
||||||
<div class="release-actions">
|
<div class="release-actions">
|
||||||
|
<div class="release-action-row release-meta-actions">
|
||||||
<button class="release-action-btn secondary"
|
<button class="release-action-btn secondary"
|
||||||
@click.stop="$store.library.openReleaseInfo($store.library.currentRelease)"
|
@click.stop="$store.library.openReleaseInfo($store.library.currentRelease)"
|
||||||
:title="$store.library.releaseInfo($store.library.currentRelease)"
|
:title="$store.library.releaseInfo($store.library.currentRelease)"
|
||||||
@@ -787,6 +783,15 @@
|
|||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
|
||||||
{{ t.player_info }}
|
{{ t.player_info }}
|
||||||
</button>
|
</button>
|
||||||
|
<button class="release-action-btn secondary release-share-btn"
|
||||||
|
@click.stop="$store.sharing.copyRelease($store.library.currentRelease, $event.currentTarget)"
|
||||||
|
title="{{ t.player_share }}"
|
||||||
|
aria-label="{{ t.player_share }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 10.6l6.8-3.9M8.6 13.4l6.8 3.9"/></svg>
|
||||||
|
{{ t.player_share }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="release-action-row release-playback-actions">
|
||||||
<button class="release-action-btn primary" @click="$store.queue.playRelease($store.library.currentRelease.tracks, 0)">
|
<button class="release-action-btn primary" @click="$store.queue.playRelease($store.library.currentRelease.tracks, 0)">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
||||||
{{ t.player_play }}
|
{{ t.player_play }}
|
||||||
@@ -802,6 +807,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<!-- Track list -->
|
<!-- Track list -->
|
||||||
<div class="track-list-header">
|
<div class="track-list-header">
|
||||||
<span>#</span>
|
<span>#</span>
|
||||||
@@ -812,7 +818,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<template x-for="(track, idx) in $store.library.currentRelease.tracks" :key="track.id">
|
<template x-for="(track, idx) in $store.library.currentRelease.tracks" :key="track.id">
|
||||||
<div class="track-row"
|
<div class="track-row"
|
||||||
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
|
:data-shared-track-id="track.id"
|
||||||
|
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id, 'shared-target': $store.sharing.isSharedTrack(track) }"
|
||||||
@dblclick="$store.queue.playRelease([track], 0)">
|
@dblclick="$store.queue.playRelease([track], 0)">
|
||||||
<span class="track-num" x-text="track.track_number || (idx + 1)"></span>
|
<span class="track-num" x-text="track.track_number || (idx + 1)"></span>
|
||||||
<div class="track-info">
|
<div class="track-info">
|
||||||
@@ -846,6 +853,9 @@
|
|||||||
<button class="track-action-btn queue-insert-btn queue-end-btn" @click.stop="$store.queue.addToEnd([track])" title="{{ t.player_add_to_queue }}">
|
<button class="track-action-btn queue-insert-btn queue-end-btn" @click.stop="$store.queue.addToEnd([track])" title="{{ t.player_add_to_queue }}">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h14M5 18h7"/><path d="M17 15l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h14M5 18h7"/><path d="M17 15l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="track-action-btn track-share-btn" @click.stop="$store.sharing.copyTrack(track, $event.currentTarget)" title="{{ t.player_share_track }}" aria-label="{{ t.player_share_track }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 10.6l6.8-3.9M8.6 13.4l6.8 3.9"/></svg>
|
||||||
|
</button>
|
||||||
<button class="track-action-btn playlist-add-btn" @click.stop="$store.playlists.showPicker([track.id])" title="{{ t.player_add_to_playlist }}">
|
<button class="track-action-btn playlist-add-btn" @click.stop="$store.playlists.showPicker([track.id])" title="{{ t.player_add_to_playlist }}">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -919,6 +929,9 @@
|
|||||||
<button class="track-action-btn queue-insert-btn queue-end-btn" @click.stop="$store.queue.addToEnd([track])" title="{{ t.player_add_to_queue }}">
|
<button class="track-action-btn queue-insert-btn queue-end-btn" @click.stop="$store.queue.addToEnd([track])" title="{{ t.player_add_to_queue }}">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h14M5 18h7"/><path d="M17 15l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h14M5 18h7"/><path d="M17 15l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="track-action-btn track-share-btn" @click.stop="$store.sharing.copyTrack(track, $event.currentTarget)" title="{{ t.player_share_track }}" aria-label="{{ t.player_share_track }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 10.6l6.8-3.9M8.6 13.4l6.8 3.9"/></svg>
|
||||||
|
</button>
|
||||||
<button class="track-action-btn playlist-add-btn" @click.stop="$store.playlists.showPicker([track.id])" title="{{ t.player_add_to_playlist }}">
|
<button class="track-action-btn playlist-add-btn" @click.stop="$store.playlists.showPicker([track.id])" title="{{ t.player_add_to_playlist }}">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -938,8 +951,13 @@
|
|||||||
<div class="queue-panel" :class="{ hidden: !$store.queue.visible }">
|
<div class="queue-panel" :class="{ hidden: !$store.queue.visible }">
|
||||||
<div class="queue-header">
|
<div class="queue-header">
|
||||||
<h3>{{ t.player_queue }}</h3>
|
<h3>{{ t.player_queue }}</h3>
|
||||||
|
<div class="queue-header-actions">
|
||||||
|
<button class="queue-share-btn" @click="$store.sharing.copyQueue($event.currentTarget)" :disabled="$store.queue.tracks.length === 0" title="{{ t.player_share_queue }}" aria-label="{{ t.player_share_queue }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 10.6l6.8-3.9M8.6 13.4l6.8 3.9"/></svg>
|
||||||
|
</button>
|
||||||
<button class="queue-clear-btn" @click="$store.queue.clear()">{{ t.player_clear }}</button>
|
<button class="queue-clear-btn" @click="$store.queue.clear()">{{ t.player_clear }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="queue-tracks">
|
<div class="queue-tracks">
|
||||||
<template x-if="$store.queue.tracks.length === 0">
|
<template x-if="$store.queue.tracks.length === 0">
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
@@ -1015,7 +1033,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<div class="player-now-playing">
|
<div class="player-now-playing">
|
||||||
<template x-if="$store.player.currentTrack">
|
<template x-if="$store.player.currentTrack">
|
||||||
<div style="display:flex;align-items:center;gap:12px;overflow:hidden">
|
<div class="player-current-track">
|
||||||
<div class="player-cover"
|
<div class="player-cover"
|
||||||
@click.stop="$store.mobile.openPlayerFullscreen()">
|
@click.stop="$store.mobile.openPlayerFullscreen()">
|
||||||
<template x-if="$store.player.currentTrack.cover_url">
|
<template x-if="$store.player.currentTrack.cover_url">
|
||||||
@@ -1035,6 +1053,12 @@
|
|||||||
aria-label="{{ t.player_like }}">
|
aria-label="{{ t.player_like }}">
|
||||||
<svg viewBox="0 0 24 24" :fill="$store.likes.has($store.player.currentTrack.id) ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
|
<svg viewBox="0 0 24 24" :fill="$store.likes.has($store.player.currentTrack.id) ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="track-action-btn player-current-share"
|
||||||
|
@click.stop="$store.sharing.copyTrack($store.player.currentTrack, $event.currentTarget)"
|
||||||
|
title="{{ t.player_share_track }}"
|
||||||
|
aria-label="{{ t.player_share_track }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 10.6l6.8-3.9M8.6 13.4l6.8 3.9"/></svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="player-track-artist">
|
<div class="player-track-artist">
|
||||||
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks($store.player.currentTrack)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks($store.player.currentTrack)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
||||||
@@ -1060,7 +1084,7 @@
|
|||||||
<button class="player-btn" :class="{ active: $store.player.shuffle }" @click="$store.player.toggleShuffle()" title="{{ t.player_shuffle }}">
|
<button class="player-btn" :class="{ active: $store.player.shuffle }" @click="$store.player.toggleShuffle()" title="{{ t.player_shuffle }}">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="player-btn" @click="$store.player.prev()" title="{{ t.player_previous }}">
|
<button class="player-btn player-btn-prev" @click="$store.player.prev()" title="{{ t.player_previous }}">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="player-btn player-btn-play" @click="$store.player.toggle()">
|
<button class="player-btn player-btn-play" @click="$store.player.toggle()">
|
||||||
@@ -1071,7 +1095,7 @@
|
|||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 4h4v16H6zM14 4h4v16h-4z"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 4h4v16H6zM14 4h4v16h-4z"/></svg>
|
||||||
</template>
|
</template>
|
||||||
</button>
|
</button>
|
||||||
<button class="player-btn" @click="$store.player.next()" title="{{ t.player_next }}">
|
<button class="player-btn player-btn-next" @click="$store.player.next()" title="{{ t.player_next }}">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="player-btn" :class="{ active: $store.player.repeatMode !== 'off' }" @click="$store.player.cycleRepeat()" title="{{ t.player_repeat }}">
|
<button class="player-btn" :class="{ active: $store.player.repeatMode !== 'off' }" @click="$store.player.cycleRepeat()" title="{{ t.player_repeat }}">
|
||||||
@@ -1085,7 +1109,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="player-timeline">
|
<div class="player-timeline">
|
||||||
<span class="player-time" x-text="formatTime($store.player.currentTime)"></span>
|
<span class="player-time" x-text="formatTime($store.player.currentTime)"></span>
|
||||||
<div class="progress-bar" @click="$store.player.seekFromClick($event)">
|
<div class="progress-bar"
|
||||||
|
@pointerdown.prevent="$store.player.startProgressDrag($event)"
|
||||||
|
aria-label="{{ t.player_duration }}">
|
||||||
<div class="progress-bar-fill" :style="'width:' + $store.player.progress + '%'">
|
<div class="progress-bar-fill" :style="'width:' + $store.player.progress + '%'">
|
||||||
<div class="progress-bar-thumb"></div>
|
<div class="progress-bar-thumb"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1222,6 +1248,14 @@
|
|||||||
</button>
|
</button>
|
||||||
<div class="jam-create-panel" x-show="$store.devices.jamPanelOpen" x-transition x-cloak>
|
<div class="jam-create-panel" x-show="$store.devices.jamPanelOpen" x-transition x-cloak>
|
||||||
<div class="jam-panel-title" x-text="$store.devices.jamPanelMode === 'manage' ? 'Invite listeners' : 'Start Jam'"></div>
|
<div class="jam-panel-title" x-text="$store.devices.jamPanelMode === 'manage' ? 'Invite listeners' : 'Start Jam'"></div>
|
||||||
|
<label class="jam-local-playback-toggle"
|
||||||
|
x-show="$store.devices.jamPanelMode === 'manage' && $store.devices.canPlayJamLocally()"
|
||||||
|
x-cloak>
|
||||||
|
<input type="checkbox"
|
||||||
|
:checked="$store.devices.jamLocalPlayback"
|
||||||
|
@change="$store.devices.setJamLocalPlayback($event.target.checked)">
|
||||||
|
<span>{{ t.player_jam_play_on_this_device }}</span>
|
||||||
|
</label>
|
||||||
<div class="jam-selected-users" x-show="$store.devices.jamSelectedUsers.length > 0">
|
<div class="jam-selected-users" x-show="$store.devices.jamSelectedUsers.length > 0">
|
||||||
<template x-for="user in $store.devices.jamSelectedUsers" :key="user.id">
|
<template x-for="user in $store.devices.jamSelectedUsers" :key="user.id">
|
||||||
<button class="jam-user-chip" @click="$store.devices.removeJamInvitee(user.id)">
|
<button class="jam-user-chip" @click="$store.devices.removeJamInvitee(user.id)">
|
||||||
|
|||||||
+348
-45
@@ -735,6 +735,26 @@ button.user-stat:hover {
|
|||||||
.track-row:hover { background: var(--bg-hover); }
|
.track-row:hover { background: var(--bg-hover); }
|
||||||
.track-row.playing { color: var(--accent); }
|
.track-row.playing { color: var(--accent); }
|
||||||
.track-row.playing .track-num { color: var(--accent); }
|
.track-row.playing .track-num { color: var(--accent); }
|
||||||
|
.track-row.shared-target {
|
||||||
|
background: rgba(29, 185, 84, 0.12);
|
||||||
|
box-shadow: inset 3px 0 0 var(--accent);
|
||||||
|
animation: shared-track-pulse 1.2s ease;
|
||||||
|
}
|
||||||
|
.track-row.shared-target .track-num,
|
||||||
|
.track-row.shared-target .track-title {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shared-track-pulse {
|
||||||
|
0% {
|
||||||
|
background: rgba(29, 185, 84, 0.28);
|
||||||
|
box-shadow: inset 3px 0 0 var(--accent), 0 0 0 1px rgba(29, 185, 84, 0.18);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background: rgba(29, 185, 84, 0.12);
|
||||||
|
box-shadow: inset 3px 0 0 var(--accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.track-num {
|
.track-num {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -953,6 +973,14 @@ button.user-stat:hover {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-action-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.release-action-btn {
|
.release-action-btn {
|
||||||
@@ -1080,6 +1108,88 @@ button.user-stat:hover {
|
|||||||
|
|
||||||
.queue-header h3 { font-size: 14px; font-weight: 600; }
|
.queue-header h3 { font-size: 14px; font-weight: 600; }
|
||||||
|
|
||||||
|
.queue-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-share-btn {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-share-btn:hover:not(:disabled) {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-share-btn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-share-btn svg {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-share-btn.share-copy-flash,
|
||||||
|
.queue-share-btn.share-copy-flash,
|
||||||
|
.player-current-share.share-copy-flash,
|
||||||
|
.release-share-btn.share-copy-flash {
|
||||||
|
animation: share-copy-flash 0.72s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-share-btn.share-copy-failed,
|
||||||
|
.queue-share-btn.share-copy-failed,
|
||||||
|
.player-current-share.share-copy-failed,
|
||||||
|
.release-share-btn.share-copy-failed {
|
||||||
|
animation: share-copy-failed 0.72s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes share-copy-flash {
|
||||||
|
0% {
|
||||||
|
background: rgba(29,185,84,0.28);
|
||||||
|
border-color: rgba(29,185,84,0.52);
|
||||||
|
color: #c9ffd9;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
35% {
|
||||||
|
background: rgba(29,185,84,0.34);
|
||||||
|
border-color: rgba(29,185,84,0.72);
|
||||||
|
color: #d9ffe5;
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background: inherit;
|
||||||
|
border-color: inherit;
|
||||||
|
color: inherit;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes share-copy-failed {
|
||||||
|
0%, 100% {
|
||||||
|
color: inherit;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
35% {
|
||||||
|
background: rgba(229,96,96,0.2);
|
||||||
|
border-color: rgba(229,96,96,0.5);
|
||||||
|
color: #ffc7c7;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.queue-clear-btn {
|
.queue-clear-btn {
|
||||||
background: rgba(229, 96, 96, 0.13);
|
background: rgba(229, 96, 96, 0.13);
|
||||||
border: 1px solid rgba(229, 96, 96, 0.18);
|
border: 1px solid rgba(229, 96, 96, 0.18);
|
||||||
@@ -1239,6 +1349,13 @@ button.user-stat:hover {
|
|||||||
|
|
||||||
.player-now-playing > div { min-width: 0; }
|
.player-now-playing > div { min-width: 0; }
|
||||||
|
|
||||||
|
.player-current-track {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.player-cover {
|
.player-cover {
|
||||||
width: 56px;
|
width: 56px;
|
||||||
height: 56px;
|
height: 56px;
|
||||||
@@ -1282,6 +1399,18 @@ button.user-stat:hover {
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.player-current-share {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 4px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-current-share svg {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
.player-track-artist {
|
.player-track-artist {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-subdued);
|
color: var(--text-subdued);
|
||||||
@@ -1373,24 +1502,44 @@ button.user-stat:hover {
|
|||||||
|
|
||||||
.progress-bar {
|
.progress-bar {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 4px;
|
height: 28px;
|
||||||
background: var(--bg-active);
|
background: transparent;
|
||||||
border-radius: 2px;
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
touch-action: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar:hover { height: 6px; }
|
.progress-bar::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 50%;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--bg-active);
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar:hover::before { height: 6px; }
|
||||||
|
|
||||||
.progress-bar-fill {
|
.progress-bar-fill {
|
||||||
height: 100%;
|
height: 4px;
|
||||||
background: var(--text-primary);
|
background: var(--text-primary);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
pointer-events: none;
|
||||||
transition: width 0.1s linear;
|
transition: width 0.1s linear;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar:hover .progress-bar-fill { background: var(--accent); }
|
.progress-bar:hover .progress-bar-fill {
|
||||||
|
height: 6px;
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.progress-bar-thumb {
|
.progress-bar-thumb {
|
||||||
width: 12px;
|
width: 12px;
|
||||||
@@ -1405,7 +1554,8 @@ button.user-stat:hover {
|
|||||||
transition: opacity 0.15s;
|
transition: opacity 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar:hover .progress-bar-thumb { opacity: 1; }
|
.progress-bar:hover .progress-bar-thumb,
|
||||||
|
.progress-bar:active .progress-bar-thumb { opacity: 1; }
|
||||||
|
|
||||||
.player-right {
|
.player-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1722,6 +1872,34 @@ button.user-stat:hover {
|
|||||||
font-weight: 750;
|
font-weight: 750;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.jam-local-playback-toggle {
|
||||||
|
min-height: 30px;
|
||||||
|
margin: -1px 0 7px;
|
||||||
|
padding: 5px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(82,145,255,0.055);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jam-local-playback-toggle:hover {
|
||||||
|
background: rgba(82,145,255,0.085);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jam-local-playback-toggle input {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
margin: 0;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.jam-selected-users {
|
.jam-selected-users {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -3936,7 +4114,7 @@ button.user-stat:hover {
|
|||||||
|
|
||||||
@media (max-width: 900px), (pointer: coarse) and (max-width: 1024px) {
|
@media (max-width: 900px), (pointer: coarse) and (max-width: 1024px) {
|
||||||
:root {
|
:root {
|
||||||
--player-height: 168px;
|
--player-height: 214px;
|
||||||
--player-bar-space: calc(var(--player-height) + var(--safe-bottom));
|
--player-bar-space: calc(var(--player-height) + var(--safe-bottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4109,12 +4287,13 @@ button.user-stat:hover {
|
|||||||
|
|
||||||
.player-bar {
|
.player-bar {
|
||||||
position: relative;
|
position: relative;
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
grid-template-rows: 62px 58px;
|
grid-template-rows: 62px 58px 44px;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
"now now"
|
"now"
|
||||||
"buttons actions";
|
"buttons"
|
||||||
gap: 4px 10px;
|
"actions";
|
||||||
|
gap: 4px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 34px 12px calc(9px + var(--safe-bottom));
|
padding: 34px 12px calc(9px + var(--safe-bottom));
|
||||||
touch-action: auto;
|
touch-action: auto;
|
||||||
@@ -4123,15 +4302,19 @@ button.user-stat:hover {
|
|||||||
|
|
||||||
.player-now-playing {
|
.player-now-playing {
|
||||||
grid-area: now;
|
grid-area: now;
|
||||||
justify-content: center;
|
justify-content: stretch;
|
||||||
text-align: center;
|
text-align: left;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-now-playing > div {
|
.player-bar:not(.mobile-expanded) .player-current-track {
|
||||||
flex-direction: row;
|
display: grid;
|
||||||
justify-content: center;
|
grid-template-columns: 52px minmax(0, 1fr) 30px 30px;
|
||||||
gap: 10px !important;
|
grid-template-rows: repeat(3, auto);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: stretch;
|
||||||
|
column-gap: 10px;
|
||||||
|
row-gap: 1px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 620px;
|
max-width: 620px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -4144,23 +4327,64 @@ button.user-stat:hover {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-track-info {
|
.player-bar:not(.mobile-expanded) .player-track-info {
|
||||||
width: min(58vw, 360px);
|
display: contents;
|
||||||
max-width: 360px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-track-title-row {
|
.player-bar:not(.mobile-expanded) .player-track-title-row {
|
||||||
justify-content: center;
|
display: contents;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-current-like {
|
.player-bar:not(.mobile-expanded) .player-current-like,
|
||||||
display: none;
|
.player-bar:not(.mobile-expanded) .player-current-share {
|
||||||
|
display: flex;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
flex: 0 0 30px;
|
||||||
|
padding: 5px;
|
||||||
|
align-self: center;
|
||||||
|
justify-self: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-track-title,
|
.player-bar:not(.mobile-expanded) .player-current-like svg,
|
||||||
.player-track-artist,
|
.player-bar:not(.mobile-expanded) .player-current-share svg {
|
||||||
.player-track-release {
|
width: 17px;
|
||||||
text-align: center;
|
height: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-bar:not(.mobile-expanded) .player-track-title,
|
||||||
|
.player-bar:not(.mobile-expanded) .player-track-artist,
|
||||||
|
.player-bar:not(.mobile-expanded) .player-track-release {
|
||||||
|
grid-column: 2;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-bar:not(.mobile-expanded) .player-cover {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1 / span 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-bar:not(.mobile-expanded) .player-track-title {
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-bar:not(.mobile-expanded) .player-track-artist {
|
||||||
|
grid-row: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-bar:not(.mobile-expanded) .player-track-release {
|
||||||
|
grid-row: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-bar:not(.mobile-expanded) .player-current-share {
|
||||||
|
grid-column: 3;
|
||||||
|
grid-row: 1 / span 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-bar:not(.mobile-expanded) .player-current-like {
|
||||||
|
grid-column: 4;
|
||||||
|
grid-row: 1 / span 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-track-release {
|
.player-track-release {
|
||||||
@@ -4178,7 +4402,7 @@ button.user-stat:hover {
|
|||||||
|
|
||||||
.player-buttons {
|
.player-buttons {
|
||||||
grid-area: buttons;
|
grid-area: buttons;
|
||||||
justify-self: start;
|
justify-self: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4193,11 +4417,31 @@ button.user-stat:hover {
|
|||||||
padding: 7px;
|
padding: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.player-btn-prev {
|
||||||
|
min-width: 40px;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-btn-next {
|
||||||
|
min-width: 50px;
|
||||||
|
min-height: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
.player-btn svg {
|
.player-btn svg {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.player-bar:not(.mobile-expanded) .player-btn-prev svg {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-bar:not(.mobile-expanded) .player-btn-next svg {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
.player-btn-play {
|
.player-btn-play {
|
||||||
width: 56px;
|
width: 56px;
|
||||||
height: 56px;
|
height: 56px;
|
||||||
@@ -4238,7 +4482,12 @@ button.user-stat:hover {
|
|||||||
background: rgba(29, 185, 84, 0.18);
|
background: rgba(29, 185, 84, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.player-bar:not(.mobile-expanded) .progress-bar::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.player-bar:not(.mobile-expanded) .progress-bar-fill {
|
.player-bar:not(.mobile-expanded) .progress-bar-fill {
|
||||||
|
height: 100%;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
}
|
}
|
||||||
@@ -4267,8 +4516,8 @@ button.user-stat:hover {
|
|||||||
.player-version-chip {
|
.player-version-chip {
|
||||||
display: block;
|
display: block;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 12px;
|
right: 8px;
|
||||||
bottom: calc(12px + var(--safe-bottom));
|
bottom: calc(2px + var(--safe-bottom));
|
||||||
width: auto;
|
width: auto;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
@@ -4286,9 +4535,9 @@ button.user-stat:hover {
|
|||||||
|
|
||||||
.player-right {
|
.player-right {
|
||||||
grid-area: actions;
|
grid-area: actions;
|
||||||
justify-self: end;
|
justify-self: stretch;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(132px, 1fr) 40px 40px;
|
grid-template-columns: minmax(0, 1fr) 40px 40px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -4297,12 +4546,19 @@ button.user-stat:hover {
|
|||||||
|
|
||||||
.volume-control {
|
.volume-control {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 30px minmax(112px, 1fr);
|
grid-template-columns: 30px minmax(0, 1fr);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
justify-self: center;
|
||||||
|
width: min(80vw, 360px);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.player-right > .queue-toggle-btn,
|
||||||
|
.player-right > .device-picker {
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
.volume-btn {
|
.volume-btn {
|
||||||
min-width: 30px;
|
min-width: 30px;
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
@@ -4335,11 +4591,13 @@ button.user-stat:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar {
|
.progress-bar {
|
||||||
height: 6px;
|
height: 34px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.progress-bar::before,
|
||||||
.progress-bar-fill {
|
.progress-bar-fill {
|
||||||
|
height: 6px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4482,6 +4740,12 @@ button.user-stat:hover {
|
|||||||
height: 38px;
|
height: 38px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.player-bar.mobile-expanded .mobile-full-player .player-current-share {
|
||||||
|
display: flex;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
.player-bar.mobile-expanded .mobile-full-player .player-track-release {
|
.player-bar.mobile-expanded .mobile-full-player .player-track-release {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -4512,11 +4776,26 @@ button.user-stat:hover {
|
|||||||
min-height: 56px;
|
min-height: 56px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.player-bar.mobile-expanded .mobile-full-player .player-btn-prev {
|
||||||
|
min-width: 52px;
|
||||||
|
min-height: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-bar.mobile-expanded .mobile-full-player .player-btn-next {
|
||||||
|
min-width: 64px;
|
||||||
|
min-height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
.player-bar.mobile-expanded .mobile-full-player .player-btn svg {
|
.player-bar.mobile-expanded .mobile-full-player .player-btn svg {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.player-bar.mobile-expanded .mobile-full-player .player-btn-next svg {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
.player-bar.mobile-expanded .mobile-full-player .player-btn-play {
|
.player-bar.mobile-expanded .mobile-full-player .player-btn-play {
|
||||||
width: 72px;
|
width: 72px;
|
||||||
height: 72px;
|
height: 72px;
|
||||||
@@ -4532,10 +4811,15 @@ button.user-stat:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.player-bar.mobile-expanded .mobile-full-player .progress-bar {
|
.player-bar.mobile-expanded .mobile-full-player .progress-bar {
|
||||||
height: 7px;
|
height: 36px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.player-bar.mobile-expanded .mobile-full-player .progress-bar::before,
|
||||||
|
.player-bar.mobile-expanded .mobile-full-player .progress-bar-fill {
|
||||||
|
height: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
.player-bar.mobile-expanded .mobile-full-player .progress-bar-thumb {
|
.player-bar.mobile-expanded .mobile-full-player .progress-bar-thumb {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
@@ -4698,7 +4982,7 @@ button.user-stat:hover {
|
|||||||
|
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
:root {
|
:root {
|
||||||
--player-height: 170px;
|
--player-height: 214px;
|
||||||
--player-bar-space: calc(var(--player-height) + var(--safe-bottom));
|
--player-bar-space: calc(var(--player-height) + var(--safe-bottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4810,11 +5094,19 @@ button.user-stat:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.release-actions {
|
.release-actions {
|
||||||
flex-wrap: wrap;
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 7px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-action-row {
|
||||||
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.release-action-btn {
|
.release-action-btn {
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.track-list-header {
|
.track-list-header {
|
||||||
@@ -5172,7 +5464,7 @@ button.user-stat:hover {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding-left: 10px;
|
padding-left: 10px;
|
||||||
padding-right: 10px;
|
padding-right: 10px;
|
||||||
grid-template-rows: 60px 58px;
|
grid-template-rows: 60px 56px 42px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-track-title { font-size: 12px; }
|
.player-track-title { font-size: 12px; }
|
||||||
@@ -5181,14 +5473,15 @@ button.user-stat:hover {
|
|||||||
.player-buttons { gap: 2px; }
|
.player-buttons { gap: 2px; }
|
||||||
|
|
||||||
.player-right {
|
.player-right {
|
||||||
grid-template-columns: minmax(68px, 1fr) 34px 34px;
|
grid-template-columns: minmax(0, 1fr) 34px 34px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
gap: 3px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.volume-control {
|
.volume-control {
|
||||||
grid-template-columns: 22px minmax(44px, 1fr);
|
grid-template-columns: 22px minmax(0, 1fr);
|
||||||
gap: 3px;
|
width: min(80vw, 320px);
|
||||||
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-version-chip {
|
.player-version-chip {
|
||||||
@@ -5224,6 +5517,16 @@ button.user-stat:hover {
|
|||||||
padding: 7px;
|
padding: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.player-btn-prev {
|
||||||
|
min-width: 40px;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-btn-next {
|
||||||
|
min-width: 50px;
|
||||||
|
min-height: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
.player-btn svg {
|
.player-btn svg {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
|
|||||||
Reference in New Issue
Block a user