Compare commits
13
Commits
5fdeda2ede
...
v0.3.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0dbfe26c1f | ||
|
|
0345d948ac | ||
|
|
51ff98eff1 | ||
|
|
f5008b8d8e | ||
|
|
cc8090d143 | ||
|
|
48dcb1fd97 | ||
|
|
22ddb5f49c | ||
|
|
7168a97afa | ||
|
|
4737bb9ddf | ||
|
|
baa69f74b0 | ||
|
|
a4805e957d | ||
|
|
f6c925d3bd | ||
|
|
d391f7e419 |
No files matched your search
+10
-7
@@ -1,10 +1,13 @@
|
||||
.git
|
||||
.codegraph
|
||||
.superpowers
|
||||
.worktrees
|
||||
.tmp
|
||||
.playwright-mcp
|
||||
.env
|
||||
*
|
||||
!LICENSE
|
||||
!cmd
|
||||
!db
|
||||
!docker
|
||||
!go.mod
|
||||
!go.sum
|
||||
!internal
|
||||
!protocol
|
||||
!web
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/*_test.go
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
name: CD
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [CI]
|
||||
types: [completed]
|
||||
|
||||
concurrency:
|
||||
group: cd-alisten
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: >-
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
startsWith(github.event.workflow_run.head_branch, 'v')
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TAG: ${{ github.event.workflow_run.head_branch }}
|
||||
steps:
|
||||
- uses: azure/setup-helm@v5
|
||||
with:
|
||||
version: v3.20.0
|
||||
- name: Configure k3s access
|
||||
shell: bash
|
||||
env:
|
||||
K3S_KUBECONFIG: ${{ secrets.K3S_KUBECONFIG }}
|
||||
KUBECONFIG: ${{ runner.temp }}/k3s-kubeconfig
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
printf '%s\n' "$K3S_KUBECONFIG" > "$KUBECONFIG"
|
||||
- name: Upgrade alisten
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
KUBECONFIG: ${{ runner.temp }}/k3s-kubeconfig
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chart_version="${TAG#v}"
|
||||
repository="$(printf '%s' "${GITHUB_REPOSITORY}" | tr '[:upper:]' '[:lower:]')"
|
||||
printf '%s' "$GITHUB_TOKEN" | helm registry login ghcr.io --username "$GITHUB_ACTOR" --password-stdin
|
||||
helm upgrade alisten "oci://ghcr.io/${repository}/charts/alisten" \
|
||||
--version "$chart_version" \
|
||||
--namespace alisten \
|
||||
--reuse-values \
|
||||
--set-string "serve.image.tag=${chart_version}" \
|
||||
--set-string "worker.image.tag=${chart_version}" \
|
||||
--set-string "web.image.tag=${chart_version}" \
|
||||
--atomic \
|
||||
--wait \
|
||||
--timeout 6m
|
||||
- name: Smoke test
|
||||
shell: bash
|
||||
env:
|
||||
KUBECONFIG: ${{ runner.temp }}/k3s-kubeconfig
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
kubectl port-forward \
|
||||
--namespace alisten \
|
||||
--address 127.0.0.1 \
|
||||
service/alisten-serve 18090:9090 &
|
||||
pid=$!
|
||||
trap 'kill "$pid" 2>/dev/null || true' EXIT
|
||||
|
||||
base_url="http://127.0.0.1:18090"
|
||||
|
||||
curl --silent --show-error --fail \
|
||||
--connect-timeout 2 \
|
||||
--max-time 5 \
|
||||
--retry 20 \
|
||||
--retry-delay 1 \
|
||||
--retry-connrefused \
|
||||
--retry-max-time 60 \
|
||||
"${base_url}/readyz"
|
||||
|
||||
expected="${TAG}+${HEAD_SHA}"
|
||||
actual="$(curl --silent --show-error --fail \
|
||||
--connect-timeout 2 \
|
||||
--max-time 5 \
|
||||
"${base_url}/version")"
|
||||
|
||||
diff -u --label expected --label actual \
|
||||
<(echo "$expected") <(echo "$actual")
|
||||
+5
-9
@@ -2,19 +2,15 @@ FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS build
|
||||
ARG TARGETOS TARGETARCH
|
||||
ARG VERSION=dev
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
COPY cmd ./cmd
|
||||
COPY db ./db
|
||||
COPY internal ./internal
|
||||
COPY protocol ./protocol
|
||||
COPY --parents go.mod go.sum cmd db internal protocol ./
|
||||
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath -tags grpcnotrace \
|
||||
-ldflags "-s -w -X github.com/dihemoe/alisten/internal/bootstrap.version=$VERSION" \
|
||||
-o /out/alisten ./cmd/alisten
|
||||
-o /out/usr/local/bin/alisten ./cmd/alisten \
|
||||
&& install -Dm644 /etc/ssl/certs/ca-certificates.crt /out/etc/ssl/certs/ca-certificates.crt
|
||||
COPY LICENSE /out/usr/share/doc/alisten/
|
||||
|
||||
FROM gcr.io/distroless/static-debian13:nonroot AS alisten
|
||||
COPY LICENSE /usr/share/doc/alisten/
|
||||
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
COPY --from=build /out/alisten /usr/local/bin/alisten
|
||||
COPY --from=build /out /
|
||||
USER 65532:65532
|
||||
EXPOSE 8080 9090
|
||||
HEALTHCHECK --start-period=30s --start-interval=1s --interval=10s --timeout=3s --retries=3 \
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
case "$TARGETARCH" in
|
||||
amd64) arch=x86_64 triple=x86_64-linux-gnu cross_gcc=gcc-x86-64-linux-gnu ;;
|
||||
arm64) arch=aarch64 triple=aarch64-linux-gnu cross_gcc=gcc-aarch64-linux-gnu ;;
|
||||
esac
|
||||
|
||||
dpkg --add-architecture "$TARGETARCH"
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install --yes --no-install-recommends \
|
||||
ca-certificates gcc libc6-dev make nasm wget "$cross_gcc" \
|
||||
"libc6-dev:$TARGETARCH" "libmp3lame-dev:$TARGETARCH" "zlib1g-dev:$TARGETARCH"
|
||||
|
||||
wget -q --tries=3 --timeout=30 -O /tmp/ffmpeg.tar.gz \
|
||||
"https://github.com/FFmpeg/FFmpeg/archive/refs/tags/n$FFMPEG_VERSION.tar.gz"
|
||||
mkdir /tmp/ffmpeg
|
||||
tar -xzf /tmp/ffmpeg.tar.gz -C /tmp/ffmpeg --strip-components=1
|
||||
cd /tmp/ffmpeg
|
||||
|
||||
audio_decoders=$(sed -n '/\/\* audio codecs \*\//,/\/\* subtitles \*\//s/.*ff_\([a-z0-9_]*\)_decoder;.*/\1/p' \
|
||||
libavcodec/allcodecs.c | paste -sd,)
|
||||
audio_decoders_listed_as_video=comfortnoise,dvaudio,eightsvx_exp,eightsvx_fib,rka,s302m,speex
|
||||
|
||||
./configure \
|
||||
--enable-cross-compile --arch="$arch" --target-os=linux --cross-prefix="$triple-" \
|
||||
--disable-everything --disable-autodetect --disable-doc --disable-debug --disable-network \
|
||||
--disable-ffplay --disable-avdevice --disable-swscale \
|
||||
--extra-ldexeflags=-static \
|
||||
--enable-zlib --enable-libmp3lame --enable-encoder=libmp3lame \
|
||||
--enable-protocol=file --enable-demuxers --enable-parsers --enable-bsfs \
|
||||
--enable-muxers --enable-filter=aresample \
|
||||
--enable-decoder="$audio_decoders,$audio_decoders_listed_as_video"
|
||||
make -j"$(nproc)"
|
||||
install -D -t /out/usr/local/bin ffmpeg ffprobe
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
case "$TARGETARCH" in
|
||||
amd64) ytdlp=yt-dlp_linux deno=x86_64 ;;
|
||||
arm64) ytdlp=yt-dlp_linux_aarch64 deno=aarch64 ;;
|
||||
esac
|
||||
|
||||
wget -q --tries=3 --timeout=30 -O /tmp/yt-dlp.zip \
|
||||
"https://github.com/yt-dlp/yt-dlp/releases/download/$YTDLP_VERSION/$ytdlp.zip"
|
||||
wget -q --tries=3 --timeout=30 -O /tmp/deno.zip \
|
||||
"https://github.com/denoland/deno/releases/download/$DENO_VERSION/deno-$deno-unknown-linux-gnu.zip"
|
||||
|
||||
mkdir -p /out/opt/yt-dlp /out/usr/local/bin
|
||||
unzip -q /tmp/yt-dlp.zip -d /out/opt/yt-dlp
|
||||
unzip -q /tmp/deno.zip -d /out/usr/local/bin
|
||||
ln -s "/opt/yt-dlp/$ytdlp" /out/usr/local/bin/yt-dlp
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
func NewHandler(ready func() bool, metrics prometheus.Gatherer) http.Handler {
|
||||
func NewHandler(ready func() bool, metrics prometheus.Gatherer, version string) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, "ok\n")
|
||||
@@ -20,6 +20,9 @@ func NewHandler(ready func() bool, metrics prometheus.Gatherer) http.Handler {
|
||||
}
|
||||
_, _ = io.WriteString(w, "ok\n")
|
||||
})
|
||||
mux.HandleFunc("GET /version", func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, version+"\n")
|
||||
})
|
||||
mux.Handle("GET /metrics", promhttp.HandlerFor(metrics, promhttp.HandlerOpts{}))
|
||||
return mux
|
||||
}
|
||||
@@ -15,7 +15,7 @@ func TestHandler(t *testing.T) {
|
||||
registry := prometheus.NewRegistry()
|
||||
registry.MustRegister(prometheus.NewGauge(prometheus.GaugeOpts{Name: "test_gauge", Help: "测试用指标。"}))
|
||||
var ready atomic.Bool
|
||||
handler := NewHandler(ready.Load, registry)
|
||||
handler := NewHandler(ready.Load, registry, "v1.2.3+abc")
|
||||
|
||||
get := func(path string) *httptest.ResponseRecorder {
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -28,6 +28,12 @@ func TestHandler(t *testing.T) {
|
||||
t.Errorf("/healthz 状态码 = %d,期望 200", rec.Code)
|
||||
}
|
||||
})
|
||||
t.Run("version 返回构建版本", func(t *testing.T) {
|
||||
rec := get("/version")
|
||||
if body := rec.Body.String(); rec.Code != http.StatusOK || body != "v1.2.3+abc\n" {
|
||||
t.Errorf("/version 状态码 = %d,响应 = %q,期望 200 和 \"v1.2.3+abc\\n\"", rec.Code, body)
|
||||
}
|
||||
})
|
||||
t.Run("metrics 输出注册的指标", func(t *testing.T) {
|
||||
rec := get("/metrics")
|
||||
body, _ := io.ReadAll(rec.Body)
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
func TestHealthcheckFollowsReadiness(t *testing.T) {
|
||||
var ready atomic.Bool
|
||||
server := httptest.NewServer(ops.NewHandler(ready.Load, prometheus.NewRegistry()))
|
||||
server := httptest.NewServer(ops.NewHandler(ready.Load, prometheus.NewRegistry(), version))
|
||||
defer server.Close()
|
||||
t.Setenv("ALISTEN_OPS_LISTEN", server.Listener.Addr().String())
|
||||
|
||||
|
||||
@@ -182,7 +182,7 @@ func runServe(base *slog.Logger) (err error) {
|
||||
|
||||
opsLogger := base.With("component", "ops")
|
||||
publicServer := newHTTPServer(p.handler, base.With("component", "http"))
|
||||
opsServer := newHTTPServer(recoverPanics(ops.NewHandler(ready, metrics), opsLogger), opsLogger)
|
||||
opsServer := newHTTPServer(recoverPanics(ops.NewHandler(ready, metrics, version), opsLogger), opsLogger)
|
||||
go func() { fatal <- fmt.Errorf("HTTP 服务异常退出:%w", publicServer.Serve(publicListener)) }()
|
||||
go func() { fatal <- fmt.Errorf("HTTP 服务异常退出:%w", opsServer.Serve(opsListener)) }()
|
||||
logger.Info("服务已启动", "listen", publicListener.Addr().String(), "ops_listen", opsListener.Addr().String(), "instance", instance)
|
||||
|
||||
@@ -275,7 +275,7 @@ func (w mediaWorker) run(ctx context.Context, listener net.Listener) error {
|
||||
}),
|
||||
)
|
||||
opsLogger := w.base.With("component", "ops")
|
||||
opsServer := newHTTPServer(recoverPanics(ops.NewHandler(ready, w.metrics), opsLogger), opsLogger)
|
||||
opsServer := newHTTPServer(recoverPanics(ops.NewHandler(ready, w.metrics, version), opsLogger), opsLogger)
|
||||
go func() { fatal <- fmt.Errorf("运维端口异常退出:%w", opsServer.Serve(listener)) }()
|
||||
w.logger.Info("worker 已启动", "ops_listen", listener.Addr().String(), "instance", w.instance)
|
||||
|
||||
|
||||
+15
-7
@@ -5,16 +5,24 @@ COPY web/package.json web/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY web/ .
|
||||
RUN npm run build \
|
||||
&& find dist -type f \( -name '*.html' -o -name '*.js' -o -name '*.css' -o -name '*.svg' -o -name '*.json' -o -name '*.webmanifest' \) -exec gzip -9 -k {} + \
|
||||
&& sed "s|@VERSION@|${VERSION}|" nginx/default.conf > /tmp/default.conf
|
||||
&& find dist -type f ! -name '*.png' -exec gzip -9 -k {} + \
|
||||
&& install -d -m 775 /out/etc/nginx /out/etc/nginx/conf.d \
|
||||
&& mkdir -p /out/usr/share/nginx \
|
||||
&& sed "s|@VERSION@|${VERSION}|" nginx/default.conf > /out/etc/nginx/conf.d/default.conf \
|
||||
&& cp nginx/nginx.conf /out/etc/nginx/nginx.conf \
|
||||
&& cp -R nginx/templates /out/etc/nginx/alisten-templates \
|
||||
&& mv dist /out/usr/share/nginx/html
|
||||
COPY LICENSE /out/usr/share/doc/alisten/
|
||||
|
||||
FROM nginxinc/nginx-unprivileged:1.30-alpine
|
||||
ENV NGINX_ENTRYPOINT_QUIET_LOGS=1
|
||||
COPY web/nginx/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY --from=build /tmp/default.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /src/dist /usr/share/nginx/html
|
||||
COPY web/nginx/templates /etc/nginx/alisten-templates
|
||||
COPY LICENSE /usr/share/doc/alisten/
|
||||
COPY --from=build --parents \
|
||||
/out/./etc/nginx/nginx.conf \
|
||||
/out/./etc/nginx/conf.d/default.conf \
|
||||
/out/./etc/nginx/alisten-templates \
|
||||
/out/./usr/share/nginx/html \
|
||||
/out/./usr/share/doc/alisten/LICENSE \
|
||||
/
|
||||
EXPOSE 8080 9090
|
||||
HEALTHCHECK --start-period=30s --start-interval=1s --interval=10s --timeout=3s --retries=3 \
|
||||
CMD wget -q -O /dev/null http://127.0.0.1:9090/healthz || exit 1
|
||||
+2
-2
@@ -19,7 +19,7 @@ import { ErrorBoundary, RegionFailed } from './error-boundary'
|
||||
import { navigationStore } from './navigation'
|
||||
import { pwaStore } from './pwa'
|
||||
import { UpdatePrompt } from './update-prompt'
|
||||
import { withViewTransition } from './view-transition'
|
||||
import { panelDirection, withViewTransition } from './view-transition'
|
||||
|
||||
// adminStore 不在 main.tsx 中启动:静态导入会把后台页打包进入口 chunk。
|
||||
const AdminPage = lazyPage(() => import('@/features/admin').then((module) => {
|
||||
@@ -219,7 +219,7 @@ export function App() {
|
||||
const shownPanel = joined && panel !== page ? panel : null
|
||||
const switchPanel = (next: Panel | null) => {
|
||||
if (shownPanel && next && next !== shownPanel && next !== page)
|
||||
withViewTransition(() => navigationStore.setPanel(next))
|
||||
withViewTransition(() => navigationStore.setPanel(next), panelDirection(shownPanel, next))
|
||||
else
|
||||
navigationStore.setPanel(next)
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export function DetailPanel({ view, children }: { view: Panel | null, children:
|
||||
return (
|
||||
<aside
|
||||
ref={panelRef}
|
||||
className={`detail-panel${open ? '' : ' detail-panel-closing'}`}
|
||||
className={`detail-panel${shown === 'chat' ? ' detail-panel-sheet' : ''}${open ? '' : ' detail-panel-closing'}`}
|
||||
aria-hidden={!open || undefined}
|
||||
inert={!open}
|
||||
onAnimationEnd={(event) => {
|
||||
|
||||
+25
-8
@@ -36,7 +36,7 @@
|
||||
:root[data-performance='low'] button { transition: opacity 160ms; }
|
||||
}
|
||||
|
||||
.app-shell { height: calc(var(--vh, 1vh) * 100); height: 100dvh; display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 104px; overflow: hidden; padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom); background: var(--color-frame); }
|
||||
.app-shell { height: calc(var(--vh, 1vh) * 100); height: 100dvh; display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) auto; overflow: hidden; padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom); background: var(--color-frame); }
|
||||
.workspace { grid-column: 1; grid-row: 1; display: flex; flex-direction: column; min-height: 0; min-width: 0; }
|
||||
.app-shell > .player-bar { grid-column: 1; grid-row: 2; }
|
||||
.workspace-header { position: relative; z-index: 30; height: 76px; flex-shrink: 0; display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 0 24px; background: var(--color-frame); color: var(--color-foreground); border: 0; border-radius: 0; }
|
||||
@@ -76,16 +76,23 @@
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
.detail-panel { --detail-panel-width: 480px; width: var(--detail-panel-width); flex-shrink: 0; position: relative; min-height: 0; display: flex; flex-direction: column; border: 0; border-radius: 12px; background: var(--color-panel); overflow: hidden; animation: detail-panel-open 240ms cubic-bezier(.2, .8, .2, 1) both; }
|
||||
:root { --detail-panel-width: 480px; --view-in: translateX(100%); --view-out: translateX(-14%); --view-swap: translateX(10px); }
|
||||
:root[data-view-direction='backward'] { --view-in: translateX(-100%); --view-out: translateX(14%); --view-swap: translateX(-10px); }
|
||||
:root[data-view-direction='rise'] { --view-in: translateY(100%); --view-out: translateY(-8%); --view-swap: translateY(10px); }
|
||||
:root[data-view-direction='sink'] { --view-in: translateY(-8%); --view-out: translateY(100%); --view-swap: translateY(-10px); }
|
||||
.detail-panel { width: var(--detail-panel-width); flex-shrink: 0; position: relative; min-height: 0; display: flex; flex-direction: column; border: 0; border-radius: 12px; background: var(--color-panel); overflow: hidden; animation: detail-panel-open 240ms cubic-bezier(.2, .8, .2, 1) both; }
|
||||
.detail-panel-content { view-transition-name: detail-panel-content; width: var(--detail-panel-width); flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||
/* 只换关键帧里的位移:换 animation-name 会让面板在切换内容时重播打开动画。 */
|
||||
.detail-panel { --detail-panel-offset: translateX(16px); }
|
||||
.detail-panel-sheet { --detail-panel-offset: translateY(100%); }
|
||||
.detail-panel-closing { pointer-events: none; animation-name: detail-panel-close; }
|
||||
@keyframes detail-panel-open {
|
||||
from { width: 0; margin-left: -8px; opacity: 0; transform: translateX(16px); }
|
||||
from { width: 0; margin-left: -8px; opacity: 0; transform: var(--detail-panel-offset); }
|
||||
to { width: var(--detail-panel-width); margin-left: 0; opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
@keyframes detail-panel-close {
|
||||
from { width: var(--detail-panel-width); margin-left: 0; opacity: 1; transform: translateX(0); }
|
||||
to { width: 0; margin-left: -8px; opacity: 0; transform: translateX(16px); }
|
||||
to { width: 0; margin-left: -8px; opacity: 0; transform: var(--detail-panel-offset); }
|
||||
}
|
||||
/* 视图过渡只移动面板内容:它的过渡分组裁剪在面板内,根快照不做动画,顶栏、主内容和播放器保持不动。 */
|
||||
::view-transition-group(root) { animation: none; overflow: clip; }
|
||||
@@ -96,17 +103,20 @@
|
||||
::view-transition-old(detail-panel-content), ::view-transition-new(detail-panel-content) { background: var(--color-panel); mix-blend-mode: normal; }
|
||||
::view-transition-old(detail-panel-content) { animation: view-recede 300ms cubic-bezier(.2, .8, .2, 1) both; }
|
||||
::view-transition-new(detail-panel-content) { animation: view-push-in 300ms cubic-bezier(.2, .8, .2, 1) both; }
|
||||
/* 聊天在升起和沉下时都要压在列表上面,而新视图默认才在上层。 */
|
||||
:root[data-view-direction='rise']::view-transition-new(detail-panel-content),
|
||||
:root[data-view-direction='sink']::view-transition-old(detail-panel-content) { z-index: 1; border-radius: 12px 12px 0 0; box-shadow: 0 -8px 24px var(--color-shadow); }
|
||||
@keyframes view-recede {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(-14%); }
|
||||
to { transform: var(--view-out); }
|
||||
}
|
||||
@keyframes view-push-in {
|
||||
from { transform: translateX(100%); }
|
||||
from { transform: var(--view-in); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
.detail-panel-swap { animation: detail-panel-swap-in 180ms cubic-bezier(.2, .8, .2, 1) both; }
|
||||
@keyframes detail-panel-swap-in {
|
||||
from { opacity: 0; transform: translateX(10px); }
|
||||
from { opacity: 0; transform: var(--view-swap); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
:root[data-performance='low'] .header-menu[open] .main-nav { animation: fade-in 160ms ease both; }
|
||||
@@ -124,7 +134,14 @@
|
||||
.detail-tab[aria-pressed='true'] { color: var(--color-foreground); border-bottom-color: var(--color-brand); }
|
||||
.mobile-nav { display: none; }
|
||||
@media (max-width: 1100px) {
|
||||
.detail-panel { --detail-panel-width: 420px; }
|
||||
:root { --detail-panel-width: 420px; }
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.app-shell > .player-bar { margin: 0 8px 8px; }
|
||||
}
|
||||
/* 通知相对窗口居中,侧栏打开时移到内容区中间,否则会盖住侧栏。窗口太窄时内容区放不下通知,留在原位。 */
|
||||
@media (min-width: 1024px) {
|
||||
body:has(.detail-panel:not(.detail-panel-closing)) .notifications { left: calc(50% - (var(--detail-panel-width) + 8px) / 2); }
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.app-shell { grid-template-rows: minmax(0, 1fr) auto auto; padding-left: env(safe-area-inset-left); padding-right: env(safe-area-inset-right); padding-bottom: 0; }
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { panelDirection } from './view-transition'
|
||||
|
||||
describe('panelDirection', () => {
|
||||
it.each([
|
||||
['queue', 'history', 'forward'],
|
||||
['history', 'queue', 'backward'],
|
||||
['queue', 'chat', 'rise'],
|
||||
['history', 'chat', 'rise'],
|
||||
['chat', 'queue', 'sink'],
|
||||
['chat', 'history', 'sink'],
|
||||
] as const)('%s 到 %s', (from, to, direction) => {
|
||||
expect(panelDirection(from, to)).toBe(direction)
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,24 @@
|
||||
import type { Panel } from './route'
|
||||
import { flushSync } from 'react-dom'
|
||||
import { performanceStore } from '@/features/settings'
|
||||
|
||||
type ViewDirection = 'forward' | 'backward' | 'rise' | 'sink'
|
||||
|
||||
// 播放列表和播放历史是左右并排的 tab;聊天从播放栏升起,盖在它们上面。
|
||||
export function panelDirection(from: Panel, to: Panel): ViewDirection {
|
||||
if (to === 'chat')
|
||||
return 'rise'
|
||||
if (from === 'chat')
|
||||
return 'sink'
|
||||
return to === 'queue' ? 'backward' : 'forward'
|
||||
}
|
||||
|
||||
export function canPushViews(): boolean {
|
||||
return 'startViewTransition' in document && performanceStore.smoothMotion()
|
||||
}
|
||||
|
||||
export function withViewTransition(update: () => void): void {
|
||||
export function withViewTransition(update: () => void, direction: ViewDirection): void {
|
||||
document.documentElement.dataset.viewDirection = direction
|
||||
if (!canPushViews()) {
|
||||
update()
|
||||
return
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export type { RoomNotice, RoomState } from './replica'
|
||||
export { roomStore } from './room-store'
|
||||
export { sameTrack } from './track'
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Track } from '@/core/realtime'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { sameTrack } from './track'
|
||||
|
||||
describe('sameTrack', () => {
|
||||
const track: Track = { source: 'qq', id: '1' }
|
||||
|
||||
it('音源和 ID 都相同才是同一首歌', () => {
|
||||
expect(sameTrack(track, { source: 'qq', id: '1' })).toBe(true)
|
||||
expect(sameTrack(track, { source: 'qq', id: '2' })).toBe(false)
|
||||
expect(sameTrack(track, { source: 'netease', id: '1' })).toBe(false)
|
||||
})
|
||||
|
||||
it('没有在放的歌时不相同', () => {
|
||||
expect(sameTrack(track, undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { Track } from '@/core/realtime'
|
||||
|
||||
export function sameTrack(a: Track, b: Track | undefined): boolean {
|
||||
return a.source === b?.source && a.id === b.id
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { PlayRecord } from './records'
|
||||
import { CirclePlus, Download, History, Search, Trash2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { roomStore } from '@/core/room'
|
||||
import { roomStore, sameTrack } from '@/core/room'
|
||||
import { enqueue, sourceName } from '@/features/queue'
|
||||
import { bookTitle } from '@/shared/lib/book-title'
|
||||
import { formatClock } from '@/shared/lib/time'
|
||||
import { useStore } from '@/shared/lib/use-store'
|
||||
import { AsyncIconButton, EmptyState, IconButton, Modal, notificationStore, SongArtwork } from '@/shared/ui'
|
||||
import { AsyncIconButton, EmptyState, IconButton, Modal, notificationStore, PlayingBars, SongArtwork } from '@/shared/ui'
|
||||
import { historyStore } from './history-store'
|
||||
import './history.css'
|
||||
|
||||
@@ -22,15 +22,18 @@ function playedOn(ms: number): string {
|
||||
return new Date(ms).toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })
|
||||
}
|
||||
|
||||
function HistoryRow({ record, compact, connected }: { record: PlayRecord, compact: boolean, connected: boolean }) {
|
||||
function HistoryRow({ record, compact, connected, playing }: { record: PlayRecord, compact: boolean, connected: boolean, playing: boolean }) {
|
||||
const { title, artist, album, coverUrl, pageUrl } = record.meta
|
||||
const performer = artist || '未知艺术家'
|
||||
const source = sourceName(record.track)
|
||||
return (
|
||||
<div className="song-row">
|
||||
<div className="song-row" data-playing={playing || undefined}>
|
||||
<SongArtwork coverUrl={coverUrl} title={title} href={pageUrl} sourceName={source} />
|
||||
<div className="song-copy">
|
||||
<strong className="text-ui-base truncate">{title}</strong>
|
||||
<span className="song-title">
|
||||
{playing && <PlayingBars />}
|
||||
<strong className="text-ui-base truncate">{title}</strong>
|
||||
</span>
|
||||
<span className="text-ui-sm muted truncate">{[performer, source, album].filter(Boolean).join(' · ')}</span>
|
||||
</div>
|
||||
{!compact && <time className="history-time text-ui-sm muted" dateTime={new Date(record.playedAtMs).toISOString()}>{formatClock(record.playedAtMs)}</time>}
|
||||
@@ -45,7 +48,7 @@ function HistoryRow({ record, compact, connected }: { record: PlayRecord, compac
|
||||
/>
|
||||
<IconButton
|
||||
icon={Trash2}
|
||||
className="song-action"
|
||||
className="song-action song-action-secondary"
|
||||
label={`删除${bookTitle(title)}的播放记录`}
|
||||
onClick={() => {
|
||||
historyStore.removePlay(record)
|
||||
@@ -59,6 +62,7 @@ function HistoryRow({ record, compact, connected }: { record: PlayRecord, compac
|
||||
export function HistoryPage({ compact = false, filter = '', onFilter }: { compact?: boolean, filter?: string, onFilter?: (filter: string) => void }) {
|
||||
const plays = useStore(historyStore, s => s.plays)
|
||||
const connected = useStore(roomStore, s => s.status === 'open')
|
||||
const playing = useStore(roomStore, s => s.room?.nowPlaying?.entry.track)
|
||||
const [limit, setLimit] = useState(batchSize)
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const visible = plays.filter(record => matches(record, filter))
|
||||
@@ -100,7 +104,7 @@ export function HistoryPage({ compact = false, filter = '', onFilter }: { compac
|
||||
return (
|
||||
<li key={`${record.track.source}:${record.track.id}`}>
|
||||
{(index === 0 || playedOn(shown[index - 1].playedAtMs) !== day) && <h2 className="history-date text-ui-sm muted">{day}</h2>}
|
||||
<HistoryRow record={record} compact={compact} connected={connected} />
|
||||
<HistoryRow record={record} compact={compact} connected={connected} playing={sameTrack(record.track, playing)} />
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -15,12 +15,18 @@
|
||||
.player-tools { display: flex; align-items: center; justify-content: flex-end; gap: 4px; }
|
||||
.player-tools .volume-control { display: flex; align-items: center; gap: 6px; margin-right: 16px; }
|
||||
.player-tools .volume-slider { width: 90px; accent-color: var(--color-foreground); }
|
||||
.player-tools .volume-input { box-sizing: border-box; width: 4.5ch; min-width: 0; padding: 4px 3px; text-align: center; font-variant-numeric: tabular-nums; -moz-appearance: textfield; }
|
||||
.player-tools .volume-input::-webkit-inner-spin-button, .player-tools .volume-input::-webkit-outer-spin-button { margin: 0; -webkit-appearance: none; }
|
||||
.player-tools .volume-unit { color: var(--color-foreground-subtle); font-size: var(--text-ui-xs); }
|
||||
.player-tools .volume-readout { min-width: 5ch; color: var(--color-foreground-subtle); font-size: var(--text-ui-sm); font-variant-numeric: tabular-nums; }
|
||||
.player-panel-toggle { color: var(--color-foreground-subtle); }
|
||||
.player-panel-toggle:hover:not(:disabled), .player-panel-toggle:focus-visible:not(:disabled), .player-panel-toggle[aria-pressed='true'] { color: var(--color-brand); background: transparent; }
|
||||
.player-panel-toggle:active:not(:disabled) { color: var(--color-brand); background: transparent; transform: none; }
|
||||
/* 播放栏在布局里占一行,不压在页面上,所以各页面不用留出底部空间。 */
|
||||
@media (min-width: 768px) {
|
||||
.player-bar { --color-hover: var(--color-surface-hover); padding: 10px 14px; border-radius: 12px; background: var(--color-panel); }
|
||||
.player-artwork .artwork { width: 56px; background: var(--color-surface); }
|
||||
.player-right .icon-button { width: 40px; height: 40px; }
|
||||
.player-right .icon-button svg { width: 22px; height: 22px; }
|
||||
.player-center .icon-button svg { width: 24px; height: 24px; }
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.player-bar { gap: 12px; }
|
||||
.player-tools .volume-control { margin-right: 4px; }
|
||||
|
||||
@@ -4,7 +4,7 @@ import { roomStore } from '@/core/room'
|
||||
import { sourceName } from '@/features/queue'
|
||||
import { formatDuration } from '@/shared/lib/time'
|
||||
import { useStore } from '@/shared/lib/use-store'
|
||||
import { Artwork, AsyncIconButton, IconButton, VolumeInput } from '@/shared/ui'
|
||||
import { Artwork, AsyncIconButton, IconButton } from '@/shared/ui'
|
||||
import { playerStore } from './player-store'
|
||||
import { volumeStore } from './volume-store'
|
||||
import './playback.css'
|
||||
@@ -64,6 +64,7 @@ export function PlayerBar({ onSkip, onLyrics, onQueue, onChat, queueOpen, chatOp
|
||||
const connected = useStore(roomStore, s => s.status === 'open')
|
||||
const volume = useStore(volumeStore, s => s.volume)
|
||||
const muted = useStore(volumeStore, s => s.muted)
|
||||
const audibleVolume = muted ? 0 : volume
|
||||
const meta = nowPlaying?.entry.meta
|
||||
const artist = meta?.artist || '未知艺术家'
|
||||
const source = nowPlaying ? sourceName(nowPlaying.entry.track) : undefined
|
||||
@@ -90,15 +91,23 @@ export function PlayerBar({ onSkip, onLyrics, onQueue, onChat, queueOpen, chatOp
|
||||
</div>
|
||||
<div className="player-tools">
|
||||
<IconButton icon={muted ? VolumeX : Volume2} label={muted ? '取消静音' : '静音'} onClick={() => volumeStore.setMuted(!muted)} />
|
||||
<VolumeInput
|
||||
volume={volume}
|
||||
sliderValue={muted ? 0 : volume}
|
||||
onCommit={(next) => {
|
||||
volumeStore.setVolume(next)
|
||||
if (muted && next > 0)
|
||||
volumeStore.setMuted(false)
|
||||
}}
|
||||
/>
|
||||
<div className="volume-control">
|
||||
<input
|
||||
className="volume-slider"
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={audibleVolume}
|
||||
aria-label="音量"
|
||||
onChange={(event) => {
|
||||
const next = Number(event.target.value)
|
||||
volumeStore.setVolume(next)
|
||||
if (muted && next > 0)
|
||||
volumeStore.setMuted(false)
|
||||
}}
|
||||
/>
|
||||
<span className="volume-readout" aria-hidden="true">{`${audibleVolume}%`}</span>
|
||||
</div>
|
||||
<IconButton icon={MessageCircle} label="房间聊天" className="player-panel-toggle" disabled={!canOpenPanels} aria-pressed={chatOpen} onClick={onChat} />
|
||||
<IconButton icon={ListMusic} label="播放列表" className="player-panel-toggle" disabled={!canOpenPanels} aria-pressed={queueOpen} onClick={onQueue} />
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Heart, ListMusic, Trash2 } from 'lucide-react'
|
||||
import { roomStore } from '@/core/room'
|
||||
import { bookTitle } from '@/shared/lib/book-title'
|
||||
import { useStore } from '@/shared/lib/use-store'
|
||||
import { EmptyState, Select, SongArtwork, SongList } from '@/shared/ui'
|
||||
import { EmptyState, PlayingBars, Select, SongArtwork, SongList } from '@/shared/ui'
|
||||
import { likeEntry, removeEntry, setPlayMode } from './actions'
|
||||
import { sourceName } from './source'
|
||||
import './queue.css'
|
||||
@@ -26,7 +26,7 @@ function songItem(entry: Entry): SongItem {
|
||||
}
|
||||
}
|
||||
|
||||
function QueueCard({ label, entry, className = '' }: { label: string, entry: Entry, className?: string }) {
|
||||
function QueueCard({ label, entry, className = '', playing = false }: { label: string, entry: Entry, className?: string, playing?: boolean }) {
|
||||
const song = songItem(entry)
|
||||
return (
|
||||
<div className={`queue-current ${className}`}>
|
||||
@@ -36,6 +36,7 @@ function QueueCard({ label, entry, className = '' }: { label: string, entry: Ent
|
||||
<strong className="text-ui-base truncate">{song.title}</strong>
|
||||
<span className="text-ui-sm muted truncate">{[song.artist, song.sourceName, song.note].filter(Boolean).join(' · ')}</span>
|
||||
</div>
|
||||
{playing && <PlayingBars />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -66,15 +67,15 @@ export function QueuePage({ compact = false }: { compact?: boolean }) {
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{nowPlaying && <QueueCard label="正在播放" entry={nowPlaying.entry} />}
|
||||
{nowPlaying && <QueueCard label="正在播放" entry={nowPlaying.entry} playing />}
|
||||
{pending && <QueueCard label="下一首" entry={pending} className="queue-pending" />}
|
||||
{items.length
|
||||
? (
|
||||
<SongList
|
||||
songs={items.map(songItem)}
|
||||
actions={song => [
|
||||
{ icon: Heart, label: `点赞${bookTitle(song.title)}`, disabled: !connected, run: () => likeEntry(song.key, song.title) },
|
||||
{ icon: Trash2, label: `移除${bookTitle(song.title)}`, disabled: !connected, run: () => removeEntry(song.key, song.title) },
|
||||
{ icon: Heart, label: `点赞${bookTitle(song.title)}`, disabled: !connected, secondary: true, run: () => likeEntry(song.key, song.title) },
|
||||
{ icon: Trash2, label: `移除${bookTitle(song.title)}`, disabled: !connected, secondary: true, run: () => removeEntry(song.key, song.title) },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -3,12 +3,12 @@ import type { PlaylistSummary, TrackSummary } from '@/core/realtime'
|
||||
import type { SearchCategory, SearchSource } from '@/features/history'
|
||||
import type { SongItem } from '@/shared/ui'
|
||||
import { ArrowLeft, ArrowRight, CirclePlus, Clock3, Search, Sparkles } from 'lucide-react'
|
||||
import { roomStore } from '@/core/room'
|
||||
import { roomStore, sameTrack } from '@/core/room'
|
||||
import { historyStore } from '@/features/history'
|
||||
import { enqueue } from '@/features/queue'
|
||||
import { bookTitle } from '@/shared/lib/book-title'
|
||||
import { useStore } from '@/shared/lib/use-store'
|
||||
import { Artwork, AsyncIconButton, EmptyState, IconButton, Select, Skeleton, SongArtwork, SongList } from '@/shared/ui'
|
||||
import { Artwork, AsyncIconButton, EmptyState, IconButton, PlayingBars, Select, Skeleton, SongArtwork, SongList } from '@/shared/ui'
|
||||
import { SearchBox } from './search-box'
|
||||
import { canOpenPlaylists, keywordSearch, pageSize, recordSearch, searchStore } from './search-store'
|
||||
import './search.css'
|
||||
@@ -38,9 +38,10 @@ function songItem({ track, meta }: TrackSummary): SongItem {
|
||||
}
|
||||
|
||||
function TrackRows({ items, connected }: { items: TrackSummary[], connected: boolean }) {
|
||||
const playing = useStore(roomStore, s => s.room?.nowPlaying?.entry.track)
|
||||
return (
|
||||
<SongList
|
||||
songs={items.map(songItem)}
|
||||
songs={items.map(item => ({ ...songItem(item), playing: sameTrack(item.track, playing) }))}
|
||||
actions={(song, index) => [{
|
||||
icon: CirclePlus,
|
||||
label: `点歌${bookTitle(song.title)}`,
|
||||
@@ -110,6 +111,7 @@ function Overview({ tracks, playlists, connected, onCategory, onOpenPlaylist }:
|
||||
onOpenPlaylist: (playlist: PlaylistSummary) => void
|
||||
}) {
|
||||
const featured = tracks.at(0)
|
||||
const playing = useStore(roomStore, s => s.room?.nowPlaying?.entry.track)
|
||||
return (
|
||||
<>
|
||||
{featured && (
|
||||
@@ -121,7 +123,10 @@ function Overview({ tracks, playlists, connected, onCategory, onOpenPlaylist }:
|
||||
<h3 className="text-ui-xl truncate" title={featured.meta.title}>{featured.meta.title}</h3>
|
||||
<p className="text-ui-caption muted truncate">{featured.meta.artist || '未知艺术家'}</p>
|
||||
<div className="search-feature-footer">
|
||||
<span className="text-ui-caption">歌曲</span>
|
||||
<span className="search-feature-kind text-ui-caption">
|
||||
{sameTrack(featured.track, playing) && <PlayingBars />}
|
||||
歌曲
|
||||
</span>
|
||||
<AsyncIconButton
|
||||
icon={CirclePlus}
|
||||
label={`点歌${bookTitle(featured.meta.title)}`}
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
.search-featured-song .artwork { width: 88px; background: var(--color-card-hover); }
|
||||
.search-featured-song h3 { margin-bottom: 4px; }
|
||||
.search-feature-footer { display: flex; align-items: center; justify-content: space-between; margin-top: 6px; }
|
||||
.search-feature-kind { display: inline-flex; align-items: center; gap: 8px; }
|
||||
.search-feature-footer .icon-button { width: 36px; height: 36px; }
|
||||
.search-feature-footer .icon-button svg { width: 26px; height: 26px; }
|
||||
.search-preview .song-row { display: grid; grid-template-columns: 42px minmax(0, 1fr) 48px 36px; min-height: 64px; padding: 8px; gap: 12px; }
|
||||
|
||||
@@ -6,10 +6,11 @@ import { errorMessage } from '@/core/errors'
|
||||
import { historyStore, searchLimits } from '@/features/history'
|
||||
import { reminderStore, requestNotificationPermission, systemNotificationsSupported, volumeStore } from '@/features/playback'
|
||||
import { useStore } from '@/shared/lib/use-store'
|
||||
import { notificationStore, Select, VolumeInput } from '@/shared/ui'
|
||||
import { notificationStore, Select } from '@/shared/ui'
|
||||
import { appearanceStore, fontSizes, themes } from './appearance-store'
|
||||
import { performanceLevels, performanceStore } from './performance-store'
|
||||
import { profileStore } from './profile-store'
|
||||
import { VolumeInput } from './volume-input'
|
||||
import './settings.css'
|
||||
|
||||
interface SettingsDraft {
|
||||
|
||||
File renamed without changes.
File renamed without changes.
@@ -4,13 +4,12 @@ import { beginVolumeEdit, changeVolumeDraft, commitVolumeDraft, initialVolumeEdi
|
||||
interface VolumeInputProps {
|
||||
volume: number
|
||||
onCommit: (volume: number) => void
|
||||
sliderValue?: number
|
||||
id?: string
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function VolumeInput({ volume, onCommit, sliderValue, id, className = '', disabled = false }: VolumeInputProps) {
|
||||
export function VolumeInput({ volume, onCommit, id, className = '', disabled = false }: VolumeInputProps) {
|
||||
const [editor, setEditor] = useState(() => initialVolumeEditor(volume))
|
||||
const syncedEditor = syncVolumeDraft(editor, volume)
|
||||
const commit = () => {
|
||||
@@ -27,7 +26,7 @@ export function VolumeInput({ volume, onCommit, sliderValue, id, className = '',
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={sliderValue ?? volume}
|
||||
value={volume}
|
||||
aria-label="音量"
|
||||
disabled={disabled}
|
||||
onChange={(event) => {
|
||||
@@ -3,10 +3,11 @@ import { useState } from 'react'
|
||||
|
||||
export function Artwork({ src, title, className = '' }: { src?: string, title: string, className?: string }) {
|
||||
const [failedSrc, setFailedSrc] = useState<string>()
|
||||
const [loadedSrc, setLoadedSrc] = useState<string>()
|
||||
return (
|
||||
<div className={`artwork ${className}`}>
|
||||
{src && src !== failedSrc
|
||||
? <img src={src} alt={title} loading="lazy" decoding="async" onError={() => setFailedSrc(src)} />
|
||||
? <img src={src} alt={title} loading="lazy" decoding="async" data-loaded={src === loadedSrc || undefined} onLoad={() => setLoadedSrc(src)} onError={() => setFailedSrc(src)} />
|
||||
: <Music2 size={40} strokeWidth={1.2} aria-hidden="true" />}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -9,10 +9,10 @@ export { lazyPage } from './lazy-page'
|
||||
export { Modal } from './modal'
|
||||
export { notificationStore } from './notification-store'
|
||||
export { Notifications } from './notifications'
|
||||
export { PlayingBars } from './playing-bars'
|
||||
export { Select } from './select'
|
||||
export type { SelectOption } from './select'
|
||||
export { Skeleton } from './skeleton'
|
||||
export { SongArtwork } from './song-artwork'
|
||||
export { SongList } from './song-list'
|
||||
export type { SongAction, SongItem } from './song-list'
|
||||
export { VolumeInput } from './volume-input'
|
||||
@@ -0,0 +1,9 @@
|
||||
export function PlayingBars() {
|
||||
return (
|
||||
<span className="playing-bars" role="img" aria-label="正在播放">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { formatDuration } from '@/shared/lib/time'
|
||||
import { AsyncIconButton } from './icon-button'
|
||||
import { PlayingBars } from './playing-bars'
|
||||
import { SongArtwork } from './song-artwork'
|
||||
|
||||
export interface SongItem {
|
||||
@@ -16,12 +17,15 @@ export interface SongItem {
|
||||
sourceName?: string
|
||||
note?: string
|
||||
badge?: string
|
||||
playing?: boolean
|
||||
}
|
||||
|
||||
export interface SongAction {
|
||||
icon: LucideIcon
|
||||
label: string
|
||||
disabled?: boolean
|
||||
// 次要操作只在鼠标移到这一行时出现。
|
||||
secondary?: boolean
|
||||
run: () => Promise<void>
|
||||
}
|
||||
|
||||
@@ -29,11 +33,12 @@ export function SongList({ songs, actions }: { songs: SongItem[], actions: (song
|
||||
return (
|
||||
<ol className="song-list">
|
||||
{songs.map((song, index) => (
|
||||
<li className="song-row" key={song.key}>
|
||||
<li className="song-row" data-playing={song.playing || undefined} key={song.key}>
|
||||
<span className="song-index text-ui-sm muted">{String(index + 1).padStart(2, '0')}</span>
|
||||
<SongArtwork coverUrl={song.coverUrl} title={song.title} href={song.sourceUrl} sourceName={song.sourceName} />
|
||||
<div className="song-copy">
|
||||
<span className="song-title">
|
||||
{song.playing && <PlayingBars />}
|
||||
<strong className="text-ui-base truncate">{song.title}</strong>
|
||||
{song.badge && <span className="song-badge text-ui-xs">{song.badge}</span>}
|
||||
</span>
|
||||
@@ -44,7 +49,7 @@ export function SongList({ songs, actions }: { songs: SongItem[], actions: (song
|
||||
<span className="song-album text-ui-sm muted truncate">{song.album}</span>
|
||||
<span className="song-duration text-ui-sm muted">{song.durationMs > 0 ? formatDuration(song.durationMs) : ''}</span>
|
||||
{actions(song, index).map(action => (
|
||||
<AsyncIconButton key={action.label} icon={action.icon} className="song-action" label={action.label} disabled={action.disabled} onAction={action.run} />
|
||||
<AsyncIconButton key={action.label} icon={action.icon} className={action.secondary ? 'song-action song-action-secondary' : 'song-action'} label={action.label} disabled={action.disabled} onAction={action.run} />
|
||||
))}
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -33,7 +33,8 @@
|
||||
.skeleton-row > div > span { display: block; width: 55%; height: 12px; border-radius: 4px; background: var(--color-surface-hover); }
|
||||
.skeleton-row > div > span + span { width: 35%; height: 10px; margin-top: 10px; background: var(--color-surface); }
|
||||
.artwork { flex-shrink: 0; display: flex; align-items: center; justify-content: center; overflow: hidden; background: var(--color-surface); color: var(--color-foreground-subtlest); border-radius: 8px; aspect-ratio: 1; }
|
||||
.artwork img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.artwork img { width: 100%; height: 100%; object-fit: cover; opacity: 0; transition: opacity 240ms ease; }
|
||||
.artwork img[data-loaded] { opacity: 1; }
|
||||
.avatar { display: inline-grid; place-items: center; flex-shrink: 0; width: 32px; aspect-ratio: 1; overflow: hidden; border-radius: 50%; background: var(--color-accent); color: var(--color-brand); font-weight: 600; line-height: 1; }
|
||||
.avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.song-artwork { display: block; position: relative; flex-shrink: 0; border-radius: 8px; }
|
||||
@@ -43,12 +44,29 @@
|
||||
.song-list { list-style: none; padding: 0; margin: 0; }
|
||||
.song-row { display: flex; align-items: center; gap: 12px; padding: 10px 8px; border-radius: 8px; min-height: 68px; content-visibility: auto; contain-intrinsic-size: auto 68px; }
|
||||
.song-row:hover { background: var(--color-surface-hover); }
|
||||
.song-row[data-playing] { background: color-mix(in srgb, var(--color-brand) 10%, transparent); }
|
||||
.song-row[data-playing]:hover { background: color-mix(in srgb, var(--color-brand) 14%, transparent); }
|
||||
.song-row[data-playing] strong { color: var(--color-brand); }
|
||||
.playing-bars { display: inline-flex; align-items: flex-end; flex-shrink: 0; gap: 2px; width: 14px; height: 12px; }
|
||||
.playing-bars i { flex: 1; height: 100%; border-radius: 1px; background: var(--color-brand); transform: scaleY(.6); transform-origin: bottom; animation: playing-bar 720ms ease-in-out infinite alternate; }
|
||||
.playing-bars i:nth-child(2) { transform: scaleY(1); animation-delay: -420ms; }
|
||||
.playing-bars i:nth-child(3) { transform: scaleY(.8); animation-delay: -220ms; }
|
||||
:root[data-performance='low'] .playing-bars i { animation: none; }
|
||||
@keyframes playing-bar {
|
||||
from { transform: scaleY(.3); }
|
||||
to { transform: scaleY(1); }
|
||||
}
|
||||
.song-row .artwork { width: 42px; }
|
||||
.song-row .artwork > svg { width: 22px; }
|
||||
.song-action { color: var(--color-foreground-subtle); }
|
||||
.song-action:hover:not(:disabled), .song-action:focus-visible:not(:disabled) { color: var(--color-brand); background: transparent; }
|
||||
.song-action:active:not(:disabled) { color: var(--color-brand); background: transparent; transform: none; }
|
||||
.song-action[aria-busy='true'] { cursor: progress; opacity: .7; color: var(--color-brand); }
|
||||
/* 触屏没有悬停,按钮要一直显示。占位不变,出现时这一行的文字不会跳动。 */
|
||||
@media (hover: hover) {
|
||||
.song-action-secondary { opacity: 0; transition: opacity 120ms ease; }
|
||||
.song-row:hover .song-action-secondary, .song-row:focus-within .song-action-secondary, .song-action-secondary[aria-busy='true'] { opacity: 1; }
|
||||
}
|
||||
.song-index { width: 22px; flex-shrink: 0; }
|
||||
.song-copy { display: flex; flex-direction: column; min-width: 0; gap: 3px; flex: 1; }
|
||||
.song-title { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
|
||||
+20
-19
@@ -2,37 +2,38 @@ FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS build
|
||||
ARG TARGETOS TARGETARCH
|
||||
ARG VERSION=dev
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
COPY cmd ./cmd
|
||||
COPY db ./db
|
||||
COPY internal ./internal
|
||||
COPY protocol ./protocol
|
||||
COPY --parents go.mod go.sum cmd db internal protocol ./
|
||||
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath -tags grpcnotrace \
|
||||
-ldflags "-s -w -X github.com/dihemoe/alisten/internal/bootstrap.version=$VERSION" \
|
||||
-o /out/alisten ./cmd/alisten
|
||||
-o /out/usr/local/bin/alisten ./cmd/alisten
|
||||
COPY LICENSE /out/usr/share/doc/alisten/
|
||||
|
||||
FROM --platform=$BUILDPLATFORM debian:trixie-slim AS ffmpeg
|
||||
ARG TARGETARCH
|
||||
ARG FFMPEG_VERSION=7.1.5
|
||||
COPY docker/build-ffmpeg.sh /tmp/
|
||||
RUN sh /tmp/build-ffmpeg.sh
|
||||
|
||||
FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS tools
|
||||
ARG TARGETARCH
|
||||
ARG YTDLP_VERSION=2026.10.21
|
||||
ARG DENO_VERSION=v2.9.8
|
||||
RUN case "$TARGETARCH" in amd64) ytdlp=yt-dlp_linux deno=x86_64 ;; arm64) ytdlp=yt-dlp_linux_aarch64 deno=aarch64 ;; esac \
|
||||
&& wget -q --tries=3 --timeout=30 -O /tmp/yt-dlp.zip "https://github.com/yt-dlp/yt-dlp/releases/download/$YTDLP_VERSION/$ytdlp.zip" \
|
||||
&& wget -q --tries=3 --timeout=30 -O /tmp/deno.zip "https://github.com/denoland/deno/releases/download/$DENO_VERSION/deno-$deno-unknown-linux-gnu.zip" \
|
||||
&& mkdir -p /out/opt/yt-dlp /out/usr/local/bin \
|
||||
&& unzip -q /tmp/yt-dlp.zip -d /out/opt/yt-dlp \
|
||||
&& unzip -q /tmp/deno.zip -d /out/usr/local/bin \
|
||||
&& ln -s "/opt/yt-dlp/$ytdlp" /out/usr/local/bin/yt-dlp
|
||||
COPY --from=ffmpeg /out /out
|
||||
COPY docker/fetch-tools.sh /tmp/
|
||||
RUN sh /tmp/fetch-tools.sh
|
||||
|
||||
FROM debian:trixie-slim AS alisten-worker
|
||||
COPY --from=tools /out /
|
||||
RUN apt-get update \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install --yes --no-install-recommends ca-certificates ffmpeg \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install --yes --no-install-recommends ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& groupadd --system --gid 65532 alisten \
|
||||
&& useradd --system --uid 65532 --gid alisten --home-dir /var/cache/alisten --no-create-home --shell /usr/sbin/nologin alisten \
|
||||
&& install -d -o alisten -g alisten /var/cache/alisten
|
||||
COPY LICENSE /usr/share/doc/alisten/
|
||||
COPY --from=tools /out /
|
||||
COPY --from=build /out/alisten /usr/local/bin/alisten
|
||||
&& useradd --system --uid 65532 --gid alisten --home-dir /var/cache/alisten --no-create-home \
|
||||
--shell /usr/sbin/nologin alisten \
|
||||
&& install -d -o alisten -g alisten /var/cache/alisten \
|
||||
&& ffmpeg -hide_banner -encoders | grep -q libmp3lame \
|
||||
&& ffprobe -hide_banner -version >/dev/null
|
||||
COPY --from=build /out /
|
||||
ENV XDG_CACHE_HOME=/var/cache/alisten
|
||||
USER 65532:65532
|
||||
EXPOSE 9090
|
||||
|
||||
Reference in new issue
Block a user