Compare commits

...

5 Commits

Author SHA1 Message Date
ab 8073ac9a97 PLAYER: Added Jam feature
Build and Publish / Build and Publish Docker Image (push) Successful in 3m16s
2026-05-28 23:38:33 +03:00
ab ec7c5c9049 PLAYER: Added Jam feature 2026-05-28 23:38:07 +03:00
ab d1113effa5 PLAYER: Added users media editor
Build and Publish / Build and Publish Docker Image (push) Successful in 2m57s
2026-05-28 17:34:37 +03:00
ab 072c00a48e PLAYER: Fixed connected device logic.
Build and Publish / Build and Publish Docker Image (push) Successful in 2m50s
2026-05-28 15:17:59 +03:00
ab 34e25fac2c CORE: added 'connected devices' like in spotify
Build and Publish / Build and Publish Docker Image (push) Successful in 2m56s
2026-05-28 13:15:42 +03:00
11 changed files with 5344 additions and 72 deletions
Generated
+1 -1
View File
@@ -1418,7 +1418,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]] [[package]]
name = "furumusic" name = "furumusic"
version = "0.2.1" version = "0.2.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "furumusic" name = "furumusic"
version = "0.2.1" version = "0.2.5"
edition = "2024" edition = "2024"
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL" description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
+4
View File
@@ -338,6 +338,10 @@ translations! {
player_lastfm_connected: "Connected as {user}" , "Подключён: {user}"; player_lastfm_connected: "Connected as {user}" , "Подключён: {user}";
player_lastfm_reconnect: "Reconnect Last.fm" , "Переподключить Last.fm"; player_lastfm_reconnect: "Reconnect Last.fm" , "Переподключить Last.fm";
player_lastfm_not_configured: "Last.fm is not configured" , "Last.fm не настроен"; player_lastfm_not_configured: "Last.fm is not configured" , "Last.fm не настроен";
player_lastfm_status_connect: "connect account" , "подключить аккаунт";
player_lastfm_status_connected: "connected" , "подключён";
player_lastfm_status_reconnect: "reconnect account" , "переподключить аккаунт";
player_lastfm_status_not_configured: "not configured" , "не настроен";
player_lastfm_disconnect_confirm: "Disconnect Last.fm account {user}?" , "Отвязать аккаунт Last.fm {user}?"; player_lastfm_disconnect_confirm: "Disconnect Last.fm account {user}?" , "Отвязать аккаунт Last.fm {user}?";
player_lastfm_connect_failed: "Could not open Last.fm connection" , "Не удалось открыть подключение Last.fm"; player_lastfm_connect_failed: "Could not open Last.fm connection" , "Не удалось открыть подключение Last.fm";
player_lastfm_disconnect_failed: "Could not disconnect Last.fm" , "Не удалось отвязать Last.fm"; player_lastfm_disconnect_failed: "Could not disconnect Last.fm" , "Не удалось отвязать Last.fm";
+233 -2
View File
@@ -40,13 +40,13 @@ pub(super) struct ArtistDetail {
pub(super) featured_tracks: Vec<ArtistAppearanceTrack>, pub(super) featured_tracks: Vec<ArtistAppearanceTrack>,
} }
#[derive(Debug, Serialize, JsonSchema)] #[derive(Debug, Clone, Serialize, JsonSchema)]
pub(super) struct ArtistRef { pub(super) struct ArtistRef {
pub(super) id: i64, pub(super) id: i64,
pub(super) name: String, pub(super) name: String,
} }
#[derive(Debug, Serialize, JsonSchema)] #[derive(Debug, Clone, Serialize, JsonSchema)]
pub(super) struct TrackItem { pub(super) struct TrackItem {
pub(super) id: i64, pub(super) id: i64,
pub(super) title: String, pub(super) title: String,
@@ -137,6 +137,125 @@ pub(super) struct PlaybackStateDto {
pub(super) volume: f64, pub(super) volume: f64,
} }
#[derive(Debug, Deserialize, JsonSchema)]
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>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub(super) struct DeviceSelectRequest {
pub(super) device_id: String,
pub(super) current_device_id: Option<String>,
}
#[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,
}
#[derive(Debug, Serialize, JsonSchema)]
pub(super) struct PlayerDeviceDto {
pub(super) id: String,
pub(super) name: String,
pub(super) kind: String,
pub(super) is_current: bool,
pub(super) is_active: bool,
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,
pub(super) command: String,
pub(super) payload: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub(super) struct PlayerDevicePlaybackStateDto {
pub(super) track: Option<serde_json::Value>,
#[serde(default)]
pub(super) tracks: Vec<serde_json::Value>,
pub(super) index: i32,
pub(super) position_seconds: f64,
pub(super) duration_seconds: f64,
pub(super) paused: bool,
pub(super) shuffle: bool,
pub(super) repeat_mode: String,
pub(super) volume: f64,
#[serde(default)]
pub(super) updated_at_ms: i64,
}
#[derive(Debug, Serialize, JsonSchema)]
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>,
}
#[derive(Debug, Serialize, JsonSchema)]
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>,
}
#[derive(Debug, Serialize, JsonSchema)] #[derive(Debug, Serialize, JsonSchema)]
pub(super) struct PlaylistDetail { pub(super) struct PlaylistDetail {
pub(super) id: i64, pub(super) id: i64,
@@ -207,6 +326,118 @@ pub(super) struct AgentQueueStatus {
pub(super) processing_count: i64, 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)] #[derive(Debug, Serialize, JsonSchema)]
pub(super) struct PlayHistoryItem { pub(super) struct PlayHistoryItem {
pub(super) id: i64, pub(super) id: i64,
+2596 -3
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>, pub(super) ids: Vec<i64>,
} }
#[derive(Debug, Deserialize)]
pub(super) struct UserUploadsQuery {
pub(super) limit: Option<i32>,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(super) struct CreatePlaylistRequest { pub(super) struct CreatePlaylistRequest {
pub(super) title: String, pub(super) title: String,
@@ -62,6 +67,12 @@ pub(super) struct SearchQuery {
pub(super) limit: Option<i32>, 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)] #[derive(Debug, Deserialize)]
pub(super) struct PathTrackId { pub(super) struct PathTrackId {
pub(super) track_id: i64, pub(super) track_id: i64,
+79
View File
@@ -12,6 +12,14 @@ pub(super) struct CountRow {
pub(super) count: i64, 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)] #[derive(sqlx::FromRow)]
pub(super) struct ReleaseRow { pub(super) struct ReleaseRow {
pub(super) id: i64, pub(super) id: i64,
@@ -60,6 +68,13 @@ pub(super) struct TrackArtistRow {
pub(super) role: String, 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)] #[derive(sqlx::FromRow)]
pub(super) struct MediaFileRow { pub(super) struct MediaFileRow {
pub(super) file_path: String, pub(super) file_path: String,
@@ -124,6 +139,70 @@ pub(super) struct PlaylistTrackRow {
pub(super) lastfm_updated_at: Option<String>, 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)] #[derive(sqlx::FromRow)]
pub(super) struct AppearanceTrackRow { pub(super) struct AppearanceTrackRow {
pub(super) id: i64, pub(super) id: i64,
+281
View File
@@ -112,6 +112,21 @@
</div> </div>
</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"> <div class="torrent-manager-layout">
<aside class="torrent-manager-sidebar"> <aside class="torrent-manager-sidebar">
<div class="torrent-manager-title"> <div class="torrent-manager-title">
@@ -319,6 +334,272 @@
</template> </template>
</section> </section>
</div> </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-danger" @click="$store.torrents.deleteUploadReview()" :disabled="$store.torrents.uploadReviewSavingId === $store.torrents.uploadReviewEditId">Delete review</button>
<button class="modal-btn modal-btn-primary" @click="$store.torrents.approveUploadReview()" :disabled="$store.torrents.uploadReviewSavingId === $store.torrents.uploadReviewEditId">Approve</button>
</div>
</div>
</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>
</div> </div>
</template> </template>
File diff suppressed because it is too large Load Diff
+127 -7
View File
@@ -41,9 +41,15 @@
<button class="lastfm-profile-action" <button class="lastfm-profile-action"
:class="$store.user.lastfmClass()" :class="$store.user.lastfmClass()"
:disabled="$store.user.lastfmBusy || !$store.user.lastfm?.configured" :disabled="$store.user.lastfmBusy || !$store.user.lastfm?.configured"
@click="$store.user.handleLastfm()"> @click="$store.user.handleLastfm()"
:title="$store.user.lastfmLabel()"
:aria-label="$store.user.lastfmLabel()">
<span class="lastfm-dot"></span> <span class="lastfm-dot"></span>
<span class="lastfm-profile-text" x-text="$store.user.lastfmLabel()"></span> <span class="lastfm-profile-text">
<span class="lastfm-profile-brand">{{ t.player_lastfm_profile }}</span>
<span class="lastfm-profile-separator">·</span>
<span class="lastfm-profile-status" x-text="$store.user.lastfmStatusLabel()"></span>
</span>
</button> </button>
</div> </div>
<div class="sidebar-header"> <div class="sidebar-header">
@@ -348,9 +354,15 @@
<button class="lastfm-profile-action" <button class="lastfm-profile-action"
:class="$store.user.lastfmClass()" :class="$store.user.lastfmClass()"
:disabled="$store.user.lastfmBusy || !$store.user.lastfm?.configured" :disabled="$store.user.lastfmBusy || !$store.user.lastfm?.configured"
@click="$store.user.handleLastfm()"> @click="$store.user.handleLastfm()"
:title="$store.user.lastfmLabel()"
:aria-label="$store.user.lastfmLabel()">
<span class="lastfm-dot"></span> <span class="lastfm-dot"></span>
<span class="lastfm-profile-text" x-text="$store.user.lastfmLabel()"></span> <span class="lastfm-profile-text">
<span class="lastfm-profile-brand">{{ t.player_lastfm_profile }}</span>
<span class="lastfm-profile-separator">·</span>
<span class="lastfm-profile-status" x-text="$store.user.lastfmStatusLabel()"></span>
</span>
</button> </button>
<button class="modal-btn modal-btn-primary mobile-account-logout" <button class="modal-btn modal-btn-primary mobile-account-logout"
@click="$store.user.logout()"> @click="$store.user.logout()">
@@ -750,7 +762,7 @@
<template x-for="(track, idx) in $store.library.currentRelease.tracks" :key="track.id"> <template x-for="(track, idx) in $store.library.currentRelease.tracks" :key="track.id">
<div class="track-row" <div class="track-row"
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }" :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> <span class="track-num" x-text="track.track_number || (idx + 1)"></span>
<div class="track-info"> <div class="track-info">
<div class="track-title" x-text="track.title"></div> <div class="track-title" x-text="track.title"></div>
@@ -774,7 +786,7 @@
<span x-show="$store.library.hasPopularity(track)" x-text="$store.library.popularityLabel(track)"></span> <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> <span x-show="!$store.library.hasPopularity(track)" class="info-letter">i</span>
</button> </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> <svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</button> </button>
<button class="like-btn" :class="{ liked: $store.likes.has(track.id) }" @click.stop="$store.likes.toggle(track.id)" title="{{ t.player_like }}"> <button class="like-btn" :class="{ liked: $store.likes.has(track.id) }" @click.stop="$store.likes.toggle(track.id)" title="{{ t.player_like }}">
@@ -1038,7 +1050,7 @@
<div class="volume-slider" <div class="volume-slider"
@pointerdown.prevent="$store.player.startVolumeDrag($event)" @pointerdown.prevent="$store.player.startVolumeDrag($event)"
aria-label="{{ t.player_volume }}"> aria-label="{{ t.player_volume }}">
<div class="volume-slider-fill" :style="'width:' + ($store.player.volume * 100) + '%'"> <div class="volume-slider-fill" :style="'width:' + $store.player.volumeSliderPercent() + '%'">
<div class="volume-slider-thumb"></div> <div class="volume-slider-thumb"></div>
</div> </div>
</div> </div>
@@ -1046,5 +1058,113 @@
<button class="queue-toggle-btn" :class="{ active: $store.queue.visible }" @click="$store.queue.visible = !$store.queue.visible" title="{{ t.player_queue }}"> <button class="queue-toggle-btn" :class="{ active: $store.queue.visible }" @click="$store.queue.visible = !$store.queue.visible" title="{{ t.player_queue }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
</button> </button>
<div class="device-picker" @click.outside="$store.devices.open = false">
<button class="queue-toggle-btn device-toggle-btn"
:class="{ active: $store.devices.isActive() }"
@click="$store.devices.toggle()"
:title="$store.devices.activeLabel()"
aria-label="Devices">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="12" rx="2"/>
<path d="M8 20h8"/>
<path d="M12 16v4"/>
</svg>
</button>
<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, 'current-device': device.is_current }"
@click="$store.devices.select(device.id)">
<span class="device-row-icon">
<template x-if="device.kind === 'phone'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="7" y="2" width="10" height="20" rx="2"/>
<path d="M11 18h2"/>
</svg>
</template>
<template x-if="device.kind !== 'phone'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="12" rx="2"/>
<path d="M8 20h8"/>
<path d="M12 16v4"/>
</svg>
</template>
</span>
<span class="device-row-main">
<span class="device-row-name" x-text="device.name"></span>
<span class="device-row-current" x-show="device.is_current">This device</span>
</span>
<span class="device-row-check" x-show="device.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>
<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> </div>
</div> </div>
File diff suppressed because it is too large Load Diff