PLAYER: Added users media editor
Build and Publish / Build and Publish Docker Image (push) Successful in 2m57s
Build and Publish / Build and Publish Docker Image (push) Successful in 2m57s
This commit is contained in:
Generated
+1
-1
@@ -1418,7 +1418,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
||||
|
||||
[[package]]
|
||||
name = "furumusic"
|
||||
version = "0.2.2"
|
||||
version = "0.2.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
||||
+1
-1
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user