music(player): 收藏功能 — title 旁 ★/☆,收藏的曲目置顶
deploy music / build-and-deploy (push) Successful in 1m58s
deploy music / build-and-deploy (push) Successful in 1m58s
This commit is contained in:
@@ -95,7 +95,15 @@
|
|||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<header class="now-playing">
|
<header class="now-playing">
|
||||||
<h2>{{ selected.title }}</h2>
|
<h2>
|
||||||
|
<button
|
||||||
|
class="fav-btn"
|
||||||
|
:class="{ on: selected.favorite }"
|
||||||
|
:title="selected.favorite ? '取消收藏' : '收藏'"
|
||||||
|
@click="toggleFavorite"
|
||||||
|
>{{ selected.favorite ? '★' : '☆' }}</button>
|
||||||
|
{{ selected.title }}
|
||||||
|
</h2>
|
||||||
<div class="np-sub">
|
<div class="np-sub">
|
||||||
<span v-if="selected.artist">{{ selected.artist }}</span>
|
<span v-if="selected.artist">{{ selected.artist }}</span>
|
||||||
<span v-if="selected.category">· {{ selected.category }}</span>
|
<span v-if="selected.category">· {{ selected.category }}</span>
|
||||||
@@ -921,6 +929,22 @@ function setTab(k) {
|
|||||||
activeTab.value = k
|
activeTab.value = k
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleFavorite() {
|
||||||
|
if (!selectedId.value || !selected.value) return
|
||||||
|
const next = !selected.value.favorite
|
||||||
|
selected.value.favorite = next // optimistic
|
||||||
|
const inList = pieces.value.find(p => p.id === selectedId.value)
|
||||||
|
if (inList) inList.favorite = next
|
||||||
|
try {
|
||||||
|
await patchPiece(selectedId.value, { favorite: next })
|
||||||
|
} catch (e) {
|
||||||
|
// 回滚
|
||||||
|
selected.value.favorite = !next
|
||||||
|
if (inList) inList.favorite = !next
|
||||||
|
alert(e.message || String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// notes auto-save
|
// notes auto-save
|
||||||
function onNotesInput() {
|
function onNotesInput() {
|
||||||
if (!selectedId.value) return
|
if (!selectedId.value) return
|
||||||
@@ -1445,7 +1469,23 @@ onBeforeUnmount(() => {
|
|||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
.fav-btn {
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--text-mute);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 4px;
|
||||||
|
transition: color 0.15s, transform 0.05s;
|
||||||
|
}
|
||||||
|
.fav-btn:hover { color: #f5b800; }
|
||||||
|
.fav-btn.on { color: #f5b800; }
|
||||||
|
.fav-btn:active { transform: scale(0.85); }
|
||||||
.np-sub {
|
.np-sub {
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|||||||
+25
-4
@@ -135,6 +135,11 @@ async fn main() -> std::io::Result<()> {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_pp_piece ON playlist_pieces(piece_id);",
|
CREATE INDEX IF NOT EXISTS idx_pp_piece ON playlist_pieces(piece_id);",
|
||||||
)
|
)
|
||||||
.expect("init schema");
|
.expect("init schema");
|
||||||
|
// 兼容旧 db:增量加 favorite 列
|
||||||
|
let _ = conn.execute(
|
||||||
|
"ALTER TABLE pieces ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0",
|
||||||
|
[],
|
||||||
|
);
|
||||||
tracing::info!(%db_path, blobs = %blobs_dir.display(), "music ready");
|
tracing::info!(%db_path, blobs = %blobs_dir.display(), "music ready");
|
||||||
|
|
||||||
let chord_url =
|
let chord_url =
|
||||||
@@ -216,6 +221,7 @@ struct PieceSummary {
|
|||||||
kinds: Vec<String>,
|
kinds: Vec<String>,
|
||||||
tags: Vec<String>,
|
tags: Vec<String>,
|
||||||
has_lyrics: bool,
|
has_lyrics: bool,
|
||||||
|
favorite: bool,
|
||||||
created_at: String,
|
created_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,6 +238,7 @@ struct PieceDetail {
|
|||||||
created_at: String,
|
created_at: String,
|
||||||
attachments: Vec<Attachment>,
|
attachments: Vec<Attachment>,
|
||||||
tags: Vec<String>,
|
tags: Vec<String>,
|
||||||
|
favorite: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -271,6 +278,7 @@ struct PatchPiece {
|
|||||||
tags: Option<Vec<String>>,
|
tags: Option<Vec<String>>,
|
||||||
/// admin / import 用:直接写 play_count(mvp 无认证)
|
/// admin / import 用:直接写 play_count(mvp 无认证)
|
||||||
play_count: Option<i64>,
|
play_count: Option<i64>,
|
||||||
|
favorite: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Default)]
|
#[derive(Deserialize, Default)]
|
||||||
@@ -312,12 +320,13 @@ async fn list_pieces(
|
|||||||
CASE WHEN p.lyrics IS NOT NULL AND length(p.lyrics) > 0 THEN 1 ELSE 0 END AS has_lyrics,
|
CASE WHEN p.lyrics IS NOT NULL AND length(p.lyrics) > 0 THEN 1 ELSE 0 END AS has_lyrics,
|
||||||
COALESCE((SELECT GROUP_CONCAT(t.name, char(9))
|
COALESCE((SELECT GROUP_CONCAT(t.name, char(9))
|
||||||
FROM piece_tags pt2 JOIN tags t ON t.id = pt2.tag_id
|
FROM piece_tags pt2 JOIN tags t ON t.id = pt2.tag_id
|
||||||
WHERE pt2.piece_id = p.id), '') AS tags
|
WHERE pt2.piece_id = p.id), '') AS tags,
|
||||||
|
COALESCE(p.favorite, 0) AS favorite
|
||||||
FROM pieces p
|
FROM pieces p
|
||||||
{filter_join}
|
{filter_join}
|
||||||
{filter_where}
|
{filter_where}
|
||||||
GROUP BY p.id
|
GROUP BY p.id
|
||||||
ORDER BY p.title COLLATE NOCASE ASC, p.id ASC"
|
ORDER BY p.favorite DESC, p.title COLLATE NOCASE ASC, p.id ASC"
|
||||||
);
|
);
|
||||||
let mut stmt = conn.prepare(&sql)?;
|
let mut stmt = conn.prepare(&sql)?;
|
||||||
let bind_refs: Vec<&dyn rusqlite::ToSql> = bind.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
|
let bind_refs: Vec<&dyn rusqlite::ToSql> = bind.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
|
||||||
@@ -336,6 +345,7 @@ async fn list_pieces(
|
|||||||
tags_raw.split('\t').map(|x| x.to_string()).collect()
|
tags_raw.split('\t').map(|x| x.to_string()).collect()
|
||||||
};
|
};
|
||||||
let has_lyrics: i64 = r.get(9)?;
|
let has_lyrics: i64 = r.get(9)?;
|
||||||
|
let fav: i64 = r.get(11)?;
|
||||||
Ok(PieceSummary {
|
Ok(PieceSummary {
|
||||||
id: r.get(0)?,
|
id: r.get(0)?,
|
||||||
title: r.get(1)?,
|
title: r.get(1)?,
|
||||||
@@ -348,6 +358,7 @@ async fn list_pieces(
|
|||||||
kinds,
|
kinds,
|
||||||
tags,
|
tags,
|
||||||
has_lyrics: has_lyrics != 0,
|
has_lyrics: has_lyrics != 0,
|
||||||
|
favorite: fav != 0,
|
||||||
})
|
})
|
||||||
})?
|
})?
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
@@ -392,10 +403,12 @@ async fn get_piece(
|
|||||||
i64,
|
i64,
|
||||||
Option<String>,
|
Option<String>,
|
||||||
String,
|
String,
|
||||||
|
i64,
|
||||||
);
|
);
|
||||||
let row: Option<PieceRow> = conn
|
let row: Option<PieceRow> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT title, artist, category, notes, lyrics, play_count, last_played_at, created_at
|
"SELECT title, artist, category, notes, lyrics, play_count, last_played_at, created_at,
|
||||||
|
COALESCE(favorite, 0)
|
||||||
FROM pieces WHERE id = ?1",
|
FROM pieces WHERE id = ?1",
|
||||||
params![id],
|
params![id],
|
||||||
|r| {
|
|r| {
|
||||||
@@ -408,11 +421,12 @@ async fn get_piece(
|
|||||||
r.get(5)?,
|
r.get(5)?,
|
||||||
r.get(6)?,
|
r.get(6)?,
|
||||||
r.get(7)?,
|
r.get(7)?,
|
||||||
|
r.get(8)?,
|
||||||
))
|
))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.optional()?;
|
.optional()?;
|
||||||
let (title, artist, category, notes, lyrics, play_count, last_played_at, created_at) =
|
let (title, artist, category, notes, lyrics, play_count, last_played_at, created_at, favorite) =
|
||||||
row.ok_or(AppError::NotFound)?;
|
row.ok_or(AppError::NotFound)?;
|
||||||
|
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
@@ -456,6 +470,7 @@ async fn get_piece(
|
|||||||
created_at,
|
created_at,
|
||||||
attachments,
|
attachments,
|
||||||
tags,
|
tags,
|
||||||
|
favorite: favorite != 0,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,6 +531,12 @@ async fn patch_piece(
|
|||||||
params![pc, id],
|
params![pc, id],
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
if let Some(fav) = body.favorite {
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE pieces SET favorite = ?1 WHERE id = ?2",
|
||||||
|
params![fav as i64, id],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
if let Some(tags) = body.tags {
|
if let Some(tags) = body.tags {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"DELETE FROM piece_tags WHERE piece_id = ?1",
|
"DELETE FROM piece_tags WHERE piece_id = ?1",
|
||||||
|
|||||||
Reference in New Issue
Block a user