PLAYER: Added Jam feature
This commit is contained in:
Generated
+1
-1
@@ -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
@@ -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({
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user