PLAYER: Added Jam feature
This commit is contained in:
Generated
+1
-1
@@ -1418,7 +1418,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
||||
|
||||
[[package]]
|
||||
name = "furumusic"
|
||||
version = "0.2.3"
|
||||
version = "0.2.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
||||
+55
-1
@@ -9,7 +9,7 @@ use cot::http::header::{
|
||||
use cot::json::Json;
|
||||
use cot::request::extractors::{Path, UrlQuery};
|
||||
use cot::response::IntoResponse;
|
||||
use cot::router::method::{get, post};
|
||||
use cot::router::method::{delete, get, post};
|
||||
use cot::router::{Route, Router};
|
||||
use cot::session::Session;
|
||||
use cot::{App, Body, Template};
|
||||
@@ -1877,6 +1877,36 @@ async fn user_upload_review_save_handler(
|
||||
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(
|
||||
session: Session,
|
||||
db: Database,
|
||||
@@ -5778,6 +5808,30 @@ impl App for PlayerApp {
|
||||
}),
|
||||
"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(
|
||||
"/uploads/reviews/{id}/approve",
|
||||
post({
|
||||
|
||||
@@ -472,7 +472,7 @@
|
||||
</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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -411,6 +411,7 @@ document.addEventListener('alpine:init', () => {
|
||||
duration: 0,
|
||||
volume: 0.7,
|
||||
_prevVolume: 0.7,
|
||||
_volumeCurve: 2.4,
|
||||
shuffle: false,
|
||||
repeatMode: 'off', // off, all, one
|
||||
progress: 0,
|
||||
@@ -662,10 +663,21 @@ document.addEventListener('alpine:init', () => {
|
||||
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) {
|
||||
const rect = bar.getBoundingClientRect();
|
||||
const pct = rect.width > 0 ? (clientX - rect.left) / rect.width : 0;
|
||||
this.setVolume(pct);
|
||||
this.setVolume(this._volumeFromSliderPosition(pct));
|
||||
},
|
||||
|
||||
setVolumeFromClick(event) {
|
||||
@@ -2706,20 +2718,19 @@ document.addEventListener('alpine:init', () => {
|
||||
this.uploadReviewDraft = null;
|
||||
},
|
||||
|
||||
async saveUploadReview() {
|
||||
if (!this.uploadReviewEditId || !this.uploadReviewDraft) return;
|
||||
async deleteUploadReview() {
|
||||
if (!this.uploadReviewEditId) return;
|
||||
const id = this.uploadReviewEditId;
|
||||
this.uploadReviewSavingId = id;
|
||||
try {
|
||||
const res = await fetch(`/api/player/uploads/reviews/${id}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(this.uploadReviewPayload()),
|
||||
method: 'DELETE',
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to save review');
|
||||
this.uploadPending = this.uploadPending.map(item => item.id === id ? data : item);
|
||||
this._setMessage('Pending metadata saved');
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to delete review');
|
||||
this.applyUploadPage(data);
|
||||
this.cancelUploadReviewEdit();
|
||||
this._setMessage('Review deleted');
|
||||
} catch (err) {
|
||||
this._setMessage(err.message || String(err), true);
|
||||
} finally {
|
||||
|
||||
@@ -1050,7 +1050,7 @@
|
||||
<div class="volume-slider"
|
||||
@pointerdown.prevent="$store.player.startVolumeDrag($event)"
|
||||
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>
|
||||
</div>
|
||||
|
||||
@@ -1287,7 +1287,7 @@ button.user-stat:hover {
|
||||
.volume-btn svg { width: 18px; height: 18px; }
|
||||
|
||||
.volume-slider {
|
||||
width: 80px;
|
||||
width: 104px;
|
||||
height: 4px;
|
||||
background: var(--bg-active);
|
||||
border-radius: 2px;
|
||||
@@ -2920,13 +2920,13 @@ button.user-stat:hover {
|
||||
z-index: 140;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
background: rgba(0,0,0,0.32);
|
||||
}
|
||||
|
||||
.upload-editor-drawer {
|
||||
width: min(460px, calc(100vw - 48px));
|
||||
width: min(680px, calc(100vw - 48px));
|
||||
max-height: calc(100vh - 80px);
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-color);
|
||||
@@ -3662,7 +3662,7 @@ button.user-stat:hover {
|
||||
}
|
||||
|
||||
.volume-slider {
|
||||
width: 74px;
|
||||
width: 88px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
@@ -4142,7 +4142,7 @@ button.user-stat:hover {
|
||||
}
|
||||
|
||||
.volume-slider {
|
||||
width: 58px;
|
||||
width: 72px;
|
||||
}
|
||||
|
||||
.player-btn {
|
||||
|
||||
Reference in New Issue
Block a user