PLAYER: Added Jam feature

This commit is contained in:
2026-05-28 23:38:07 +03:00
parent d1113effa5
commit ec7c5c9049
6 changed files with 83 additions and 18 deletions
Generated
+1 -1
View File
@@ -1418,7 +1418,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]] [[package]]
name = "furumusic" name = "furumusic"
version = "0.2.3" version = "0.2.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
+55 -1
View File
@@ -9,7 +9,7 @@ use cot::http::header::{
use cot::json::Json; use cot::json::Json;
use cot::request::extractors::{Path, UrlQuery}; use cot::request::extractors::{Path, UrlQuery};
use cot::response::IntoResponse; use cot::response::IntoResponse;
use cot::router::method::{get, post}; use cot::router::method::{delete, get, post};
use cot::router::{Route, Router}; use cot::router::{Route, Router};
use cot::session::Session; use cot::session::Session;
use cot::{App, Body, Template}; use cot::{App, Body, Template};
@@ -1877,6 +1877,36 @@ async fn user_upload_review_save_handler(
Json(review).into_response() Json(review).into_response()
} }
async fn user_upload_review_delete_handler(
session: Session,
db: Database,
pool: &sqlx::PgPool,
path: Path<PathId>,
) -> 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 review_id = path.0.id;
let uploaded_by_pattern = format!(r#""uploaded_by_user_id"\s*:\s*{}([^0-9]|$)"#, user.id);
let result = sqlx::query(
r#"DELETE FROM furumusic__pending_review
WHERE id = $1
AND context_json IS NOT NULL
AND context_json ~ $2
AND status IN ('pending', 'failed')"#,
)
.bind(review_id)
.bind(uploaded_by_pattern)
.execute(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
if result.rows_affected() == 0 {
return Ok(json_error(StatusCode::NOT_FOUND, "upload review not found"));
}
let page = load_user_uploads_page(pool, user.id, 500).await?;
Json(page).into_response()
}
async fn user_upload_review_approve_handler( async fn user_upload_review_approve_handler(
session: Session, session: Session,
db: Database, db: Database,
@@ -5778,6 +5808,30 @@ impl App for PlayerApp {
}), }),
"player_upload_review_save", "player_upload_review_save",
), ),
Route::with_handler_and_name(
"/uploads/reviews/{id}",
delete({
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
move |session: Session, db: Database, path: Path<PathId>| {
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;
user_upload_review_delete_handler(session, db, pg_pool, path).await
}
}
}),
"player_upload_review_delete",
),
Route::with_handler_and_name( Route::with_handler_and_name(
"/uploads/reviews/{id}/approve", "/uploads/reviews/{id}/approve",
post({ post({
+1 -1
View File
@@ -472,7 +472,7 @@
</label> </label>
<label class="upload-field upload-field-wide"><span>Notes</span><textarea rows="4" x-model="$store.torrents.uploadReviewDraft.notes"></textarea></label> <label class="upload-field upload-field-wide"><span>Notes</span><textarea rows="4" x-model="$store.torrents.uploadReviewDraft.notes"></textarea></label>
<div class="upload-editor-actions"> <div class="upload-editor-actions">
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.saveUploadReview()" :disabled="$store.torrents.uploadReviewSavingId === $store.torrents.uploadReviewEditId">Save draft</button> <button class="modal-btn modal-btn-danger" @click="$store.torrents.deleteUploadReview()" :disabled="$store.torrents.uploadReviewSavingId === $store.torrents.uploadReviewEditId">Delete review</button>
<button class="modal-btn modal-btn-primary" @click="$store.torrents.approveUploadReview()" :disabled="$store.torrents.uploadReviewSavingId === $store.torrents.uploadReviewEditId">Approve</button> <button class="modal-btn modal-btn-primary" @click="$store.torrents.approveUploadReview()" :disabled="$store.torrents.uploadReviewSavingId === $store.torrents.uploadReviewEditId">Approve</button>
</div> </div>
</div> </div>
+20 -9
View File
@@ -411,6 +411,7 @@ document.addEventListener('alpine:init', () => {
duration: 0, duration: 0,
volume: 0.7, volume: 0.7,
_prevVolume: 0.7, _prevVolume: 0.7,
_volumeCurve: 2.4,
shuffle: false, shuffle: false,
repeatMode: 'off', // off, all, one repeatMode: 'off', // off, all, one
progress: 0, progress: 0,
@@ -662,10 +663,21 @@ document.addEventListener('alpine:init', () => {
audio.volume = this.volume; audio.volume = this.volume;
}, },
volumeSliderPercent() {
if (this.volume <= 0) return 0;
return Math.pow(this.volume, 1 / this._volumeCurve) * 100;
},
_volumeFromSliderPosition(position) {
const pct = Math.max(0, Math.min(1, Number(position || 0)));
if (pct <= 0.002) return 0;
return Math.pow(pct, this._volumeCurve);
},
_setVolumeFromClientX(clientX, bar) { _setVolumeFromClientX(clientX, bar) {
const rect = bar.getBoundingClientRect(); const rect = bar.getBoundingClientRect();
const pct = rect.width > 0 ? (clientX - rect.left) / rect.width : 0; const pct = rect.width > 0 ? (clientX - rect.left) / rect.width : 0;
this.setVolume(pct); this.setVolume(this._volumeFromSliderPosition(pct));
}, },
setVolumeFromClick(event) { setVolumeFromClick(event) {
@@ -2706,20 +2718,19 @@ document.addEventListener('alpine:init', () => {
this.uploadReviewDraft = null; this.uploadReviewDraft = null;
}, },
async saveUploadReview() { async deleteUploadReview() {
if (!this.uploadReviewEditId || !this.uploadReviewDraft) return; if (!this.uploadReviewEditId) return;
const id = this.uploadReviewEditId; const id = this.uploadReviewEditId;
this.uploadReviewSavingId = id; this.uploadReviewSavingId = id;
try { try {
const res = await fetch(`/api/player/uploads/reviews/${id}`, { const res = await fetch(`/api/player/uploads/reviews/${id}`, {
method: 'POST', method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.uploadReviewPayload()),
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to save review'); if (!res.ok) throw new Error(data.error || 'Failed to delete review');
this.uploadPending = this.uploadPending.map(item => item.id === id ? data : item); this.applyUploadPage(data);
this._setMessage('Pending metadata saved'); this.cancelUploadReviewEdit();
this._setMessage('Review deleted');
} catch (err) { } catch (err) {
this._setMessage(err.message || String(err), true); this._setMessage(err.message || String(err), true);
} finally { } finally {
+1 -1
View File
@@ -1050,7 +1050,7 @@
<div class="volume-slider" <div class="volume-slider"
@pointerdown.prevent="$store.player.startVolumeDrag($event)" @pointerdown.prevent="$store.player.startVolumeDrag($event)"
aria-label="{{ t.player_volume }}"> aria-label="{{ t.player_volume }}">
<div class="volume-slider-fill" :style="'width:' + ($store.player.volume * 100) + '%'"> <div class="volume-slider-fill" :style="'width:' + $store.player.volumeSliderPercent() + '%'">
<div class="volume-slider-thumb"></div> <div class="volume-slider-thumb"></div>
</div> </div>
</div> </div>
+5 -5
View File
@@ -1287,7 +1287,7 @@ button.user-stat:hover {
.volume-btn svg { width: 18px; height: 18px; } .volume-btn svg { width: 18px; height: 18px; }
.volume-slider { .volume-slider {
width: 80px; width: 104px;
height: 4px; height: 4px;
background: var(--bg-active); background: var(--bg-active);
border-radius: 2px; border-radius: 2px;
@@ -2920,13 +2920,13 @@ button.user-stat:hover {
z-index: 140; z-index: 140;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: center;
padding: 40px; padding: 40px;
background: rgba(0,0,0,0.32); background: rgba(0,0,0,0.32);
} }
.upload-editor-drawer { .upload-editor-drawer {
width: min(460px, calc(100vw - 48px)); width: min(680px, calc(100vw - 48px));
max-height: calc(100vh - 80px); max-height: calc(100vh - 80px);
overflow-y: auto; overflow-y: auto;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@@ -3662,7 +3662,7 @@ button.user-stat:hover {
} }
.volume-slider { .volume-slider {
width: 74px; width: 88px;
height: 6px; height: 6px;
border-radius: 999px; border-radius: 999px;
} }
@@ -4142,7 +4142,7 @@ button.user-stat:hover {
} }
.volume-slider { .volume-slider {
width: 58px; width: 72px;
} }
.player-btn { .player-btn {