feat:增加分享音乐功能
This commit is contained in:
@@ -13,11 +13,11 @@ android {
|
||||
applicationId = "com.memory.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 24
|
||||
versionName = "1.4.0"
|
||||
versionCode = 27
|
||||
versionName = "1.4.3"
|
||||
|
||||
buildConfigField("String", "API_BASE_URL", "\"https://x.amos.us.kg/api/\"")
|
||||
buildConfigField("int", "VERSION_CODE", "24")
|
||||
buildConfigField("int", "VERSION_CODE", "27")
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
|
||||
@@ -25,12 +25,24 @@ data class Post(
|
||||
@SerialName("updated_at") val updatedAt: String? = null,
|
||||
val user: User? = null,
|
||||
val media: List<Media> = emptyList(),
|
||||
val music: MusicInfo? = null,
|
||||
val reactions: List<ReactionGroup> = emptyList(),
|
||||
@SerialName("like_count") val likeCount: Int = 0,
|
||||
val liked: Boolean = false,
|
||||
@SerialName("comment_count") val commentCount: Int = 0
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MusicInfo(
|
||||
val id: Long,
|
||||
@SerialName("post_id") val postId: Long,
|
||||
val title: String,
|
||||
val artist: String,
|
||||
val cover: String = "",
|
||||
@SerialName("share_url") val shareUrl: String,
|
||||
val platform: String = "qqmusic"
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Media(
|
||||
val id: Long,
|
||||
@@ -87,7 +99,8 @@ data class LoginResponse(
|
||||
data class CreatePostRequest(
|
||||
val content: String,
|
||||
@SerialName("media_ids") val mediaIds: List<String> = emptyList(),
|
||||
val visibility: Int = 0 // 0=所有人可见, 1=仅自己可见
|
||||
val visibility: Int = 0, // 0=所有人可见, 1=仅自己可见
|
||||
@SerialName("music_url") val musicUrl: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
package com.memory.app.ui.components
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.MusicNote
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import coil.compose.AsyncImage
|
||||
import com.memory.app.data.model.MusicInfo
|
||||
import com.memory.app.ui.theme.Brand500
|
||||
|
||||
@Composable
|
||||
fun MusicCard(
|
||||
music: MusicInfo,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var isExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
) {
|
||||
Column {
|
||||
// 歌曲信息行
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { isExpanded = !isExpanded }
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 封面图
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(56.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (music.cover.isNotEmpty()) {
|
||||
AsyncImage(
|
||||
model = music.cover,
|
||||
contentDescription = "专辑封面",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
// 播放按钮遮罩
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.radialGradient(
|
||||
colors = listOf(
|
||||
Color.Black.copy(alpha = 0.3f),
|
||||
Color.Transparent
|
||||
)
|
||||
)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.PlayArrow,
|
||||
contentDescription = "播放",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MusicNote,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
// 歌曲信息
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = music.title,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = music.artist,
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
// 展开/收起图标
|
||||
Icon(
|
||||
imageVector = if (isExpanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown,
|
||||
contentDescription = if (isExpanded) "收起" else "展开播放器",
|
||||
tint = Brand500,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// 内嵌播放器
|
||||
AnimatedVisibility(
|
||||
visible = isExpanded,
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically()
|
||||
) {
|
||||
EmbeddedMusicPlayer(
|
||||
url = music.shareUrl,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(65.dp)
|
||||
.padding(horizontal = 12.dp)
|
||||
.padding(bottom = 12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
@Composable
|
||||
private fun EmbeddedMusicPlayer(
|
||||
url: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
WebView(context).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
|
||||
settings.apply {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
mediaPlaybackRequiresUserGesture = false
|
||||
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
||||
useWideViewPort = true
|
||||
loadWithOverviewMode = true
|
||||
setSupportZoom(false)
|
||||
builtInZoomControls = false
|
||||
displayZoomControls = false
|
||||
}
|
||||
|
||||
webViewClient = WebViewClient()
|
||||
webChromeClient = WebChromeClient()
|
||||
|
||||
// 设置透明背景
|
||||
setBackgroundColor(android.graphics.Color.TRANSPARENT)
|
||||
|
||||
loadUrl(url)
|
||||
}
|
||||
},
|
||||
modifier = modifier.clip(RoundedCornerShape(8.dp))
|
||||
)
|
||||
}
|
||||
@@ -61,6 +61,7 @@ import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import coil.compose.AsyncImage
|
||||
import com.memory.app.data.api.ApiClient
|
||||
import com.memory.app.data.model.MusicInfo
|
||||
import com.memory.app.data.model.Post
|
||||
import com.memory.app.data.model.UserInfoResponse
|
||||
import com.memory.app.ui.theme.*
|
||||
@@ -76,6 +77,7 @@ fun PostCard(
|
||||
onCommentClick: () -> Unit,
|
||||
onReactionClick: (String) -> Unit,
|
||||
onEditClick: ((Post) -> Unit)? = null,
|
||||
onMusicClick: ((MusicInfo) -> Unit)? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var showEmojiPicker by remember { mutableStateOf(false) }
|
||||
@@ -188,6 +190,15 @@ fun PostCard(
|
||||
)
|
||||
}
|
||||
|
||||
// Music Card
|
||||
if (post.music != null) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
MusicCard(
|
||||
music = post.music,
|
||||
onClick = { onMusicClick?.invoke(post.music) }
|
||||
)
|
||||
}
|
||||
|
||||
// Reactions display
|
||||
if (post.reactions.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
@@ -200,6 +200,7 @@ fun MainNavigation(
|
||||
onImageClick = { }
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Bottom Navigation
|
||||
@@ -261,8 +262,8 @@ fun MainNavigation(
|
||||
CreatePostScreen(
|
||||
user = user,
|
||||
onClose = { showCreatePost = false },
|
||||
onPost = { content, images, visibility ->
|
||||
homeViewModel.createPost(context, content, images, visibility)
|
||||
onPost = { content, images, visibility, musicUrl ->
|
||||
homeViewModel.createPost(context, content, images, visibility, musicUrl)
|
||||
},
|
||||
isLoading = isPosting
|
||||
)
|
||||
|
||||
@@ -12,8 +12,10 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.MusicNote
|
||||
import androidx.compose.material.icons.outlined.Image
|
||||
import androidx.compose.material.icons.outlined.Lock
|
||||
import androidx.compose.material.icons.outlined.MusicNote
|
||||
import androidx.compose.material.icons.outlined.Mood
|
||||
import androidx.compose.material.icons.outlined.Public
|
||||
import androidx.compose.material3.*
|
||||
@@ -34,7 +36,7 @@ import com.memory.app.ui.theme.*
|
||||
fun CreatePostScreen(
|
||||
user: User?,
|
||||
onClose: () -> Unit,
|
||||
onPost: (String, List<Uri>, Int) -> Unit, // 添加 visibility 参数
|
||||
onPost: (String, List<Uri>, Int, String?) -> Unit, // content, images, visibility, musicUrl
|
||||
isLoading: Boolean
|
||||
) {
|
||||
// 拦截系统返回手势
|
||||
@@ -46,6 +48,8 @@ fun CreatePostScreen(
|
||||
var selectedImages by remember { mutableStateOf<List<Uri>>(emptyList()) }
|
||||
var showEmojiPicker by remember { mutableStateOf(false) }
|
||||
var visibility by remember { mutableStateOf(0) } // 0=所有人可见, 1=仅自己可见
|
||||
var musicUrl by remember { mutableStateOf("") }
|
||||
var showMusicInput by remember { mutableStateOf(false) }
|
||||
|
||||
val imagePicker = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.GetMultipleContents()
|
||||
@@ -83,7 +87,7 @@ fun CreatePostScreen(
|
||||
|
||||
// Publish button
|
||||
Button(
|
||||
onClick = { onPost(content, selectedImages, visibility) },
|
||||
onClick = { onPost(content, selectedImages, visibility, musicUrl.ifBlank { null }) },
|
||||
enabled = content.isNotBlank() && !isLoading,
|
||||
shape = RoundedCornerShape(50),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
@@ -211,6 +215,46 @@ fun CreatePostScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Music Link Display
|
||||
if (musicUrl.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.MusicNote,
|
||||
contentDescription = null,
|
||||
tint = Brand500,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "已添加音乐链接",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
IconButton(
|
||||
onClick = { musicUrl = "" },
|
||||
modifier = Modifier.size(24.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "移除",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,6 +286,20 @@ fun CreatePostScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Music Button
|
||||
IconButton(
|
||||
onClick = { showMusicInput = true },
|
||||
enabled = musicUrl.isBlank(),
|
||||
modifier = Modifier.size(40.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.MusicNote,
|
||||
contentDescription = "添加音乐",
|
||||
tint = if (musicUrl.isBlank()) Brand500 else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Emoji Button
|
||||
IconButton(
|
||||
onClick = { showEmojiPicker = true },
|
||||
@@ -312,6 +370,17 @@ fun CreatePostScreen(
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Music Link Input Dialog
|
||||
if (showMusicInput) {
|
||||
MusicLinkInputDialog(
|
||||
onDismiss = { showMusicInput = false },
|
||||
onConfirm = { url ->
|
||||
musicUrl = url
|
||||
showMusicInput = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -445,3 +514,97 @@ private fun CreatePostEmojiPicker(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun MusicLinkInputDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (String) -> Unit
|
||||
) {
|
||||
var inputUrl by remember { mutableStateOf("") }
|
||||
val isValidUrl = inputUrl.contains("y.qq.com") || inputUrl.contains("qq.com")
|
||||
|
||||
androidx.compose.ui.window.Dialog(onDismissRequest = onDismiss) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shadowElevation = 16.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(modifier = Modifier.padding(24.dp)) {
|
||||
// 标题
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(4.dp, 24.dp)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.background(Brand500)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = "添加音乐",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 20.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "粘贴 QQ 音乐分享链接",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 输入框
|
||||
OutlinedTextField(
|
||||
value = inputUrl,
|
||||
onValueChange = { inputUrl = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
"https://y.qq.com/...",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = Brand500,
|
||||
unfocusedBorderColor = MaterialTheme.colorScheme.outline
|
||||
),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
// 按钮
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(
|
||||
"取消",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = { onConfirm(inputUrl) },
|
||||
enabled = isValidUrl,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Brand500,
|
||||
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Text("确定", fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.memory.app.data.model.Post
|
||||
import com.memory.app.ui.components.MusicCard
|
||||
import com.memory.app.ui.theme.*
|
||||
|
||||
@Composable
|
||||
@@ -213,6 +214,15 @@ fun EditPostScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Music Card - 显示已有音乐(编辑时不可修改)
|
||||
if (post.music != null) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
MusicCard(
|
||||
music = post.music,
|
||||
onClick = { }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import com.memory.app.data.model.Comment
|
||||
import com.memory.app.data.model.Post
|
||||
import com.memory.app.ui.components.EmojiPickerDialog
|
||||
import com.memory.app.ui.components.FullScreenImageViewer
|
||||
import com.memory.app.ui.components.MusicCard
|
||||
import com.memory.app.ui.theme.*
|
||||
import com.memory.app.util.TimeUtils
|
||||
|
||||
@@ -204,6 +205,15 @@ fun PostDetailScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Music Card
|
||||
if (post.music != null) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
MusicCard(
|
||||
music = post.music,
|
||||
onClick = { }
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Stats Row
|
||||
|
||||
@@ -190,7 +190,7 @@ class HomeViewModel : ViewModel() {
|
||||
loadPosts()
|
||||
}
|
||||
|
||||
fun createPost(context: Context, content: String, images: List<Uri>, visibility: Int = 0) {
|
||||
fun createPost(context: Context, content: String, images: List<Uri>, visibility: Int = 0, musicUrl: String? = null) {
|
||||
if (_isPosting.value) return
|
||||
viewModelScope.launch {
|
||||
_isPosting.value = true
|
||||
@@ -217,7 +217,8 @@ class HomeViewModel : ViewModel() {
|
||||
val request = CreatePostRequest(
|
||||
content = content,
|
||||
mediaIds = imageUrls,
|
||||
visibility = visibility
|
||||
visibility = visibility,
|
||||
musicUrl = musicUrl
|
||||
)
|
||||
val response = ApiClient.api.createPost(request)
|
||||
if (response.isSuccessful) {
|
||||
|
||||
@@ -169,5 +169,20 @@ func migrate(db *sql.DB) error {
|
||||
db.Exec("CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id)")
|
||||
db.Exec("CREATE INDEX IF NOT EXISTS idx_notifications_is_read ON notifications(is_read)")
|
||||
|
||||
// 迁移:创建音乐分享表
|
||||
db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS post_music (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
post_id INTEGER NOT NULL UNIQUE,
|
||||
title TEXT NOT NULL,
|
||||
artist TEXT NOT NULL,
|
||||
cover TEXT DEFAULT '',
|
||||
share_url TEXT NOT NULL,
|
||||
platform TEXT DEFAULT 'qqmusic',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE
|
||||
)
|
||||
`)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
176
server/internal/handler/music.go
Normal file
176
server/internal/handler/music.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// QQ音乐链接解析
|
||||
type QQMusicParser struct{}
|
||||
|
||||
type MusicParseResult struct {
|
||||
Title string `json:"title"`
|
||||
Artist string `json:"artist"`
|
||||
Cover string `json:"cover"`
|
||||
ShareURL string `json:"share_url"`
|
||||
Platform string `json:"platform"`
|
||||
SongID string `json:"song_id"` // 新增:歌曲ID,用于生成外链播放器
|
||||
}
|
||||
|
||||
// ParseQQMusicURL 解析QQ音乐分享链接
|
||||
func (p *QQMusicParser) Parse(url string) (*MusicParseResult, error) {
|
||||
// 支持的链接格式:
|
||||
// 1. https://c6.y.qq.com/base/fcgi-bin/u?__=xxx (短链接)
|
||||
// 2. https://y.qq.com/n/ryqq/songDetail/xxx (歌曲详情页)
|
||||
// 3. https://i.y.qq.com/v8/playsong.html?songmid=xxx (播放页)
|
||||
|
||||
// 先获取最终URL(处理短链接重定向)
|
||||
finalURL, err := p.getFinalURL(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 提取歌曲ID (songmid)
|
||||
songMid := p.extractSongMid(finalURL)
|
||||
if songMid == "" {
|
||||
// 尝试从原始URL提取
|
||||
songMid = p.extractSongMid(url)
|
||||
}
|
||||
if songMid == "" {
|
||||
return nil, fmt.Errorf("无法解析歌曲ID")
|
||||
}
|
||||
|
||||
// 获取歌曲信息
|
||||
return p.getSongInfo(songMid, finalURL)
|
||||
}
|
||||
|
||||
func (p *QQMusicParser) getFinalURL(url string) (string, error) {
|
||||
client := &http.Client{
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return url, nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 301 || resp.StatusCode == 302 {
|
||||
location := resp.Header.Get("Location")
|
||||
if location != "" {
|
||||
return location, nil
|
||||
}
|
||||
}
|
||||
|
||||
return url, nil
|
||||
}
|
||||
|
||||
func (p *QQMusicParser) extractSongMid(url string) string {
|
||||
// 匹配 songmid=xxx 或 songDetail/xxx
|
||||
patterns := []string{
|
||||
`songmid=([a-zA-Z0-9]+)`,
|
||||
`songDetail/([a-zA-Z0-9]+)`,
|
||||
`song/([a-zA-Z0-9]+)`,
|
||||
`ADBE=([a-zA-Z0-9]+)`,
|
||||
}
|
||||
|
||||
for _, pattern := range patterns {
|
||||
re := regexp.MustCompile(pattern)
|
||||
matches := re.FindStringSubmatch(url)
|
||||
if len(matches) > 1 {
|
||||
return matches[1]
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (p *QQMusicParser) getSongInfo(songMid string, shareURL string) (*MusicParseResult, error) {
|
||||
// 使用QQ音乐的公开API获取歌曲信息
|
||||
apiURL := fmt.Sprintf("https://c.y.qq.com/v8/fcg-bin/fcg_play_single_song.fcg?songmid=%s&format=json", songMid)
|
||||
|
||||
req, _ := http.NewRequest("GET", apiURL, nil)
|
||||
req.Header.Set("Referer", "https://y.qq.com/")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Data []struct {
|
||||
ID int `json:"id"` // 数字ID,用于外链播放器
|
||||
Name string `json:"name"`
|
||||
Album struct {
|
||||
Mid string `json:"mid"`
|
||||
Name string `json:"name"`
|
||||
} `json:"album"`
|
||||
Singer []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"singer"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if result.Code != 0 || len(result.Data) == 0 {
|
||||
return nil, fmt.Errorf("获取歌曲信息失败")
|
||||
}
|
||||
|
||||
song := result.Data[0]
|
||||
|
||||
// 拼接歌手名
|
||||
var artists []string
|
||||
for _, s := range song.Singer {
|
||||
artists = append(artists, s.Name)
|
||||
}
|
||||
|
||||
// 专辑封面
|
||||
cover := ""
|
||||
if song.Album.Mid != "" {
|
||||
cover = fmt.Sprintf("https://y.qq.com/music/photo_new/T002R300x300M000%s.jpg", song.Album.Mid)
|
||||
}
|
||||
|
||||
// 构建外链播放器URL(使用数字songid)
|
||||
// 格式: https://i.y.qq.com/n2/m/outchain/player/index.html?songid=127570280&songtype=0
|
||||
playerURL := fmt.Sprintf("https://i.y.qq.com/n2/m/outchain/player/index.html?songid=%d&songtype=0", song.ID)
|
||||
|
||||
return &MusicParseResult{
|
||||
Title: song.Name,
|
||||
Artist: strings.Join(artists, " / "),
|
||||
Cover: cover,
|
||||
ShareURL: playerURL, // 使用外链播放器URL
|
||||
SongID: fmt.Sprintf("%d", song.ID),
|
||||
Platform: "qqmusic",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// IsQQMusicURL 检查是否是QQ音乐链接
|
||||
func IsQQMusicURL(url string) bool {
|
||||
patterns := []string{
|
||||
`y\.qq\.com`,
|
||||
`c6\.y\.qq\.com`,
|
||||
`i\.y\.qq\.com`,
|
||||
}
|
||||
|
||||
for _, pattern := range patterns {
|
||||
if matched, _ := regexp.MatchString(pattern, url); matched {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -57,6 +57,22 @@ func (h *PostHandler) Create(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 处理音乐链接
|
||||
if req.MusicURL != "" && IsQQMusicURL(req.MusicURL) {
|
||||
parser := &QQMusicParser{}
|
||||
musicInfo, err := parser.Parse(req.MusicURL)
|
||||
if err == nil && musicInfo != nil {
|
||||
_, err = tx.Exec(
|
||||
"INSERT INTO post_music (post_id, title, artist, cover, share_url, platform) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
postID, musicInfo.Title, musicInfo.Artist, musicInfo.Cover, musicInfo.ShareURL, musicInfo.Platform,
|
||||
)
|
||||
if err != nil {
|
||||
// 音乐解析失败不影响帖子创建,只记录日志
|
||||
// log.Printf("Failed to save music info: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to commit"})
|
||||
return
|
||||
@@ -135,6 +151,8 @@ func (h *PostHandler) List(c *gin.Context) {
|
||||
post.Media = h.getPostMedia(post.ID)
|
||||
// 获取表情反应
|
||||
post.Reactions = h.getPostReactions(post.ID, userID)
|
||||
// 获取音乐信息
|
||||
post.Music = h.getPostMusic(post.ID)
|
||||
|
||||
posts = append(posts, post)
|
||||
}
|
||||
@@ -182,6 +200,7 @@ func (h *PostHandler) Get(c *gin.Context) {
|
||||
}
|
||||
post.Media = h.getPostMedia(post.ID)
|
||||
post.Reactions = h.getPostReactions(post.ID, userID)
|
||||
post.Music = h.getPostMusic(post.ID)
|
||||
|
||||
c.JSON(http.StatusOK, post)
|
||||
}
|
||||
@@ -412,3 +431,15 @@ func (h *PostHandler) getPostReactions(postID, userID int64) []model.ReactionGro
|
||||
}
|
||||
return reactions
|
||||
}
|
||||
|
||||
func (h *PostHandler) getPostMusic(postID int64) *model.MusicInfo {
|
||||
var music model.MusicInfo
|
||||
err := h.db.QueryRow(`
|
||||
SELECT id, post_id, title, artist, cover, share_url, platform
|
||||
FROM post_music WHERE post_id = ?
|
||||
`, postID).Scan(&music.ID, &music.PostID, &music.Title, &music.Artist, &music.Cover, &music.ShareURL, &music.Platform)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &music
|
||||
}
|
||||
|
||||
@@ -23,12 +23,23 @@ type Post struct {
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
User *User `json:"user,omitempty"`
|
||||
Media []Media `json:"media,omitempty"`
|
||||
Music *MusicInfo `json:"music,omitempty"`
|
||||
Reactions []ReactionGroup `json:"reactions,omitempty"`
|
||||
LikeCount int `json:"like_count"`
|
||||
Liked bool `json:"liked"`
|
||||
CommentCount int `json:"comment_count"`
|
||||
}
|
||||
|
||||
type MusicInfo struct {
|
||||
ID int64 `json:"id"`
|
||||
PostID int64 `json:"post_id"`
|
||||
Title string `json:"title"`
|
||||
Artist string `json:"artist"`
|
||||
Cover string `json:"cover"`
|
||||
ShareURL string `json:"share_url"`
|
||||
Platform string `json:"platform"` // qqmusic, netease, etc.
|
||||
}
|
||||
|
||||
type Media struct {
|
||||
ID int64 `json:"id"`
|
||||
PostID int64 `json:"post_id"`
|
||||
@@ -89,6 +100,7 @@ type CreatePostRequest struct {
|
||||
Content string `json:"content" binding:"required,max=1000"`
|
||||
MediaIDs []string `json:"media_ids"`
|
||||
Visibility int `json:"visibility"` // 0=所有人可见, 1=仅自己可见
|
||||
MusicURL string `json:"music_url"` // QQ音乐分享链接
|
||||
}
|
||||
|
||||
type UpdatePostRequest struct {
|
||||
|
||||
Reference in New Issue
Block a user