Add global mini-player and improve configuration

- Add global activeRoom store for persistent WebSocket connection
- Add MiniPlayer component for playback controls across pages
- Add chunked S3 streaming with 64KB chunks and Range support
- Add queue item removal button
- Move DB credentials to environment variables
- Update .env.example with DB configuration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-12 15:02:02 +03:00
parent 2f1e1f35e3
commit f77a453158
12 changed files with 572 additions and 119 deletions

View File

@@ -3,7 +3,7 @@
<h3>Чат</h3>
<div class="messages" ref="messagesRef">
<ChatMessage
v-for="msg in messages"
v-for="msg in allMessages"
:key="msg.id"
:message="msg"
/>
@@ -13,7 +13,7 @@
type="text"
v-model="newMessage"
placeholder="Написать сообщение..."
:disabled="!ws.connected"
:disabled="!activeRoomStore.connected"
/>
<button type="submit" class="btn-primary" :disabled="!newMessage.trim()">
Отправить
@@ -23,53 +23,43 @@
</template>
<script setup>
import { ref, onMounted, watch, nextTick } from 'vue'
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import api from '../../composables/useApi'
import { useActiveRoomStore } from '../../stores/activeRoom'
import ChatMessage from './ChatMessage.vue'
const props = defineProps({
roomId: {
type: String,
required: true
},
ws: {
type: Object,
required: true
}
})
const messages = ref([])
const activeRoomStore = useActiveRoomStore()
const historyMessages = ref([])
const newMessage = ref('')
const messagesRef = ref(null)
onMounted(async () => {
const response = await api.get(`/api/rooms/${props.roomId}/messages`)
messages.value = response.data
scrollToBottom()
// Combine history + new messages
const allMessages = computed(() => {
return [...historyMessages.value, ...activeRoomStore.chatMessages]
})
// Listen for new messages from WebSocket
watch(() => props.ws, (wsObj) => {
if (wsObj?.messages) {
watch(wsObj.messages, (msgs) => {
const lastMsg = msgs[msgs.length - 1]
if (lastMsg?.type === 'chat_message') {
messages.value.push({
id: lastMsg.id,
user_id: lastMsg.user_id,
username: lastMsg.username,
text: lastMsg.text,
created_at: lastMsg.created_at
})
nextTick(scrollToBottom)
}
}, { deep: true })
}
}, { immediate: true })
onMounted(async () => {
const response = await api.get(`/api/rooms/${props.roomId}/messages`)
historyMessages.value = response.data
nextTick(scrollToBottom)
})
// Auto-scroll when new messages arrive
watch(() => activeRoomStore.chatMessages.length, () => {
nextTick(scrollToBottom)
})
function sendMessage() {
if (!newMessage.value.trim()) return
props.ws.sendChatMessage(newMessage.value)
activeRoomStore.sendChatMessage(newMessage.value)
newMessage.value = ''
}

View File

@@ -0,0 +1,211 @@
<template>
<div class="mini-player" v-if="activeRoomStore.isInRoom">
<div class="mini-player-info" @click="goToRoom">
<div class="room-name">{{ activeRoomStore.roomName }}</div>
<div class="track-info" v-if="currentTrack">
{{ currentTrack.title }} - {{ currentTrack.artist }}
</div>
<div class="track-info" v-else>Нет трека</div>
</div>
<div class="mini-player-controls">
<button class="control-btn" @click="handlePrev">
<span></span>
</button>
<button class="control-btn play-btn" @click="togglePlay">
<span>{{ playerStore.isPlaying ? '⏸' : '▶' }}</span>
</button>
<button class="control-btn" @click="handleNext">
<span></span>
</button>
</div>
<div class="mini-player-progress">
<div class="progress-bar" @click="handleSeek">
<div class="progress-fill" :style="{ width: progressPercent + '%' }"></div>
</div>
<span class="time">{{ formatTime(playerStore.position) }} / {{ formatTime(playerStore.duration) }}</span>
</div>
<button class="leave-btn" @click="handleLeave" title="Выйти из комнаты">
</button>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useActiveRoomStore } from '../../stores/activeRoom'
import { usePlayerStore } from '../../stores/player'
import { useTracksStore } from '../../stores/tracks'
const router = useRouter()
const activeRoomStore = useActiveRoomStore()
const playerStore = usePlayerStore()
const tracksStore = useTracksStore()
const currentTrack = computed(() => {
if (!playerStore.currentTrack?.id) return null
return tracksStore.tracks.find(t => t.id === playerStore.currentTrack.id)
})
const progressPercent = computed(() => {
if (!playerStore.duration) return 0
return (playerStore.position / playerStore.duration) * 100
})
function formatTime(ms) {
const seconds = Math.floor(ms / 1000)
const minutes = Math.floor(seconds / 60)
const secs = seconds % 60
return `${minutes}:${secs.toString().padStart(2, '0')}`
}
function togglePlay() {
if (playerStore.isPlaying) {
activeRoomStore.sendPlayerAction('pause', playerStore.position)
} else {
activeRoomStore.sendPlayerAction('play', playerStore.position)
}
}
function handlePrev() {
activeRoomStore.sendPlayerAction('prev')
}
function handleNext() {
activeRoomStore.sendPlayerAction('next')
}
function handleSeek(e) {
const rect = e.currentTarget.getBoundingClientRect()
const percent = (e.clientX - rect.left) / rect.width
const position = Math.floor(percent * playerStore.duration)
activeRoomStore.sendPlayerAction('seek', position)
}
function goToRoom() {
router.push(`/room/${activeRoomStore.roomId}`)
}
async function handleLeave() {
await activeRoomStore.leaveRoom()
router.push('/')
}
</script>
<style scoped>
.mini-player {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #1a1a2e;
border-top: 1px solid #333;
padding: 12px 20px;
display: flex;
align-items: center;
gap: 20px;
z-index: 1000;
}
.mini-player-info {
flex: 0 0 200px;
cursor: pointer;
overflow: hidden;
}
.room-name {
font-size: 12px;
color: #7c3aed;
margin-bottom: 2px;
}
.track-info {
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mini-player-controls {
display: flex;
align-items: center;
gap: 8px;
}
.control-btn {
background: transparent;
border: none;
color: white;
cursor: pointer;
padding: 8px;
font-size: 16px;
border-radius: 50%;
transition: background 0.2s;
}
.control-btn:hover {
background: #333;
}
.play-btn {
background: #7c3aed;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.play-btn:hover {
background: #6d28d9;
}
.mini-player-progress {
flex: 1;
display: flex;
align-items: center;
gap: 12px;
}
.progress-bar {
flex: 1;
height: 6px;
background: #333;
border-radius: 3px;
cursor: pointer;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: #7c3aed;
border-radius: 3px;
transition: width 0.1s linear;
}
.time {
font-size: 12px;
color: #888;
min-width: 90px;
text-align: right;
}
.leave-btn {
background: transparent;
border: none;
color: #666;
cursor: pointer;
padding: 8px 12px;
font-size: 18px;
border-radius: 4px;
transition: all 0.2s;
}
.leave-btn:hover {
background: #ff4444;
color: white;
}
</style>

View File

@@ -7,14 +7,16 @@
v-for="(track, index) in queue"
:key="track.id"
class="queue-item"
@click="$emit('play-track', track)"
>
<span class="queue-index">{{ index + 1 }}</span>
<div class="queue-track-info">
<div class="queue-track-info" @click="$emit('play-track', track)">
<span class="queue-track-title">{{ track.title }}</span>
<span class="queue-track-artist">{{ track.artist }}</span>
</div>
<span class="queue-duration">{{ formatDuration(track.duration) }}</span>
<button class="btn-remove" @click.stop="$emit('remove-track', track)" title="Удалить из очереди">
</button>
</div>
</div>
</template>
@@ -27,7 +29,7 @@ defineProps({
}
})
defineEmits(['play-track'])
defineEmits(['play-track', 'remove-track'])
function formatDuration(ms) {
const seconds = Math.floor(ms / 1000)
@@ -95,4 +97,20 @@ function formatDuration(ms) {
color: #aaa;
font-size: 12px;
}
.btn-remove {
background: transparent;
border: none;
color: #666;
cursor: pointer;
padding: 4px 8px;
font-size: 14px;
border-radius: 4px;
transition: all 0.2s;
}
.btn-remove:hover {
background: #ff4444;
color: white;
}
</style>