Reworked torrent UI
Build and Publish / Build and Publish Docker Image (push) Successful in 4m51s

This commit is contained in:
2026-05-26 12:55:11 +03:00
parent 4170ce269d
commit 16de1fb711
9 changed files with 6363 additions and 5229 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "furumusic"
version = "0.1.10"
version = "0.1.11"
edition = "2024"
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
+60
View File
@@ -1578,6 +1578,65 @@ pub mod db_migrations {
&[Operation::custom(add_media_file_uploader).build()];
}
// -- M0031: persistent torrent import sessions ---------------------------
#[cot::db::migrations::migration_op]
async fn create_torrent_session(ctx: migrations::MigrationContext<'_>) -> cot::db::Result<()> {
ctx.db
.raw(
"CREATE TABLE IF NOT EXISTS furumusic__torrent_session (
id VARCHAR(36) PRIMARY KEY,
user_id BIGINT NOT NULL,
name TEXT NOT NULL,
info_hash VARCHAR(80) NOT NULL,
source_kind VARCHAR(32) NOT NULL,
source_label TEXT,
torrent_bytes BYTEA NOT NULL,
files_json TEXT NOT NULL,
selected_files_json TEXT NOT NULL DEFAULT '[]',
status VARCHAR(32) NOT NULL,
total_size BIGINT NOT NULL DEFAULT 0,
selected_size BIGINT NOT NULL DEFAULT 0,
downloaded_bytes BIGINT NOT NULL DEFAULT 0,
uploaded_bytes BIGINT NOT NULL DEFAULT 0,
progress_percent DOUBLE PRECISION NOT NULL DEFAULT 0,
error TEXT,
created_at VARCHAR(32) NOT NULL,
updated_at VARCHAR(32) NOT NULL,
completed_at VARCHAR(32)
)",
)
.await?;
ctx.db
.raw(
"CREATE INDEX IF NOT EXISTS idx_torrent_session_user_updated
ON furumusic__torrent_session (user_id, updated_at DESC)",
)
.await?;
ctx.db
.raw(
"CREATE INDEX IF NOT EXISTS idx_torrent_session_user_status
ON furumusic__torrent_session (user_id, status)",
)
.await?;
Ok(())
}
#[derive(Debug, Copy, Clone)]
pub struct M0031CreateTorrentSession;
impl migrations::Migration for M0031CreateTorrentSession {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0031_create_torrent_session";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0030_add_media_file_uploader",
)];
const OPERATIONS: &'static [Operation] =
&[Operation::custom(create_torrent_session).build()];
}
pub const MIGRATIONS: &[&SyncDynMigration] = &[
&M0006CreateMediaFile,
&M0007CreateArtist,
@@ -1599,5 +1658,6 @@ pub mod db_migrations {
&M0028AddModelNameColumns,
&M0029AddPlaybackVolume,
&M0030AddMediaFileUploader,
&M0031CreateTorrentSession,
];
}
+177 -9
View File
@@ -2136,28 +2136,170 @@ impl App for PlayerApp {
),
// -- Torrent import widget --
Route::with_handler_and_name(
"/torrents/preview",
"/torrents",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
let torrent_service = Arc::clone(&torrent_service);
let scheduler_handle = Arc::clone(&self.scheduler_handle);
post(
move |session: Session, db: Database, json: Json<TorrentPreviewRequest>| {
get(move |session: Session, db: Database| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
let torrent_service = Arc::clone(&torrent_service);
let scheduler_handle = Arc::clone(&scheduler_handle);
async move {
let Some(user) = auth::get_session_user(&session, &db).await else {
return Ok(json_error(
StatusCode::UNAUTHORIZED,
"not authenticated",
));
};
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;
let service = torrent_service
.get_or_init(|| async {
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
})
.await;
match service.list(pg_pool, user.id).await {
Ok(items) => Json(items).into_response(),
Err(err) => {
Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string()))
}
}
}
})
},
"player_torrent_list",
),
Route::with_handler_and_name(
"/torrents/session/{id}",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
let torrent_service = Arc::clone(&torrent_service);
let scheduler_handle = Arc::clone(&self.scheduler_handle);
get({
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
let torrent_service = Arc::clone(&torrent_service);
let scheduler_handle = Arc::clone(&scheduler_handle);
move |session: Session, db: Database, path: Path<PathStringId>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
let torrent_service = Arc::clone(&torrent_service);
let scheduler_handle = Arc::clone(&scheduler_handle);
async move {
let Some(_user) = auth::get_session_user(&session, &db).await
else {
let Some(user) = auth::get_session_user(&session, &db).await else {
return Ok(json_error(
StatusCode::UNAUTHORIZED,
"not authenticated",
));
};
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;
let service = torrent_service
.get_or_init(|| async {
Arc::new(TorrentService::new(Arc::clone(
&scheduler_handle,
)))
})
.await;
match service.details(pg_pool, user.id, &path.0.id).await {
Ok(details) => Json(details).into_response(),
Err(err) => {
Ok(json_error(StatusCode::NOT_FOUND, &err.to_string()))
}
}
}
}
})
.delete(move |session: Session, db: Database, path: Path<PathStringId>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
let torrent_service = Arc::clone(&torrent_service);
let scheduler_handle = Arc::clone(&scheduler_handle);
async move {
let Some(user) = auth::get_session_user(&session, &db).await else {
return Ok(json_error(
StatusCode::UNAUTHORIZED,
"not authenticated",
));
};
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;
let service = torrent_service
.get_or_init(|| async {
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
})
.await;
match service.remove(pg_pool, user.id, &path.0.id).await {
Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
Err(err) => {
Ok(json_error(StatusCode::NOT_FOUND, &err.to_string()))
}
}
}
})
},
"player_torrent_detail",
),
Route::with_handler_and_name(
"/torrents/preview",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
let torrent_service = Arc::clone(&torrent_service);
let scheduler_handle = Arc::clone(&self.scheduler_handle);
post(
move |session: Session, db: Database, json: Json<TorrentPreviewRequest>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
let torrent_service = Arc::clone(&torrent_service);
let scheduler_handle = Arc::clone(&scheduler_handle);
async move {
let Some(user) = auth::get_session_user(&session, &db).await else {
return Ok(json_error(
StatusCode::UNAUTHORIZED,
"not authenticated",
));
};
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;
let service = torrent_service
.get_or_init(|| async {
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
})
.await;
match service.preview(json.0).await {
match service.preview(pg_pool, user.id, json.0).await {
Ok(preview) => Json(preview).into_response(),
Err(err) => {
Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string()))
@@ -2172,6 +2314,8 @@ impl App for PlayerApp {
Route::with_handler_and_name(
"/torrents/{id}/start",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
let torrent_service = Arc::clone(&torrent_service);
let scheduler_handle = Arc::clone(&self.scheduler_handle);
post(
@@ -2179,6 +2323,8 @@ impl App for PlayerApp {
db: Database,
path: Path<PathStringId>,
json: Json<TorrentStartRequest>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
let torrent_service = Arc::clone(&torrent_service);
let scheduler_handle = Arc::clone(&scheduler_handle);
async move {
@@ -2188,6 +2334,15 @@ impl App for PlayerApp {
"not authenticated",
));
};
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;
let (live_config, _) = AppConfig::load_with_db(&db).await;
let service = torrent_service
.get_or_init(|| async {
@@ -2196,6 +2351,7 @@ impl App for PlayerApp {
.await;
match service
.start(
pg_pool,
&path.0.id,
json.0.selected_files,
live_config.agent_inbox_dir,
@@ -2217,26 +2373,38 @@ impl App for PlayerApp {
Route::with_handler_and_name(
"/torrents/{id}/status",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
let torrent_service = Arc::clone(&torrent_service);
let scheduler_handle = Arc::clone(&self.scheduler_handle);
get(
move |session: Session, db: Database, path: Path<PathStringId>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
let torrent_service = Arc::clone(&torrent_service);
let scheduler_handle = Arc::clone(&scheduler_handle);
async move {
let Some(_user) = auth::get_session_user(&session, &db).await
else {
let Some(user) = auth::get_session_user(&session, &db).await else {
return Ok(json_error(
StatusCode::UNAUTHORIZED,
"not authenticated",
));
};
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;
let service = torrent_service
.get_or_init(|| async {
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
})
.await;
match service.status(&path.0.id).await {
match service.status(pg_pool, user.id, &path.0.id).await {
Ok(job) => Json(job).into_response(),
Err(err) => {
Ok(json_error(StatusCode::NOT_FOUND, &err.to_string()))
+596 -102
View File
@@ -9,14 +9,16 @@ use librqbit::{
AddTorrent, AddTorrentOptions, AddTorrentResponse, ManagedTorrent, Session, SessionOptions,
};
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, PgPool};
use tokio::sync::{Mutex, OnceCell};
use uuid::Uuid;
use crate::scheduler::SchedulerHandle;
const METADATA_TIMEOUT: Duration = Duration::from_secs(90);
const TORRENT_LIST_LIMIT: i64 = 100;
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TorrentFileDto {
pub index: usize,
pub name: String,
@@ -40,11 +42,29 @@ pub struct TorrentJobDto {
pub name: String,
pub info_hash: String,
pub status: String,
pub client_state: Option<String>,
pub total_size: u64,
pub selected_size: u64,
pub downloaded_bytes: u64,
pub uploaded_bytes: u64,
pub progress_percent: f64,
pub download_speed_mbps: Option<f64>,
pub upload_speed_mbps: Option<f64>,
pub peers_live: Option<usize>,
pub peers_seen: Option<usize>,
pub eta: Option<String>,
pub active: bool,
pub error: Option<String>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
pub completed_at: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct TorrentSessionDto {
pub job: TorrentJobDto,
pub preview: TorrentPreviewDto,
pub selected_files: Vec<usize>,
}
#[derive(Debug, Deserialize)]
@@ -54,11 +74,21 @@ pub enum TorrentPreviewKind {
TorrentFile,
}
impl TorrentPreviewKind {
fn as_str(&self) -> &'static str {
match self {
Self::Magnet => "magnet",
Self::TorrentFile => "torrent_file",
}
}
}
#[derive(Debug, Deserialize)]
pub struct TorrentPreviewRequest {
pub kind: TorrentPreviewKind,
pub magnet: Option<String>,
pub torrent_base64: Option<String>,
pub source_label: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -73,6 +103,7 @@ enum TorrentJobStatus {
Moving,
Complete,
Failed,
Paused,
}
impl TorrentJobStatus {
@@ -83,21 +114,165 @@ impl TorrentJobStatus {
Self::Moving => "moving",
Self::Complete => "complete",
Self::Failed => "failed",
Self::Paused => "paused",
}
}
fn from_str(value: &str) -> Self {
match value {
"downloading" => Self::Downloading,
"moving" => Self::Moving,
"complete" => Self::Complete,
"failed" => Self::Failed,
"paused" => Self::Paused,
_ => Self::Preview,
}
}
}
struct TorrentJob {
id: String,
user_id: i64,
name: String,
info_hash: String,
source_kind: String,
source_label: Option<String>,
torrent_bytes: Vec<u8>,
files: Vec<TorrentFileDto>,
status: TorrentJobStatus,
output_dir: PathBuf,
selected_files: Vec<usize>,
handle: Option<Arc<ManagedTorrent>>,
downloaded_bytes: u64,
uploaded_bytes: u64,
progress_percent: f64,
error: Option<String>,
created_at: String,
updated_at: String,
completed_at: Option<String>,
}
#[derive(Debug, FromRow)]
struct TorrentSessionRow {
id: String,
user_id: i64,
name: String,
info_hash: String,
source_kind: String,
source_label: Option<String>,
torrent_bytes: Vec<u8>,
files_json: String,
selected_files_json: String,
status: String,
total_size: i64,
selected_size: i64,
downloaded_bytes: i64,
uploaded_bytes: i64,
progress_percent: f64,
error: Option<String>,
created_at: String,
updated_at: String,
completed_at: Option<String>,
}
impl TorrentSessionRow {
fn files(&self) -> anyhow::Result<Vec<TorrentFileDto>> {
serde_json::from_str(&self.files_json).context("invalid torrent file list")
}
fn selected_files(&self) -> Vec<usize> {
serde_json::from_str(&self.selected_files_json).unwrap_or_default()
}
fn dto(&self, handle: Option<&Arc<ManagedTorrent>>) -> TorrentJobDto {
let active = handle.is_some();
let status = if active {
self.status.as_str()
} else if self.status == "downloading" || self.status == "moving" {
"paused"
} else {
self.status.as_str()
};
let stats = handle.map(|h| h.stats());
let downloaded_bytes = stats
.as_ref()
.map(|s| s.progress_bytes)
.unwrap_or_else(|| i64_to_u64(self.downloaded_bytes));
let uploaded_bytes = stats
.as_ref()
.map(|s| s.uploaded_bytes)
.unwrap_or_else(|| i64_to_u64(self.uploaded_bytes));
let total_bytes = stats
.as_ref()
.map(|s| s.total_bytes)
.filter(|v| *v > 0)
.unwrap_or_else(|| i64_to_u64(self.selected_size));
let progress_percent = progress_percent(downloaded_bytes, total_bytes)
.unwrap_or(self.progress_percent)
.clamp(0.0, 100.0);
let live = stats.as_ref().and_then(|s| s.live.as_ref());
let peer_stats = live.map(|l| &l.snapshot.peer_stats);
TorrentJobDto {
id: self.id.clone(),
name: self.name.clone(),
info_hash: self.info_hash.clone(),
status: status.to_string(),
client_state: stats.as_ref().map(|s| s.state.to_string()),
total_size: i64_to_u64(self.total_size),
selected_size: i64_to_u64(self.selected_size),
downloaded_bytes,
uploaded_bytes,
progress_percent,
download_speed_mbps: live.map(|l| l.download_speed.mbps),
upload_speed_mbps: live.map(|l| l.upload_speed.mbps),
peers_live: peer_stats.map(|p| p.live),
peers_seen: peer_stats.map(|p| p.seen),
eta: live.and_then(|l| l.time_remaining.as_ref().map(|eta| eta.to_string())),
active,
error: self.error.clone(),
created_at: Some(self.created_at.clone()),
updated_at: Some(self.updated_at.clone()),
completed_at: self.completed_at.clone(),
}
}
fn preview(&self) -> anyhow::Result<TorrentPreviewDto> {
Ok(TorrentPreviewDto {
id: self.id.clone(),
name: self.name.clone(),
info_hash: self.info_hash.clone(),
total_size: i64_to_u64(self.total_size),
files: self.files()?,
})
}
fn into_job(self, temp_root: &Path) -> anyhow::Result<TorrentJob> {
let id = self.id.clone();
let files = self.files()?;
let selected_files = self.selected_files();
Ok(TorrentJob {
id: id.clone(),
user_id: self.user_id,
name: self.name,
info_hash: self.info_hash,
source_kind: self.source_kind,
source_label: self.source_label,
torrent_bytes: self.torrent_bytes,
files,
status: TorrentJobStatus::from_str(&self.status),
output_dir: temp_root.join(&id).join("download"),
selected_files,
handle: None,
downloaded_bytes: i64_to_u64(self.downloaded_bytes),
uploaded_bytes: i64_to_u64(self.uploaded_bytes),
progress_percent: self.progress_percent,
error: self.error,
created_at: self.created_at,
updated_at: self.updated_at,
completed_at: self.completed_at,
})
}
}
impl TorrentJob {
@@ -106,65 +281,72 @@ impl TorrentJob {
}
fn selected_size(&self) -> u64 {
if self.selected_files.is_empty() {
return 0;
selected_size(&self.files, &self.selected_files)
}
fn preview(&self) -> TorrentPreviewDto {
TorrentPreviewDto {
id: self.id.clone(),
name: self.name.clone(),
info_hash: self.info_hash.clone(),
total_size: self.total_size(),
files: self.files.clone(),
}
self.files
.iter()
.filter(|f| self.selected_files.contains(&f.index))
.map(|f| f.length)
.sum()
}
fn refresh_progress(&mut self) {
let Some(handle) = &self.handle else {
return;
};
let stats = handle.stats();
self.downloaded_bytes = stats.progress_bytes;
self.uploaded_bytes = stats.uploaded_bytes;
self.progress_percent = progress_percent(stats.progress_bytes, stats.total_bytes)
.unwrap_or(self.progress_percent)
.clamp(0.0, 100.0);
}
fn dto(&self) -> TorrentJobDto {
let stats = self.handle.as_ref().map(|h| h.stats());
let downloaded_bytes = stats.as_ref().map(|s| s.progress_bytes).unwrap_or(0);
let downloaded_bytes = stats
.as_ref()
.map(|s| s.progress_bytes)
.unwrap_or(self.downloaded_bytes);
let uploaded_bytes = stats
.as_ref()
.map(|s| s.uploaded_bytes)
.unwrap_or(self.uploaded_bytes);
let total_bytes = stats
.as_ref()
.map(|s| s.total_bytes)
.filter(|v| *v > 0)
.unwrap_or_else(|| self.selected_size());
let progress_percent = if total_bytes == 0 {
0.0
} else {
downloaded_bytes as f64 / total_bytes as f64 * 100.0
};
let live = stats.as_ref().and_then(|s| s.live.as_ref());
let peer_stats = live.map(|l| &l.snapshot.peer_stats);
Self::dto_from_parts(
&self.id,
&self.name,
&self.info_hash,
self.status,
self.total_size(),
self.selected_size(),
downloaded_bytes,
progress_percent,
self.error.clone(),
)
}
#[allow(clippy::too_many_arguments)]
fn dto_from_parts(
id: &str,
name: &str,
info_hash: &str,
status: TorrentJobStatus,
total_size: u64,
selected_size: u64,
downloaded_bytes: u64,
progress_percent: f64,
error: Option<String>,
) -> TorrentJobDto {
TorrentJobDto {
id: id.to_string(),
name: name.to_string(),
info_hash: info_hash.to_string(),
status: status.as_str().to_string(),
total_size,
selected_size,
id: self.id.clone(),
name: self.name.clone(),
info_hash: self.info_hash.clone(),
status: self.status.as_str().to_string(),
client_state: stats.as_ref().map(|s| s.state.to_string()),
total_size: self.total_size(),
selected_size: self.selected_size(),
downloaded_bytes,
progress_percent: progress_percent.clamp(0.0, 100.0),
error,
uploaded_bytes,
progress_percent: progress_percent(downloaded_bytes, total_bytes)
.unwrap_or(self.progress_percent)
.clamp(0.0, 100.0),
download_speed_mbps: live.map(|l| l.download_speed.mbps),
upload_speed_mbps: live.map(|l| l.upload_speed.mbps),
peers_live: peer_stats.map(|p| p.live),
peers_seen: peer_stats.map(|p| p.seen),
eta: live.and_then(|l| l.time_remaining.as_ref().map(|eta| eta.to_string())),
active: self.handle.is_some(),
error: self.error.clone(),
created_at: Some(self.created_at.clone()),
updated_at: Some(self.updated_at.clone()),
completed_at: self.completed_at.clone(),
}
}
}
@@ -205,15 +387,75 @@ impl TorrentService {
.cloned()
}
pub async fn list(&self, pool: &PgPool, user_id: i64) -> anyhow::Result<Vec<TorrentJobDto>> {
let rows = sqlx::query_as::<_, TorrentSessionRow>(
r#"SELECT id, user_id, name, info_hash, source_kind, source_label, torrent_bytes,
files_json, selected_files_json, status, total_size, selected_size,
downloaded_bytes, uploaded_bytes, progress_percent, error,
created_at, updated_at, completed_at
FROM furumusic__torrent_session
WHERE user_id = $1
ORDER BY updated_at DESC, created_at DESC
LIMIT $2"#,
)
.bind(user_id)
.bind(TORRENT_LIST_LIMIT)
.fetch_all(pool)
.await?;
let handles = {
let jobs = self.jobs.lock().await;
jobs.iter()
.filter_map(|(id, job)| job.handle.as_ref().map(|h| (id.clone(), Arc::clone(h))))
.collect::<HashMap<_, _>>()
};
Ok(rows
.iter()
.map(|row| row.dto(handles.get(&row.id)))
.collect())
}
pub async fn details(
&self,
pool: &PgPool,
user_id: i64,
id: &str,
) -> anyhow::Result<TorrentSessionDto> {
if let Some(session) = self.memory_details(user_id, id).await {
return Ok(session);
}
let row = load_row(pool, user_id, id).await?;
let selected_files = row.selected_files();
let job = row.dto(None);
let preview = row.preview()?;
Ok(TorrentSessionDto {
job,
preview,
selected_files,
})
}
pub async fn preview(
&self,
pool: &PgPool,
user_id: i64,
request: TorrentPreviewRequest,
) -> anyhow::Result<TorrentPreviewDto> {
) -> anyhow::Result<TorrentSessionDto> {
let session = self.session().await?;
let id = Uuid::new_v4().to_string();
let output_dir = self.temp_root.join(&id).join("download");
tokio::fs::create_dir_all(&output_dir).await?;
let source_kind = request.kind.as_str().to_string();
let source_label = request
.source_label
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned);
let add = match request.kind {
TorrentPreviewKind::Magnet => {
let magnet = request
@@ -269,50 +511,101 @@ impl TorrentService {
.filename
.to_string()
.unwrap_or_else(|_| "<invalid filename>".to_string());
let selected = is_audio_path(&name);
files.push(TorrentFileDto {
index,
name,
components: details.filename.to_vec().unwrap_or_default(),
length: details.len,
selected,
selected: true,
});
}
let total_size = files.iter().map(|f| f.length).sum();
let dto = TorrentPreviewDto {
id: id.clone(),
name: name.clone(),
info_hash: list.info_hash.as_string(),
total_size,
files: files.clone(),
};
let selected_files = files.iter().map(|f| f.index).collect::<Vec<_>>();
let now = now_string();
let job = TorrentJob {
id: id.clone(),
user_id,
name,
info_hash: dto.info_hash.clone(),
info_hash: list.info_hash.as_string(),
source_kind,
source_label,
torrent_bytes: list.torrent_bytes.to_vec(),
files,
status: TorrentJobStatus::Preview,
output_dir,
selected_files: Vec::new(),
selected_files,
handle: None,
downloaded_bytes: 0,
uploaded_bytes: 0,
progress_percent: 0.0,
error: None,
created_at: now.clone(),
updated_at: now,
completed_at: None,
};
insert_job(pool, &job).await?;
let dto = TorrentSessionDto {
job: job.dto(),
preview: job.preview(),
selected_files: job.selected_files.clone(),
};
self.jobs.lock().await.insert(id, job);
Ok(dto)
}
pub async fn status(&self, id: &str) -> anyhow::Result<TorrentJobDto> {
let jobs = self.jobs.lock().await;
let job = jobs.get(id).context("torrent job not found")?;
Ok(job.dto())
pub async fn status(
&self,
pool: &PgPool,
user_id: i64,
id: &str,
) -> anyhow::Result<TorrentJobDto> {
let dto = {
let mut jobs = self.jobs.lock().await;
jobs.get_mut(id)
.filter(|job| job.user_id == user_id)
.map(|job| {
job.refresh_progress();
job.dto()
})
};
if let Some(dto) = dto {
persist_progress(pool, &dto).await?;
return Ok(dto);
}
let row = load_row(pool, user_id, id).await?;
Ok(row.dto(None))
}
pub async fn remove(&self, pool: &PgPool, user_id: i64, id: &str) -> anyhow::Result<()> {
let removed = {
let mut jobs = self.jobs.lock().await;
jobs.remove(id).and_then(|job| job.handle)
};
if let Some(handle) = removed {
self.stop_torrent(&handle).await;
}
let result = sqlx::query(
"DELETE FROM furumusic__torrent_session WHERE id = $1 AND user_id = $2",
)
.bind(id)
.bind(user_id)
.execute(pool)
.await?;
if result.rows_affected() == 0 {
bail!("torrent session not found");
}
Ok(())
}
pub async fn start(
self: &Arc<Self>,
pool: &PgPool,
id: &str,
selected_files: Vec<usize>,
inbox_dir: String,
@@ -326,21 +619,34 @@ impl TorrentService {
}
let inbox_dir = validate_inbox_dir(&inbox_dir)?;
self.ensure_memory_job(pool, uploader_user_id, id).await?;
let (torrent_bytes, output_dir) = {
let mut jobs = self.jobs.lock().await;
let job = jobs.get_mut(id).context("torrent job not found")?;
if job.status != TorrentJobStatus::Preview && job.status != TorrentJobStatus::Failed {
bail!("torrent job is already started");
if job.user_id != uploader_user_id {
bail!("torrent job not found");
}
if job.handle.is_some() && matches!(job.status, TorrentJobStatus::Downloading | TorrentJobStatus::Moving) {
bail!("torrent job is already running");
}
validate_selection(&job.files, &selected_files)?;
job.status = TorrentJobStatus::Downloading;
job.selected_files = selected_files.clone();
job.downloaded_bytes = 0;
job.uploaded_bytes = 0;
job.progress_percent = 0.0;
job.error = None;
job.completed_at = None;
job.updated_at = now_string();
(job.torrent_bytes.clone(), job.output_dir.clone())
};
tokio::fs::create_dir_all(&output_dir).await?;
mark_job_started(pool, id, &selected_files, &self.memory_job_dto(id).await?).await?;
let session = self.session().await?;
let response = session
let response = match session
.add_torrent(
AddTorrent::from_bytes(torrent_bytes),
Some(AddTorrentOptions {
@@ -350,11 +656,23 @@ impl TorrentService {
..Default::default()
}),
)
.await?;
.await
{
Ok(response) => response,
Err(err) => {
self.fail_job(pool, id, err.to_string()).await;
return Err(err.into());
}
};
let handle = response
.into_handle()
.context("torrent did not return a download handle")?;
let handle = match response.into_handle() {
Some(handle) => handle,
None => {
let err = anyhow::anyhow!("torrent did not return a download handle");
self.fail_job(pool, id, err.to_string()).await;
return Err(err);
}
};
let dto = {
let mut jobs = self.jobs.lock().await;
@@ -362,32 +680,84 @@ impl TorrentService {
job.handle = Some(handle.clone());
job.dto()
};
persist_progress(pool, &dto).await?;
let service = Arc::clone(self);
let pool = pool.clone();
let id = id.to_string();
tokio::spawn(async move {
if let Err(err) = handle.wait_until_completed().await {
service.stop_torrent(&handle).await;
service.fail_job(&id, err.to_string()).await;
service.fail_job(&pool, &id, err.to_string()).await;
return;
}
service.stop_torrent(&handle).await;
if let Err(err) = service
.finalize_completed(&id, &inbox_dir, uploader_user_id)
.finalize_completed(&pool, &id, &inbox_dir, uploader_user_id)
.await
{
service.fail_job(&id, err.to_string()).await;
service.fail_job(&pool, &id, err.to_string()).await;
}
});
Ok(dto)
}
async fn fail_job(&self, id: &str, error: String) {
let mut jobs = self.jobs.lock().await;
if let Some(job) = jobs.get_mut(id) {
job.status = TorrentJobStatus::Failed;
job.error = Some(error);
async fn memory_details(&self, user_id: i64, id: &str) -> Option<TorrentSessionDto> {
let jobs = self.jobs.lock().await;
let job = jobs.get(id)?;
if job.user_id != user_id {
return None;
}
Some(TorrentSessionDto {
job: job.dto(),
preview: job.preview(),
selected_files: job.selected_files.clone(),
})
}
async fn ensure_memory_job(&self, pool: &PgPool, user_id: i64, id: &str) -> anyhow::Result<()> {
if self.jobs.lock().await.contains_key(id) {
return Ok(());
}
let row = load_row(pool, user_id, id).await?;
let job = row.into_job(&self.temp_root)?;
self.jobs.lock().await.insert(id.to_string(), job);
Ok(())
}
async fn memory_job_dto(&self, id: &str) -> anyhow::Result<TorrentJobDto> {
let jobs = self.jobs.lock().await;
let job = jobs.get(id).context("torrent job not found")?;
Ok(job.dto())
}
async fn fail_job(&self, pool: &PgPool, id: &str, error: String) {
let dto = {
let mut jobs = self.jobs.lock().await;
jobs.get_mut(id).map(|job| {
job.refresh_progress();
job.status = TorrentJobStatus::Failed;
job.error = Some(error.clone());
job.handle = None;
job.updated_at = now_string();
job.dto()
})
};
if let Some(dto) = dto {
let _ = persist_progress(pool, &dto).await;
} else {
let _ = sqlx::query(
"UPDATE furumusic__torrent_session
SET status = 'failed', error = $2, updated_at = $3
WHERE id = $1",
)
.bind(id)
.bind(error)
.bind(now_string())
.execute(pool)
.await;
}
}
@@ -406,6 +776,7 @@ impl TorrentService {
async fn finalize_completed(
&self,
pool: &PgPool,
id: &str,
inbox_dir: &Path,
uploader_user_id: i64,
@@ -413,7 +784,9 @@ impl TorrentService {
let (name, files, selected_files, output_dir) = {
let mut jobs = self.jobs.lock().await;
let job = jobs.get_mut(id).context("torrent job not found")?;
job.refresh_progress();
job.status = TorrentJobStatus::Moving;
job.updated_at = now_string();
(
job.name.clone(),
job.files.clone(),
@@ -422,6 +795,9 @@ impl TorrentService {
)
};
let moving_dto = self.memory_job_dto(id).await?;
persist_progress(pool, &moving_dto).await?;
let destination_root = inbox_dir
.join("user_uploads")
.join(uploader_user_id.to_string())
@@ -443,11 +819,18 @@ impl TorrentService {
let job_root = self.temp_root.join(id);
let _ = tokio::fs::remove_dir_all(job_root).await;
{
let completed_dto = {
let mut jobs = self.jobs.lock().await;
let job = jobs.get_mut(id).context("torrent job not found")?;
job.refresh_progress();
job.status = TorrentJobStatus::Complete;
}
job.completed_at = Some(now_string());
job.updated_at = now_string();
let dto = job.dto();
job.handle = None;
dto
};
persist_progress(pool, &completed_dto).await?;
if let Some(handle) = self.scheduler_handle.get() {
let handle = Arc::clone(handle);
@@ -462,6 +845,108 @@ impl TorrentService {
}
}
async fn load_row(pool: &PgPool, user_id: i64, id: &str) -> anyhow::Result<TorrentSessionRow> {
sqlx::query_as::<_, TorrentSessionRow>(
r#"SELECT id, user_id, name, info_hash, source_kind, source_label, torrent_bytes,
files_json, selected_files_json, status, total_size, selected_size,
downloaded_bytes, uploaded_bytes, progress_percent, error,
created_at, updated_at, completed_at
FROM furumusic__torrent_session
WHERE id = $1 AND user_id = $2"#,
)
.bind(id)
.bind(user_id)
.fetch_optional(pool)
.await?
.context("torrent session not found")
}
async fn insert_job(pool: &PgPool, job: &TorrentJob) -> anyhow::Result<()> {
sqlx::query(
r#"INSERT INTO furumusic__torrent_session
(id, user_id, name, info_hash, source_kind, source_label, torrent_bytes,
files_json, selected_files_json, status, total_size, selected_size,
downloaded_bytes, uploaded_bytes, progress_percent, error,
created_at, updated_at, completed_at)
VALUES ($1, $2, $3, $4, $5, $6, $7,
$8, $9, $10, $11, $12,
0, 0, 0, NULL,
$13, $14, NULL)"#,
)
.bind(&job.id)
.bind(job.user_id)
.bind(&job.name)
.bind(&job.info_hash)
.bind(&job.source_kind)
.bind(&job.source_label)
.bind(&job.torrent_bytes)
.bind(serde_json::to_string(&job.files)?)
.bind(serde_json::to_string(&job.selected_files)?)
.bind(job.status.as_str())
.bind(u64_to_i64(job.total_size()))
.bind(u64_to_i64(job.selected_size()))
.bind(&job.created_at)
.bind(&job.updated_at)
.execute(pool)
.await?;
Ok(())
}
async fn mark_job_started(
pool: &PgPool,
id: &str,
selected_files: &[usize],
dto: &TorrentJobDto,
) -> anyhow::Result<()> {
sqlx::query(
r#"UPDATE furumusic__torrent_session
SET selected_files_json = $2,
status = 'downloading',
selected_size = $3,
downloaded_bytes = 0,
uploaded_bytes = 0,
progress_percent = 0,
error = NULL,
updated_at = $4,
completed_at = NULL
WHERE id = $1"#,
)
.bind(id)
.bind(serde_json::to_string(selected_files)?)
.bind(u64_to_i64(dto.selected_size))
.bind(now_string())
.execute(pool)
.await?;
Ok(())
}
async fn persist_progress(pool: &PgPool, dto: &TorrentJobDto) -> anyhow::Result<()> {
sqlx::query(
r#"UPDATE furumusic__torrent_session
SET status = $2,
selected_size = $3,
downloaded_bytes = $4,
uploaded_bytes = $5,
progress_percent = $6,
error = $7,
updated_at = $8,
completed_at = $9
WHERE id = $1"#,
)
.bind(&dto.id)
.bind(&dto.status)
.bind(u64_to_i64(dto.selected_size))
.bind(u64_to_i64(dto.downloaded_bytes))
.bind(u64_to_i64(dto.uploaded_bytes))
.bind(dto.progress_percent)
.bind(&dto.error)
.bind(now_string())
.bind(&dto.completed_at)
.execute(pool)
.await?;
Ok(())
}
fn validate_selection(files: &[TorrentFileDto], selected_files: &[usize]) -> anyhow::Result<()> {
for index in selected_files {
if !files.iter().any(|file| file.index == *index) {
@@ -483,26 +968,35 @@ fn validate_inbox_dir(inbox_dir: &str) -> anyhow::Result<PathBuf> {
Ok(path)
}
fn is_audio_path(path: &str) -> bool {
let Some(ext) = Path::new(path).extension().and_then(|e| e.to_str()) else {
return false;
};
matches!(
ext.to_ascii_lowercase().as_str(),
"mp3"
| "flac"
| "ogg"
| "opus"
| "aac"
| "m4a"
| "wav"
| "ape"
| "wv"
| "wma"
| "tta"
| "aiff"
| "aif"
)
fn selected_size(files: &[TorrentFileDto], selected_files: &[usize]) -> u64 {
if selected_files.is_empty() {
return 0;
}
files
.iter()
.filter(|f| selected_files.contains(&f.index))
.map(|f| f.length)
.sum()
}
fn progress_percent(downloaded: u64, total: u64) -> Option<f64> {
if total == 0 {
None
} else {
Some(downloaded as f64 / total as f64 * 100.0)
}
}
fn now_string() -> String {
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
}
fn u64_to_i64(value: u64) -> i64 {
value.min(i64::MAX as u64) as i64
}
fn i64_to_u64(value: i64) -> u64 {
value.max(0) as u64
}
fn sanitize_path_component(value: &str) -> String {
+4 -5117
View File
File diff suppressed because it is too large Load Diff
+258
View File
@@ -0,0 +1,258 @@
<!-- Info Modal -->
<template x-if="$store.info.modal">
<div class="modal-overlay" @click.self="$store.info.close()">
<div class="modal-box info-modal">
<div class="info-modal-head">
<h3 x-text="$store.info.modal.title"></h3>
<button class="mobile-list-action" @click="$store.info.close()" title="Close">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<pre class="info-modal-body" x-text="$store.info.modal.body"></pre>
</div>
</div>
</template>
<!-- Create / Rename Playlist Modal -->
<template x-if="$store.playlists.modal">
<div class="modal-overlay" @click.self="$store.playlists.modal = null">
<div class="modal-box">
<h3 x-text="$store.playlists.modal.mode === 'create' ? 'New Playlist' : 'Rename Playlist'"></h3>
<input type="text" x-model="$store.playlists.modal.title" placeholder="Playlist name"
@keydown.enter="$store.playlists.submitModal()" x-init="$nextTick(() => $el.focus())">
<div class="modal-footer">
<button class="modal-btn modal-btn-ghost" @click="$store.playlists.modal = null">Cancel</button>
<button class="modal-btn modal-btn-primary" @click="$store.playlists.submitModal()"
x-text="$store.playlists.modal.mode === 'create' ? 'Create' : 'Save'"></button>
</div>
</div>
</div>
</template>
<!-- Add to Playlist Modal -->
<template x-if="$store.playlists.picker">
<div class="modal-overlay" @click.self="$store.playlists.picker = null">
<div class="modal-box">
<h3>Add to Playlist</h3>
<div class="modal-playlist-list">
<template x-for="pl in $store.playlists.list.filter(p => p.kind === 'user' && p.is_own)" :key="pl.id">
<div class="modal-playlist-item" @click="$store.playlists.addToPicked(pl.id)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
<span x-text="pl.title"></span>
</div>
</template>
</div>
<div class="modal-footer">
<button class="modal-btn modal-btn-ghost" @click="$store.playlists.picker = null">Cancel</button>
<button class="modal-btn modal-btn-primary" @click="$store.playlists.picker = null; $store.playlists.showCreate()">New Playlist</button>
</div>
</div>
</div>
</template>
<!-- Torrent Import Modal -->
<template x-if="$store.torrents.modal">
<div class="modal-overlay" @click.self="$store.torrents.close()">
<div class="modal-box torrent-modal">
<div class="torrent-modal-head">
<div>
<h3>Torrent manager</h3>
<p class="torrent-message" style="margin:4px 0 0"
:class="{ error: $store.torrents.error }"
x-text="$store.torrents.message"></p>
</div>
<div class="torrent-client-status">
<span class="torrent-status-pill"
:class="{ active: $store.torrents.activeCount() > 0 }"
x-text="$store.torrents.clientSummary()"></span>
<span class="torrent-status-pill"
x-text="$store.torrents.sessions.length + ' saved'"></span>
</div>
</div>
<div class="torrent-manager-layout">
<aside class="torrent-manager-sidebar">
<div class="torrent-manager-title">
<span>Saved torrents</span>
<button class="modal-btn modal-btn-ghost" style="padding:4px 8px"
@click="$store.torrents.loadSessions()"
:disabled="$store.torrents.loading">Refresh</button>
</div>
<div class="torrent-session-list">
<template x-if="!$store.torrents.loadingSessions && $store.torrents.sessions.length === 0">
<div class="empty-state" style="padding:28px 12px">
<p>No saved torrents</p>
</div>
</template>
<template x-for="job in $store.torrents.sessions" :key="job.id">
<div class="torrent-session-row"
:class="{ active: $store.torrents.previewData && $store.torrents.previewData.id === job.id }"
@click="$store.torrents.openSession(job.id)">
<div style="min-width:0">
<div class="torrent-session-name" x-text="job.name"></div>
<div class="torrent-session-meta" x-text="$store.torrents.sessionMeta(job)"></div>
</div>
<button class="torrent-session-remove"
@click.stop="$store.torrents.removeSession(job.id)">Delete</button>
</div>
</template>
</div>
</aside>
<section class="torrent-workspace">
<div class="torrent-modal-grid">
<div>
<label for="torrent-file-input">Torrent file</label>
<input id="torrent-file-input" type="file" accept=".torrent,application/x-bittorrent"
@change="$store.torrents.file = $event.target.files[0] || null">
</div>
<div>
<label for="torrent-magnet-input">Magnet link</label>
<input id="torrent-magnet-input" type="text"
x-model="$store.torrents.magnet"
placeholder="magnet:?xt=urn:btih:...">
</div>
</div>
<div class="torrent-actions">
<button class="modal-btn modal-btn-primary" @click="$store.torrents.preview()" :disabled="$store.torrents.loading">
Preview content
</button>
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.clearSelection()" :disabled="!$store.torrents.previewData">Clear</button>
</div>
<template x-if="$store.torrents.currentJob">
<div class="torrent-progress-card">
<div class="torrent-progress-head">
<span x-text="$store.torrents.statusText($store.torrents.currentJob)"></span>
<span x-text="$store.torrents.currentJob.progress_percent.toFixed(1) + '%'"></span>
</div>
<div class="torrent-progress-track">
<div class="torrent-progress-bar"
:style="'width:' + Math.max(0, Math.min(100, $store.torrents.currentJob.progress_percent || 0)) + '%'"></div>
</div>
<div class="torrent-progress-details">
<span x-text="$store.torrents.bytes($store.torrents.currentJob.downloaded_bytes) + ' / ' + $store.torrents.bytes($store.torrents.currentJob.selected_size || $store.torrents.currentJob.total_size)"></span>
<span x-text="$store.torrents.speedText($store.torrents.currentJob)"></span>
<span x-text="$store.torrents.peerText($store.torrents.currentJob)"></span>
</div>
</div>
</template>
<template x-if="$store.torrents.previewData">
<div class="torrent-preview-panel">
<div class="torrent-preview-head">
<div style="min-width:0">
<div class="torrent-preview-title" x-text="$store.torrents.previewData.name"></div>
<div class="torrent-preview-meta"
x-text="$store.torrents.previewData.files.length + ' files - ' + $store.torrents.bytes($store.torrents.previewData.total_size)"></div>
</div>
<button class="modal-btn modal-btn-primary" @click="$store.torrents.start()" :disabled="$store.torrents.loading">
Download selected
</button>
</div>
<div class="torrent-tree-toolbar">
<div class="torrent-selected-summary"
x-text="$store.torrents.selected.size + ' selected - ' + $store.torrents.bytes($store.torrents.selectedBytes())"></div>
<div class="torrent-actions" style="margin-top:0">
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.expandAll(true)">Expand all</button>
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.expandAll(false)">Collapse</button>
</div>
</div>
<div class="torrent-file-tree">
<template x-for="node in $store.torrents.visibleNodes()" :key="node.key">
<div class="torrent-tree-row" :style="'--indent:' + $store.torrents.rowIndent(node) + 'px'">
<button class="torrent-tree-toggle"
:class="{ expanded: $store.torrents.expanded.has(node.key) }"
@click="$store.torrents.toggleExpand(node)"
:style="node.type === 'folder' ? '' : 'visibility:hidden'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
</button>
<button class="torrent-tree-check"
:class="$store.torrents.nodeCheckClass(node)"
@click="$store.torrents.toggleNode(node)">
<template x-if="$store.torrents.nodeState(node) === 'checked'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
<polyline points="20 6 9 17 4 12"/>
</svg>
</template>
<template x-if="$store.torrents.nodeState(node) === 'partial'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</template>
</button>
<div class="torrent-tree-label" :title="node.name">
<template x-if="node.type === 'folder'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 7a2 2 0 012-2h5l2 2h7a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/>
</svg>
</template>
<template x-if="node.type === 'file'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
</svg>
</template>
<span class="torrent-file-name" x-text="node.name"></span>
</div>
<span class="torrent-file-size" x-text="$store.torrents.bytes(node.size)"></span>
</div>
</template>
</div>
</div>
</template>
</section>
</div>
</div>
</div>
</template>
<!-- Play History Modal -->
<template x-if="$store.history.modal">
<div class="modal-overlay" @click.self="$store.history.close()">
<div class="modal-box history-modal">
<h3>Play history</h3>
<p class="torrent-message" :class="{ error: $store.history.error }"
x-text="$store.history.message"></p>
<div class="history-list">
<template x-if="!$store.history.loading && $store.history.items.length === 0">
<div class="empty-state" style="padding:32px 16px">
<p>No plays yet</p>
</div>
</template>
<template x-for="item in $store.history.items" :key="item.id">
<div class="history-row">
<div style="min-width:0">
<div class="history-title" x-text="item.track_title"></div>
<div class="history-release" x-text="item.release_title || 'Unknown release'"></div>
</div>
<div>
<div class="history-date" x-text="$store.history.date(item.played_at)"></div>
<div class="history-duration" x-text="$store.history.duration(item.duration_listened)"></div>
</div>
</div>
</template>
</div>
<div class="history-pager">
<button class="modal-btn modal-btn-ghost"
@click="$store.history.load($store.history.page - 1)"
:disabled="$store.history.loading || $store.history.page <= 1">
Previous
</button>
<span class="history-release"
x-text="'Page ' + $store.history.page + ' of ' + $store.history.totalPages()"></span>
<button class="modal-btn modal-btn-primary"
@click="$store.history.load($store.history.page + 1)"
:disabled="$store.history.loading || $store.history.page >= $store.history.totalPages()">
Next
</button>
</div>
</div>
</div>
</template>
</div>
File diff suppressed because it is too large Load Diff
+981
View File
@@ -0,0 +1,981 @@
<div class="app-layout"
x-data
@keydown.window.space="if (!['INPUT','TEXTAREA','SELECT'].includes(document.activeElement?.tagName)) { $event.preventDefault(); $store.player.toggle(); }"
@keydown.window.arrow-left="if (!['INPUT','TEXTAREA','SELECT'].includes(document.activeElement?.tagName)) { $event.preventDefault(); $store.player.seekRelative(-5); }"
@keydown.window.arrow-right="if (!['INPUT','TEXTAREA','SELECT'].includes(document.activeElement?.tagName)) { $event.preventDefault(); $store.player.seekRelative(5); }"
@keydown.window="if ((e=$event).ctrlKey && e.key==='k') { e.preventDefault(); document.getElementById('search-input')?.focus(); } else if (e.key==='/' && !['INPUT','TEXTAREA','SELECT'].includes(document.activeElement?.tagName)) { e.preventDefault(); document.getElementById('search-input')?.focus(); }"
>
<div class="main-content">
<!-- Left Sidebar -->
<div class="sidebar-left">
<div class="user-widget" x-show="$store.user.profile" x-cloak>
<div class="user-widget-main">
<div class="user-avatar" x-text="$store.user.initials()"></div>
<div style="min-width:0">
<div class="user-name" x-text="$store.user.profile?.name || ''"></div>
<div class="user-role" x-text="$store.user.profile?.role || ''"></div>
</div>
<button class="user-logout-btn" @click="$store.user.logout()" title="Log out">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
</button>
</div>
<div class="user-stats">
<button class="user-stat" @click="$store.history.open()">
<span class="user-stat-value" x-text="$store.user.format($store.user.profile?.stats?.plays)"></span>
<span class="user-stat-label">plays</span>
</button>
<div class="user-stat">
<span class="user-stat-value" x-text="$store.user.format($store.user.profile?.stats?.liked_tracks)"></span>
<span class="user-stat-label">likes</span>
</div>
<div class="user-stat">
<span class="user-stat-value" x-text="$store.user.duration($store.user.profile?.stats?.listened_minutes)"></span>
<span class="user-stat-label">listened</span>
</div>
</div>
</div>
<div class="sidebar-header">
<h2>Library</h2>
</div>
<div class="sidebar-nav">
<div class="sidebar-nav-item"
:class="{ active: $store.library.view === 'artists' }"
@click="$store.library.goArtists()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
Artists
</div>
</div>
<div class="sidebar-section">
<div class="sidebar-section-title">
Following
<span x-show="$store.follows.artists.length > 0"
x-text="'(' + $store.follows.artists.length + ')'"></span>
</div>
<template x-if="$store.follows.artists.length === 0">
<div class="following-empty">No followed artists</div>
</template>
<div class="following-list" x-show="$store.follows.artists.length > 0" x-cloak>
<template x-for="artist in $store.follows.artists" :key="artist.id">
<div class="following-artist"
:class="{ active: $store.library.currentArtist && $store.library.currentArtist.id === artist.id }"
@click="$store.library.openArtist(artist.id)">
<div class="following-avatar">
<template x-if="artist.image_url">
<img :src="artist.image_url" :alt="artist.name" loading="lazy">
</template>
<template x-if="!artist.image_url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
</template>
</div>
<div class="following-name" x-text="artist.name"></div>
</div>
</template>
</div>
</div>
<div class="playlist-list">
<template x-for="pl in $store.playlists.regularList()" :key="pl.id">
<div class="playlist-item-row">
<div class="playlist-item" @click="$store.library.openPlaylist(pl.id)">
<template x-if="pl.kind === 'likes'">
<span style="display:flex;align-items:center;gap:6px">
<svg viewBox="0 0 24 24" fill="var(--accent)" stroke="none" width="14" height="14"><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>
<span x-text="pl.title"></span>
</span>
</template>
<template x-if="pl.kind !== 'likes'">
<span x-text="pl.title"></span>
</template>
<span class="playlist-count" x-text="pl.track_count + ' tracks'"></span>
</div>
<template x-if="pl.is_own && pl.kind === 'user'">
<div class="playlist-item-actions">
<button class="playlist-action-btn" @click.stop="$store.playlists.startRename(pl)" title="Rename">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
<button class="playlist-action-btn" @click.stop="$store.playlists.deletePlaylist(pl.id)" title="Delete">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
</button>
</div>
</template>
</div>
</template>
<button class="sidebar-create-btn" @click="$store.playlists.showCreate()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
New Playlist
</button>
<template x-if="$store.playlists.publishedList().length > 0">
<div class="playlist-public-section">
<div class="sidebar-section-title playlist-subtitle">Published Playlists</div>
<template x-for="pl in $store.playlists.publishedList()" :key="'published-' + pl.id">
<div class="playlist-item-row">
<div class="playlist-item playlist-item-public" @click="$store.library.openPlaylist(pl.id)">
<div class="playlist-title-line">
<span class="playlist-title-text" x-text="pl.title"></span>
<span class="playlist-public-badge">Public</span>
</div>
<div class="playlist-meta-line">
<span class="playlist-owner" x-show="pl.owner_name" x-text="'by ' + pl.owner_name"></span>
<span x-show="pl.owner_name">&middot;</span>
<span x-text="pl.track_count + ' tracks'"></span>
</div>
</div>
</div>
</template>
</div>
</template>
</div>
<div class="sidebar-bottom">
<a href="/admin/">Admin Panel</a>
</div>
</div>
<template x-if="$store.mobile.libraryOpen">
<div class="mobile-library-backdrop" @click.self="$store.mobile.closeLibrary()" x-cloak>
<aside class="mobile-library-drawer">
<div class="mobile-drawer-head">
<div>
<div class="mobile-drawer-title">Library</div>
<div class="playlist-count">Playlists and followed artists</div>
</div>
<button class="mobile-list-action" @click="$store.mobile.closeLibrary()" title="Close">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="mobile-drawer-body">
<div class="mobile-drawer-section">
<div class="sidebar-nav-item"
:class="{ active: $store.library.view === 'artists' }"
@click="$store.library.goArtists(); $store.mobile.closeLibrary()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
Artists
</div>
</div>
<div class="mobile-drawer-section">
<div class="sidebar-section-title">
Following
<span x-show="$store.follows.artists.length > 0"
x-text="'(' + $store.follows.artists.length + ')'"></span>
</div>
<template x-if="$store.follows.artists.length === 0">
<div class="following-empty">No followed artists</div>
</template>
<div class="following-list" x-show="$store.follows.artists.length > 0" x-cloak>
<template x-for="artist in $store.follows.artists" :key="'mobile-follow-' + artist.id">
<div class="mobile-list-row">
<div class="following-artist"
:class="{ active: $store.library.currentArtist && $store.library.currentArtist.id === artist.id }"
@click="$store.library.openArtist(artist.id); $store.mobile.closeLibrary()">
<div class="following-avatar">
<template x-if="artist.image_url">
<img :src="artist.image_url" :alt="artist.name" loading="lazy">
</template>
<template x-if="!artist.image_url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
</template>
</div>
<div class="following-name" x-text="artist.name"></div>
</div>
<button class="mobile-list-action"
@click.stop="$store.follows.toggle(artist.id)"
title="Unfollow 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"/>
<line x1="17" y1="11" x2="23" y2="11"/>
</svg>
</button>
</div>
</template>
</div>
</div>
<div class="mobile-drawer-section">
<div class="sidebar-section-title">Playlists</div>
<template x-for="pl in $store.playlists.regularList()" :key="'mobile-playlist-' + pl.id">
<div class="playlist-item-row">
<div class="playlist-item" @click="$store.library.openPlaylist(pl.id); $store.mobile.closeLibrary()">
<template x-if="pl.kind === 'likes'">
<span style="display:flex;align-items:center;gap:6px">
<svg viewBox="0 0 24 24" fill="var(--accent)" stroke="none" width="14" height="14"><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>
<span x-text="pl.title"></span>
</span>
</template>
<template x-if="pl.kind !== 'likes'">
<span x-text="pl.title"></span>
</template>
<span class="playlist-count" x-text="pl.track_count + ' tracks'"></span>
</div>
<template x-if="pl.is_own && pl.kind === 'user'">
<div class="playlist-item-actions">
<button class="playlist-action-btn" @click.stop="$store.mobile.closeLibrary(); $store.playlists.startRename(pl)" title="Rename">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
<button class="playlist-action-btn" @click.stop="$store.playlists.deletePlaylist(pl.id)" title="Delete">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
</button>
</div>
</template>
</div>
</template>
<button class="sidebar-create-btn" @click="$store.mobile.closeLibrary(); $store.playlists.showCreate()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
New Playlist
</button>
<template x-if="$store.playlists.publishedList().length > 0">
<div class="playlist-public-section">
<div class="sidebar-section-title playlist-subtitle">Published Playlists</div>
<template x-for="pl in $store.playlists.publishedList()" :key="'mobile-published-' + pl.id">
<div class="playlist-item-row">
<div class="playlist-item playlist-item-public" @click="$store.library.openPlaylist(pl.id); $store.mobile.closeLibrary()">
<div class="playlist-title-line">
<span class="playlist-title-text" x-text="pl.title"></span>
<span class="playlist-public-badge">Public</span>
</div>
<div class="playlist-meta-line">
<span class="playlist-owner" x-show="pl.owner_name" x-text="'by ' + pl.owner_name"></span>
<span x-show="pl.owner_name">&middot;</span>
<span x-text="pl.track_count + ' tracks'"></span>
</div>
</div>
</div>
</template>
</div>
</template>
</div>
</div>
</aside>
</div>
</template>
<!-- Center Content -->
<div class="center-content" id="center-scroll">
<!-- Search / account bar -->
<div class="content-topbar" @click.outside="$store.user.menuOpen = false">
<button class="mobile-library-btn"
@click="$store.user.menuOpen = false; $store.mobile.toggleLibrary()"
title="Library">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 19.5A2.5 2.5 0 016.5 17H20"/>
<path d="M4 4.5A2.5 2.5 0 016.5 2H20v20H6.5A2.5 2.5 0 014 19.5z"/>
</svg>
</button>
<div class="search-bar">
<span class="search-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span>
<input id="search-input" type="text" placeholder="Search artists, releases, tracks..."
x-model="$store.library.searchQuery"
@input.debounce.300ms="$store.library.search($store.library.searchQuery)"
@keydown.escape="$store.library.clearSearch(); $el.blur()">
<template x-if="$store.library.searchQuery">
<button class="search-clear" @click="$store.library.clearSearch()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</template>
<template x-if="!$store.library.searchQuery">
<span class="search-shortcut">Ctrl+K</span>
</template>
</div>
<button class="torrent-import-btn"
@click="$store.torrents.open()"
title="Import torrent">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</button>
<span class="version-chip">v{{ t.app_version() }}</span>
<button class="mobile-account-chip"
x-show="$store.user.profile"
x-cloak
@click="$store.mobile.closeLibrary(); $store.user.menuOpen = !$store.user.menuOpen"
:title="$store.user.profile?.name || 'Account'">
<span class="user-avatar" x-text="$store.user.initials()"></span>
<span class="mobile-account-name" x-text="$store.user.profile?.name || ''"></span>
</button>
<div class="mobile-account-popover"
x-show="$store.user.menuOpen && $store.user.profile"
x-cloak>
<div class="user-widget-main">
<span class="user-avatar" x-text="$store.user.initials()"></span>
<div style="min-width:0">
<div class="user-name" x-text="$store.user.profile?.name || ''"></div>
<div class="user-role" x-text="$store.user.profile?.role || ''"></div>
</div>
</div>
<div class="user-stats">
<button class="user-stat" @click="$store.history.open(); $store.user.menuOpen = false">
<span class="user-stat-value" x-text="$store.user.format($store.user.profile?.stats?.plays)"></span>
<span class="user-stat-label">plays</span>
</button>
<div class="user-stat">
<span class="user-stat-value" x-text="$store.user.format($store.user.profile?.stats?.liked_tracks)"></span>
<span class="user-stat-label">likes</span>
</div>
<div class="user-stat">
<span class="user-stat-value" x-text="$store.user.duration($store.user.profile?.stats?.listened_minutes)"></span>
<span class="user-stat-label">listened</span>
</div>
</div>
<button class="modal-btn modal-btn-primary mobile-account-logout"
@click="$store.user.logout()">
Log out
</button>
</div>
</div>
<!-- Search Results -->
<template x-if="$store.library.view === 'search'">
<div>
<template x-if="$store.library.searchLoading">
<div class="loading-spinner"><div class="spinner"></div></div>
</template>
<template x-if="!$store.library.searchLoading && $store.library.searchResults">
<div>
<template x-if="$store.library.searchResults.artists.length === 0 && $store.library.searchResults.releases.length === 0 && $store.library.searchResults.tracks.length === 0">
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<p>No results found</p>
</div>
</template>
<!-- Artists section -->
<template x-if="$store.library.searchResults.artists.length > 0">
<div class="search-section">
<h2 class="search-section-title">Artists</h2>
<div class="search-artists-row">
<template x-for="artist in $store.library.searchResults.artists" :key="artist.id">
<div class="search-artist-card" @click="$store.library.openArtist(artist.id)">
<div class="search-artist-img">
<template x-if="artist.image_url">
<img :src="artist.image_url" :alt="artist.name" loading="lazy">
</template>
<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>
</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) ? 'Unfollow artist' : '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 class="search-artist-name" x-text="artist.name"></div>
</div>
</template>
</div>
</div>
</template>
<!-- Releases section -->
<template x-if="$store.library.searchResults.releases.length > 0">
<div class="search-section">
<h2 class="search-section-title">Releases</h2>
<div class="search-releases-row">
<template x-for="release in $store.library.searchResults.releases" :key="release.id">
<div class="search-release-card" @click="$store.library.openRelease(release.id)" style="position:relative">
<div class="search-release-cover" style="position:relative">
<template x-if="release.cover_url">
<img :src="release.cover_url" :alt="release.title" loading="lazy">
</template>
<template x-if="!release.cover_url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
</template>
<button class="card-info-btn" @click.stop="$store.info.open('Release info', $store.library.releaseInfo(release))" :title="$store.library.releaseInfo(release)" aria-label="Release info">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
</button>
</div>
<div class="card-title" x-text="release.title"></div>
<div class="card-subtitle">
<span x-text="release.year || ''"></span>
<span x-text="release.release_type"></span>
</div>
</div>
</template>
</div>
</div>
</template>
<!-- Tracks section -->
<template x-if="$store.library.searchResults.tracks.length > 0">
<div class="search-section">
<h2 class="search-section-title">Tracks</h2>
<div class="track-list-header">
<span>#</span>
<span>Title</span>
<span></span>
<span></span>
<span style="text-align:right">Duration</span>
</div>
<template x-for="(track, idx) in $store.library.searchResults.tracks" :key="track.id">
<div class="track-row"
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
@dblclick="$store.library.playSearchTrack(idx)">
<span class="track-num" x-text="idx + 1"></span>
<div class="track-info">
<div class="track-title" x-text="track.title"></div>
<div class="track-artists-inline">
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
<span>
<template x-if="artistIdx > 0"><span>, </span></template>
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
</span>
</template>
</div>
</div>
<span></span>
<div class="track-actions">
<button class="track-action-btn info-btn" @click.stop="$store.info.open('Track info', $store.library.trackInfo(track))" :title="$store.library.trackInfo(track)" aria-label="Track info">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
</button>
<button class="track-action-btn play-btn" @click.stop="$store.library.playSearchTrack(idx)" title="Play">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</button>
<button class="like-btn" :class="{ liked: $store.likes.has(track.id) }" @click.stop="$store.likes.toggle(track.id)" title="Like">
<svg viewBox="0 0 24 24" :fill="$store.likes.has(track.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 class="track-action-btn" @click.stop="$store.queue.addNextInQueue([track])" title="Play next">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h8M5 18h14"/><path d="M17 10l4 3-4 3" fill="currentColor" stroke="none"/></svg>
</button>
<button class="track-action-btn" @click.stop="$store.queue.addToEnd([track])" title="Add to queue">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<button class="track-action-btn" @click.stop="$store.playlists.showPicker([track.id])" title="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>
</button>
</div>
<span class="track-duration" x-text="formatTime(track.duration_seconds)"></span>
</div>
</template>
</div>
</template>
</div>
</template>
</div>
</template>
<!-- Artists Grid -->
<template x-if="$store.library.view === 'artists'">
<div>
<h1 class="section-title">Artists</h1>
<div class="card-grid">
<template x-for="artist in $store.library.artists" :key="artist.id">
<div class="card" @click="$store.library.openArtist(artist.id)">
<div class="card-img">
<template x-if="artist.image_url">
<img :src="artist.image_url" :alt="artist.name" loading="lazy">
</template>
<template x-if="!artist.image_url">
<span class="placeholder-icon"><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></span>
</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) ? 'Unfollow artist' : '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 class="card-title" x-text="artist.name"></div>
<div class="card-subtitle" x-text="artist.release_count + ' releases · ' + artist.track_count + ' tracks'"></div>
</div>
</template>
</div>
<template x-if="$store.library.loading">
<div class="loading-spinner"><div class="spinner"></div></div>
</template>
<div id="artist-sentinel" style="height:1px"></div>
</div>
</template>
<!-- Artist Detail -->
<template x-if="$store.library.view === 'artist_detail' && $store.library.currentArtist">
<div>
<div class="breadcrumb">
<a @click="$store.library.goArtists()">Artists</a>
<span>/</span>
<span x-text="$store.library.currentArtist.name"></span>
</div>
<div class="artist-header">
<div class="artist-img">
<template x-if="$store.library.currentArtist.image_url">
<img :src="$store.library.currentArtist.image_url" :alt="$store.library.currentArtist.name">
</template>
<template x-if="!$store.library.currentArtist.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>
</template>
</div>
<div>
<div class="artist-name" x-text="$store.library.currentArtist.name"></div>
<div class="artist-stats">
<span x-text="$store.library.currentArtist.releases.length + ' releases'"></span>
<span></span>
<span x-text="$store.library.currentArtist.total_track_count + ' tracks'"></span>
<span></span>
<span x-text="$store.library.currentArtist.total_play_count + ' plays'"></span>
</div>
<div class="release-actions">
<button class="release-action-btn secondary"
:class="{ followed: $store.follows.has($store.library.currentArtist.id) }"
@click="$store.follows.toggle($store.library.currentArtist.id)"
:title="$store.follows.has($store.library.currentArtist.id) ? 'Unfollow artist' : '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($store.library.currentArtist.id)" d="M19 8v6M16 11h6"/>
<path x-show="$store.follows.has($store.library.currentArtist.id)" d="M16 11l2 2 4-5"/>
</svg>
<span x-text="$store.follows.has($store.library.currentArtist.id) ? 'Following' : 'Follow'"></span>
</button>
</div>
</div>
</div>
<template x-for="group in $store.library.artistReleaseGroups()" :key="group.type">
<section class="artist-release-group">
<h2 class="artist-release-group-title" x-text="group.label"></h2>
<div class="card-grid">
<template x-for="release in group.releases" :key="release.id">
<div class="card" @click="$store.library.openRelease(release.id)">
<div class="card-img">
<template x-if="release.cover_url">
<img :src="release.cover_url" :alt="release.title" loading="lazy">
</template>
<template x-if="!release.cover_url">
<span class="placeholder-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg></span>
</template>
<button class="card-info-btn" @click.stop="$store.info.open('Release info', $store.library.releaseInfo(release))" :title="$store.library.releaseInfo(release)" aria-label="Release info">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
</button>
<button class="card-enqueue-btn" @click.stop="$store.library.enqueueRelease(release.id)" title="Add to queue">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<button class="card-play-btn" @click.stop="$store.library.playRelease(release.id)">
<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
</button>
</div>
<div class="card-title" x-text="release.title"></div>
<div class="card-subtitle">
<span x-text="release.year || ''"></span>
<span x-text="release.track_count + ' tracks'"></span>
</div>
</div>
</template>
</div>
</section>
</template>
<template x-if="$store.library.currentArtist.featured_tracks && $store.library.currentArtist.featured_tracks.length > 0">
<section class="artist-release-group">
<h2 class="artist-release-group-title">Appears on</h2>
<div class="track-list-header">
<span>#</span>
<span>Title</span>
<span></span>
<span></span>
<span style="text-align:right">Duration</span>
</div>
<template x-for="(track, idx) in $store.library.currentArtist.featured_tracks" :key="track.id">
<div class="track-row"
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
@dblclick="$store.queue.playRelease($store.library.currentArtist.featured_tracks, idx)">
<span class="track-num" x-text="idx + 1"></span>
<div class="track-info">
<div class="track-title">
<span x-text="track.title"></span>
<span style="color:var(--text-subdued)"> · </span>
<a class="artist-link" @click.stop="$store.library.openRelease(track.release_id)" x-text="track.release_title"></a>
</div>
<div class="track-artists-inline">
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
<span>
<template x-if="artistIdx > 0"><span>, </span></template>
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
</span>
</template>
</div>
</div>
<span></span>
<div class="track-actions">
<button class="track-action-btn info-btn" @click.stop="$store.info.open('Track info', $store.library.trackInfo(track))" :title="$store.library.trackInfo(track)" aria-label="Track info">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
</button>
<button class="track-action-btn play-btn" @click.stop="$store.queue.playRelease($store.library.currentArtist.featured_tracks, idx)" title="Play">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</button>
<button class="like-btn" :class="{ liked: $store.likes.has(track.id) }" @click.stop="$store.likes.toggle(track.id)" title="Like">
<svg viewBox="0 0 24 24" :fill="$store.likes.has(track.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 class="track-action-btn" @click.stop="$store.queue.addNextInQueue([track])" title="Play next">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h8M5 18h14"/><path d="M17 10l4 3-4 3" fill="currentColor" stroke="none"/></svg>
</button>
<button class="track-action-btn" @click.stop="$store.queue.addToEnd([track])" title="Add to queue">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<button class="track-action-btn" @click.stop="$store.playlists.showPicker([track.id])" title="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>
</button>
</div>
<span class="track-duration" x-text="formatTime(track.duration_seconds)"></span>
</div>
</template>
</section>
</template>
</div>
</template>
<!-- Release Detail -->
<template x-if="$store.library.view === 'release_detail' && $store.library.currentRelease">
<div>
<div class="breadcrumb">
<a @click="$store.library.goArtists()">Artists</a>
<span>/</span>
<template x-if="$store.library.currentRelease.artists.length > 0">
<a @click="$store.library.openArtist($store.library.currentRelease.artists[0].id)" x-text="$store.library.currentRelease.artists[0].name"></a>
</template>
<span>/</span>
<span x-text="$store.library.currentRelease.title"></span>
</div>
<div class="release-header">
<div class="release-cover">
<template x-if="$store.library.currentRelease.cover_url">
<img :src="$store.library.currentRelease.cover_url" :alt="$store.library.currentRelease.title">
</template>
<template x-if="!$store.library.currentRelease.cover_url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
</template>
</div>
<div class="release-meta">
<div class="release-type" x-text="$store.library.currentRelease.release_type"></div>
<div class="release-title" x-text="$store.library.currentRelease.title"></div>
<div class="release-artists">
<template x-for="(artist, artistIdx) in $store.library.currentRelease.artists" :key="artist.id">
<span>
<template x-if="artistIdx > 0"><span>, </span></template>
<a class="artist-link" @click="$store.library.openArtist(artist.id)" x-text="artist.name"></a>
</span>
</template>
</div>
<div class="release-year" x-text="$store.library.currentRelease.year || ''"></div>
<div class="release-actions">
<button class="release-action-btn secondary"
@click.stop="$store.info.open('Release info', $store.library.releaseInfo($store.library.currentRelease))"
:title="$store.library.releaseInfo($store.library.currentRelease)"
aria-label="Release info">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
Info
</button>
<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>
Play
</button>
<button class="like-btn like-btn-lg" style="margin-left:4px"
:class="{ liked: $store.likes.isReleaseLiked($store.library.currentRelease) }"
@click.stop="$store.likes.toggleRelease($store.library.currentRelease.id)"
title="Like">
<svg viewBox="0 0 24 24" :fill="$store.likes.isReleaseLiked($store.library.currentRelease) ? '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 class="release-action-btn secondary" @click="$store.queue.addToEnd($store.library.currentRelease.tracks)" title="Add to end of queue">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Queue
</button>
<button class="release-action-btn secondary" @click="$store.queue.addNextInQueue($store.library.currentRelease.tracks)" title="Play next">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h8M5 18h14"/><path d="M17 10l4 3-4 3" fill="currentColor" stroke="none"/></svg>
Next
</button>
</div>
</div>
</div>
<!-- Track list -->
<div class="track-list-header">
<span>#</span>
<span>Title</span>
<span></span>
<span></span>
<span style="text-align:right">Duration</span>
</div>
<template x-for="(track, idx) in $store.library.currentRelease.tracks" :key="track.id">
<div class="track-row"
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
@dblclick="$store.queue.playRelease($store.library.currentRelease.tracks, idx)">
<span class="track-num" x-text="track.track_number || (idx + 1)"></span>
<div class="track-info">
<div class="track-title" x-text="track.title"></div>
<div class="track-artists-inline">
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
<span>
<template x-if="artistIdx > 0"><span>, </span></template>
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
</span>
</template>
</div>
</div>
<span></span>
<div class="track-actions">
<button class="track-action-btn info-btn" @click.stop="$store.info.open('Track info', $store.library.trackInfo(track))" :title="$store.library.trackInfo(track)" aria-label="Track info">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
</button>
<button class="track-action-btn play-btn" @click.stop="$store.queue.playRelease($store.library.currentRelease.tracks, idx)" title="Play">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</button>
<button class="like-btn" :class="{ liked: $store.likes.has(track.id) }" @click.stop="$store.likes.toggle(track.id)" title="Like">
<svg viewBox="0 0 24 24" :fill="$store.likes.has(track.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 class="track-action-btn" @click.stop="$store.queue.addNextInQueue([track])" title="Play next">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h8M5 18h14"/><path d="M17 10l4 3-4 3" fill="currentColor" stroke="none"/></svg>
</button>
<button class="track-action-btn" @click.stop="$store.queue.addToEnd([track])" title="Add to queue">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<button class="track-action-btn" @click.stop="$store.playlists.showPicker([track.id])" title="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>
</button>
</div>
<span class="track-duration" x-text="formatTime(track.duration_seconds)"></span>
</div>
</template>
</div>
</template>
<!-- Playlist Detail -->
<template x-if="$store.library.view === 'playlist_detail' && $store.library.currentPlaylist">
<div>
<div class="breadcrumb">
<a @click="$store.library.goArtists()">Library</a>
<span>/</span>
<span x-text="$store.library.currentPlaylist.title"></span>
</div>
<h1 class="section-title" x-text="$store.library.currentPlaylist.title"></h1>
<div class="playlist-detail-meta"
x-show="$store.library.currentPlaylist.owner_name || $store.library.currentPlaylist.is_public">
<span x-show="$store.library.currentPlaylist.owner_name"
x-text="'by ' + $store.library.currentPlaylist.owner_name"></span>
<span x-show="$store.library.currentPlaylist.owner_name && $store.library.currentPlaylist.is_public">&middot;</span>
<span class="playlist-public-badge"
x-show="$store.library.currentPlaylist.is_public">Published</span>
</div>
<template x-if="$store.library.currentPlaylist.description">
<p style="color:var(--text-subdued);margin-bottom:16px" x-text="$store.library.currentPlaylist.description"></p>
</template>
<div class="track-list-header">
<span>#</span>
<span>Title</span>
<span></span>
<span></span>
<span style="text-align:right">Duration</span>
</div>
<template x-for="(track, idx) in $store.library.currentPlaylist.tracks" :key="track.id">
<div class="track-row"
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
@dblclick="$store.queue.playRelease($store.library.currentPlaylist.tracks, idx)">
<span class="track-num" x-text="idx + 1"></span>
<div class="track-info">
<div class="track-title" x-text="track.title"></div>
<div class="track-artists-inline">
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
<span>
<template x-if="artistIdx > 0"><span>, </span></template>
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
</span>
</template>
</div>
</div>
<span></span>
<div class="track-actions">
<button class="track-action-btn info-btn" @click.stop="$store.info.open('Track info', $store.library.trackInfo(track))" :title="$store.library.trackInfo(track)" aria-label="Track info">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
</button>
<button class="track-action-btn play-btn" @click.stop="$store.queue.playRelease($store.library.currentPlaylist.tracks, idx)" title="Play">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</button>
<button class="like-btn" :class="{ liked: $store.likes.has(track.id) }" @click.stop="$store.likes.toggle(track.id)" title="Like">
<svg viewBox="0 0 24 24" :fill="$store.likes.has(track.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 class="track-action-btn" @click.stop="$store.queue.addNextInQueue([track])" title="Play next">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h8M5 18h14"/><path d="M17 10l4 3-4 3" fill="currentColor" stroke="none"/></svg>
</button>
<button class="track-action-btn" @click.stop="$store.queue.addToEnd([track])" title="Add to queue">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<button class="track-action-btn" @click.stop="$store.playlists.showPicker([track.id])" title="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>
</button>
</div>
<span class="track-duration" x-text="formatTime(track.duration_seconds)"></span>
</div>
</template>
</div>
</template>
</div>
<!-- Queue Panel -->
<div class="queue-backdrop"
x-show="$store.queue.visible"
x-cloak
@click="$store.queue.visible = false"></div>
<div class="queue-panel" :class="{ hidden: !$store.queue.visible }">
<div class="queue-header">
<h3>Queue</h3>
<button class="queue-clear-btn" @click="$store.queue.clear()">Clear</button>
</div>
<div class="queue-tracks">
<template x-if="$store.queue.tracks.length === 0">
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
<p>Queue is empty</p>
</div>
</template>
<template x-for="(track, idx) in $store.queue.tracks" :key="idx + '-' + track.id">
<div class="queue-track"
:class="{ active: idx === $store.queue.currentIndex, dragging: $store.queue._dragIdx === idx }"
@click="$store.queue.playFromIndex(idx)"
draggable="true"
@dragstart="$store.queue._dragIdx = idx; $event.dataTransfer.effectAllowed = 'move'"
@dragend="$store.queue._dragIdx = null; document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'))"
@dragover.prevent="$event.dataTransfer.dropEffect = 'move'; $event.currentTarget.classList.add('drag-over')"
@dragleave="$event.currentTarget.classList.remove('drag-over')"
@drop.prevent="$event.currentTarget.classList.remove('drag-over'); if ($store.queue._dragIdx !== null) { $store.queue.moveTrack($store.queue._dragIdx, idx); $store.queue._dragIdx = null; }">
<div class="queue-drag-handle" @mousedown.stop>
<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/></svg>
</div>
<div class="queue-track-cover">
<template x-if="track.cover_url">
<img :src="track.cover_url" :alt="track.title" loading="lazy">
</template>
<template x-if="!track.cover_url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
</template>
</div>
<div class="queue-track-info">
<div class="queue-track-title" x-text="track.title"></div>
<div class="queue-track-artist">
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
<span>
<template x-if="artistIdx > 0"><span>, </span></template>
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
</span>
</template>
</div>
</div>
<div class="queue-track-actions">
<button class="queue-track-remove info-btn" @click.stop="$store.info.open('Track info', $store.library.trackInfo(track))" :title="$store.library.trackInfo(track)" aria-label="Track info">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
</button>
<button class="queue-track-remove" @click.stop="$store.queue.remove(idx)" title="Remove">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
</div>
</template>
</div>
</div>
</div>
<!-- Player Bar -->
<div class="player-bar">
<div class="player-now-playing">
<template x-if="$store.player.currentTrack">
<div style="display:flex;align-items:center;gap:12px;overflow:hidden">
<div class="player-cover">
<template x-if="$store.player.currentTrack.cover_url">
<img :src="$store.player.currentTrack.cover_url" :alt="$store.player.currentTrack.title">
</template>
<template x-if="!$store.player.currentTrack.cover_url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
</template>
</div>
<div class="player-track-info">
<div class="player-track-title" x-text="$store.player.currentTrack.title"></div>
<div class="player-track-artist">
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks($store.player.currentTrack)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
<span>
<template x-if="artistIdx > 0"><span>, </span></template>
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
</span>
</template>
<template x-if="$store.player.currentTrack.release_year">
<span class="player-release-year" x-text="' · ' + $store.player.currentTrack.release_year"></span>
</template>
</div>
</div>
</div>
</template>
</div>
<div class="player-controls">
<div class="player-buttons">
<button class="player-btn" :class="{ active: $store.player.shuffle }" @click="$store.player.toggleShuffle()" title="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>
</button>
<button class="player-btn" @click="$store.player.prev()" title="Previous">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
</button>
<button class="player-btn player-btn-play" @click="$store.player.toggle()">
<template x-if="!$store.player.isPlaying">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</template>
<template x-if="$store.player.isPlaying">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 4h4v16H6zM14 4h4v16h-4z"/></svg>
</template>
</button>
<button class="player-btn" @click="$store.player.next()" title="Next">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
</button>
<button class="player-btn" :class="{ active: $store.player.repeatMode !== 'off' }" @click="$store.player.cycleRepeat()" title="Repeat">
<template x-if="$store.player.repeatMode !== 'one'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 014-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 01-4 4H3"/></svg>
</template>
<template x-if="$store.player.repeatMode === 'one'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 014-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 01-4 4H3"/><text x="12" y="14" font-size="8" fill="currentColor" text-anchor="middle" font-weight="bold">1</text></svg>
</template>
</button>
</div>
<div class="player-timeline">
<span class="player-time" x-text="formatTime($store.player.currentTime)"></span>
<div class="progress-bar" @click="$store.player.seekFromClick($event)">
<div class="progress-bar-fill" :style="'width:' + $store.player.progress + '%'">
<div class="progress-bar-thumb"></div>
</div>
</div>
<span class="player-time" x-text="formatTime($store.player.duration)"></span>
</div>
</div>
<div class="player-right">
<div class="volume-control">
<button class="volume-btn" @click="$store.player.toggleMute()">
<template x-if="$store.player.volume === 0">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg>
</template>
<template x-if="$store.player.volume > 0 && $store.player.volume < 0.5">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 010 7.07"/></svg>
</template>
<template x-if="$store.player.volume >= 0.5">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 010 14.14M15.54 8.46a5 5 0 010 7.07"/></svg>
</template>
</button>
<div class="volume-slider"
@pointerdown.prevent="$store.player.startVolumeDrag($event)"
aria-label="Volume">
<div class="volume-slider-fill" :style="'width:' + ($store.player.volume * 100) + '%'">
<div class="volume-slider-thumb"></div>
</div>
</div>
</div>
<button class="queue-toggle-btn" :class="{ active: $store.queue.visible }" @click="$store.queue.visible = !$store.queue.visible" title="Queue">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
</button>
</div>
</div>
File diff suppressed because it is too large Load Diff