PLAYER: Added users media editor
Build and Publish / Build and Publish Docker Image (push) Successful in 2m57s

This commit is contained in:
2026-05-28 17:34:37 +03:00
parent 072c00a48e
commit d1113effa5
10 changed files with 4053 additions and 69 deletions
Generated
+1 -1
View File
@@ -1418,7 +1418,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]]
name = "furumusic"
version = "0.2.2"
version = "0.2.3"
dependencies = [
"anyhow",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "furumusic"
version = "0.2.3"
version = "0.2.4"
edition = "2024"
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
+162 -2
View File
@@ -40,13 +40,13 @@ pub(super) struct ArtistDetail {
pub(super) featured_tracks: Vec<ArtistAppearanceTrack>,
}
#[derive(Debug, Serialize, JsonSchema)]
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub(super) struct ArtistRef {
pub(super) id: i64,
pub(super) name: String,
}
#[derive(Debug, Serialize, JsonSchema)]
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub(super) struct TrackItem {
pub(super) id: i64,
pub(super) title: String,
@@ -141,6 +141,7 @@ pub(super) struct PlaybackStateDto {
pub(super) struct DeviceHeartbeatRequest {
pub(super) device_id: String,
pub(super) user_agent: Option<String>,
pub(super) current_jam_id: Option<String>,
pub(super) playback_state: Option<PlayerDevicePlaybackStateDto>,
}
@@ -153,6 +154,7 @@ pub(super) struct DeviceSelectRequest {
#[derive(Debug, Deserialize, JsonSchema)]
pub(super) struct DeviceCommandRequest {
pub(super) target_device_id: Option<String>,
pub(super) jam_id: Option<String>,
pub(super) command: String,
#[serde(default)]
pub(super) payload: serde_json::Value,
@@ -168,6 +170,48 @@ pub(super) struct PlayerDeviceDto {
pub(super) last_seen_ms: i64,
}
#[derive(Debug, Serialize, JsonSchema)]
pub(super) struct PlayerJamDto {
pub(super) id: String,
pub(super) name: String,
pub(super) host_user_id: i64,
pub(super) host_name: String,
pub(super) is_owner: bool,
pub(super) is_member: bool,
pub(super) is_pending: bool,
pub(super) is_active: bool,
pub(super) member_count: i64,
pub(super) host_last_seen_ms: i64,
pub(super) host_device_online: bool,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub(super) struct PlayerJamCreateRequest {
pub(super) device_id: String,
#[serde(default)]
pub(super) invitee_user_ids: Vec<i64>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub(super) struct PlayerJamJoinRequest {
pub(super) jam_id: String,
pub(super) device_id: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub(super) struct PlayerJamLeaveRequest {
pub(super) jam_id: String,
pub(super) device_id: String,
}
#[derive(Debug, Serialize, JsonSchema)]
pub(super) struct PlayerJamUserDto {
pub(super) id: i64,
pub(super) username: String,
pub(super) display_name: Option<String>,
pub(super) email: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub(super) struct PlayerDeviceCommandDto {
pub(super) id: String,
@@ -196,6 +240,8 @@ pub(super) struct PlayerDevicesResponse {
pub(super) device_id: String,
pub(super) active_device_id: Option<String>,
pub(super) devices: Vec<PlayerDeviceDto>,
pub(super) jams: Vec<PlayerJamDto>,
pub(super) current_jam_id: Option<String>,
pub(super) playback_state: Option<PlayerDevicePlaybackStateDto>,
}
@@ -204,6 +250,8 @@ pub(super) struct PlayerDevicePollResponse {
pub(super) device_id: String,
pub(super) active_device_id: Option<String>,
pub(super) devices: Vec<PlayerDeviceDto>,
pub(super) jams: Vec<PlayerJamDto>,
pub(super) current_jam_id: Option<String>,
pub(super) commands: Vec<PlayerDeviceCommandDto>,
pub(super) playback_state: Option<PlayerDevicePlaybackStateDto>,
}
@@ -278,6 +326,118 @@ pub(super) struct AgentQueueStatus {
pub(super) processing_count: i64,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub(super) struct UserUploadTrack {
pub(super) track: TrackItem,
pub(super) media_file_id: i64,
pub(super) is_hidden: bool,
pub(super) release_is_hidden: bool,
pub(super) release_type: String,
pub(super) year: Option<i32>,
pub(super) uploaded_at: String,
}
#[derive(Debug, Serialize, JsonSchema)]
pub(super) struct UserUploadRelease {
pub(super) id: i64,
pub(super) title: String,
pub(super) release_type: String,
pub(super) year: Option<i32>,
pub(super) is_hidden: bool,
pub(super) artists: Vec<ArtistRef>,
pub(super) tracks: Vec<UserUploadTrack>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub(super) struct UserUploadReviewFields {
pub(super) title: String,
pub(super) artist: String,
pub(super) album: String,
pub(super) year: String,
pub(super) track_number: String,
pub(super) genre: String,
pub(super) featured_artists: Vec<String>,
pub(super) release_type: String,
pub(super) notes: String,
}
#[derive(Debug, Serialize, JsonSchema)]
pub(super) struct UserUploadReviewItem {
pub(super) id: i64,
pub(super) status: String,
pub(super) filename: String,
pub(super) created_at: String,
pub(super) updated_at: String,
pub(super) error_message: Option<String>,
pub(super) fields: UserUploadReviewFields,
}
#[derive(Debug, Serialize, JsonSchema)]
pub(super) struct UserUploadQueueItem {
pub(super) id: i64,
pub(super) status: String,
pub(super) filename: String,
pub(super) created_at: String,
pub(super) updated_at: String,
pub(super) error_message: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub(super) struct UserUploadsPage {
pub(super) tracks: Vec<UserUploadTrack>,
pub(super) releases: Vec<UserUploadRelease>,
pub(super) pending: Vec<UserUploadReviewItem>,
pub(super) queued: Vec<UserUploadQueueItem>,
pub(super) pending_total: i64,
pub(super) queued_total: i64,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub(super) struct UserUploadTrackUpdateRequest {
pub(super) title: Option<String>,
pub(super) artist_names: Option<Vec<String>>,
pub(super) featured_artist_names: Option<Vec<String>>,
pub(super) release_title: Option<String>,
pub(super) release_type: Option<String>,
pub(super) release_year: Option<String>,
pub(super) track_number: Option<String>,
pub(super) disc_number: Option<String>,
pub(super) is_hidden: Option<bool>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub(super) struct UserUploadReleaseUpdateRequest {
pub(super) title: Option<String>,
pub(super) artist_names: Option<Vec<String>>,
pub(super) release_type: Option<String>,
pub(super) year: Option<String>,
pub(super) is_hidden: Option<bool>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub(super) struct UserUploadBulkTrackUpdateRequest {
pub(super) track_ids: Vec<i64>,
pub(super) artist_names: Option<Vec<String>>,
pub(super) featured_artist_names: Option<Vec<String>>,
pub(super) release_title: Option<String>,
pub(super) release_type: Option<String>,
pub(super) release_year: Option<String>,
pub(super) is_hidden: Option<bool>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub(super) struct UserUploadReviewUpdateRequest {
pub(super) title: Option<String>,
pub(super) artist: Option<String>,
pub(super) album: Option<String>,
pub(super) year: Option<String>,
pub(super) track_number: Option<String>,
pub(super) genre: Option<String>,
pub(super) featured_artists: Option<Vec<String>>,
pub(super) release_type: Option<String>,
pub(super) notes: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub(super) struct PlayHistoryItem {
pub(super) id: i64,
+2012 -22
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -19,6 +19,11 @@ pub(super) struct TracksByIdsRequest {
pub(super) ids: Vec<i64>,
}
#[derive(Debug, Deserialize)]
pub(super) struct UserUploadsQuery {
pub(super) limit: Option<i32>,
}
#[derive(Debug, Deserialize)]
pub(super) struct CreatePlaylistRequest {
pub(super) title: String,
@@ -62,6 +67,12 @@ pub(super) struct SearchQuery {
pub(super) limit: Option<i32>,
}
#[derive(Debug, Deserialize)]
pub(super) struct JamUserSearchQuery {
pub(super) q: Option<String>,
pub(super) limit: Option<i32>,
}
#[derive(Debug, Deserialize)]
pub(super) struct PathTrackId {
pub(super) track_id: i64,
+79
View File
@@ -12,6 +12,14 @@ pub(super) struct CountRow {
pub(super) count: i64,
}
#[derive(sqlx::FromRow)]
pub(super) struct PlayerJamUserRow {
pub(super) id: i64,
pub(super) username: String,
pub(super) display_name: Option<String>,
pub(super) email: Option<String>,
}
#[derive(sqlx::FromRow)]
pub(super) struct ReleaseRow {
pub(super) id: i64,
@@ -60,6 +68,13 @@ pub(super) struct TrackArtistRow {
pub(super) role: String,
}
#[derive(sqlx::FromRow)]
pub(super) struct ReleaseArtistRefRow {
pub(super) release_id: i64,
pub(super) artist_id: i64,
pub(super) artist_name: String,
}
#[derive(sqlx::FromRow)]
pub(super) struct MediaFileRow {
pub(super) file_path: String,
@@ -124,6 +139,70 @@ pub(super) struct PlaylistTrackRow {
pub(super) lastfm_updated_at: Option<String>,
}
#[derive(sqlx::FromRow)]
pub(super) struct UploadedTrackRow {
pub(super) id: i64,
pub(super) title: String,
pub(super) track_number: Option<i32>,
pub(super) disc_number: Option<i32>,
pub(super) duration_seconds: f64,
pub(super) cover_file_id: Option<i64>,
pub(super) release_cover_file_id: Option<i64>,
pub(super) release_id: i64,
pub(super) release_title: String,
pub(super) release_type: String,
pub(super) release_year: Option<i32>,
pub(super) release_is_hidden: bool,
pub(super) uploader_name: String,
pub(super) audio_format: Option<String>,
pub(super) audio_bitrate: Option<i32>,
pub(super) audio_sample_rate: Option<i32>,
pub(super) audio_bit_depth: Option<i32>,
pub(super) file_size_bytes: Option<i64>,
pub(super) lastfm_listeners: Option<i64>,
pub(super) lastfm_playcount: Option<i64>,
pub(super) lastfm_rating: Option<f64>,
pub(super) lastfm_updated_at: Option<String>,
pub(super) media_file_id: i64,
pub(super) is_hidden: bool,
pub(super) year: Option<i32>,
pub(super) uploaded_at: String,
}
#[derive(sqlx::FromRow)]
pub(super) struct UserUploadQueueRow {
pub(super) id: i64,
pub(super) status: String,
pub(super) input_path: Option<String>,
pub(super) created_at: String,
pub(super) updated_at: String,
pub(super) error_message: Option<String>,
}
#[derive(sqlx::FromRow)]
pub(super) struct UserUploadReviewRow {
pub(super) id: i64,
pub(super) status: String,
pub(super) input_path: Option<String>,
pub(super) result_json: Option<String>,
pub(super) context_json: Option<String>,
pub(super) created_at: String,
pub(super) updated_at: String,
pub(super) error_message: Option<String>,
}
#[derive(sqlx::FromRow)]
pub(super) struct UploadTrackEditRow {
pub(super) release_id: i64,
pub(super) title: String,
pub(super) track_number: Option<i32>,
pub(super) disc_number: Option<i32>,
pub(super) is_hidden: bool,
pub(super) release_title: String,
pub(super) release_type: String,
pub(super) release_year: Option<i32>,
}
#[derive(sqlx::FromRow)]
pub(super) struct AppearanceTrackRow {
pub(super) id: i64,
+281
View File
@@ -112,6 +112,21 @@
</div>
</div>
<div class="torrent-tabs">
<button class="torrent-tab-btn"
:class="{ active: $store.torrents.activeTab === 'import' }"
@click="$store.torrents.showImportTab()">Import</button>
<button class="torrent-tab-btn"
:class="{ active: $store.torrents.activeTab === 'uploads' }"
@click="$store.torrents.showUploadsTab()">
<span>My uploads</span>
<span class="torrent-tab-count"
x-show="$store.torrents.uploadPendingTotal + $store.torrents.uploadQueuedTotal > 0"
x-text="$store.torrents.uploadPendingTotal + $store.torrents.uploadQueuedTotal"></span>
</button>
</div>
<template x-if="$store.torrents.activeTab === 'import'">
<div class="torrent-manager-layout">
<aside class="torrent-manager-sidebar">
<div class="torrent-manager-title">
@@ -319,6 +334,272 @@
</template>
</section>
</div>
</template>
<template x-if="$store.torrents.activeTab === 'uploads'">
<section class="upload-manager-panel">
<div class="upload-manager-head">
<div>
<h4>My uploaded tracks</h4>
<p x-text="$store.torrents.uploadSummary()"></p>
</div>
<button class="modal-btn modal-btn-ghost"
@click="$store.torrents.loadUploads()"
:disabled="$store.torrents.uploadHasEditorOpen()">{{ t.player_refresh }}</button>
</div>
<template x-if="$store.torrents.uploadLoaded && $store.torrents.uploadTracks.length === 0 && $store.torrents.uploadPending.length === 0 && $store.torrents.uploadQueued.length === 0">
<div class="empty-state torrent-workspace-empty">
<p>No uploaded tracks yet</p>
</div>
</template>
<div class="upload-manager-grid">
<aside class="upload-review-column">
<div class="upload-panel-card">
<div class="upload-panel-title">Needs approval</div>
<p class="upload-panel-subtitle" x-text="$store.torrents.uploadPendingTotal + ' pending or failed'"></p>
<template x-if="$store.torrents.uploadPending.length === 0">
<div class="upload-mini-empty">No tracks need approval</div>
</template>
<div class="upload-review-list">
<template x-for="item in $store.torrents.uploadPending" :key="item.id">
<button class="upload-review-row" :class="{ active: $store.torrents.uploadReviewEditId === item.id, failed: item.status === 'failed' }" @click="$store.torrents.editUploadReview(item)">
<span class="torrent-status-badge" :class="'status-' + item.status" x-text="item.status"></span>
<span class="upload-review-name" x-text="item.filename"></span>
<span class="upload-review-error" x-show="item.error_message" x-text="item.error_message"></span>
</button>
</template>
</div>
</div>
<template x-if="$store.torrents.uploadQueuedTotal > 0">
<div class="upload-panel-card upload-queue-panel">
<div class="upload-panel-title">Queued / processing</div>
<template x-for="item in $store.torrents.compactQueuedUploads()" :key="item.id">
<div class="upload-queue-row">
<span class="torrent-status-badge" :class="'status-' + item.status" x-text="item.status"></span>
<span class="upload-queue-name" x-text="item.filename"></span>
</div>
</template>
<div class="upload-mini-empty" x-show="$store.torrents.uploadQueuedTotal > $store.torrents.uploadQueued.length" x-text="'Showing ' + $store.torrents.uploadQueued.length + ' of ' + $store.torrents.uploadQueuedTotal"></div>
</div>
</template>
</aside>
<section class="upload-library-column">
<div class="upload-bulk-bar" x-show="$store.torrents.uploadSelectedCount() > 0">
<div class="upload-bulk-title" x-text="$store.torrents.uploadSelectedCount() + ' selected'"></div>
<input type="text" placeholder="Artists" x-model="$store.torrents.uploadBulkDraft.artists">
<input type="text" placeholder="Featured" x-model="$store.torrents.uploadBulkDraft.featured_artists">
<input type="text" placeholder="Album" x-model="$store.torrents.uploadBulkDraft.release_title">
<input type="number" placeholder="Year" x-model="$store.torrents.uploadBulkDraft.release_year">
<select x-model="$store.torrents.uploadBulkDraft.release_type">
<option value="">Type unchanged</option><option value="album">Album</option><option value="single">Single</option><option value="ep">EP</option><option value="compilation">Compilation</option><option value="mixtape">Mixtape</option><option value="live">Live</option><option value="soundtrack">Soundtrack</option><option value="remix">Remix</option><option value="demo">Demo</option>
</select>
<select x-model="$store.torrents.uploadBulkDraft.hidden">
<option value="">Visibility unchanged</option><option value="false">Visible</option><option value="true">Hidden</option>
</select>
<button class="modal-btn modal-btn-primary" @click="$store.torrents.saveUploadBulkEdit()" :disabled="$store.torrents.uploadBulkSaving">Apply</button>
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.clearUploadSelection()">Clear</button>
</div>
<div class="upload-release-tree">
<template x-for="release in $store.torrents.uploadReleases" :key="release.id">
<div class="upload-release-node" :class="{ hidden: release.is_hidden }">
<div class="upload-release-row">
<button class="torrent-tree-check" :class="$store.torrents.uploadReleaseSelectionState(release)" @click="$store.torrents.toggleUploadReleaseSelection(release)">
<template x-if="$store.torrents.uploadReleaseSelectionState(release) === '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.uploadReleaseSelectionState(release) === '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>
<button class="torrent-tree-toggle" :class="{ expanded: $store.torrents.uploadReleaseExpanded(release.id) }" @click="$store.torrents.toggleUploadRelease(release.id)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg></button>
<div class="upload-release-main">
<div class="upload-release-title"><span x-text="release.title"></span><span class="upload-hidden-pill" x-show="release.is_hidden">hidden</span></div>
<div class="upload-track-meta"><span x-text="$store.torrents.uploadReleaseArtistsText(release)"></span><span>-</span><span x-text="release.year || 'no year'"></span><span>-</span><span x-text="release.tracks.length + ' tracks'"></span></div>
</div>
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.editUploadRelease(release)">Edit release</button>
</div>
<div class="upload-track-children" x-show="$store.torrents.uploadReleaseExpanded(release.id)">
<template x-for="item in release.tracks" :key="item.track.id">
<div class="upload-tree-track" :class="{ hidden: item.is_hidden, selected: $store.torrents.selectedUploadTracks.has(item.track.id) }">
<button class="torrent-tree-check" :class="{ checked: $store.torrents.selectedUploadTracks.has(item.track.id) }" @click="$store.torrents.toggleUploadTrackSelection(item.track.id)">
<template x-if="$store.torrents.selectedUploadTracks.has(item.track.id)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg></template>
</button>
<button class="track-action-btn play-btn" @click="$store.player.play(item.track)" title="{{ t.player_play }}"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg></button>
<div class="upload-track-main">
<div class="upload-track-title"><span x-text="item.track.track_number ? item.track.track_number + '. ' + item.track.title : item.track.title"></span><span class="upload-hidden-pill" x-show="item.is_hidden">hidden</span></div>
<div class="upload-track-meta"><span x-text="$store.torrents.uploadArtistsText(item)"></span><span x-show="$store.torrents.uploadFeaturedArtistsText(item)">feat.</span><span x-show="$store.torrents.uploadFeaturedArtistsText(item)" x-text="$store.torrents.uploadFeaturedArtistsText(item)"></span></div>
</div>
<div class="upload-track-actions">
<button class="track-action-btn" @click="$store.queue.addNextInQueue([item.track])" title="{{ t.player_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="modal-btn modal-btn-ghost" @click="$store.torrents.editUpload(item)">Edit</button>
</div>
</div>
</template>
</div>
</div>
</template>
</div>
</section>
</div>
<template x-if="$store.torrents.uploadHasEditorOpen()">
<div class="upload-editor-backdrop" @click.self="$store.torrents.closeUploadEditor()">
<aside class="upload-editor-drawer">
<div class="upload-editor-head">
<div>
<div class="upload-panel-title" x-text="$store.torrents.uploadEditorKicker()"></div>
<h4 x-text="$store.torrents.uploadEditorTitle()"></h4>
</div>
<button class="track-action-btn" @click="$store.torrents.closeUploadEditor()" title="Close">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
</div>
<template x-if="$store.torrents.uploadReviewDraft">
<div class="upload-editor-form">
<label class="upload-field upload-field-half"><span>Title</span><input type="text" x-model="$store.torrents.uploadReviewDraft.title"></label>
<label class="upload-field upload-field-half"><span>Artist</span><input type="text" x-model="$store.torrents.uploadReviewDraft.artist"></label>
<label class="upload-field upload-field-half"><span>Album</span><input type="text" x-model="$store.torrents.uploadReviewDraft.album"></label>
<label class="upload-field upload-field-half"><span>Featured</span><input type="text" x-model="$store.torrents.uploadReviewDraft.featured_artists" placeholder="Artist, Artist"></label>
<label class="upload-field upload-field-compact"><span>Year</span><input type="number" x-model="$store.torrents.uploadReviewDraft.year"></label>
<label class="upload-field upload-field-compact"><span>Track #</span><input type="number" min="1" x-model="$store.torrents.uploadReviewDraft.track_number"></label>
<label class="upload-field upload-field-compact"><span>Genre</span><input type="text" x-model="$store.torrents.uploadReviewDraft.genre"></label>
<label class="upload-field upload-field-compact">
<span>Type</span>
<select x-model="$store.torrents.uploadReviewDraft.release_type">
<option value="album">Album</option><option value="single">Single</option><option value="ep">EP</option><option value="compilation">Compilation</option><option value="mixtape">Mixtape</option><option value="live">Live</option><option value="soundtrack">Soundtrack</option><option value="remix">Remix</option><option value="demo">Demo</option>
</select>
</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-primary" @click="$store.torrents.approveUploadReview()" :disabled="$store.torrents.uploadReviewSavingId === $store.torrents.uploadReviewEditId">Approve</button>
</div>
</div>
</template>
<template x-if="$store.torrents.uploadReleaseDraft">
<div class="upload-editor-form">
<label class="upload-field upload-field-wide"><span>Album</span><input type="text" x-model="$store.torrents.uploadReleaseDraft.title"></label>
<label class="upload-field upload-field-wide"><span>Album artists</span><input type="text" x-model="$store.torrents.uploadReleaseDraft.artists"></label>
<label class="upload-field upload-field-half"><span>Year</span><input type="number" x-model="$store.torrents.uploadReleaseDraft.year"></label>
<label class="upload-field upload-field-half"><span>Type</span><select x-model="$store.torrents.uploadReleaseDraft.release_type"><option value="album">Album</option><option value="single">Single</option><option value="ep">EP</option><option value="compilation">Compilation</option><option value="mixtape">Mixtape</option><option value="live">Live</option><option value="soundtrack">Soundtrack</option><option value="remix">Remix</option><option value="demo">Demo</option></select></label>
<label class="upload-field upload-field-toggle"><input type="checkbox" x-model="$store.torrents.uploadReleaseDraft.is_hidden"><span>Hidden</span></label>
<div class="upload-editor-actions">
<button class="modal-btn modal-btn-primary" @click="$store.torrents.saveUploadReleaseEdit()" :disabled="$store.torrents.uploadReleaseSavingId === $store.torrents.uploadReleaseEditId">Save release</button>
</div>
</div>
</template>
<template x-if="$store.torrents.uploadDraft">
<div class="upload-editor-form">
<label class="upload-field upload-field-wide"><span>Title</span><input type="text" x-model="$store.torrents.uploadDraft.title"></label>
<label class="upload-field upload-field-half"><span>Artists</span><input type="text" x-model="$store.torrents.uploadDraft.artists"></label>
<label class="upload-field upload-field-half"><span>Featured</span><input type="text" x-model="$store.torrents.uploadDraft.featured_artists"></label>
<label class="upload-field upload-field-half"><span>Album</span><input type="text" x-model="$store.torrents.uploadDraft.release_title"></label>
<label class="upload-field upload-field-half"><span>Type</span><select x-model="$store.torrents.uploadDraft.release_type"><option value="album">Album</option><option value="single">Single</option><option value="ep">EP</option><option value="compilation">Compilation</option><option value="mixtape">Mixtape</option><option value="live">Live</option><option value="soundtrack">Soundtrack</option><option value="remix">Remix</option><option value="demo">Demo</option></select></label>
<label class="upload-field upload-field-compact"><span>Year</span><input type="number" x-model="$store.torrents.uploadDraft.release_year"></label>
<label class="upload-field upload-field-compact"><span>Track #</span><input type="number" min="1" x-model="$store.torrents.uploadDraft.track_number"></label>
<label class="upload-field upload-field-compact"><span>Disc #</span><input type="number" min="1" x-model="$store.torrents.uploadDraft.disc_number"></label>
<label class="upload-field upload-field-toggle"><input type="checkbox" x-model="$store.torrents.uploadDraft.is_hidden"><span>Hidden</span></label>
<div class="upload-editor-actions">
<button class="modal-btn modal-btn-primary" @click="$store.torrents.saveUploadEdit()" :disabled="$store.torrents.uploadSavingId === $store.torrents.uploadEditId">Save track</button>
</div>
</div>
</template>
</aside>
</div>
</template>
<div class="upload-track-list" x-show="false">
<template x-for="item in $store.torrents.uploadTracks" :key="item.track.id">
<div class="upload-track-card" :class="{ hidden: item.is_hidden }">
<template x-if="$store.torrents.uploadEditId !== item.track.id">
<div class="upload-track-display">
<button class="track-action-btn play-btn"
@click="$store.player.play(item.track)"
title="{{ t.player_play }}">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</button>
<div class="upload-track-main">
<div class="upload-track-title">
<span x-text="item.track.title"></span>
<span class="upload-hidden-pill" x-show="item.is_hidden">hidden</span>
</div>
<div class="upload-track-meta">
<span x-text="$store.torrents.uploadArtistsText(item)"></span>
<span>·</span>
<span x-text="item.track.release_title"></span>
<span x-show="item.track.track_number">·</span>
<span x-show="item.track.track_number" x-text="'#' + item.track.track_number"></span>
</div>
</div>
<div class="upload-track-actions">
<button class="track-action-btn" @click="$store.queue.addNextInQueue([item.track])" title="{{ t.player_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="modal-btn modal-btn-ghost" @click="$store.torrents.editUpload(item)">Edit</button>
</div>
</div>
</template>
<template x-if="$store.torrents.uploadEditId === item.track.id">
<div class="upload-edit-form">
<label>
<span>Title</span>
<input type="text" x-model="$store.torrents.uploadDraft.title">
</label>
<label>
<span>Artists</span>
<input type="text" x-model="$store.torrents.uploadDraft.artists" placeholder="Artist, Featured Artist">
</label>
<label>
<span>Release</span>
<input type="text" x-model="$store.torrents.uploadDraft.release_title">
</label>
<label>
<span>Type</span>
<select x-model="$store.torrents.uploadDraft.release_type">
<option value="album">Album</option>
<option value="single">Single</option>
<option value="ep">EP</option>
<option value="compilation">Compilation</option>
<option value="mixtape">Mixtape</option>
<option value="live">Live</option>
<option value="soundtrack">Soundtrack</option>
<option value="remix">Remix</option>
<option value="demo">Demo</option>
</select>
</label>
<label>
<span>Year</span>
<input type="number" x-model="$store.torrents.uploadDraft.release_year">
</label>
<label>
<span>Track #</span>
<input type="number" min="1" x-model="$store.torrents.uploadDraft.track_number">
</label>
<label>
<span>Disc #</span>
<input type="number" min="1" x-model="$store.torrents.uploadDraft.disc_number">
</label>
<label class="upload-hidden-toggle">
<input type="checkbox" x-model="$store.torrents.uploadDraft.is_hidden">
<span>Hidden</span>
</label>
<div class="upload-edit-actions">
<button class="modal-btn modal-btn-primary"
@click="$store.torrents.saveUploadEdit()"
:disabled="$store.torrents.uploadSavingId === item.track.id">Save</button>
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.cancelUploadEdit()">Cancel</button>
</div>
</div>
</template>
</div>
</template>
</div>
</section>
</template>
</div>
</div>
</template>
+644 -9
View File
@@ -849,6 +849,16 @@ document.addEventListener('alpine:init', () => {
this.setVolume(payload.volume);
} else if (command.command === 'set_options') {
// Options were already applied above.
} else if (command.command === 'queue_add_end') {
queue._addToEndLocal(payload.tracks || []);
} else if (command.command === 'queue_add_next') {
queue._addNextLocal(payload.tracks || []);
} else if (command.command === 'queue_remove') {
queue._removeLocal(Number(payload.index));
} else if (command.command === 'queue_move') {
queue._moveTrackLocal(Number(payload.from_index), Number(payload.to_index));
} else if (command.command === 'queue_clear') {
queue._clearLocal();
}
this._saveState();
Alpine.store('devices')?.heartbeat();
@@ -1055,13 +1065,22 @@ document.addEventListener('alpine:init', () => {
Alpine.store('devices', {
id: null,
devices: [],
jams: [],
activeDeviceId: null,
currentJamId: null,
open: false,
jamPanelOpen: false,
jamQuery: '',
jamUsers: [],
jamSelectedUsers: [],
jamSearching: false,
_pollTimer: null,
_jamSearchTimer: null,
_stateRefreshTick: 0,
init() {
this.id = this._ensureId();
this.currentJamId = sessionStorage.getItem('furu_player_jam_id') || null;
this.heartbeat();
this._pollTimer = setInterval(() => this.poll(), 500);
document.addEventListener('visibilitychange', () => {
@@ -1087,6 +1106,7 @@ document.addEventListener('alpine:init', () => {
return {
device_id: this.id,
user_agent: navigator.userAgent || '',
current_jam_id: this.currentJamId,
playback_state: player && this.isActive() ? player._devicePlaybackStatePayload() : null,
};
},
@@ -1132,16 +1152,36 @@ document.addEventListener('alpine:init', () => {
const wasActive = this.isActive();
this.activeDeviceId = data.active_device_id || null;
this.devices = Array.isArray(data.devices) ? data.devices : [];
this.jams = Array.isArray(data.jams) ? data.jams : [];
if (data.current_jam_id) {
this.currentJamId = data.current_jam_id;
sessionStorage.setItem('furu_player_jam_id', this.currentJamId);
} else if (this.currentJamId && !this.jams.some(jam => jam.id === this.currentJamId)) {
this.currentJamId = null;
sessionStorage.removeItem('furu_player_jam_id');
}
if (wasActive && !this.isActive()) {
Alpine.store('player')?._pauseLocal();
}
},
selectedJam() {
return this.currentJamId ? this.jams.find(jam => jam.id === this.currentJamId) : null;
},
isControllingRemoteJam() {
const jam = this.selectedJam();
return !!jam && !jam.is_owner;
},
isActive() {
if (this.isControllingRemoteJam()) return false;
return !this.activeDeviceId || this.activeDeviceId === this.id;
},
activeLabel() {
const jam = this.selectedJam();
if (jam) return jam.name;
const active = this.devices.find(device => device.id === this.activeDeviceId);
return active ? active.name : 'Devices';
},
@@ -1156,6 +1196,7 @@ document.addEventListener('alpine:init', () => {
const player = Alpine.store('player');
try {
this.clearJamSelection();
const res = await fetch('/api/player/devices/active', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -1181,14 +1222,16 @@ document.addEventListener('alpine:init', () => {
},
async sendCommand(command, payload = {}, targetDeviceId = null) {
const target = targetDeviceId || this.activeDeviceId;
if (!target || target === this.id) return false;
const jamId = this.isControllingRemoteJam() ? this.currentJamId : null;
const target = jamId ? null : (targetDeviceId || this.activeDeviceId);
if (!jamId && (!target || target === this.id)) return false;
try {
const res = await fetch('/api/player/devices/command', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
target_device_id: target,
jam_id: jamId,
command,
payload,
}),
@@ -1198,6 +1241,118 @@ document.addEventListener('alpine:init', () => {
return false;
}
},
openJamPanel() {
this.jamPanelOpen = !this.jamPanelOpen;
if (this.jamPanelOpen && this.jamQuery.trim()) this.searchJamUsers();
},
queueJamSearch() {
clearTimeout(this._jamSearchTimer);
this._jamSearchTimer = setTimeout(() => this.searchJamUsers(), 180);
},
async searchJamUsers() {
const query = this.jamQuery.trim();
if (!query) {
this.jamUsers = [];
return;
}
this.jamSearching = true;
try {
const res = await fetch('/api/player/jams/users?q=' + encodeURIComponent(query));
if (!res.ok) return;
const selected = new Set(this.jamSelectedUsers.map(user => user.id));
this.jamUsers = (await res.json()).filter(user => !selected.has(user.id));
} catch {
} finally {
this.jamSearching = false;
}
},
addJamInvitee(user) {
if (!user || this.jamSelectedUsers.some(item => item.id === user.id)) return;
this.jamSelectedUsers.push(user);
this.jamUsers = this.jamUsers.filter(item => item.id !== user.id);
this.jamQuery = '';
},
removeJamInvitee(userId) {
this.jamSelectedUsers = this.jamSelectedUsers.filter(user => user.id !== userId);
},
async createJam() {
try {
const res = await fetch('/api/player/jams', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
device_id: this.id,
invitee_user_ids: this.jamSelectedUsers.map(user => user.id),
}),
});
if (!res.ok) return;
const data = await res.json();
this._apply(data);
this.jamPanelOpen = false;
this.jamQuery = '';
this.jamUsers = [];
this.jamSelectedUsers = [];
this.open = false;
} catch {}
},
async selectJam(jam) {
if (!jam) return;
try {
if (jam.is_pending) {
const ok = window.confirm('Join this Jam and control the shared queue?');
if (!ok) return;
}
const res = await fetch('/api/player/jams/join', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jam_id: jam.id,
device_id: this.id,
}),
});
if (!res.ok) return;
const data = await res.json();
this.currentJamId = jam.id;
sessionStorage.setItem('furu_player_jam_id', jam.id);
this._apply(data);
this.open = false;
const player = Alpine.store('player');
if (player && this.isControllingRemoteJam() && data.playback_state) {
player._applyRemotePlaybackState(data.playback_state);
}
} catch {}
},
async leaveJam(jamId = null) {
const id = jamId || this.currentJamId;
if (!id) return;
try {
const res = await fetch('/api/player/jams/leave', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jam_id: id,
device_id: this.id,
}),
});
if (res.ok) this._apply(await res.json());
} catch {
} finally {
this.clearJamSelection();
}
},
clearJamSelection() {
this.currentJamId = null;
sessionStorage.removeItem('furu_player_jam_id');
},
});
// -----------------------------------------------------------------------
@@ -1210,16 +1365,27 @@ document.addEventListener('alpine:init', () => {
_dragIdx: null,
add(track) {
this.tracks.push(track);
this.addToEnd([track]);
},
addToEnd(tracks) {
this.tracks = [...this.tracks, ...tracks];
const items = this._trackList(tracks);
if (!items.length) return;
if (this._sendRemoteQueueCommand('queue_add_end', { tracks: items })) {
this._addToEndLocal(items);
return;
}
this._addToEndLocal(items);
},
addNextInQueue(tracks) {
const insertAt = this.currentIndex + 1;
this.tracks.splice(insertAt, 0, ...tracks);
const items = this._trackList(tracks);
if (!items.length) return;
if (this._sendRemoteQueueCommand('queue_add_next', { tracks: items })) {
this._addNextLocal(items);
return;
}
this._addNextLocal(items);
},
playRelease(tracks, startIndex) {
@@ -1233,6 +1399,54 @@ document.addEventListener('alpine:init', () => {
},
remove(idx) {
if (this._sendRemoteQueueCommand('queue_remove', { index: idx })) {
this._removeLocal(idx);
return;
}
this._removeLocal(idx);
},
moveTrack(fromIdx, toIdx) {
if (this._sendRemoteQueueCommand('queue_move', { from_index: fromIdx, to_index: toIdx })) {
this._moveTrackLocal(fromIdx, toIdx);
return;
}
this._moveTrackLocal(fromIdx, toIdx);
},
clear() {
if (this._sendRemoteQueueCommand('queue_clear')) {
this._clearLocal();
return;
}
this._clearLocal();
},
_trackList(tracks) {
return (Array.isArray(tracks) ? tracks : [tracks]).filter(Boolean);
},
_sendRemoteQueueCommand(command, payload = {}) {
const player = Alpine.store('player');
if (!player?._shouldSendRemote()) return false;
Alpine.store('devices')?.sendCommand(command, payload);
return true;
},
_addToEndLocal(tracks) {
const items = this._trackList(tracks);
if (!items.length) return;
this.tracks = [...this.tracks, ...items];
},
_addNextLocal(tracks) {
const items = this._trackList(tracks);
if (!items.length) return;
const insertAt = Math.min(this.currentIndex + 1, this.tracks.length);
this.tracks.splice(insertAt, 0, ...items);
},
_removeLocal(idx) {
if (idx < 0 || idx >= this.tracks.length) return;
this.tracks.splice(idx, 1);
if (this.tracks.length === 0) {
@@ -1246,7 +1460,7 @@ document.addEventListener('alpine:init', () => {
}
},
moveTrack(fromIdx, toIdx) {
_moveTrackLocal(fromIdx, toIdx) {
if (fromIdx === toIdx) return;
if (fromIdx < 0 || fromIdx >= this.tracks.length) return;
if (toIdx < 0 || toIdx >= this.tracks.length) return;
@@ -1262,7 +1476,7 @@ document.addEventListener('alpine:init', () => {
}
},
clear() {
_clearLocal() {
this.tracks = [];
this.currentIndex = 0;
},
@@ -1933,6 +2147,35 @@ document.addEventListener('alpine:init', () => {
loadingAgentStatus: false,
uploadProgress: 0,
uploadProgressText: '',
activeTab: 'import',
uploadTracks: [],
uploadReleases: [],
uploadPending: [],
uploadQueued: [],
uploadPendingTotal: 0,
uploadQueuedTotal: 0,
uploadLoaded: false,
uploadLoading: false,
uploadEditId: null,
uploadSavingId: null,
uploadDraft: null,
uploadReviewEditId: null,
uploadReviewSavingId: null,
uploadReviewDraft: null,
uploadReleaseEditId: null,
uploadReleaseSavingId: null,
uploadReleaseDraft: null,
selectedUploadTracks: new Set(),
expandedUploadReleases: new Set(),
uploadBulkSaving: false,
uploadBulkDraft: {
artists: '',
featured_artists: '',
release_title: '',
release_type: '',
release_year: '',
hidden: '',
},
open() {
this.modal = true;
@@ -1940,6 +2183,7 @@ document.addEventListener('alpine:init', () => {
this.error = false;
this.loadSessions();
this.loadAgentStatus();
if (this.activeTab === 'uploads') this.loadUploads();
this._startRefresh();
},
@@ -1959,6 +2203,18 @@ document.addEventListener('alpine:init', () => {
return this.workspaceMode === 'new';
},
showImportTab() {
this.activeTab = 'import';
this._setMessage('');
},
showUploadsTab() {
this.activeTab = 'uploads';
this._stopPoll();
this._setMessage('');
this.loadUploads();
},
addNew() {
if (this.loading) return;
this._stopPoll();
@@ -2161,11 +2417,390 @@ document.addEventListener('alpine:init', () => {
}
},
async loadUploads({ silent = false, preserveEditor = true } = {}) {
if (preserveEditor && this.uploadHasEditorOpen()) return;
if (this.uploadLoading) return;
this.uploadLoading = true;
try {
const res = await fetch('/api/player/uploads/tracks');
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to load uploaded tracks');
this.applyUploadPage(data);
this.uploadLoaded = true;
if (this.uploadEditId && !this.uploadTracks.some(item => item.track.id === this.uploadEditId)) {
this.cancelUploadEdit();
}
} catch (err) {
if (!silent) this._setMessage(err.message || String(err), true);
if (!this.uploadLoaded) {
this.uploadTracks = [];
this.uploadReleases = [];
this.uploadPending = [];
this.uploadQueued = [];
this.uploadPendingTotal = 0;
this.uploadQueuedTotal = 0;
}
} finally {
this.uploadLoading = false;
}
},
uploadSummary() {
const trackCount = this.uploadTracks.length;
const releaseCount = this.uploadReleases.length;
const parts = [trackCount + ' tracks', releaseCount + ' releases'];
if (this.uploadPendingTotal > 0) parts.push(this.uploadPendingTotal + ' need approval');
if (this.uploadQueuedTotal > 0) parts.push(this.uploadQueuedTotal + ' queued');
return parts.join(' / ');
},
uploadArtistsText(item) {
const track = item?.track || item;
const names = [
...((track?.artists || []).map(artist => artist.name)),
...((track?.featured_artists || []).map(artist => 'ft. ' + artist.name)),
];
return names.join(', ') || T.unknown;
},
uploadReleaseArtistsText(release) {
const names = (release?.artists || []).map(artist => artist.name);
return names.join(', ') || T.unknown;
},
uploadFeaturedArtistsText(item) {
const track = item?.track || item;
return (track?.featured_artists || []).map(artist => artist.name).join(', ');
},
compactQueuedUploads() {
return this.uploadQueued.slice(0, 6);
},
uploadHasEditorOpen() {
return !!(this.uploadEditId || this.uploadReviewEditId || this.uploadReleaseEditId);
},
uploadEditorKicker() {
if (this.uploadReviewDraft) return 'Needs approval';
if (this.uploadReleaseDraft) return 'Release metadata';
if (this.uploadDraft) return 'Track metadata';
return 'Metadata';
},
uploadEditorTitle() {
if (this.uploadReviewDraft) return 'Approve metadata';
if (this.uploadReleaseDraft) return this.uploadReleaseDraft.title || 'Edit release';
if (this.uploadDraft) return this.uploadDraft.title || 'Edit track';
return 'Edit metadata';
},
closeUploadEditor() {
this.cancelUploadEdit();
this.cancelUploadReleaseEdit();
this.cancelUploadReviewEdit();
},
uploadReleaseExpanded(releaseId) {
return this.expandedUploadReleases.has(releaseId);
},
toggleUploadRelease(releaseId) {
if (this.expandedUploadReleases.has(releaseId)) this.expandedUploadReleases.delete(releaseId);
else this.expandedUploadReleases.add(releaseId);
},
uploadReleaseTrackIds(release) {
return (release?.tracks || []).map(item => item.track.id);
},
uploadReleaseSelectionState(release) {
const ids = this.uploadReleaseTrackIds(release);
const selected = ids.filter(id => this.selectedUploadTracks.has(id)).length;
if (selected === 0) return 'empty';
return selected === ids.length ? 'checked' : 'partial';
},
toggleUploadReleaseSelection(release) {
const ids = this.uploadReleaseTrackIds(release);
const state = this.uploadReleaseSelectionState(release);
if (state === 'checked') ids.forEach(id => this.selectedUploadTracks.delete(id));
else ids.forEach(id => this.selectedUploadTracks.add(id));
},
toggleUploadTrackSelection(trackId) {
if (this.selectedUploadTracks.has(trackId)) this.selectedUploadTracks.delete(trackId);
else this.selectedUploadTracks.add(trackId);
},
uploadSelectedCount() {
return this.selectedUploadTracks.size;
},
clearUploadSelection() {
this.selectedUploadTracks.clear();
this.uploadBulkDraft = {
artists: '',
featured_artists: '',
release_title: '',
release_type: '',
release_year: '',
hidden: '',
};
},
pruneUploadSelection() {
const valid = new Set(this.uploadTracks.map(item => item.track.id));
for (const id of [...this.selectedUploadTracks]) {
if (!valid.has(id)) this.selectedUploadTracks.delete(id);
}
},
editUpload(item) {
if (!item || !item.track) return;
this.cancelUploadReleaseEdit();
this.cancelUploadReviewEdit();
this.uploadEditId = item.track.id;
this.uploadDraft = {
title: item.track.title || '',
artists: (item.track.artists || []).map(artist => artist.name).join(', '),
featured_artists: this.uploadFeaturedArtistsText(item),
release_title: item.track.release_title || '',
release_type: item.release_type || 'album',
release_year: item.track.release_year == null ? '' : String(item.track.release_year),
track_number: item.track.track_number == null ? '' : String(item.track.track_number),
disc_number: item.track.disc_number == null ? '' : String(item.track.disc_number),
is_hidden: !!item.is_hidden,
};
},
cancelUploadEdit() {
this.uploadEditId = null;
this.uploadSavingId = null;
this.uploadDraft = null;
},
async saveUploadEdit() {
if (!this.uploadEditId || !this.uploadDraft) return;
const id = this.uploadEditId;
const draft = this.uploadDraft;
const artistNames = String(draft.artists || '')
.split(',')
.map(name => name.trim())
.filter(Boolean);
this.uploadSavingId = id;
try {
const res = await fetch(`/api/player/uploads/tracks/${id}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: draft.title,
artist_names: artistNames,
featured_artist_names: String(draft.featured_artists || '').split(',').map(name => name.trim()).filter(Boolean),
release_title: draft.release_title,
release_type: draft.release_type,
release_year: String(draft.release_year || ''),
track_number: String(draft.track_number || ''),
disc_number: String(draft.disc_number || ''),
is_hidden: !!draft.is_hidden,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to save track');
this.uploadTracks = this.uploadTracks.map(item => item.track.id === id ? data : item);
this.cancelUploadEdit();
this.loadUploads({ silent: true, preserveEditor: false });
this._setMessage('Track metadata saved');
} catch (err) {
this._setMessage(err.message || String(err), true);
} finally {
this.uploadSavingId = null;
}
},
editUploadRelease(release) {
if (!release) return;
this.cancelUploadEdit();
this.cancelUploadReviewEdit();
this.uploadReleaseEditId = release.id;
this.uploadReleaseDraft = {
title: release.title || '',
artists: this.uploadReleaseArtistsText(release),
release_type: release.release_type || 'album',
year: release.year == null ? '' : String(release.year),
is_hidden: !!release.is_hidden,
};
},
cancelUploadReleaseEdit() {
this.uploadReleaseEditId = null;
this.uploadReleaseSavingId = null;
this.uploadReleaseDraft = null;
},
async saveUploadReleaseEdit() {
if (!this.uploadReleaseEditId || !this.uploadReleaseDraft) return;
const id = this.uploadReleaseEditId;
const draft = this.uploadReleaseDraft;
this.uploadReleaseSavingId = id;
try {
const res = await fetch(`/api/player/uploads/releases/${id}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: draft.title,
artist_names: String(draft.artists || '').split(',').map(name => name.trim()).filter(Boolean),
release_type: draft.release_type,
year: String(draft.year || ''),
is_hidden: !!draft.is_hidden,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to save release');
this.applyUploadPage(data);
this.cancelUploadReleaseEdit();
this._setMessage('Release metadata saved');
} catch (err) {
this._setMessage(err.message || String(err), true);
} finally {
this.uploadReleaseSavingId = null;
}
},
uploadReviewPayload() {
const draft = this.uploadReviewDraft || {};
return {
title: draft.title,
artist: draft.artist,
album: draft.album,
year: String(draft.year || ''),
track_number: String(draft.track_number || ''),
genre: draft.genre,
featured_artists: String(draft.featured_artists || '').split(',').map(name => name.trim()).filter(Boolean),
release_type: draft.release_type || 'album',
notes: draft.notes,
};
},
editUploadReview(item) {
if (!item) return;
this.cancelUploadEdit();
this.cancelUploadReleaseEdit();
this.uploadReviewEditId = item.id;
this.uploadReviewDraft = {
title: item.fields?.title || '',
artist: item.fields?.artist || '',
album: item.fields?.album || '',
year: item.fields?.year || '',
track_number: item.fields?.track_number || '',
genre: item.fields?.genre || '',
featured_artists: (item.fields?.featured_artists || []).join(', '),
release_type: item.fields?.release_type || 'album',
notes: item.fields?.notes || '',
};
},
cancelUploadReviewEdit() {
this.uploadReviewEditId = null;
this.uploadReviewSavingId = null;
this.uploadReviewDraft = null;
},
async saveUploadReview() {
if (!this.uploadReviewEditId || !this.uploadReviewDraft) 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()),
});
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');
} catch (err) {
this._setMessage(err.message || String(err), true);
} finally {
this.uploadReviewSavingId = null;
}
},
async approveUploadReview() {
if (!this.uploadReviewEditId || !this.uploadReviewDraft) return;
const id = this.uploadReviewEditId;
this.uploadReviewSavingId = id;
try {
const res = await fetch(`/api/player/uploads/reviews/${id}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.uploadReviewPayload()),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to approve review');
this.applyUploadPage(data);
this.cancelUploadReviewEdit();
this._setMessage('Track approved and imported');
} catch (err) {
this._setMessage(err.message || String(err), true);
} finally {
this.uploadReviewSavingId = null;
}
},
async saveUploadBulkEdit() {
const trackIds = [...this.selectedUploadTracks];
if (trackIds.length === 0) return;
const draft = this.uploadBulkDraft;
const hidden = draft.hidden === '' ? null : draft.hidden === 'true';
const artists = String(draft.artists || '').split(',').map(name => name.trim()).filter(Boolean);
const featured = String(draft.featured_artists || '').split(',').map(name => name.trim()).filter(Boolean);
this.uploadBulkSaving = true;
try {
const res = await fetch('/api/player/uploads/bulk-tracks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
track_ids: trackIds,
artist_names: artists.length ? artists : null,
featured_artist_names: featured.length ? featured : null,
release_title: draft.release_title || null,
release_type: draft.release_type || null,
release_year: draft.release_year === '' ? null : String(draft.release_year),
is_hidden: hidden,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to update selected tracks');
this.applyUploadPage(data);
this.clearUploadSelection();
this._setMessage('Selected tracks updated');
} catch (err) {
this._setMessage(err.message || String(err), true);
} finally {
this.uploadBulkSaving = false;
}
},
applyUploadPage(data) {
this.uploadTracks = Array.isArray(data.tracks) ? data.tracks : [];
this.uploadReleases = Array.isArray(data.releases) ? data.releases : [];
this.uploadPending = Array.isArray(data.pending) ? data.pending : [];
this.uploadQueued = Array.isArray(data.queued) ? data.queued : [];
this.uploadPendingTotal = Number(data.pending_total || this.uploadPending.length || 0);
this.uploadQueuedTotal = Number(data.queued_total || this.uploadQueued.length || 0);
this.pruneUploadSelection();
},
_startRefresh() {
this._stopRefresh();
this._refreshTimer = setInterval(() => {
if (!this.modal) return;
this.loadSessions();
if (this.activeTab === 'uploads') {
this.loadUploads({ silent: true });
}
else this.loadSessions();
this.loadAgentStatus();
}, 5000);
},
+66 -3
View File
@@ -762,7 +762,7 @@
<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)">
@dblclick="$store.queue.playRelease([track], 0)">
<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>
@@ -786,7 +786,7 @@
<span x-show="$store.library.hasPopularity(track)" x-text="$store.library.popularityLabel(track)"></span>
<span x-show="!$store.library.hasPopularity(track)" class="info-letter">i</span>
</button>
<button class="track-action-btn play-btn" @click.stop="$store.queue.playRelease($store.library.currentRelease.tracks, idx)" title="{{ t.player_play }}">
<button class="track-action-btn play-btn" @click.stop="$store.queue.playRelease([track], 0)" title="{{ t.player_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="{{ t.player_like }}">
@@ -1073,7 +1073,7 @@
<div class="device-popover" x-show="$store.devices.open" x-transition x-cloak>
<template x-for="device in $store.devices.devices" :key="device.id">
<button class="device-row"
:class="{ active: device.is_active }"
:class="{ active: device.is_active, 'current-device': device.is_current }"
@click="$store.devices.select(device.id)">
<span class="device-row-icon">
<template x-if="device.kind === 'phone'">
@@ -1101,6 +1101,69 @@
</span>
</button>
</template>
<template x-if="$store.devices.jams.length > 0">
<div class="device-section-label jam-section-label">Jams</div>
</template>
<template x-for="jam in $store.devices.jams" :key="jam.id">
<button class="device-row jam-row"
:class="{ active: jam.is_active, pending: jam.is_pending }"
@click="$store.devices.selectJam(jam)">
<span class="device-row-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="8" cy="8" r="3"/>
<circle cx="16" cy="8" r="3"/>
<path d="M4 20v-1a4 4 0 014-4h0a4 4 0 014 4v1"/>
<path d="M12 20v-1a4 4 0 014-4h0a4 4 0 014 4v1"/>
</svg>
</span>
<span class="device-row-main">
<span class="device-row-name" x-text="jam.name"></span>
<span class="device-row-current"
x-text="!jam.host_device_online ? 'Host offline' : (jam.is_pending ? 'Invite pending' : (jam.is_owner ? 'Your Jam' : 'Shared queue'))"></span>
</span>
<span class="device-row-check" x-show="jam.is_active">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4">
<polyline points="20 6 9 17 4 12"/>
</svg>
</span>
</button>
</template>
<button class="device-row start-jam-row" @click="$store.devices.openJamPanel()">
<span class="device-row-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 5v14"/>
<path d="M5 12h14"/>
</svg>
</span>
<span class="device-row-main">
<span class="device-row-name">Start Jam</span>
<span class="device-row-current">Invite listeners</span>
</span>
</button>
<div class="jam-create-panel" x-show="$store.devices.jamPanelOpen" x-transition x-cloak>
<div class="jam-selected-users" x-show="$store.devices.jamSelectedUsers.length > 0">
<template x-for="user in $store.devices.jamSelectedUsers" :key="user.id">
<button class="jam-user-chip" @click="$store.devices.removeJamInvitee(user.id)">
<span x-text="user.display_name || user.username"></span>
<span aria-hidden="true">×</span>
</button>
</template>
</div>
<input class="jam-user-search"
type="search"
placeholder="Search users"
x-model="$store.devices.jamQuery"
@input="$store.devices.queueJamSearch()">
<div class="jam-search-results" x-show="$store.devices.jamUsers.length > 0">
<template x-for="user in $store.devices.jamUsers" :key="user.id">
<button class="jam-search-row" @click="$store.devices.addJamInvitee(user)">
<span x-text="user.display_name || user.username"></span>
<small x-text="user.email || user.username"></small>
</button>
</template>
</div>
<button class="jam-create-btn" @click="$store.devices.createJam()">Create Jam</button>
</div>
</div>
</div>
</div>
+796 -31
View File
@@ -744,18 +744,6 @@ button.user-stat:hover {
align-items: center;
gap: 2px;
opacity: 1;
transition: opacity 0.15s;
}
.track-actions > :not(.popularity-info-btn) {
opacity: 0;
pointer-events: none;
transition: opacity 0.15s;
}
.track-row:hover .track-actions > * {
opacity: 1;
pointer-events: auto;
}
.track-action-btn {
@@ -1055,21 +1043,9 @@ button.user-stat:hover {
align-items: center;
gap: 2px;
opacity: 1;
transition: opacity 0.15s;
flex-shrink: 0;
}
.queue-track-actions > :not(.popularity-info-btn) {
opacity: 0;
pointer-events: none;
transition: opacity 0.15s;
}
.queue-track:hover .queue-track-actions > * {
opacity: 1;
pointer-events: auto;
}
.queue-track-remove {
background: none;
border: none;
@@ -1107,11 +1083,9 @@ button.user-stat:hover {
display: flex;
align-items: center;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.15s;
opacity: 1;
}
.queue-track:hover .queue-drag-handle { opacity: 1; }
.queue-drag-handle:active { cursor: grabbing; }
.queue-drag-handle svg { width: 14px; height: 14px; }
@@ -1412,6 +1386,14 @@ button.user-stat:hover {
color: var(--text-primary);
}
.device-row.current-device {
background: rgba(255,255,255,0.035);
}
.device-row.current-device:hover {
background: rgba(255,255,255,0.055);
}
.device-row.active {
color: var(--accent);
}
@@ -1460,6 +1442,123 @@ button.user-stat:hover {
white-space: nowrap;
}
.device-section-label {
padding: 8px 8px 4px;
color: var(--text-subdued);
font-size: 11px;
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
}
.jam-section-label,
.jam-row,
.start-jam-row,
.jam-create-panel {
background: rgba(82,145,255,0.045);
}
.jam-row:hover,
.start-jam-row:hover {
background: rgba(82,145,255,0.075);
}
.jam-row.active {
background: rgba(82,145,255,0.105);
color: #9bbcff;
}
.jam-row.pending .device-row-name {
color: var(--text-primary);
}
.start-jam-row {
margin-top: 4px;
border-top: 1px solid rgba(82,145,255,0.16);
}
.jam-create-panel {
margin: 6px 2px 2px;
padding: 8px;
border: 1px solid rgba(82,145,255,0.18);
border-radius: 6px;
}
.jam-selected-users {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-bottom: 7px;
}
.jam-user-chip {
border: 1px solid rgba(82,145,255,0.2);
border-radius: 4px;
background: rgba(82,145,255,0.07);
color: var(--text-secondary);
display: inline-flex;
align-items: center;
gap: 5px;
max-width: 100%;
padding: 3px 6px;
font-size: 12px;
cursor: pointer;
}
.jam-user-search {
width: 100%;
height: 30px;
border: 1px solid rgba(82,145,255,0.2);
border-radius: 4px;
background: rgba(82,145,255,0.045);
color: var(--text-primary);
padding: 0 8px;
font-size: 13px;
}
.jam-search-results {
margin-top: 6px;
display: grid;
gap: 2px;
}
.jam-search-row {
border: 0;
border-radius: 4px;
background: rgba(82,145,255,0.035);
color: var(--text-secondary);
display: grid;
gap: 1px;
padding: 6px;
text-align: left;
cursor: pointer;
}
.jam-search-row:hover {
background: rgba(82,145,255,0.075);
color: var(--text-primary);
}
.jam-search-row small {
color: var(--text-subdued);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.jam-create-btn {
width: 100%;
height: 30px;
margin-top: 8px;
border: 0;
border-radius: 4px;
background: rgba(82,145,255,0.14);
color: #c9dcff;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
/* Loading */
.loading-spinner {
display: flex;
@@ -2195,6 +2294,50 @@ button.user-stat:hover {
box-shadow: 0 0 0 3px rgba(240,184,77,0.14);
}
.torrent-tabs {
display: flex;
gap: 6px;
margin-bottom: 12px;
flex: 0 0 auto;
}
.torrent-tab-btn {
min-height: 34px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-primary);
color: var(--text-secondary);
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 12px;
font-size: 13px;
font-weight: 800;
cursor: pointer;
}
.torrent-tab-btn:hover,
.torrent-tab-btn.active {
color: var(--text-primary);
background: var(--bg-hover);
}
.torrent-tab-btn.active {
border-color: rgba(29,185,84,0.42);
}
.torrent-tab-count {
min-width: 18px;
height: 18px;
border-radius: 999px;
background: rgba(29,185,84,0.2);
color: #9ff0b9;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
}
.torrent-manager-layout {
display: grid;
grid-template-columns: minmax(260px, 320px) minmax(0, 1fr);
@@ -2327,6 +2470,16 @@ button.user-stat:hover {
color: #d0b6ff;
}
.torrent-status-badge.status-queued {
background: rgba(122,162,255,0.16);
color: #adc3ff;
}
.torrent-status-badge.status-processing {
background: rgba(240,184,77,0.18);
color: #ffd78a;
}
.torrent-status-badge.status-downloading {
background: rgba(29,185,84,0.16);
color: #9ff0b9;
@@ -2543,6 +2696,8 @@ button.user-stat:hover {
.torrent-modal input[type="file"],
.torrent-modal input[type="text"],
.torrent-modal input[type="number"],
.torrent-modal select,
.torrent-modal textarea {
width: 100%;
padding: 10px 12px;
@@ -2556,6 +2711,8 @@ button.user-stat:hover {
.torrent-modal input[type="file"]:focus,
.torrent-modal input[type="text"]:focus,
.torrent-modal input[type="number"]:focus,
.torrent-modal select:focus,
.torrent-modal textarea:focus {
border-color: var(--text-secondary);
}
@@ -2569,6 +2726,508 @@ button.user-stat:hover {
.torrent-message.error { color: #ff8b8b; }
.upload-manager-panel {
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
flex: 1 1 auto;
position: relative;
}
.upload-manager-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.upload-manager-head h4 {
margin: 0;
color: var(--text-primary);
font-size: 16px;
}
.upload-manager-head p {
margin: 3px 0 0;
color: var(--text-subdued);
font-size: 12px;
}
.upload-manager-grid {
min-height: 0;
display: grid;
grid-template-columns: minmax(260px, 330px) minmax(0, 1fr);
gap: 12px;
flex: 1 1 auto;
overflow: hidden;
}
.upload-review-column,
.upload-library-column {
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 10px;
padding-right: 4px;
}
.upload-panel-card {
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
padding: 10px;
}
.upload-panel-subtitle,
.upload-mini-empty {
margin: 4px 0 0;
color: var(--text-subdued);
font-size: 12px;
}
.upload-review-list {
display: grid;
gap: 6px;
margin-top: 8px;
max-height: 196px;
overflow-y: auto;
}
.upload-review-list.editing {
max-height: none;
}
.upload-review-item {
display: grid;
gap: 6px;
}
.upload-review-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 7px;
align-items: center;
width: 100%;
min-height: 34px;
padding: 7px;
border: 1px solid var(--border-color);
border-radius: 7px;
background: var(--bg-secondary);
color: var(--text-secondary);
cursor: pointer;
text-align: left;
}
.upload-review-row.active,
.upload-review-row:hover {
border-color: var(--text-subdued);
background: var(--bg-elevated);
}
.upload-review-row.failed {
border-color: rgba(255,139,139,0.28);
}
.upload-review-name,
.upload-review-error {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.upload-review-name {
font-size: 13px;
font-weight: 750;
}
.upload-review-error {
grid-column: 2;
color: #ffb9b9;
font-size: 11px;
}
.upload-review-editor-inline {
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
padding: 10px;
}
.upload-review-editor-inline .upload-panel-title {
margin-bottom: 8px;
}
.upload-review-form {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.upload-field {
display: flex;
flex-direction: column;
gap: 5px;
min-width: 0;
margin: 0;
}
.upload-field span {
color: var(--text-subdued);
font-size: 11px;
font-weight: 850;
text-transform: uppercase;
}
.upload-field-half {
grid-column: span 2;
}
.upload-field-compact {
grid-column: span 1;
}
.upload-field-wide,
.upload-review-actions {
grid-column: 1 / -1;
}
.upload-review-form input,
.upload-review-form select,
.upload-review-form textarea {
min-height: 36px;
font-size: 14px;
}
.upload-review-form textarea {
min-height: 72px;
resize: vertical;
}
.upload-review-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.upload-editor-backdrop {
position: fixed;
inset: 0;
z-index: 140;
display: flex;
align-items: center;
justify-content: flex-end;
padding: 40px;
background: rgba(0,0,0,0.32);
}
.upload-editor-drawer {
width: min(460px, calc(100vw - 48px));
max-height: calc(100vh - 80px);
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: 10px;
background: var(--bg-elevated);
box-shadow: 0 18px 60px rgba(0,0,0,0.42);
padding: 14px;
}
.upload-editor-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.upload-editor-head h4 {
margin: 0;
color: var(--text-primary);
font-size: 16px;
line-height: 1.25;
overflow-wrap: anywhere;
}
.upload-editor-form {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.upload-editor-form input,
.upload-editor-form select,
.upload-editor-form textarea {
min-height: 38px;
font-size: 14px;
}
.upload-editor-form textarea {
min-height: 96px;
resize: vertical;
}
.upload-field-toggle {
grid-column: 1 / -1;
flex-direction: row;
align-items: center;
min-height: 38px;
}
.upload-field-toggle input {
width: auto !important;
}
.upload-editor-actions {
grid-column: 1 / -1;
display: flex;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.upload-bulk-bar {
display: grid;
grid-template-columns: auto repeat(4, minmax(86px, 1fr)) auto auto auto;
gap: 8px;
align-items: center;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
}
.upload-bulk-title {
color: var(--text-primary);
font-size: 13px;
font-weight: 900;
white-space: nowrap;
}
.upload-release-tree {
display: grid;
gap: 8px;
}
.upload-release-node {
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
overflow: hidden;
}
.upload-release-node.hidden,
.upload-tree-track.hidden {
opacity: 0.72;
}
.upload-release-row {
display: grid;
grid-template-columns: 24px 24px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
padding: 9px 10px;
}
.upload-release-main,
.upload-track-main {
min-width: 0;
}
.upload-release-title {
color: var(--text-primary);
font-size: 14px;
font-weight: 900;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.upload-track-children {
border-top: 1px solid var(--border-color);
}
.upload-tree-track {
display: grid;
grid-template-columns: 24px 30px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
min-height: 44px;
padding: 7px 10px 7px 28px;
border-top: 1px solid rgba(255,255,255,0.04);
}
.upload-tree-track:first-child {
border-top: 0;
}
.upload-tree-track.selected {
background: var(--bg-hover);
}
.upload-release-edit-form,
.upload-track-edit-form {
margin: 0 10px 10px;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
}
.upload-tree-track .upload-track-edit-form {
grid-column: 1 / -1;
margin: 4px 0 0;
}
.upload-wide-field {
grid-column: 1 / -1;
}
.upload-queue-panel {
flex: 0 0 auto;
margin-bottom: 12px;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
}
.upload-panel-title {
margin-bottom: 6px;
color: var(--text-secondary);
font-size: 12px;
font-weight: 900;
text-transform: uppercase;
}
.upload-queue-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 8px;
min-height: 30px;
}
.upload-queue-name,
.upload-queue-error {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.upload-queue-name {
color: var(--text-secondary);
font-size: 13px;
}
.upload-queue-error {
grid-column: 2;
color: #ffb9b9;
font-size: 11px;
}
.upload-track-list {
min-height: 0;
overflow-y: auto;
display: grid;
gap: 8px;
padding-right: 4px;
}
.upload-track-card {
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
padding: 10px;
}
.upload-track-card.hidden {
opacity: 0.72;
}
.upload-track-display {
display: grid;
grid-template-columns: 34px minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
}
.upload-track-main {
min-width: 0;
}
.upload-track-title {
color: var(--text-primary);
font-size: 14px;
font-weight: 800;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.upload-hidden-pill {
display: inline-flex;
margin-left: 6px;
padding: 1px 5px;
border-radius: 999px;
background: rgba(240,184,77,0.16);
color: #ffd78a;
font-size: 10px;
text-transform: uppercase;
}
.upload-track-meta {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 3px;
color: var(--text-subdued);
font-size: 12px;
}
.upload-track-actions {
display: flex;
align-items: center;
gap: 6px;
}
.upload-edit-form {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.upload-edit-form label {
margin: 0;
}
.upload-edit-form label span {
display: block;
margin-bottom: 4px;
}
.upload-hidden-toggle {
display: flex !important;
align-items: center;
gap: 8px;
padding-top: 22px;
}
.upload-hidden-toggle input {
width: auto;
}
.upload-edit-actions {
grid-column: 1 / -1;
display: flex;
justify-content: flex-end;
gap: 8px;
}
.torrent-preview-head {
display: flex;
justify-content: space-between;
@@ -2746,13 +3405,10 @@ button.user-stat:hover {
.playlist-item-actions {
display: flex;
gap: 2px;
opacity: 0;
transition: opacity 0.15s;
opacity: 1;
flex-shrink: 0;
}
.playlist-item-row:hover .playlist-item-actions { opacity: 1; }
.playlist-action-btn {
background: none;
border: none;
@@ -3271,6 +3927,115 @@ button.user-stat:hover {
flex: 0 0 178px;
}
.torrent-tabs {
overflow-x: auto;
padding-bottom: 2px;
}
.upload-manager-panel {
overflow-y: auto;
}
.upload-manager-grid {
grid-template-columns: 1fr;
overflow: visible;
}
.upload-review-column,
.upload-library-column {
overflow: visible;
}
.upload-bulk-bar {
grid-template-columns: 1fr;
}
.upload-release-row {
grid-template-columns: 24px 24px minmax(0, 1fr);
}
.upload-release-row .modal-btn {
grid-column: 1 / -1;
justify-self: flex-end;
}
.upload-tree-track {
grid-template-columns: 24px 30px minmax(0, 1fr);
padding-left: 12px;
}
.upload-tree-track .upload-track-actions {
grid-column: 1 / -1;
justify-content: flex-end;
}
.upload-manager-head {
align-items: stretch;
flex-direction: column;
gap: 8px;
}
.upload-track-display {
grid-template-columns: 32px minmax(0, 1fr);
}
.upload-track-actions {
grid-column: 1 / -1;
justify-content: flex-end;
}
.upload-edit-form {
grid-template-columns: 1fr;
}
.upload-review-form,
.upload-editor-form {
grid-template-columns: 1fr;
}
.upload-field-half,
.upload-field-compact {
grid-column: 1 / -1;
}
.upload-review-actions {
justify-content: stretch;
}
.upload-review-actions .modal-btn {
flex: 1 1 120px;
min-height: 36px;
}
.upload-editor-backdrop {
align-items: flex-end;
padding: 0;
background: rgba(0,0,0,0.42);
}
.upload-editor-drawer {
width: 100%;
max-height: min(88dvh, calc(100dvh - 56px));
border-right: 0;
border-bottom: 0;
border-left: 0;
border-radius: 12px 12px 0 0;
padding: 14px 14px calc(14px + env(safe-area-inset-bottom));
}
.upload-editor-actions {
justify-content: stretch;
}
.upload-editor-actions .modal-btn {
flex: 1 1 130px;
min-height: 38px;
}
.upload-hidden-toggle {
padding-top: 0;
}
.torrent-progress-head {
align-items: flex-start;
flex-direction: column;