From dedddc7cd80eedc6d8f7911eee60d81f9130363b Mon Sep 17 00:00:00 2001 From: AB Date: Fri, 29 May 2026 13:08:33 +0300 Subject: [PATCH] PLAYER: improved jam feature --- Cargo.toml | 2 +- src/player/dto.rs | 19 ++++ src/player/mod.rs | 193 +++++++++++++++++++++++++++++++--- templates/player/scripts.html | 192 ++++++++++++++++++++++++++++++++- templates/player/shell.html | 37 +++++-- templates/player/styles.html | 80 +++++++++++++- 6 files changed, 495 insertions(+), 28 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 5305f6e..08cb619 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "furumusic" -version = "0.2.8" +version = "0.2.9" edition = "2024" description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL" diff --git a/src/player/dto.rs b/src/player/dto.rs index 885e045..92c543a 100644 --- a/src/player/dto.rs +++ b/src/player/dto.rs @@ -183,6 +183,16 @@ pub(super) struct PlayerJamDto { pub(super) member_count: i64, pub(super) host_last_seen_ms: i64, pub(super) host_device_online: bool, + pub(super) members: Vec, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub(super) struct PlayerJamMemberDto { + pub(super) user_id: i64, + pub(super) name: String, + pub(super) is_joined: bool, + pub(super) is_current_user: bool, + pub(super) last_seen_ms: i64, } #[derive(Debug, Deserialize, JsonSchema)] @@ -192,6 +202,14 @@ pub(super) struct PlayerJamCreateRequest { pub(super) invitee_user_ids: Vec, } +#[derive(Debug, Deserialize, JsonSchema)] +pub(super) struct PlayerJamInviteRequest { + pub(super) jam_id: String, + pub(super) device_id: String, + #[serde(default)] + pub(super) invitee_user_ids: Vec, +} + #[derive(Debug, Deserialize, JsonSchema)] pub(super) struct PlayerJamJoinRequest { pub(super) jam_id: String, @@ -286,6 +304,7 @@ pub(super) struct UserStats { #[derive(Debug, Serialize, JsonSchema)] pub(super) struct UserProfile { + pub(super) id: i64, pub(super) name: String, pub(super) role: String, pub(super) stats: UserStats, diff --git a/src/player/mod.rs b/src/player/mod.rs index 15905bd..32d2bc0 100644 --- a/src/player/mod.rs +++ b/src/player/mod.rs @@ -81,6 +81,7 @@ enum PlayerJamMemberStatus { #[derive(Debug, Clone)] struct PlayerJamMember { + name: String, status: PlayerJamMemberStatus, last_seen_ms: i64, } @@ -393,6 +394,10 @@ impl PlayerDeviceHub { let mut state = self.state.lock().expect("player device hub lock"); self.prune_locked(&mut state, now); + if self.user_has_joined_jam_locked(&state, host_user_id) { + return Err("leave the current jam before creating a new one"); + } + let devices = state .devices_by_user .get(&host_user_id) @@ -410,19 +415,21 @@ impl PlayerDeviceHub { members.insert( host_user_id, PlayerJamMember { + name: host_name.to_string(), status: PlayerJamMemberStatus::Joined, last_seen_ms: now, }, ); seen.insert(host_user_id); - for (user_id, _name) in invitees.into_iter().take(PLAYER_JAM_MAX_INVITEES) { + for (user_id, name) in invitees.into_iter().take(PLAYER_JAM_MAX_INVITEES) { if !seen.insert(user_id) { continue; } members.insert( user_id, PlayerJamMember { + name, status: PlayerJamMemberStatus::Invited, last_seen_ms: 0, }, @@ -444,6 +451,7 @@ impl PlayerDeviceHub { fn join_jam( &self, user_id: i64, + user_name: &str, device_id: &str, jam_id: &str, ) -> Result { @@ -457,6 +465,7 @@ impl PlayerDeviceHub { let Some(member) = jam.members.get_mut(&user_id) else { return Err("jam is not available"); }; + member.name = user_name.to_string(); member.status = PlayerJamMemberStatus::Joined; member.last_seen_ms = now; if user_id == jam.host_user_id { @@ -466,6 +475,51 @@ impl PlayerDeviceHub { Ok(self.snapshot_locked(&state, user_id, device_id, Some(jam_id), now)) } + fn invite_to_jam( + &self, + inviter_user_id: i64, + device_id: &str, + jam_id: &str, + invitees: Vec<(i64, String)>, + ) -> Result { + let now = current_millis(); + let mut state = self.state.lock().expect("player device hub lock"); + self.prune_locked(&mut state, now); + + let Some(jam) = state.jams_by_id.get_mut(jam_id) else { + return Err("jam is not available"); + }; + let Some(inviter) = jam.members.get(&inviter_user_id) else { + return Err("jam is not available"); + }; + if inviter.status != PlayerJamMemberStatus::Joined { + return Err("join the jam first"); + } + if let Some(inviter) = jam.members.get_mut(&inviter_user_id) { + inviter.last_seen_ms = now; + } + if inviter_user_id == jam.host_user_id { + jam.host_last_seen_ms = now; + } + + let available_slots = PLAYER_JAM_MAX_INVITEES.saturating_sub(jam.members.len()); + for (user_id, name) in invitees.into_iter().take(available_slots) { + if user_id == inviter_user_id || jam.members.contains_key(&user_id) { + continue; + } + jam.members.insert( + user_id, + PlayerJamMember { + name, + status: PlayerJamMemberStatus::Invited, + last_seen_ms: 0, + }, + ); + } + + Ok(self.snapshot_locked(&state, inviter_user_id, device_id, Some(jam_id), now)) + } + fn leave_jam( &self, user_id: i64, @@ -561,6 +615,14 @@ impl PlayerDeviceHub { !require_joined || member.status == PlayerJamMemberStatus::Joined } + fn user_has_joined_jam_locked(&self, state: &PlayerDeviceHubState, user_id: i64) -> bool { + state.jams_by_id.values().any(|jam| { + jam.members + .get(&user_id) + .is_some_and(|member| member.status == PlayerJamMemberStatus::Joined) + }) + } + fn jam_target_device_id_locked( &self, state: &PlayerDeviceHubState, @@ -612,6 +674,23 @@ impl PlayerDeviceHub { .values() .filter(|member| member.status == PlayerJamMemberStatus::Joined) .count() as i64; + let mut members = jam + .members + .iter() + .map(|(member_user_id, member)| PlayerJamMemberDto { + user_id: *member_user_id, + name: member.name.clone(), + is_joined: member.status == PlayerJamMemberStatus::Joined, + is_current_user: *member_user_id == user_id, + last_seen_ms: now.saturating_sub(member.last_seen_ms), + }) + .collect::>(); + members.sort_by(|a, b| { + b.is_joined + .cmp(&a.is_joined) + .then_with(|| b.is_current_user.cmp(&a.is_current_user)) + .then_with(|| a.name.cmp(&b.name)) + }); let host_device_online = self.jam_target_device_id_locked(state, jam).is_some(); Some(PlayerJamDto { id: jam.id.clone(), @@ -625,6 +704,7 @@ impl PlayerDeviceHub { member_count, host_last_seen_ms: now.saturating_sub(jam.host_last_seen_ms), host_device_online, + members, }) }) .collect(); @@ -849,6 +929,7 @@ async fn me_handler( .map_err(|e| cot::Error::internal(e.to_string()))?; Json(UserProfile { + id: user.id, name: user.name, role: user.role.code().to_string(), stats: UserStats { @@ -3899,18 +3980,42 @@ async fn devices_command_handler( None => None, }; + let mut payload = dto.payload; + if jam_id.is_some() && matches!(command, "queue_add_end" | "queue_add_next") { + stamp_jam_queue_tracks(&mut payload, user.id, &user.name); + } + match hub.enqueue_command( user.id, target_device_id.as_deref(), jam_id.as_deref(), command, - dto.payload, + payload, ) { Ok(()) => Json(serde_json::json!({"ok": true})).into_response(), Err(message) => Ok(json_error(StatusCode::BAD_REQUEST, message)), } } +fn stamp_jam_queue_tracks(payload: &mut serde_json::Value, user_id: i64, user_name: &str) { + let Some(tracks) = payload.get_mut("tracks").and_then(serde_json::Value::as_array_mut) else { + return; + }; + for track in tracks { + let Some(track_object) = track.as_object_mut() else { + continue; + }; + track_object.insert( + "added_by_user_id".to_string(), + serde_json::Value::Number(user_id.into()), + ); + track_object.insert( + "added_by_user_name".to_string(), + serde_json::Value::String(user_name.to_string()), + ); + } +} + async fn jam_users_search_handler( session: Session, db: Database, @@ -3984,19 +4089,31 @@ async fn jam_create_handler( return Ok(json_error(StatusCode::BAD_REQUEST, "invalid device id")); }; - let mut invitee_ids = dto - .invitee_user_ids + let invitees = load_jam_invitees(pool, user.id, dto.invitee_user_ids).await?; + + match hub.create_jam(user.id, &user.name, &device_id, invitees) { + Ok(response) => Json(response).into_response(), + Err(message) => Ok(json_error(StatusCode::BAD_REQUEST, message)), + } +} + +async fn load_jam_invitees( + pool: &sqlx::PgPool, + current_user_id: i64, + invitee_user_ids: Vec, +) -> cot::Result> { + let mut invitee_ids = invitee_user_ids .into_iter() - .filter(|id| *id > 0 && *id != user.id) + .filter(|id| *id > 0 && *id != current_user_id) .collect::>(); invitee_ids.sort_unstable(); invitee_ids.dedup(); invitee_ids.truncate(PLAYER_JAM_MAX_INVITEES); - let invitees = if invitee_ids.is_empty() { - Vec::new() + if invitee_ids.is_empty() { + Ok(Vec::new()) } else { - sqlx::query_as::<_, PlayerJamUserRow>( + let invitees = sqlx::query_as::<_, PlayerJamUserRow>( r#"SELECT id, username::text AS username, display_name, email FROM furumusic__user WHERE is_active = true AND id = ANY($1)"#, @@ -4015,12 +4132,8 @@ async fn jam_create_handler( .to_string(); (row.id, name) }) - .collect::>() - }; - - match hub.create_jam(user.id, &user.name, &device_id, invitees) { - Ok(response) => Json(response).into_response(), - Err(message) => Ok(json_error(StatusCode::BAD_REQUEST, message)), + .collect::>(); + Ok(invitees) } } @@ -4040,7 +4153,31 @@ async fn jam_join_handler( return Ok(json_error(StatusCode::BAD_REQUEST, "invalid device id")); }; - match hub.join_jam(user.id, &device_id, &jam_id) { + match hub.join_jam(user.id, &user.name, &device_id, &jam_id) { + Ok(response) => Json(response).into_response(), + Err(message) => Ok(json_error(StatusCode::BAD_REQUEST, message)), + } +} + +async fn jam_invite_handler( + session: Session, + db: Database, + pool: &sqlx::PgPool, + hub: Arc, + Json(dto): Json, +) -> cot::Result { + let Some(user) = auth::get_session_user(&session, &db).await else { + return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); + }; + let Some(jam_id) = normalize_device_id(&dto.jam_id) else { + return Ok(json_error(StatusCode::BAD_REQUEST, "invalid jam id")); + }; + let Some(device_id) = normalize_device_id(&dto.device_id) else { + return Ok(json_error(StatusCode::BAD_REQUEST, "invalid device id")); + }; + + let invitees = load_jam_invitees(pool, user.id, dto.invitee_user_ids).await?; + match hub.invite_to_jam(user.id, &device_id, &jam_id, invitees) { Ok(response) => Json(response).into_response(), Err(message) => Ok(json_error(StatusCode::BAD_REQUEST, message)), } @@ -6571,6 +6708,32 @@ impl App for PlayerApp { }), "player_jams_join", ), + Route::with_handler_and_name( + "/jams/invite", + post({ + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + let device_hub = Arc::clone(&device_hub); + move |session: Session, db: Database, json: Json| { + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + let device_hub = Arc::clone(&device_hub); + async move { + let pg_pool = pool + .get_or_init(|| async { + sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect(&pool_config.database_url) + .await + .expect("player pool") + }) + .await; + jam_invite_handler(session, db, pg_pool, device_hub, json).await + } + } + }), + "player_jams_invite", + ), Route::with_handler_and_name( "/jams/leave", post({ diff --git a/templates/player/scripts.html b/templates/player/scripts.html index f938100..12a746a 100644 --- a/templates/player/scripts.html +++ b/templates/player/scripts.html @@ -1210,6 +1210,8 @@ document.addEventListener('alpine:init', () => { currentJamId: null, open: false, jamPanelOpen: false, + jamPanelMode: 'create', + jamPanelJamId: null, jamQuery: '', jamUsers: [], jamSelectedUsers: [], @@ -1309,6 +1311,32 @@ document.addEventListener('alpine:init', () => { return this.currentJamId ? this.jams.find(jam => jam.id === this.currentJamId) : null; }, + hasJoinedJam() { + return this.jams.some(jam => jam.is_member); + }, + + activeJamMembers() { + const jam = this.selectedJam(); + return (jam?.members || []).filter(member => member.is_joined && Number(member.last_seen_ms || 0) <= 45000); + }, + + jamMemberIds(jamId = this.jamPanelJamId || this.currentJamId) { + const jam = this.jams.find(item => item.id === jamId); + return new Set((jam?.members || []).map(member => Number(member.user_id))); + }, + + userColorStyle(userId, name = '') { + const palette = ['#4cc9f0', '#f72585', '#f9c74f', '#90be6d', '#f8961e', '#b5179e', '#43aa8b', '#577590']; + const raw = String(userId || name || ''); + let hash = 0; + for (let i = 0; i < raw.length; i++) hash = ((hash * 31) + raw.charCodeAt(i)) >>> 0; + const hex = palette[hash % palette.length]; + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return `--jam-contributor-color:${hex};--jam-contributor-bg:rgba(${r},${g},${b},0.13);--jam-contributor-bg-active:rgba(${r},${g},${b},0.2)`; + }, + isControllingRemoteJam() { const jam = this.selectedJam(); return !!jam && !jam.is_owner; @@ -1383,10 +1411,39 @@ document.addEventListener('alpine:init', () => { }, openJamPanel() { + if (this.hasJoinedJam()) return; + this.jamPanelMode = 'create'; + this.jamPanelJamId = null; + this.jamSelectedUsers = []; + this.jamUsers = []; + this.jamQuery = ''; this.jamPanelOpen = !this.jamPanelOpen; if (this.jamPanelOpen && this.jamQuery.trim()) this.searchJamUsers(); }, + openJamManagePanel(jam) { + if (!jam?.is_member) return; + if (this.jamPanelOpen && this.jamPanelMode === 'manage' && this.jamPanelJamId === jam.id) { + this.jamPanelOpen = false; + return; + } + this.jamPanelMode = 'manage'; + this.jamPanelJamId = jam.id; + this.jamSelectedUsers = []; + this.jamUsers = []; + this.jamQuery = ''; + this.jamPanelOpen = true; + }, + + handleJamRowClick(jam) { + if (!jam) return; + if (jam.is_active && jam.is_member) { + this.openJamManagePanel(jam); + return; + } + this.selectJam(jam); + }, + queueJamSearch() { clearTimeout(this._jamSearchTimer); this._jamSearchTimer = setTimeout(() => this.searchJamUsers(), 180); @@ -1403,7 +1460,8 @@ document.addEventListener('alpine:init', () => { 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)); + const existing = this.jamMemberIds(); + this.jamUsers = (await res.json()).filter(user => !selected.has(user.id) && !existing.has(Number(user.id))); } catch { } finally { this.jamSearching = false; @@ -1421,7 +1479,13 @@ document.addEventListener('alpine:init', () => { this.jamSelectedUsers = this.jamSelectedUsers.filter(user => user.id !== userId); }, + submitJamPanel() { + if (this.jamPanelMode === 'manage') this.inviteToJam(); + else this.createJam(); + }, + async createJam() { + if (this.hasJoinedJam()) return; try { const res = await fetch('/api/player/jams', { method: 'POST', @@ -1442,6 +1506,26 @@ document.addEventListener('alpine:init', () => { } catch {} }, + async inviteToJam() { + if (!this.jamPanelJamId || this.jamSelectedUsers.length === 0) return; + try { + const res = await fetch('/api/player/jams/invite', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jam_id: this.jamPanelJamId, + device_id: this.id, + invitee_user_ids: this.jamSelectedUsers.map(user => user.id), + }), + }); + if (!res.ok) return; + this._apply(await res.json()); + this.jamQuery = ''; + this.jamUsers = []; + this.jamSelectedUsers = []; + } catch {} + }, + async selectJam(jam) { if (!jam) return; try { @@ -1491,6 +1575,8 @@ document.addEventListener('alpine:init', () => { clearJamSelection() { this.currentJamId = null; + this.jamPanelOpen = false; + this.jamPanelJamId = null; sessionStorage.removeItem('furu_player_jam_id'); }, }); @@ -1503,6 +1589,9 @@ document.addEventListener('alpine:init', () => { currentIndex: 0, visible: false, _dragIdx: null, + _dragOverIdx: null, + _pointerDragMove: null, + _pointerDragEnd: null, add(track) { this.addToEnd([track]); @@ -1514,7 +1603,7 @@ document.addEventListener('alpine:init', () => { }, addToEnd(tracks) { - const items = this._trackList(tracks); + const items = this._tracksForQueueAdd(tracks); if (!items.length) return; if (this._sendRemoteQueueCommand('queue_add_end', { tracks: items })) { this._addToEndLocal(items); @@ -1524,7 +1613,7 @@ document.addEventListener('alpine:init', () => { }, addNextInQueue(tracks) { - const items = this._trackList(tracks); + const items = this._tracksForQueueAdd(tracks); if (!items.length) return; if (this._sendRemoteQueueCommand('queue_add_next', { tracks: items })) { this._addNextLocal(items); @@ -1559,6 +1648,74 @@ document.addEventListener('alpine:init', () => { this._moveTrackLocal(fromIdx, toIdx); }, + startPointerReorder(event, idx) { + if (event.pointerType === 'mouse') return; + if (event.button && event.button !== 0) return; + if (idx < 0 || idx >= this.tracks.length) return; + event.preventDefault(); + this._endPointerReorder(false); + this._dragIdx = idx; + this._dragOverIdx = idx; + const handle = event.currentTarget; + try { + handle?.setPointerCapture?.(event.pointerId); + } catch (_) {} + + this._pointerDragMove = (moveEvent) => { + moveEvent.preventDefault(); + this._autoScrollDuringReorder(moveEvent.clientY); + const target = document + .elementFromPoint(moveEvent.clientX, moveEvent.clientY) + ?.closest?.('.queue-track[data-queue-index]'); + const targetIdx = Number(target?.dataset?.queueIndex); + if (!Number.isInteger(targetIdx) || targetIdx < 0 || targetIdx >= this.tracks.length) return; + this._dragOverIdx = targetIdx; + document.querySelectorAll('.queue-track.drag-over').forEach(el => el.classList.remove('drag-over')); + if (targetIdx !== this._dragIdx) target.classList.add('drag-over'); + }; + + this._pointerDragEnd = (endEvent) => { + try { + if (handle?.hasPointerCapture?.(endEvent.pointerId)) handle.releasePointerCapture(endEvent.pointerId); + } catch (_) {} + this._endPointerReorder(true); + }; + + window.addEventListener('pointermove', this._pointerDragMove, { passive: false }); + window.addEventListener('pointerup', this._pointerDragEnd, { passive: false }); + window.addEventListener('pointercancel', this._pointerDragEnd, { passive: false }); + }, + + _autoScrollDuringReorder(clientY) { + const scroller = document.querySelector('.queue-tracks'); + if (!scroller) return; + const rect = scroller.getBoundingClientRect(); + const edge = 52; + if (clientY < rect.top + edge) { + scroller.scrollTop -= Math.ceil((rect.top + edge - clientY) / 4); + } else if (clientY > rect.bottom - edge) { + scroller.scrollTop += Math.ceil((clientY - (rect.bottom - edge)) / 4); + } + }, + + _endPointerReorder(commit) { + if (this._pointerDragMove) window.removeEventListener('pointermove', this._pointerDragMove); + if (this._pointerDragEnd) { + window.removeEventListener('pointerup', this._pointerDragEnd); + window.removeEventListener('pointercancel', this._pointerDragEnd); + } + document.querySelectorAll('.queue-track.drag-over').forEach(el => el.classList.remove('drag-over')); + const fromIdx = this._dragIdx; + const toIdx = this._dragOverIdx; + this._pointerDragMove = null; + this._pointerDragEnd = null; + this._dragIdx = null; + this._dragOverIdx = null; + if (commit && Number.isInteger(fromIdx) && Number.isInteger(toIdx) && fromIdx !== toIdx) { + this.moveTrack(fromIdx, toIdx); + } + }, + clear() { if (this._sendRemoteQueueCommand('queue_clear')) { this._clearLocal(); @@ -1571,6 +1728,35 @@ document.addEventListener('alpine:init', () => { return (Array.isArray(tracks) ? tracks : [tracks]).filter(Boolean); }, + _tracksForQueueAdd(tracks) { + const items = this._trackList(tracks).map(track => ({ ...track })); + const devices = Alpine.store('devices'); + const jam = devices?.selectedJam?.(); + const user = Alpine.store('user')?.profile; + if (!jam || !jam.is_member || !user?.id) return items; + return items.map(track => ({ + ...track, + added_by_user_id: user.id, + added_by_user_name: user.name || 'User', + })); + }, + + isForeignJamTrack(track) { + const devices = Alpine.store('devices'); + const jam = devices?.selectedJam?.(); + const userId = Alpine.store('user')?.profile?.id; + if (!jam || !jam.is_member || !track?.added_by_user_id || !userId) return false; + return String(track.added_by_user_id) !== String(userId); + }, + + contributorTitle(track) { + return track?.added_by_user_name ? `Added by ${track.added_by_user_name}` : 'Added by another listener'; + }, + + contributorStyle(track) { + return Alpine.store('devices')?.userColorStyle(track?.added_by_user_id, track?.added_by_user_name) || ''; + }, + _sendRemoteQueueCommand(command, payload = {}) { const player = Alpine.store('player'); if (!player?._shouldSendRemote()) return false; diff --git a/templates/player/shell.html b/templates/player/shell.html index 97e44d6..998303d 100644 --- a/templates/player/shell.html +++ b/templates/player/shell.html @@ -892,7 +892,9 @@